diff --git a/.env.example b/.env.example index cb3c4ac6..90b90c6f 100644 --- a/.env.example +++ b/.env.example @@ -97,6 +97,23 @@ INTELLIGENCE_GATEWAY_WS_URL=wss://realtime.intelligence.copilotkit.ai INTELLIGENCE_API_KEY= COPILOTKIT_LICENSE_TOKEN= +# Managed Slack uses this same Intelligence project. Sign the CLI in, select this existing project, +# then run the guided setup and choose Channel name `openbot` and Slack: +# +# npx copilotkit@latest login +# npx copilotkit@latest project select +# npx copilotkit@latest channels setup +# +# The setup CLI may temporarily read Slack attachment credentials from an ignored local .env, the +# shell, or a secret manager. Remove or unset those bootstrap copies before starting OpenBot. +# Intelligence owns them after attachment; the OpenBot runtime reads no Slack credential, so this +# example intentionally contains no Slack credential placeholders. See docs/slack.md. + +# Single-workspace bridge for managed deliveries that omit canonical Slack tenant metadata. Set +# this to the workspace/team ID attached to this Channel. A conflicting known tenant is always +# rejected. Leave it unset when Channels supplies canonical tenant metadata for every delivery. +# OPENBOT_SLACK_TENANT_ID=T0123456789 + # How long a Bot's stream may say nothing before this deployment gives up on the turn, in # milliseconds. A Bot is any AG-UI endpoint, which means it will be redeployed mid-answer, its own # upstream will time out, and it will sometimes accept a connection and then write nothing at all. diff --git a/.gitignore b/.gitignore index bfc1237c..8edc3dc5 100644 --- a/.gitignore +++ b/.gitignore @@ -28,3 +28,7 @@ app/.tanstack/ # it is what makes that fetch reproducible, and ignoring it meant every build resolved the dependency # afresh, so CI and a customer install could take different subchart versions with no diff to show it. charts/*/charts/ + +# Added by CopilotKit CLI: the selected Intelligence project, the declared Channel, and the +# credentials its guided setup writes. All three are one deployment's, not the template's. +.copilotkit/ diff --git a/agent-computer/src/control.ts b/agent-computer/src/control.ts index d74913ba..053b0c3b 100644 --- a/agent-computer/src/control.ts +++ b/agent-computer/src/control.ts @@ -30,6 +30,8 @@ export type ControlState = { * doing, with the reason the Bot gave, written for whoever asked and rendered to whoever looked. */ requestedAt?: string; + /** Opaque generation of the pending help request, used only for conditional cancellation. */ + helpRequestId?: string; /** * A secret the Bot is waiting for, described by its label only. * @@ -47,6 +49,25 @@ export type ControlState = { */ secretRef?: string; secretSnapshotId?: number; + /** When this exact secret generation was requested, so it expires like a help request. */ + secretRequestedAt?: string; + /** Opaque generation of the pending secret request, used only for conditional cancellation. */ + secretRequestId?: string; +}; + +export type AssistanceStatus = + | "pending" + | "human" + | "completed" + | "expired" + | "cancelled" + | "superseded" + | "unknown"; + +export type AssistanceCancellationResult = { + cancelled: boolean; + state: ControlState; + status: AssistanceStatus; }; /** Refusal because a person is driving. Distinct from a failure, so the Bot can be told to wait. */ @@ -96,6 +117,66 @@ export function createControl( since: now(), requested: false, }; + const terminalAssistance = new Map(); + let humanAssistanceId: string | undefined; + + const remember = ( + requestId: string | undefined, + status: AssistanceStatus, + ) => { + if (!requestId) return; + terminalAssistance.delete(requestId); + terminalAssistance.set(requestId, status); + while (terminalAssistance.size > 128) { + const oldest = terminalAssistance.keys().next().value; + if (oldest === undefined) break; + terminalAssistance.delete(oldest); + } + }; + + const expirePending = () => { + const current = Date.parse(now()); + if ( + state.holder === "bot" && + state.requested && + state.requestedAt && + current - Date.parse(state.requestedAt) > HELP_REQUEST_TTL_MS + ) { + remember(state.helpRequestId, "expired"); + const { + helpRequestId: _requestId, + reason: _reason, + requestedAt: _at, + ...rest + } = state; + state = { ...rest, requested: false }; + } + if ( + state.holder === "bot" && + state.secretWanted && + state.secretRequestedAt && + current - Date.parse(state.secretRequestedAt) > HELP_REQUEST_TTL_MS + ) { + remember(state.secretRequestId, "expired"); + const { + secretRequestId: _requestId, + secretWanted: _wanted, + secretRef: _ref, + secretSnapshotId: _snapshotId, + secretRequestedAt: _at, + ...rest + } = state; + state = rest; + } + }; + + const assistanceRequestId = (candidate: unknown) => + typeof candidate === "string" && + /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test( + candidate, + ) + ? candidate + : crypto.randomUUID(); return { /** @@ -110,15 +191,7 @@ export function createControl( * than any stale prompt. */ get(): ControlState { - if ( - state.requested && - state.holder === "bot" && - state.requestedAt && - Date.parse(now()) - Date.parse(state.requestedAt) > HELP_REQUEST_TTL_MS - ) { - const { reason: _reason, requestedAt: _at, ...rest } = state; - state = { ...rest, requested: false }; - } + expirePending(); return { ...state }; }, @@ -128,11 +201,15 @@ export function createControl( * It does not take control: it says it is stuck and why, and a person decides. A Bot that could * hand itself to a human could also hand a human a page they never asked to see. */ - requestHelp(reason: unknown): ControlState { + requestHelp(reason: unknown, requestId?: unknown): ControlState { + expirePending(); + if (state.requested) remember(state.helpRequestId, "superseded"); + const id = assistanceRequestId(requestId); state = { ...state, requested: true, requestedAt: now(), + helpRequestId: id, reason: typeof reason === "string" && reason.trim() ? reason.trim() @@ -146,12 +223,16 @@ export function createControl( label?: unknown; ref?: unknown; snapshotId?: unknown; + requestId?: unknown; }): ControlState { + expirePending(); if (typeof input.ref !== "string" || !input.ref.trim()) { throw new ControlRequestError( "Say which field the value goes in, using a ref from your snapshot.", ); } + if (state.secretWanted) remember(state.secretRequestId, "superseded"); + const requestedAt = now(); state = { ...state, secretWanted: @@ -161,10 +242,69 @@ export function createControl( secretRef: input.ref.trim(), secretSnapshotId: typeof input.snapshotId === "number" ? input.snapshotId : undefined, + secretRequestId: assistanceRequestId(input.requestId), + secretRequestedAt: requestedAt, }; return this.get(); }, + /** + * Clear only the exact pending assistance generation while the Bot still owns the browser. + * A stale delivery timeout is therefore harmless after a newer request or human handoff. + */ + cancelAssistance(requestId: string): AssistanceCancellationResult { + expirePending(); + if (humanAssistanceId === requestId) { + return { cancelled: false, state: this.get(), status: "human" }; + } + if (state.holder !== "bot") { + return { + cancelled: false, + state: this.get(), + status: terminalAssistance.get(requestId) ?? "unknown", + }; + } + if (state.helpRequestId === requestId && state.requested) { + const { + helpRequestId: _requestId, + reason: _reason, + requestedAt: _requestedAt, + ...rest + } = state; + state = { ...rest, requested: false }; + remember(requestId, "cancelled"); + return { cancelled: true, state: this.get(), status: "cancelled" }; + } + if (state.secretRequestId === requestId && state.secretWanted) { + const { + secretRequestId: _requestId, + secretWanted: _wanted, + secretRef: _ref, + secretSnapshotId: _snapshotId, + secretRequestedAt: _requestedAt, + ...rest + } = state; + state = rest; + remember(requestId, "cancelled"); + return { cancelled: true, state: this.get(), status: "cancelled" }; + } + return { + cancelled: false, + state: this.get(), + status: terminalAssistance.get(requestId) ?? "unknown", + }; + }, + + assistanceStatus(requestId: string): AssistanceStatus { + expirePending(); + if (humanAssistanceId === requestId) return "human"; + if (state.requested && state.helpRequestId === requestId) + return "pending"; + if (state.secretWanted && state.secretRequestId === requestId) + return "pending"; + return terminalAssistance.get(requestId) ?? "unknown"; + }, + /** * The pending secret request, or null. * @@ -172,6 +312,7 @@ export function createControl( * masked box from being a general-purpose way to type into the page. */ pendingSecret(): { ref: string; snapshotId?: number } | null { + expirePending(); if (!state.secretWanted || !state.secretRef) return null; return { ref: state.secretRef, snapshotId: state.secretSnapshotId }; }, @@ -183,11 +324,14 @@ export function createControl( * can try again. */ secretSupplied(): void { + remember(state.secretRequestId, "completed"); state = { ...state, secretWanted: undefined, secretRef: undefined, secretSnapshotId: undefined, + secretRequestId: undefined, + secretRequestedAt: undefined, }; }, @@ -199,6 +343,11 @@ export function createControl( * box left open behind them no longer corresponds to an active request. */ take(): ControlState { + expirePending(); + if (state.holder === "bot") { + humanAssistanceId = state.requested ? state.helpRequestId : undefined; + } + if (state.secretWanted) remember(state.secretRequestId, "cancelled"); state = { holder: "human", since: now(), @@ -217,6 +366,8 @@ export function createControl( * secret box left open afterwards is asking for a password nothing is waiting for. */ release(): ControlState { + remember(humanAssistanceId, "completed"); + humanAssistanceId = undefined; state = { holder: "bot", since: now(), diff --git a/agent-computer/src/index.ts b/agent-computer/src/index.ts index 341fcc34..9b1fdda8 100644 --- a/agent-computer/src/index.ts +++ b/agent-computer/src/index.ts @@ -621,8 +621,9 @@ serve({ if (url.pathname === "/control/request" && request.method === "POST") { const body = (await request.json().catch(() => null)) as { reason?: unknown; + requestId?: unknown; } | null; - return json(session.control.requestHelp(body?.reason)); + return json(session.control.requestHelp(body?.reason, body?.requestId)); } // The Bot asking for one value it must not be told. It has already focused the field. @@ -642,6 +643,34 @@ serve({ } } + // Conditional cleanup for an assistance request whose Slack handoff definitely failed. The + // state machine matches the opaque generation and refuses to change a human-owned browser. + if ( + url.pathname === "/control/assistance/cancel" && + request.method === "POST" + ) { + const body = (await request.json().catch(() => null)) as { + requestId?: unknown; + } | null; + if (typeof body?.requestId !== "string" || !body.requestId) { + return json({ error: "An assistance request id is required." }, 400); + } + return json(session.control.cancelAssistance(body.requestId)); + } + + if ( + url.pathname === "/control/assistance/status" && + request.method === "POST" + ) { + const body = (await request.json().catch(() => null)) as { + requestId?: unknown; + } | null; + if (typeof body?.requestId !== "string" || !body.requestId) { + return json({ error: "An assistance request id is required." }, 400); + } + return json({ status: session.control.assistanceStatus(body.requestId) }); + } + /** * A person supplying that value. * diff --git a/agent-computer/src/workspace.ts b/agent-computer/src/workspace.ts index 38390ef7..fde89d2a 100644 --- a/agent-computer/src/workspace.ts +++ b/agent-computer/src/workspace.ts @@ -24,8 +24,8 @@ import { lstat, mkdir, + open, readdir, - readFile, readlink, realpath, stat, @@ -40,6 +40,7 @@ import { resolve, sep, } from "node:path"; +import { StringDecoder } from "node:string_decoder"; export class WorkspacePathError extends Error { constructor(message: string) { @@ -90,9 +91,25 @@ export const DEFAULT_WORKSPACE_LIMITS: WorkspaceLimits = { listEntries: 500, }; +type WorkspaceDependencies = { + /** Injectable only so tests can prove bounded allocation and short-read handling. */ + openFile?: (path: string) => Promise; +}; + +type WorkspaceReadHandle = { + read( + buffer: Buffer, + offset: number, + length: number, + position: number, + ): Promise<{ bytesRead: number }>; + close(): Promise; +}; + export function createWorkspace( rootPath: string, limits: WorkspaceLimits = DEFAULT_WORKSPACE_LIMITS, + dependencies: WorkspaceDependencies = {}, ) { /** * Turn a Bot's requested path into a real one inside the workspace, or refuse. @@ -268,16 +285,28 @@ export function createWorkspace( ); } - const buffer = await readFile(full); - const slice = buffer.subarray(0, limits.readBytes); + // One look-ahead byte independently proves truncation if the file grows after stat. The + // returned text remains capped at readBytes and the whole file is never allocated. + const sample = await readFileAtMost( + full, + limits.readBytes + 1, + dependencies.openFile, + ); + const slice = sample.subarray(0, limits.readBytes); + const truncated = + info.size > limits.readBytes || sample.byteLength > limits.readBytes; + const decoder = new StringDecoder("utf8"); + // When truncation bisects a code point, StringDecoder holds that incomplete suffix. Do not + // flush it: doing so would invent a replacement glyph that was not in the file. + const text = decoder.write(slice) + (truncated ? "" : decoder.end()); return { path: requested, // Decoded as UTF-8. A binary file therefore comes back as replacement characters rather // than as a base64 blob nothing can read: this tool is for the notes, CSVs and JSON a Bot // actually works with, and pretending otherwise would invite it to try images. - text: slice.toString("utf8"), - truncated: buffer.byteLength > slice.byteLength, - bytes: buffer.byteLength, + text, + truncated, + bytes: info.size, }; }, @@ -308,6 +337,26 @@ export function createWorkspace( }; } +async function readFileAtMost( + path: string, + bytes: number, + openFile: WorkspaceDependencies["openFile"], +): Promise { + const handle = openFile ? await openFile(path) : await open(path, "r"); + try { + const buffer = Buffer.alloc(bytes); + let total = 0; + while (total < bytes) { + const result = await handle.read(buffer, total, bytes - total, total); + if (result.bytesRead === 0) break; + total += result.bytesRead; + } + return buffer.subarray(0, total); + } finally { + await handle.close(); + } +} + export type Workspace = ReturnType; /** Containment, as a path comparison that cannot be fooled by a shared prefix. */ diff --git a/agent-computer/tests/control.test.ts b/agent-computer/tests/control.test.ts index bbcc3f09..8a2fc103 100644 --- a/agent-computer/tests/control.test.ts +++ b/agent-computer/tests/control.test.ts @@ -85,6 +85,78 @@ describe("the happy path: ask, hand over, hand back", () => { }); describe("the crappy paths: two drivers, one page", () => { + test("tracks one help generation through supersession and human completion", () => { + const { control } = fixture(); + const first = "11111111-1111-4111-8111-111111111111"; + const second = "22222222-2222-4222-8222-222222222222"; + control.requestHelp("First request", first); + control.requestHelp("Second request", second); + expect(control.assistanceStatus(first)).toBe("superseded"); + expect(control.assistanceStatus(second)).toBe("pending"); + control.take(); + expect(control.assistanceStatus(second)).toBe("human"); + control.release(); + expect(control.assistanceStatus(second)).toBe("completed"); + }); + + test("taking control twice preserves the exact human assistance generation", () => { + const { control } = fixture(); + const requestId = "11111111-1111-4111-8111-111111111111"; + control.requestHelp("Sign in", requestId); + control.take(); + control.take(); + expect(control.assistanceStatus(requestId)).toBe("human"); + control.release(); + expect(control.assistanceStatus(requestId)).toBe("completed"); + }); + + test("cancels only the exact pending help generation", () => { + const { control } = fixture(); + control.requestHelp( + "First request", + "11111111-1111-4111-8111-111111111111", + ); + control.requestHelp( + "Newer request", + "22222222-2222-4222-8222-222222222222", + ); + + expect( + control.cancelAssistance("11111111-1111-4111-8111-111111111111"), + ).toMatchObject({ + cancelled: false, + state: { + holder: "bot", + requested: true, + reason: "Newer request", + helpRequestId: "22222222-2222-4222-8222-222222222222", + }, + }); + expect( + control.cancelAssistance("22222222-2222-4222-8222-222222222222"), + ).toMatchObject({ + cancelled: true, + state: { holder: "bot", requested: false }, + }); + expect( + control.cancelAssistance("22222222-2222-4222-8222-222222222222"), + ).toMatchObject({ cancelled: false }); + }); + + test("a late cancellation never takes control from a person", () => { + const { control } = fixture(); + control.requestHelp("Sign in", "11111111-1111-4111-8111-111111111111"); + control.take(); + + const result = control.cancelAssistance( + "11111111-1111-4111-8111-111111111111", + ); + + expect(result.cancelled).toBe(false); + expect(result.state.holder).toBe("human"); + expect(control.humanMayDrive()).toBe(true); + }); + test("the Bot is refused while a person holds the wheel", () => { const { control } = fixture(); control.take(); @@ -147,6 +219,47 @@ describe("the crappy paths: two drivers, one page", () => { }); describe("the crappy paths: secrets", () => { + test("expires one unanswered secret generation without touching its successor", () => { + let now = Date.parse("2026-08-14T00:00:00.000Z"); + const control = createControl(() => new Date(now).toISOString()); + const first = "11111111-1111-4111-8111-111111111111"; + const second = "22222222-2222-4222-8222-222222222222"; + control.requestSecret({ ref: "e1", label: "code", requestId: first }); + control.requestSecret({ ref: "e2", label: "new code", requestId: second }); + expect(control.assistanceStatus(first)).toBe("superseded"); + now += 10 * 60 * 1000 + 1; + expect(control.assistanceStatus(second)).toBe("expired"); + expect(control.pendingSecret()).toBeNull(); + }); + + test("cancels only the exact pending secret generation", () => { + const { control } = fixture(); + control.requestSecret({ + ref: "e12", + label: "old code", + requestId: "11111111-1111-4111-8111-111111111111", + }); + control.requestSecret({ + ref: "e13", + label: "new code", + requestId: "22222222-2222-4222-8222-222222222222", + }); + + expect( + control.cancelAssistance("11111111-1111-4111-8111-111111111111"), + ).toMatchObject({ + cancelled: false, + state: { + secretWanted: "new code", + secretRequestId: "22222222-2222-4222-8222-222222222222", + }, + }); + expect( + control.cancelAssistance("22222222-2222-4222-8222-222222222222"), + ).toMatchObject({ cancelled: true }); + expect(control.pendingSecret()).toBeNull(); + }); + test("a secret request must name the field it goes in", () => { const { control } = fixture(); // The version without this typed the value into whatever happened to have focus, and reported @@ -233,7 +346,13 @@ describe("the crappy paths: secrets", () => { Object.keys(control.get()) .filter((k) => /secret/i.test(k)) .sort(), - ).toEqual(["secretRef", "secretSnapshotId", "secretWanted"]); + ).toEqual([ + "secretRef", + "secretRequestId", + "secretRequestedAt", + "secretSnapshotId", + "secretWanted", + ]); }); }); @@ -270,6 +389,7 @@ describe("an unanswered request to take the wheel", () => { expect(state.requested).toBe(false); // The reason is the part that leaked between conversations, so it goes too. expect(state.reason).toBeUndefined(); + expect(state.helpRequestId).toBeUndefined(); }); test("never takes the wheel back off a person who holds it", () => { diff --git a/agent-computer/tests/workspace.test.ts b/agent-computer/tests/workspace.test.ts index f95001e7..d7f2bd9b 100644 --- a/agent-computer/tests/workspace.test.ts +++ b/agent-computer/tests/workspace.test.ts @@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; import { mkdir, mkdtemp, + open, readFile, rm, symlink, @@ -90,6 +91,109 @@ describe("reading and writing inside the workspace", () => { expect(read.bytes).toBe(16); }); + test("reads at most the configured byte limit plus one from a large file", async () => { + let requestedBytes = 0; + const ws = createWorkspace( + root, + { readBytes: 10, writeBytes: 1000, listEntries: 500 }, + { + async openFile(path) { + const handle = await open(path, "r"); + return { + async read(buffer, offset, length, position) { + requestedBytes = Math.max(requestedBytes, buffer.byteLength); + return handle.read(buffer, offset, length, position); + }, + close: () => handle.close(), + }; + }, + }, + ); + await writeFile(join(root, "huge.txt"), "x".repeat(1_000_000)); + + const result = await ws.read("huge.txt"); + + expect(requestedBytes).toBe(11); + expect(result).toEqual({ + path: "huge.txt", + text: "xxxxxxxxxx", + truncated: true, + bytes: 1_000_000, + }); + }); + + test("continues bounded reads when the filesystem returns short chunks", async () => { + const source = Buffer.from("abcdef", "utf8"); + let calls = 0; + const ws = createWorkspace( + root, + { readBytes: 5, writeBytes: 1000, listEntries: 500 }, + { + async openFile() { + return { + async read(buffer, offset, length, position) { + calls += 1; + const start = position ?? 0; + const bytesRead = Math.min(2, length, source.length - start); + if (bytesRead > 0) { + source.copy(buffer, offset, start, start + bytesRead); + } + return { bytesRead }; + }, + async close() {}, + }; + }, + }, + ); + await writeFile(join(root, "chunked.txt"), "xxxxxx"); + + const result = await ws.read("chunked.txt"); + + expect(calls).toBe(3); + expect(result).toEqual({ + path: "chunked.txt", + text: "abcde", + truncated: true, + bytes: 6, + }); + }); + + test("distinguishes exact-limit reads from limit-plus-one reads", async () => { + const ws = createWorkspace(root, { + readBytes: 4, + writeBytes: 1000, + listEntries: 500, + }); + await writeFile(join(root, "exact.txt"), "1234"); + await writeFile(join(root, "over.txt"), "12345"); + + expect(await ws.read("exact.txt")).toMatchObject({ + text: "1234", + truncated: false, + bytes: 4, + }); + expect(await ws.read("over.txt")).toMatchObject({ + text: "1234", + truncated: true, + bytes: 5, + }); + }); + + test("does not emit a replacement artifact when truncation splits UTF-8", async () => { + const ws = createWorkspace(root, { + readBytes: 5, + writeBytes: 1000, + listEntries: 500, + }); + await writeFile(join(root, "unicode.txt"), "abc📊tail"); + + const result = await ws.read("unicode.txt"); + + expect(result.text).toBe("abc"); + expect(result.text).not.toContain("�"); + expect(result.truncated).toBe(true); + }); + test("a write that exceeds the limit is refused before it touches the disk", async () => { const ws = createWorkspace(root, { readBytes: 1000, diff --git a/app/src/components/app-sidebar/app-sidebar.tsx b/app/src/components/app-sidebar/app-sidebar.tsx index 49052645..c1f67c3b 100644 --- a/app/src/components/app-sidebar/app-sidebar.tsx +++ b/app/src/components/app-sidebar/app-sidebar.tsx @@ -52,12 +52,23 @@ import { channelListQueryOptions, } from "@/lib/channels/queries"; import { useChannelEvents } from "@/lib/channels/use-channel-events"; +import { externalThreadListQueryOptions } from "@/lib/external/queries"; import { appConfig } from "@/lib/generated/application-config"; import { EASE_OUT, ENTRANCE_SECONDS } from "@/lib/motion"; import { relativeTime } from "@/lib/relative-time"; import { Button } from "../ui/button"; import { Empty, EmptyDescription, EmptyHeader, EmptyTitle } from "../ui/empty"; import { Channel } from "./channel"; +import { + conversationRoster, + matchingRoster, + type RosterSourceStatus, + rosterKey, + shouldShowEmptyRoster, + shouldShowSearchEmpty, + type SidebarRosterRow, +} from "./roster"; +import { SlackChannel, SlackRosterProblem } from "./slack-channel"; const appLinkOptions = { to: "/" } satisfies LinkOptions; const adminLinkOptions = { to: "/admin" } satisfies LinkOptions; @@ -65,6 +76,15 @@ const settingsLinkOptions = { to: "/settings" } satisfies LinkOptions; const userMenuItemClassName = "gap-2 px-2 py-1.5"; +function rosterSourceStatus(query: { + isError: boolean; + isSuccess: boolean; +}): RosterSourceStatus { + if (query.isSuccess) return "success"; + if (query.isError) return "error"; + return "pending"; +} + function UserAvatar() { const { data: currentUser } = useQuery(currentUserQueryOptions()); const initials = @@ -87,35 +107,6 @@ function UserAvatar() { */ const MAX_ANIMATED_ROWS = 60; -/** - * The roster, narrowed to what the person typed. - * - * Matches the channel's name and the last thing said in it, because those are the two things the - * row actually shows — searching against something invisible returns results a person cannot - * account for. Message history beyond the last line is not here to search: it lives in the thread - * store, and reaching for it is a server endpoint rather than a filter. - * - * An empty query returns the input array unchanged rather than a copy, so typing and clearing does - * not hand `AnimatePresence` a new array identity and restage the whole list. - */ -function matchingChannels( - channels: ChannelSummary[] | undefined, - query: string, -): ChannelSummary[] { - if (!channels) { - return []; - } - const needle = query.trim().toLowerCase(); - if (!needle) { - return channels; - } - return channels.filter((channel) => - [channel.name, channel.lastMessage].some((field) => - field?.toLowerCase().includes(needle), - ), - ); -} - /** * Pinned channels first, everything else after, newest activity first within each group. * @@ -162,9 +153,11 @@ export function isUnread( * moves under the cursor. */ function ChannelRow({ + animateVisibility, channel, animateOrder, }: { + animateVisibility: boolean; channel: ChannelSummary; animateOrder: boolean; }) { @@ -178,12 +171,18 @@ function ChannelRow({ }); return ( @@ -204,25 +203,68 @@ function ChannelRow({ ); } +function SlackRow({ + animateVisibility, + animateOrder, + thread, +}: { + animateVisibility: boolean; + animateOrder: boolean; + thread: SidebarRosterRow & { kind: "slack" }; +}) { + const shouldReduceMotion = useReducedMotion(); + return ( + + + + ); +} + export function AppSidebar({ ...props }: React.ComponentProps) { const { data: currentUser } = useQuery(currentUserQueryOptions()); const queryClient = useQueryClient(); const navigate = useNavigate(); const signOut = useMutation(signOutMutationOptions(queryClient)); const channels = useInfiniteQuery(channelListQueryOptions()); + const slackThreads = useInfiniteQuery(externalThreadListQueryOptions()); // One socket for the app, opened where the roster is kept live. useChannelEvents(); const [search, setSearch] = useState(""); const searching = search.trim().length > 0; - const visibleChannels = pinnedFirst(matchingChannels(channels.data, search)); + const roster = conversationRoster(channels.data, slackThreads.data); + const visibleRoster = matchingRoster(roster, search); + const channelStatus = rosterSourceStatus(channels); + const slackThreadStatus = rosterSourceStatus(slackThreads); /* * FILTERING DOES NOT ANIMATE. Rows exit and relayout on every keystroke otherwise, which is a * list thrashing under somebody who is still typing — and the moving target is the very thing * they are trying to read. Order animation is for a channel that was just spoken in, which is * occasional; this is not. */ - const animateOrder = - !searching && (channels.data?.length ?? 0) <= MAX_ANIMATED_ROWS; + const animateOrder = !searching && roster.length <= MAX_ANIMATED_ROWS; + const animateVisibility = !searching; const handleSignOut = async () => { await signOut.mutateAsync(); @@ -266,7 +308,7 @@ export function AppSidebar({ ...props }: React.ComponentProps) { setSearch(event.target.value)} placeholder="Search..." value={search} @@ -283,7 +325,12 @@ export function AppSidebar({ ...props }: React.ComponentProps) { * the box has to say so and quote it back — told "you don't have channels yet" while * holding a typo, a person reads their conversations as gone. */} - {searching && visibleChannels.length === 0 ? ( + {shouldShowSearchEmpty( + visibleRoster, + search, + channelStatus, + slackThreadStatus, + ) ? (
@@ -296,7 +343,13 @@ export function AppSidebar({ ...props }: React.ComponentProps) {
) : null} - {!searching && channels.data?.length === 0 ? ( + {!searching && + shouldShowEmptyRoster( + channels.data, + slackThreads.data, + channels.isSuccess, + slackThreads.isSuccess, + ) ? (
@@ -309,14 +362,32 @@ export function AppSidebar({ ...props }: React.ComponentProps) {
) : null} + {slackThreads.isError ? ( + { + void slackThreads.refetch(); + }} + /> + ) : null} - {visibleChannels.map((channel) => ( - - ))} + {visibleRoster.map((row) => + row.kind === "openbot" ? ( + + ) : ( + + ), + )} diff --git a/app/src/components/app-sidebar/roster.ts b/app/src/components/app-sidebar/roster.ts new file mode 100644 index 00000000..dacd5741 --- /dev/null +++ b/app/src/components/app-sidebar/roster.ts @@ -0,0 +1,127 @@ +import { linkOptions, type LinkOptions } from "@tanstack/react-router"; +import type { ChannelSummary } from "@/lib/channels/queries"; +import type { ExternalThreadSummary } from "@/lib/external/queries"; + +export type SidebarRosterRow = + | { kind: "openbot"; channel: ChannelSummary } + | { kind: "slack"; thread: ExternalThreadSummary }; + +export type RosterSourceStatus = "pending" | "success" | "error"; + +const openbotChannelRoute = "/channel/$channelId" as const; +const slackThreadRoute = "/slack/thread/$threadId" as const; + +export function rosterKey(row: SidebarRosterRow): string { + return row.kind === "openbot" + ? `openbot:${row.channel.id}` + : `slack:${row.thread.threadId}`; +} + +function activityAt(row: SidebarRosterRow): string { + const source = row.kind === "openbot" ? row.channel : row.thread; + return source.lastMessageAt ?? source.createdAt; +} + +export function conversationRoster( + channels: ChannelSummary[] = [], + slackThreads: ExternalThreadSummary[] = [], +): SidebarRosterRow[] { + const nativeRows = channels.map( + (channel): SidebarRosterRow & { kind: "openbot" } => ({ + kind: "openbot", + channel, + }), + ); + const slackRows = slackThreads.map( + (thread): SidebarRosterRow & { kind: "slack" } => ({ + kind: "slack", + thread, + }), + ); + const pinned = nativeRows.filter( + (row) => row.kind === "openbot" && row.channel.pinned, + ); + const remaining = [ + ...nativeRows.filter((row) => !row.channel.pinned), + ...slackRows, + ]; + + return [ + ...pinned.sort(byActivityThenKey), + ...remaining.sort(byActivityThenKey), + ]; +} + +function byActivityThenKey(a: SidebarRosterRow, b: SidebarRosterRow): number { + const activity = activityAt(b).localeCompare(activityAt(a)); + if (activity !== 0) return activity; + return rosterKey(a).localeCompare(rosterKey(b)); +} + +export function matchingRoster( + rows: SidebarRosterRow[] | undefined, + query: string, +): SidebarRosterRow[] { + if (!rows) { + return []; + } + const needle = query.trim().toLowerCase(); + if (!needle) { + return rows; + } + return rows.filter((row) => + [rosterName(row), rosterLastMessage(row)].some((field) => + field?.toLowerCase().includes(needle), + ), + ); +} + +export function rosterName(row: SidebarRosterRow): string { + return row.kind === "openbot" ? row.channel.name : row.thread.agentName; +} + +export function rosterLastMessage(row: SidebarRosterRow): string | null { + return row.kind === "openbot" + ? row.channel.lastMessage + : row.thread.lastMessage; +} + +export function rosterDestination(row: SidebarRosterRow): LinkOptions { + return row.kind === "openbot" + ? linkOptions({ + to: openbotChannelRoute, + params: { channelId: row.channel.id }, + }) + : linkOptions({ + to: slackThreadRoute, + params: { threadId: row.thread.threadId }, + }); +} + +export function shouldShowEmptyRoster( + channels: readonly ChannelSummary[] | undefined, + slackThreads: readonly ExternalThreadSummary[] | undefined, + channelsLoaded: boolean, + slackThreadsLoaded: boolean, +): boolean { + return ( + channelsLoaded && + slackThreadsLoaded && + channels?.length === 0 && + slackThreads?.length === 0 + ); +} + +export function shouldShowSearchEmpty( + visibleRows: readonly SidebarRosterRow[], + query: string, + channelsStatus: RosterSourceStatus, + slackThreadsStatus: RosterSourceStatus, +): boolean { + return ( + query.trim().length > 0 && + channelsStatus === "success" && + slackThreadsStatus === "success" && + visibleRows.length === 0 + ); +} diff --git a/app/src/components/app-sidebar/slack-channel.tsx b/app/src/components/app-sidebar/slack-channel.tsx new file mode 100644 index 00000000..21e00845 --- /dev/null +++ b/app/src/components/app-sidebar/slack-channel.tsx @@ -0,0 +1,89 @@ +import { Link } from "@tanstack/react-router"; +import { memo } from "react"; +import type { ExternalThreadSummary } from "@/lib/external/queries"; +import { Button } from "../ui/button"; +import { ChannelAvatar } from "../channels/avatar"; + +export const SlackChannel = memo(function SlackChannel({ + lastMessageAt, + thread, +}: { + lastMessageAt?: string; + thread: ExternalThreadSummary; +}) { + return ( + + + + ); +}); + +export function SlackChannelContent({ + lastMessageAt, + thread, +}: { + lastMessageAt?: string; + thread: ExternalThreadSummary; +}) { + return ( + <> +
+ +
+
+
+
+ + {thread.agentName} + + + Slack + +
+
+ {lastMessageAt} +
+
+
+ + {thread.lastMessage} + +
+
+ + ); +} + +export function SlackRosterProblem({ + isRetrying = false, + onRetry, +}: { + isRetrying?: boolean; + onRetry: () => void; +}) { + return ( +
+

Slack conversations could not be loaded.

+ +
+ ); +} diff --git a/app/src/components/channels/external-thread-chat.tsx b/app/src/components/channels/external-thread-chat.tsx new file mode 100644 index 00000000..c3ead6a5 --- /dev/null +++ b/app/src/components/channels/external-thread-chat.tsx @@ -0,0 +1,54 @@ +import type { Message } from "@ag-ui/core"; +import { useEffect, useState } from "react"; +import { ConversationView } from "@/components/channels/conversation-view"; +import { + readExternalThreadMessages, + type ExternalThreadTarget, +} from "@/lib/external/queries"; + +export function ExternalThreadChat({ + target, +}: { + target: ExternalThreadTarget; +}) { + const [messages, setMessages] = useState([]); + const [restoring, setRestoring] = useState(true); + const [unreadable, setUnreadable] = useState(0); + + useEffect(() => { + let current = true; + void readExternalThreadMessages(target.threadId).then((stored) => { + if (!current) return; + setMessages(stored); + setUnreadable(0); + setRestoring(false); + }); + return () => { + current = false; + }; + }, [target.threadId]); + + return ( + +

+ This is the canonical Slack conversation with {target.agentName}. It + is read-only here for this demo; continue the conversation in Slack. +

+ {unreadable > 0 ? ( +

+ {unreadable === 1 + ? "One earlier message could not be read." + : `${unreadable} earlier messages could not be read.`} +

+ ) : null} + + } + onSubmit={() => undefined} + restoring={restoring} + /> + ); +} diff --git a/app/src/lib/auth/pending-return.ts b/app/src/lib/auth/pending-return.ts new file mode 100644 index 00000000..31246017 --- /dev/null +++ b/app/src/lib/auth/pending-return.ts @@ -0,0 +1,97 @@ +const PENDING_AUTH_RETURN_KEY = "openbot.pending-slack-return"; +const PENDING_AUTH_RETURN_MS = 10 * 60_000; + +type SessionStorageLike = Pick; + +type PendingAuthReturn = { + path: string; + expiresAt: number; +}; + +/** + * The only return URL allowed through the sign-in handoff. It deliberately carries no origin, + * arbitrary route, fragment, or duplicate query field, so this record can never become an open + * redirect or be forwarded to an identity provider. + */ +export function pendingAuthReturnPath(value: string): string | null { + if (!value.startsWith("/") || value.startsWith("//")) return null; + + const url = new URL(value, "https://openbot.invalid"); + if ( + (url.pathname !== "/link/slack" && url.pathname !== "/assist") || + url.hash !== "" || + [...url.searchParams.keys()].length !== 1 || + url.searchParams.getAll("token").length !== 1 + ) { + return null; + } + + const token = url.searchParams.get("token")?.trim(); + return token ? `${url.pathname}?token=${encodeURIComponent(token)}` : null; +} + +export function savePendingAuthReturn( + value: string, + storage: SessionStorageLike, + now = Date.now(), +): boolean { + const path = pendingAuthReturnPath(value); + if (!path) return false; + + try { + storage.setItem( + PENDING_AUTH_RETURN_KEY, + JSON.stringify({ path, expiresAt: now + PENDING_AUTH_RETURN_MS }), + ); + return true; + } catch { + return false; + } +} + +/** Always removes the record before inspecting it, so a return is strictly one-time. */ +export function consumePendingAuthReturn( + storage: SessionStorageLike, + now = Date.now(), +): string | null { + let raw: string | null; + try { + raw = storage.getItem(PENDING_AUTH_RETURN_KEY); + storage.removeItem(PENDING_AUTH_RETURN_KEY); + } catch { + return null; + } + + if (!raw) return null; + try { + const record = JSON.parse(raw) as Partial; + if ( + typeof record.path !== "string" || + typeof record.expiresAt !== "number" + ) { + return null; + } + if (record.expiresAt <= now) return null; + return pendingAuthReturnPath(record.path); + } catch { + return null; + } +} + +/** Prevents the authenticated boundary from redirecting to the route it is already loading. */ +export function signedInReturnRedirect( + currentHref: string, + pendingReturn: string | null, +): string | null { + const path = pendingReturn ? pendingAuthReturnPath(pendingReturn) : null; + let current: string | null = null; + try { + const currentUrl = new URL(currentHref, "https://openbot.invalid"); + current = pendingAuthReturnPath( + `${currentUrl.pathname}${currentUrl.search}${currentUrl.hash}`, + ); + } catch { + // An unparseable current location cannot equal the validated pending return. + } + return path && path !== current ? path : null; +} diff --git a/app/src/lib/copilot/computer-tools.tsx b/app/src/lib/copilot/computer-tools.tsx index 20d9f75d..fcbe2c33 100644 --- a/app/src/lib/copilot/computer-tools.tsx +++ b/app/src/lib/copilot/computer-tools.tsx @@ -1,11 +1,26 @@ import { useFrontendTool } from "@copilotkit/react-core/v2"; -import { z } from "zod"; import { ToolLine } from "@/components/channels/tool-line"; import { CommandOutput } from "@/components/computer/command-output"; import { ComputerView } from "@/components/computer/computer-view"; import { tryClient } from "@/lib/client"; import { noteBrowsed, recordActivity } from "@/lib/computers/activity"; import { type ControlState, readControl } from "@/lib/computers/control"; +import { + computerClickContract, + computerKeyContract, + computerListFilesContract, + computerNavigateContract, + computerReadContract, + computerReadFileContract, + computerRequestHelpContract, + computerRequestSecretContract, + computerRunCommandContract, + computerScrollContract, + computerSnapshotContract, + computerTypeContract, + computerWriteFileContract, + reportRefusalContract, +} from "../../../../shared/computer-tool-contracts"; import { useActiveBotHolder } from "./active-bot"; import { reportComputerActivity } from "./computer-activity"; @@ -241,14 +256,7 @@ export function ComputerTools() { const bot = useActiveBotHolder(); useFrontendTool({ - name: "computer_navigate", - description: - "Open a web page on your own computer so the person can watch. Use this when asked to look " + - "at, visit, open or check a website. Returns the page title and its readable text, so answer " + - "from what comes back rather than telling the person to go and look.", - parameters: z.object({ - url: z.string().describe("Full web address to open, including https://"), - }), + ...computerNavigateContract, handler: async ( { url }: { url: string }, // Context is optional in the SDK. @@ -340,23 +348,13 @@ export function ComputerTools() { }); useFrontendTool({ - name: "computer_read", - description: - "Read the page currently open on your computer, without opening anything. Use this after you " + - "click something that changes the page, such as submitting a form, to find out what it now says.", - parameters: z.object({}), + ...computerReadContract, handler: async () => callComputer(bot.current, "/read"), render: () => null, }); useFrontendTool({ - name: "computer_snapshot", - description: - "List the things on the current page you can act on: fields, buttons, links and checkboxes, " + - "each with a ref, its label and its current value. Call this BEFORE clicking or typing, and " + - "use the refs it returns. Always send back the snapshotId it gives you. If an action reports " + - "that your refs are stale, the page changed: call this again and use the new refs.", - parameters: z.object({}), + ...computerSnapshotContract, handler: async () => callComputer(bot.current, "/snapshot", { method: "POST" }), // Snapshot renders a count only; navigate owns the screen view. @@ -378,22 +376,7 @@ export function ComputerTools() { }); useFrontendTool({ - name: "computer_type", - description: - "Enter text into a field on the page. Give the ref of the field from your most recent " + - "snapshot and the snapshotId it came from. This replaces whatever the field already contains. " + - "Set submit to true to press Enter afterwards.", - parameters: z.object({ - ref: z - .string() - .describe("Ref of the field, from your most recent snapshot"), - snapshotId: z.number().describe("The snapshotId that ref came from"), - text: z.string().describe("The text to enter"), - submit: z - .boolean() - .optional() - .describe("Press Enter after typing, to submit a single-field form"), - }), + ...computerTypeContract, handler: async ( input: { ref: string; @@ -428,18 +411,7 @@ export function ComputerTools() { }); useFrontendTool({ - name: "computer_click", - description: - "Click something on the page: a button, a link, a checkbox or a radio option. Give the ref " + - "from your most recent snapshot and the snapshotId it came from.", - parameters: z.object({ - ref: z - .string() - .describe( - "Ref of the element to click, from your most recent snapshot", - ), - snapshotId: z.number().describe("The snapshotId that ref came from"), - }), + ...computerClickContract, handler: async ( input: { ref: string; snapshotId: number }, { signal }: { signal?: AbortSignal } = {}, @@ -474,18 +446,7 @@ export function ComputerTools() { }); useFrontendTool({ - name: "computer_key", - description: - "Press a key, such as Enter, Tab or Escape. Give a ref to press it while a particular field " + - "is focused, or omit the ref to press it on the page.", - parameters: z.object({ - key: z.string().describe("Key name, such as Enter, Tab or Escape"), - ref: z.string().optional().describe("Optional ref to press the key on"), - snapshotId: z - .number() - .optional() - .describe("The snapshotId the ref came from, required if ref is given"), - }), + ...computerKeyContract, handler: async ( input: { key: string; @@ -515,27 +476,7 @@ export function ComputerTools() { }); useFrontendTool({ - name: "computer_request_secret", - description: - "Ask the person for ONE value you must not be told: a password, a one-time code, a card number. " + - "Focus the field first with computer_click, then call this with the ref of that field and a " + - "short label for what you need. They type it into a masked box that goes straight to the page. " + - "You will never see the value, and you must not ask for it any other way. Prefer this over a " + - "full takeover when you only need one field filled in. The value is only TYPED into the field: " + - "if the form needs submitting, do that yourself afterwards with computer_click.", - parameters: z.object({ - label: z - .string() - .describe( - "What you need, in a few words, e.g. 'the code sent to your phone'", - ), - ref: z - .string() - .describe( - "Ref of the field it goes in, from your most recent snapshot", - ), - snapshotId: z.number().describe("The snapshotId that ref came from"), - }), + ...computerRequestSecretContract, handler: async ( input: { label: string; ref: string; snapshotId: number }, { signal }: { signal?: AbortSignal } = {}, @@ -574,22 +515,7 @@ export function ComputerTools() { /** Self-reported model declines: audit evidence, not an enforcement control. */ useFrontendTool({ - name: "report_refusal", - description: - "Record that you DECLINED something you were asked to do, because it looked unsafe, was outside " + - "what you are for, or you judged you should not. Call this whenever you say no to a request, in " + - "addition to telling the person. It changes nothing about your answer; it exists so an " + - "administrator can see what this Bot is being asked to do. Do not call it when you simply could " + - "not do something, only when you chose not to.", - parameters: z.object({ - reason: z - .string() - .describe("Why you declined, in one sentence and in your own words"), - request: z - .string() - .optional() - .describe("What you were asked to do, in a few words"), - }), + ...reportRefusalContract, handler: async ( input: { reason: string; request?: string }, { signal }: { signal?: AbortSignal } = {}, @@ -611,22 +537,7 @@ export function ComputerTools() { }); useFrontendTool({ - name: "computer_request_help", - description: - "Ask the person to take control of your computer and do something you cannot: sign in, enter a " + - "password or a one-time code, or clear a CAPTCHA. Say specifically what you need done. They " + - "will drive the browser themselves and hand it back, and you carry on in the same session. " + - "Use this INSTEAD of giving up, and instead of ever asking them to type a password to you. " + - "This call is the only thing that reaches them: until you make it they are not looking at the " + - "page and have no way to help, so saying you need them to sign in, or asking whether they would " + - "like to proceed, hands over nothing and leaves the page where it is.", - parameters: z.object({ - reason: z - .string() - .describe( - "What you need the person to do, in one sentence, e.g. 'This page is asking for a code sent to your phone.'", - ), - }), + ...computerRequestHelpContract, handler: async ( input: { reason: string }, { signal }: { signal?: AbortSignal } = {}, @@ -664,17 +575,7 @@ export function ComputerTools() { }); useFrontendTool({ - name: "computer_list_files", - description: - "List what is in your workspace: every file and folder you have saved, with sizes. Call this " + - "FIRST when you are asked what files you have, or before reading a file whose exact name you " + - "are not sure of. Never guess a filename.", - parameters: z.object({ - path: z - .string() - .optional() - .describe("Optional folder to list. Omit for the whole workspace."), - }), + ...computerListFilesContract, handler: async (input: { path?: string }) => { const computerId = bot.current; const result = await callComputer(computerId, "/files/list", { @@ -711,16 +612,7 @@ export function ComputerTools() { }); useFrontendTool({ - name: "computer_read_file", - description: - "Read a file you saved earlier in your own workspace. Paths are relative to your workspace, " + - "such as notes.md or reports/august.csv. Your workspace survives between conversations, so use " + - "this to pick up notes you made before.", - parameters: z.object({ - path: z - .string() - .describe("Path relative to your workspace, such as notes.md"), - }), + ...computerReadFileContract, handler: async (input: { path: string }) => { const computerId = bot.current; const result = await callComputer(computerId, "/files/read", { @@ -756,22 +648,7 @@ export function ComputerTools() { }); useFrontendTool({ - name: "computer_run_command", - description: - "Run a shell command on your own computer. Use this for anything the browser cannot do: " + - "installing a tool you need, processing a file you saved, running a script. The working " + - "directory is your workspace, so paths are relative to it and files you write here are the " + - "same ones the file tools see. Commands run in bash, so pipes and && work. Long output is " + - "truncated from the start, and a command that runs too long is stopped. " + - "You are not the root user, so anything that writes outside your workspace needs sudo, " + - "which asks for no password: installing a package is " + - "`sudo apt-get update && sudo apt-get install -y `. If sudo is refused, this " + - "computer does not grant it, so say so rather than retrying.", - parameters: z.object({ - command: z - .string() - .describe("The command to run, such as: sudo apt-get install -y jq"), - }), + ...computerRunCommandContract, handler: async ( input: { command: string }, { signal }: { signal?: AbortSignal } = {}, @@ -841,23 +718,7 @@ export function ComputerTools() { }); useFrontendTool({ - name: "computer_write_file", - description: - "Save a file in your own workspace so you still have it later. Paths are relative to your " + - "workspace and folders are created as needed. Set append to true to add to the end of an " + - "existing file rather than replacing it. Text only.", - parameters: z.object({ - path: z - .string() - .describe( - "Path relative to your workspace, such as reports/august.csv", - ), - contents: z.string().describe("The text to save"), - append: z - .boolean() - .optional() - .describe("Add to the end of the file instead of replacing it"), - }), + ...computerWriteFileContract, handler: async (input: { path: string; contents: string; @@ -908,15 +769,7 @@ export function ComputerTools() { }); useFrontendTool({ - name: "computer_scroll", - description: - "Scroll the page down, or up with a negative amount, to bring more of a long page into view.", - parameters: z.object({ - deltaY: z - .number() - .optional() - .describe("Pixels to scroll; positive is down. Defaults to 600."), - }), + ...computerScrollContract, handler: async ( input: { deltaY?: number }, { signal }: { signal?: AbortSignal } = {}, diff --git a/app/src/lib/external/queries.ts b/app/src/lib/external/queries.ts new file mode 100644 index 00000000..301891ac --- /dev/null +++ b/app/src/lib/external/queries.ts @@ -0,0 +1,147 @@ +import { infiniteQueryOptions, queryOptions } from "@tanstack/react-query"; +import { client } from "@/lib/client"; +import type { Message } from "@ag-ui/core"; + +export type ExternalThreadTarget = { + threadId: string; + agentId: string; + agentName: string; + provider: "slack"; + readOnly: true; +}; + +export type ExternalThreadSummary = ExternalThreadTarget & { + lastMessage: string | null; + lastMessageAt: string | null; + createdAt: string; +}; + +export type ExternalThreadPage = { + threads: ExternalThreadSummary[]; + nextCursor: string | null; +}; + +export const externalThreadKeys = { + all: ["external-threads"] as const, + list: () => ["external-threads", "list"] as const, + detail: (threadId: string) => + ["external-threads", "detail", threadId] as const, +}; + +const EXTERNAL_THREAD_ERROR = "Could not load this Slack conversation."; +const EXTERNAL_THREAD_LIST_ERROR = "Could not load Slack conversations."; + +function isRecord(value: unknown): value is Record { + return Boolean(value) && typeof value === "object" && !Array.isArray(value); +} + +function isNonEmptyString(value: unknown): value is string { + return typeof value === "string" && value.length > 0; +} + +function isTimestamp(value: unknown): value is string { + if (typeof value !== "string") return false; + const date = new Date(value); + return Number.isFinite(date.getTime()) && date.toISOString() === value; +} + +export function externalThreadTarget(value: unknown): ExternalThreadTarget { + if (!isRecord(value)) { + throw new Error(EXTERNAL_THREAD_ERROR); + } + const target = value as Partial; + if ( + !isNonEmptyString(target.threadId) || + !isNonEmptyString(target.agentId) || + !isNonEmptyString(target.agentName) || + target.provider !== "slack" || + target.readOnly !== true + ) { + throw new Error(EXTERNAL_THREAD_ERROR); + } + return target as ExternalThreadTarget; +} + +function externalThreadSummary(value: unknown): ExternalThreadSummary { + let target: ExternalThreadTarget; + try { + target = externalThreadTarget(value); + } catch { + throw new Error(EXTERNAL_THREAD_LIST_ERROR); + } + const summary = value as Partial; + if ( + (summary.lastMessage !== null && typeof summary.lastMessage !== "string") || + (summary.lastMessageAt !== null && !isTimestamp(summary.lastMessageAt)) || + !isTimestamp(summary.createdAt) + ) { + throw new Error(EXTERNAL_THREAD_LIST_ERROR); + } + return { + ...target, + lastMessage: summary.lastMessage, + lastMessageAt: summary.lastMessageAt, + createdAt: summary.createdAt, + }; +} + +export function externalThreadPage(value: unknown): ExternalThreadPage { + if (!isRecord(value) || !Array.isArray(value.threads)) { + throw new Error(EXTERNAL_THREAD_LIST_ERROR); + } + const nextCursor = value.nextCursor; + if ( + nextCursor !== null && + (typeof nextCursor !== "string" || nextCursor.length === 0) + ) { + throw new Error(EXTERNAL_THREAD_LIST_ERROR); + } + return { + threads: value.threads.map(externalThreadSummary), + nextCursor, + }; +} + +export function externalThreadListQueryOptions() { + return infiniteQueryOptions({ + queryKey: externalThreadKeys.list(), + initialPageParam: "", + queryFn: async ({ pageParam }): Promise => { + const suffix = pageParam + ? `?cursor=${encodeURIComponent(pageParam as string)}` + : ""; + const response = await client(`/api/external-links/threads${suffix}`, { + fallback: EXTERNAL_THREAD_LIST_ERROR, + }); + return externalThreadPage(await response.json()); + }, + getNextPageParam: (page: ExternalThreadPage) => + page.nextCursor ?? undefined, + select: (data): ExternalThreadSummary[] => + data.pages.flatMap((page) => page.threads), + }); +} + +export async function readExternalThreadMessages( + threadId: string, +): Promise { + const response = await client( + `/api/external-links/threads/${encodeURIComponent(threadId)}/messages`, + { fallback: "Could not load this Slack conversation" }, + ); + const value = (await response.json()) as { messages?: unknown }; + return Array.isArray(value.messages) ? (value.messages as Message[]) : []; +} + +export function externalThreadQueryOptions(threadId: string) { + return queryOptions({ + queryKey: externalThreadKeys.detail(threadId), + queryFn: async () => { + const response = await client( + `/api/external-links/threads/${encodeURIComponent(threadId)}`, + { fallback: EXTERNAL_THREAD_ERROR }, + ); + return externalThreadTarget(await response.json()); + }, + }); +} diff --git a/app/src/routeTree.gen.ts b/app/src/routeTree.gen.ts index 2ca64fab..76fb359c 100644 --- a/app/src/routeTree.gen.ts +++ b/app/src/routeTree.gen.ts @@ -13,6 +13,7 @@ import { Route as AuthedRouteImport } from './routes/_authed' import { Route as SignRouteImport } from './routes/sign' import { Route as AuthedAppRouteImport } from './routes/_authed/_app' import { Route as AuthedAdminRouteRouteImport } from './routes/_authed/admin/route' +import { Route as AuthedAssistRouteImport } from './routes/_authed/assist' import { Route as AuthedSettingsRouteRouteImport } from './routes/_authed/settings/route' import { Route as AuthedAppIndexRouteImport } from './routes/_authed/_app/index' import { Route as AuthedAppBotRouteImport } from './routes/_authed/_app/bot' @@ -27,6 +28,7 @@ import { Route as AuthedAdminIdentityProvidersRouteImport } from './routes/_auth import { Route as AuthedAdminPeopleRouteImport } from './routes/_authed/admin/people' import { Route as AuthedAdminPlaygroundRouteImport } from './routes/_authed/admin/playground' import { Route as AuthedAdminSkillsRouteImport } from './routes/_authed/admin/skills' +import { Route as AuthedLinkSlackRouteImport } from './routes/_authed/link/slack' import { Route as AuthedSettingsIndexRouteImport } from './routes/_authed/settings/index' import { Route as AuthedAppAgentsIndexRouteImport } from './routes/_authed/_app/agents/index' import { Route as AuthedAppChannelChannelIdRouteImport } from './routes/_authed/_app/channel/$channelId' @@ -39,6 +41,7 @@ import { Route as AuthedSettingsComponentsGalleryIndexRouteImport } from './rout import { Route as AuthedSettingsComponentsGalleryNameRouteImport } from './routes/_authed/settings/components-gallery/$name' import { Route as AuthedSettingsConnectedAccountsIndexRouteImport } from './routes/_authed/settings/connected-accounts/index' import { Route as AuthedSettingsConnectedAccountsKeyRouteImport } from './routes/_authed/settings/connected-accounts/$key' +import { Route as AuthedAppSlackThreadThreadIdRouteImport } from './routes/_authed/_app/slack/thread/$threadId' import { Route as AuthedAdminPluginsKeyToolsToolRouteImport } from './routes/_authed/admin/plugins/$key_.tools.$tool' const AuthedRoute = AuthedRouteImport.update({ @@ -59,6 +62,11 @@ const AuthedAdminRouteRoute = AuthedAdminRouteRouteImport.update({ path: '/admin', getParentRoute: () => AuthedRoute, } as any) +const AuthedAssistRoute = AuthedAssistRouteImport.update({ + id: '/assist', + path: '/assist', + getParentRoute: () => AuthedRoute, +} as any) const AuthedSettingsRouteRoute = AuthedSettingsRouteRouteImport.update({ id: '/settings', path: '/settings', @@ -130,6 +138,11 @@ const AuthedAdminSkillsRoute = AuthedAdminSkillsRouteImport.update({ path: '/skills', getParentRoute: () => AuthedAdminRouteRoute, } as any) +const AuthedLinkSlackRoute = AuthedLinkSlackRouteImport.update({ + id: '/link/slack', + path: '/link/slack', + getParentRoute: () => AuthedRoute, +} as any) const AuthedSettingsIndexRoute = AuthedSettingsIndexRouteImport.update({ id: '/', path: '/', @@ -197,6 +210,12 @@ const AuthedSettingsConnectedAccountsKeyRoute = path: '/connected-accounts/$key', getParentRoute: () => AuthedSettingsRouteRoute, } as any) +const AuthedAppSlackThreadThreadIdRoute = + AuthedAppSlackThreadThreadIdRouteImport.update({ + id: '/slack/thread/$threadId', + path: '/slack/thread/$threadId', + getParentRoute: () => AuthedAppRoute, + } as any) const AuthedAdminPluginsKeyToolsToolRoute = AuthedAdminPluginsKeyToolsToolRouteImport.update({ id: '/plugins/$key_/tools/$tool', @@ -209,6 +228,7 @@ export interface FileRoutesByFullPath { '/sign': typeof SignRoute '/admin': typeof AuthedAdminRouteRouteWithChildren '/settings': typeof AuthedSettingsRouteRouteWithChildren + '/assist': typeof AuthedAssistRoute '/bot': typeof AuthedAppBotRoute '/routines': typeof AuthedAppRoutinesRoute '/skills': typeof AuthedAppSkillsRoute @@ -220,6 +240,7 @@ export interface FileRoutesByFullPath { '/admin/people': typeof AuthedAdminPeopleRoute '/admin/playground': typeof AuthedAdminPlaygroundRoute '/admin/skills': typeof AuthedAdminSkillsRoute + '/link/slack': typeof AuthedLinkSlackRoute '/admin/': typeof AuthedAdminIndexRoute '/settings/': typeof AuthedSettingsIndexRoute '/channel/$channelId': typeof AuthedAppChannelChannelIdRoute @@ -233,11 +254,13 @@ export interface FileRoutesByFullPath { '/admin/plugins/': typeof AuthedAdminPluginsIndexRoute '/settings/components-gallery/': typeof AuthedSettingsComponentsGalleryIndexRoute '/settings/connected-accounts/': typeof AuthedSettingsConnectedAccountsIndexRoute + '/slack/thread/$threadId': typeof AuthedAppSlackThreadThreadIdRoute '/admin/plugins/$key/tools/$tool': typeof AuthedAdminPluginsKeyToolsToolRoute } export interface FileRoutesByTo { '/': typeof AuthedAppIndexRoute '/sign': typeof SignRoute + '/assist': typeof AuthedAssistRoute '/bot': typeof AuthedAppBotRoute '/routines': typeof AuthedAppRoutinesRoute '/skills': typeof AuthedAppSkillsRoute @@ -249,6 +272,7 @@ export interface FileRoutesByTo { '/admin/people': typeof AuthedAdminPeopleRoute '/admin/playground': typeof AuthedAdminPlaygroundRoute '/admin/skills': typeof AuthedAdminSkillsRoute + '/link/slack': typeof AuthedLinkSlackRoute '/admin': typeof AuthedAdminIndexRoute '/settings': typeof AuthedSettingsIndexRoute '/channel/$channelId': typeof AuthedAppChannelChannelIdRoute @@ -262,6 +286,7 @@ export interface FileRoutesByTo { '/admin/plugins': typeof AuthedAdminPluginsIndexRoute '/settings/components-gallery': typeof AuthedSettingsComponentsGalleryIndexRoute '/settings/connected-accounts': typeof AuthedSettingsConnectedAccountsIndexRoute + '/slack/thread/$threadId': typeof AuthedAppSlackThreadThreadIdRoute '/admin/plugins/$key/tools/$tool': typeof AuthedAdminPluginsKeyToolsToolRoute } export interface FileRoutesById { @@ -271,6 +296,7 @@ export interface FileRoutesById { '/_authed/admin': typeof AuthedAdminRouteRouteWithChildren '/_authed/settings': typeof AuthedSettingsRouteRouteWithChildren '/_authed/_app': typeof AuthedAppRouteWithChildren + '/_authed/assist': typeof AuthedAssistRoute '/_authed/_app/bot': typeof AuthedAppBotRoute '/_authed/_app/routines': typeof AuthedAppRoutinesRoute '/_authed/_app/skills': typeof AuthedAppSkillsRoute @@ -282,6 +308,7 @@ export interface FileRoutesById { '/_authed/admin/people': typeof AuthedAdminPeopleRoute '/_authed/admin/playground': typeof AuthedAdminPlaygroundRoute '/_authed/admin/skills': typeof AuthedAdminSkillsRoute + '/_authed/link/slack': typeof AuthedLinkSlackRoute '/_authed/_app/': typeof AuthedAppIndexRoute '/_authed/admin/': typeof AuthedAdminIndexRoute '/_authed/settings/': typeof AuthedSettingsIndexRoute @@ -296,6 +323,7 @@ export interface FileRoutesById { '/_authed/admin/plugins/': typeof AuthedAdminPluginsIndexRoute '/_authed/settings/components-gallery/': typeof AuthedSettingsComponentsGalleryIndexRoute '/_authed/settings/connected-accounts/': typeof AuthedSettingsConnectedAccountsIndexRoute + '/_authed/_app/slack/thread/$threadId': typeof AuthedAppSlackThreadThreadIdRoute '/_authed/admin/plugins/$key_/tools/$tool': typeof AuthedAdminPluginsKeyToolsToolRoute } export interface FileRouteTypes { @@ -305,6 +333,7 @@ export interface FileRouteTypes { | '/sign' | '/admin' | '/settings' + | '/assist' | '/bot' | '/routines' | '/skills' @@ -316,6 +345,7 @@ export interface FileRouteTypes { | '/admin/people' | '/admin/playground' | '/admin/skills' + | '/link/slack' | '/admin/' | '/settings/' | '/channel/$channelId' @@ -329,11 +359,13 @@ export interface FileRouteTypes { | '/admin/plugins/' | '/settings/components-gallery/' | '/settings/connected-accounts/' + | '/slack/thread/$threadId' | '/admin/plugins/$key/tools/$tool' fileRoutesByTo: FileRoutesByTo to: | '/' | '/sign' + | '/assist' | '/bot' | '/routines' | '/skills' @@ -345,6 +377,7 @@ export interface FileRouteTypes { | '/admin/people' | '/admin/playground' | '/admin/skills' + | '/link/slack' | '/admin' | '/settings' | '/channel/$channelId' @@ -358,6 +391,7 @@ export interface FileRouteTypes { | '/admin/plugins' | '/settings/components-gallery' | '/settings/connected-accounts' + | '/slack/thread/$threadId' | '/admin/plugins/$key/tools/$tool' id: | '__root__' @@ -366,6 +400,7 @@ export interface FileRouteTypes { | '/_authed/admin' | '/_authed/settings' | '/_authed/_app' + | '/_authed/assist' | '/_authed/_app/bot' | '/_authed/_app/routines' | '/_authed/_app/skills' @@ -377,6 +412,7 @@ export interface FileRouteTypes { | '/_authed/admin/people' | '/_authed/admin/playground' | '/_authed/admin/skills' + | '/_authed/link/slack' | '/_authed/_app/' | '/_authed/admin/' | '/_authed/settings/' @@ -391,6 +427,7 @@ export interface FileRouteTypes { | '/_authed/admin/plugins/' | '/_authed/settings/components-gallery/' | '/_authed/settings/connected-accounts/' + | '/_authed/_app/slack/thread/$threadId' | '/_authed/admin/plugins/$key_/tools/$tool' fileRoutesById: FileRoutesById } @@ -429,6 +466,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof AuthedAdminRouteRouteImport parentRoute: typeof AuthedRoute } + '/_authed/assist': { + id: '/_authed/assist' + path: '/assist' + fullPath: '/assist' + preLoaderRoute: typeof AuthedAssistRouteImport + parentRoute: typeof AuthedRoute + } '/_authed/settings': { id: '/_authed/settings' path: '/settings' @@ -527,6 +571,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof AuthedAdminSkillsRouteImport parentRoute: typeof AuthedAdminRouteRoute } + '/_authed/link/slack': { + id: '/_authed/link/slack' + path: '/link/slack' + fullPath: '/link/slack' + preLoaderRoute: typeof AuthedLinkSlackRouteImport + parentRoute: typeof AuthedRoute + } '/_authed/settings/': { id: '/_authed/settings/' path: '/' @@ -611,6 +662,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof AuthedSettingsConnectedAccountsKeyRouteImport parentRoute: typeof AuthedSettingsRouteRoute } + '/_authed/_app/slack/thread/$threadId': { + id: '/_authed/_app/slack/thread/$threadId' + path: '/slack/thread/$threadId' + fullPath: '/slack/thread/$threadId' + preLoaderRoute: typeof AuthedAppSlackThreadThreadIdRouteImport + parentRoute: typeof AuthedAppRoute + } '/_authed/admin/plugins/$key_/tools/$tool': { id: '/_authed/admin/plugins/$key_/tools/$tool' path: '/plugins/$key/tools/$tool' @@ -689,6 +747,7 @@ interface AuthedAppRouteChildren { AuthedAppChannelChannelIdRoute: typeof AuthedAppChannelChannelIdRoute AuthedAppChannelNewRoute: typeof AuthedAppChannelNewRoute AuthedAppAgentsIndexRoute: typeof AuthedAppAgentsIndexRoute + AuthedAppSlackThreadThreadIdRoute: typeof AuthedAppSlackThreadThreadIdRoute } const AuthedAppRouteChildren: AuthedAppRouteChildren = { @@ -699,6 +758,7 @@ const AuthedAppRouteChildren: AuthedAppRouteChildren = { AuthedAppChannelChannelIdRoute: AuthedAppChannelChannelIdRoute, AuthedAppChannelNewRoute: AuthedAppChannelNewRoute, AuthedAppAgentsIndexRoute: AuthedAppAgentsIndexRoute, + AuthedAppSlackThreadThreadIdRoute: AuthedAppSlackThreadThreadIdRoute, } const AuthedAppRouteWithChildren = AuthedAppRoute._addFileChildren( @@ -709,12 +769,16 @@ interface AuthedRouteChildren { AuthedAdminRouteRoute: typeof AuthedAdminRouteRouteWithChildren AuthedSettingsRouteRoute: typeof AuthedSettingsRouteRouteWithChildren AuthedAppRoute: typeof AuthedAppRouteWithChildren + AuthedAssistRoute: typeof AuthedAssistRoute + AuthedLinkSlackRoute: typeof AuthedLinkSlackRoute } const AuthedRouteChildren: AuthedRouteChildren = { AuthedAdminRouteRoute: AuthedAdminRouteRouteWithChildren, AuthedSettingsRouteRoute: AuthedSettingsRouteRouteWithChildren, AuthedAppRoute: AuthedAppRouteWithChildren, + AuthedAssistRoute: AuthedAssistRoute, + AuthedLinkSlackRoute: AuthedLinkSlackRoute, } const AuthedRouteWithChildren = diff --git a/app/src/routes/_authed.tsx b/app/src/routes/_authed.tsx index f3742d54..bd24b745 100644 --- a/app/src/routes/_authed.tsx +++ b/app/src/routes/_authed.tsx @@ -1,15 +1,28 @@ import { createFileRoute, Outlet, redirect } from "@tanstack/react-router"; +import { + consumePendingAuthReturn, + savePendingAuthReturn, + signedInReturnRedirect, +} from "../lib/auth/pending-return"; import { currentUserQueryOptions } from "../lib/auth/queries"; import { CopilotProvider } from "../lib/copilot/provider"; export const Route = createFileRoute("/_authed")({ - beforeLoad: async ({ context }) => { + beforeLoad: async ({ context, location }) => { const user = await context.queryClient.ensureQueryData( currentUserQueryOptions(), ); if (!user) { + if (typeof window !== "undefined") { + savePendingAuthReturn(location.href, window.sessionStorage); + } throw redirect({ to: "/sign" }); } + if (typeof window !== "undefined") { + const pendingReturn = consumePendingAuthReturn(window.sessionStorage); + const returnTo = signedInReturnRedirect(location.href, pendingReturn); + if (returnTo) throw redirect({ href: returnTo }); + } }, // Mounted INSIDE the authed boundary, not at the root: the runtime endpoint requires a session, so // a provider above the sign-in gate would open a run for a visitor who has not signed in yet. diff --git a/app/src/routes/_authed/_app/slack/thread/$threadId.tsx b/app/src/routes/_authed/_app/slack/thread/$threadId.tsx new file mode 100644 index 00000000..4542b392 --- /dev/null +++ b/app/src/routes/_authed/_app/slack/thread/$threadId.tsx @@ -0,0 +1,33 @@ +import { useQuery } from "@tanstack/react-query"; +import { createFileRoute } from "@tanstack/react-router"; +import { ExternalThreadChat } from "@/components/channels/external-thread-chat"; +import { externalThreadQueryOptions } from "@/lib/external/queries"; + +export const Route = createFileRoute("/_authed/_app/slack/thread/$threadId")({ + component: SlackThreadPage, +}); + +function SlackThreadPage() { + const { threadId } = Route.useParams(); + const target = useQuery(externalThreadQueryOptions(threadId)); + + if (target.isPending) return null; + if (target.error || !target.data) { + return ( +

+ Could not load this Slack conversation. +

+ ); + } + + return ( +
+
+ + Slack · {target.data.agentName} + +
+ +
+ ); +} diff --git a/app/src/routes/_authed/assist.tsx b/app/src/routes/_authed/assist.tsx new file mode 100644 index 00000000..c2cdf409 --- /dev/null +++ b/app/src/routes/_authed/assist.tsx @@ -0,0 +1,176 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { useEffect, useState } from "react"; +import { PageSection, PageShell } from "@/components/layout/page-shell"; +import { Button } from "@/components/ui/button"; +import { tryClient } from "@/lib/client"; + +type AssistanceOutcome = + | { kind: "loading" } + | { kind: "ready"; href: string } + | { kind: "invalid" } + | { kind: "wrong-user" } + | { kind: "error" }; + +const ASSISTANCE_SESSION_KEY = "openbot.slack-assistance-token"; +type AssistanceStorage = Pick; + +export const Route = createFileRoute("/_authed/assist")({ + validateSearch: (search: Record): { token?: string } => { + const token = assistanceToken(search); + return token ? { token } : {}; + }, + component: AssistancePage, +}); + +/** The sealed claim remains opaque and is never rendered or cached. */ +export function assistanceToken( + search: Record, +): string | null { + if (typeof search.token !== "string") return null; + return search.token.trim() || null; +} + +export function assistanceResponseOutcome( + status: number, + body: unknown, +): Exclude { + if (status === 403) return { kind: "wrong-user" }; + if (status === 410) return { kind: "invalid" }; + if ( + status !== 200 || + !body || + typeof body !== "object" || + Array.isArray(body) + ) { + return { kind: "error" }; + } + const agentId = (body as { agentId?: unknown }).agentId; + if (typeof agentId !== "string" || agentId.trim() === "") { + return { kind: "error" }; + } + return { kind: "ready", href: `/bot?agent=${encodeURIComponent(agentId)}` }; +} + +/** Strip a validated sealed claim from visible history without accepting another route shape. */ +export function assistanceHistoryPath( + currentHref: string, + expectedToken: string, +): string | null { + try { + const url = new URL(currentHref, "https://openbot.invalid"); + if ( + url.pathname !== "/assist" || + url.hash || + [...url.searchParams.keys()].length !== 1 || + url.searchParams.getAll("token").length !== 1 || + url.searchParams.get("token") !== expectedToken + ) { + return null; + } + return "/assist"; + } catch { + return null; + } +} + +/** Capture the claim in tab-scoped memory and remove it from visible history immediately. */ +export function captureAssistanceToken( + token: string | undefined, + currentHref: string, + history: { replace(path: string): void }, + storage: AssistanceStorage, +): string | null { + const captured = token?.trim() || null; + if (captured) { + storage.setItem(ASSISTANCE_SESSION_KEY, captured); + const cleanPath = assistanceHistoryPath(currentHref, captured); + if (cleanPath) history.replace(cleanPath); + return captured; + } + return storage.getItem(ASSISTANCE_SESSION_KEY)?.trim() || null; +} + +async function loadAssistance( + token: string, + signal: AbortSignal, +): Promise> { + try { + const response = await tryClient( + `/api/external-links/assistance?token=${encodeURIComponent(token)}`, + { signal }, + ); + const body = await response.json().catch(() => null); + return assistanceResponseOutcome(response.status, body); + } catch (error) { + if (error instanceof DOMException && error.name === "AbortError") + throw error; + return { kind: "error" }; + } +} + +function AssistancePage() { + const { token } = Route.useSearch(); + const [captured] = useState(() => + captureAssistanceToken( + token, + window.location.href, + { + replace: (path) => + window.history.replaceState(window.history.state, "", path), + }, + window.sessionStorage, + ), + ); + if (!captured) return ; + return ; +} + +function AssistanceLoader({ token }: { token: string }) { + const [outcome, setOutcome] = useState({ + kind: "loading", + }); + + useEffect(() => { + const controller = new AbortController(); + setOutcome({ kind: "loading" }); + loadAssistance(token, controller.signal).then( + (loaded) => { + if (!controller.signal.aborted) { + setOutcome(loaded); + if (loaded.kind !== "error") { + window.sessionStorage.removeItem(ASSISTANCE_SESSION_KEY); + } + } + }, + () => undefined, + ); + return () => controller.abort(); + }, [token]); + + return ; +} + +function AssistanceStatus({ outcome }: { outcome: AssistanceOutcome }) { + const description = + outcome.kind === "loading" + ? "Checking this secure assistance link…" + : outcome.kind === "wrong-user" + ? "This assistance request belongs to a different OpenBot account." + : outcome.kind === "invalid" + ? "This assistance link has expired or is invalid. Return to Slack and ask the coworker to try again." + : outcome.kind === "error" + ? "This assistance request could not be checked right now. Try again." + : "Continue in OpenBot to control the coworker’s computer securely."; + + return ( + + + {outcome.kind === "ready" ? ( + + ) : null} + + + ); +} diff --git a/app/src/routes/_authed/link/slack.tsx b/app/src/routes/_authed/link/slack.tsx new file mode 100644 index 00000000..eb8806b0 --- /dev/null +++ b/app/src/routes/_authed/link/slack.tsx @@ -0,0 +1,326 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { PageSection, PageShell } from "@/components/layout/page-shell"; +import { Button } from "@/components/ui/button"; +import { savePendingAuthReturn } from "@/lib/auth/pending-return"; +import { tryClient } from "@/lib/client"; + +type SlackClaim = { workspace: string; user: string; email?: string }; +type SlackLinkFailure = { kind: "error"; message: string }; +type SlackLinkCompletion = ReturnType; +type SlackLinkReauth = { kind: "reauth" }; +type SlackLinkResponse = + | SlackLinkCompletion + | SlackLinkFailure + | SlackLinkReauth; +type ClaimState = + | { kind: "loading" } + | { kind: "ready"; claim: SlackClaim } + | { kind: "terminal"; outcome: SlackLinkCompletion } + | { kind: "error" }; + +class SlackLinkTerminalError extends Error { + constructor(readonly outcome: SlackLinkCompletion) { + super(outcome.message); + } +} +class SlackLinkReauthError extends Error {} + +export const Route = createFileRoute("/_authed/link/slack")({ + validateSearch: (search: Record): { token?: string } => { + const token = slackLinkToken(search); + return token ? { token } : {}; + }, + component: SlackLinkPage, +}); + +/** A claim token is opaque: trim it for requests, but never render or cache it. */ +export function slackLinkToken(search: Record): string | null { + if (typeof search.token !== "string") return null; + const token = search.token.trim(); + return token === "" ? null : token; +} + +export function slackLinkResult(status: number) { + if (status === 200) + return { + kind: "linked", + message: "Slack is linked to your OpenBot account.", + } as const; + if (status === 409) + return { + kind: "conflict", + message: + "That Slack identity is already linked to another OpenBot account.", + } as const; + return { + kind: "invalid", + message: + "This Slack link has expired or is invalid. Return to Slack and try again.", + } as const; +} + +export function slackLinkFailure(): SlackLinkFailure { + return { + kind: "error", + message: "Slack could not be linked right now. Try again.", + }; +} + +/** Only documented token refusals are terminal-invalid; unknown responses stay retryable. */ +export function slackLinkResponseOutcome(status?: number): SlackLinkResponse { + if (status === 401) return { kind: "reauth" }; + if (status === 200 || status === 400 || status === 409) + return slackLinkResult(status); + return slackLinkFailure(); +} + +/** Selects the only identity metadata this page may display. */ +export function slackLinkClaim(value: unknown): SlackClaim | null { + if (!value || typeof value !== "object" || Array.isArray(value)) return null; + const claim = value as { + providerTenantId?: unknown; + providerUserId?: unknown; + providerEmail?: unknown; + }; + const workspace = displayString(claim.providerTenantId); + const user = displayString(claim.providerUserId); + if (!workspace || !user) return null; + if (claim.providerEmail === null) return { workspace, user }; + const email = displayString(claim.providerEmail); + return email ? { workspace, user, email } : null; +} + +function displayString(value: unknown): string | null { + if (typeof value !== "string") return null; + const display = value.trim(); + return display || null; +} + +async function loadSlackClaim( + token: string, + signal: AbortSignal, + _attempt?: number, +): Promise { + const response = await tryClient( + `/api/external-links/slack?token=${encodeURIComponent(token)}`, + { signal }, + ); + if (!response.ok) { + const outcome = slackLinkResponseOutcome(response.status); + if (outcome.kind === "reauth") throw new SlackLinkReauthError(); + if (outcome.kind !== "error") throw new SlackLinkTerminalError(outcome); + throw new Error("Could not load Slack link."); + } + const claim = slackLinkClaim(await response.json().catch(() => null)); + if (!claim) throw new Error("Could not read Slack link."); + return claim; +} + +async function completeSlackLink( + token: string, + signal: AbortSignal, +): Promise { + const response = await tryClient("/api/external-links/slack", { + method: "POST", + body: { token }, + signal, + }); + return slackLinkResponseOutcome(response.status); +} + +function SlackLinkPage() { + const { token } = Route.useSearch(); + const reauthenticate = useCallback(() => { + if (!token) return; + savePendingAuthReturn( + `/link/slack?token=${encodeURIComponent(token)}`, + window.sessionStorage, + ); + window.location.assign("/sign"); + }, [token]); + + if (!token) return ; + + // A new URL gets a new component lifetime, so no token-A state can render under token B. + return ( + + ); +} + +export function SlackLinkConfirmation({ + complete = completeSlackLink, + load = loadSlackClaim, + onReauthenticate, + token, +}: { + complete?: (token: string, signal: AbortSignal) => Promise; + load?: ( + token: string, + signal: AbortSignal, + attempt: number, + ) => Promise; + onReauthenticate: () => void; + token: string; +}) { + const [claim, setClaim] = useState({ kind: "loading" }); + const [submission, setSubmission] = useState< + SlackLinkCompletion | SlackLinkFailure | null + >(null); + const [isSubmitting, setIsSubmitting] = useState(false); + const [retry, setRetry] = useState(0); + const submitting = useRef(false); + const postController = useRef(null); + const generation = useRef(0); + + useEffect(() => { + let active = true; + const controller = new AbortController(); + setClaim({ kind: "loading" }); + setSubmission(null); + + load(token, controller.signal, retry) + .then((loaded) => { + if (active && !controller.signal.aborted) + setClaim({ kind: "ready", claim: loaded }); + }) + .catch((error: unknown) => { + if (!active || controller.signal.aborted) return; + if (error instanceof SlackLinkReauthError) return onReauthenticate(); + if (error instanceof SlackLinkTerminalError) { + setClaim({ kind: "terminal", outcome: error.outcome }); + return; + } + setClaim({ kind: "error" }); + }); + + return () => { + active = false; + controller.abort(); + postController.current?.abort(); + }; + }, [retry, token, onReauthenticate, load]); + + const submit = () => { + if (submitting.current) return; + submitting.current = true; + setIsSubmitting(true); + const attempt = { generation: ++generation.current, token }; + const controller = new AbortController(); + postController.current = controller; + setSubmission(null); + + complete(attempt.token, controller.signal) + .then((outcome) => { + if (controller.signal.aborted) return; + if (outcome.kind === "reauth") return onReauthenticate(); + setSubmission(outcome); + }) + .catch(() => { + if (!controller.signal.aborted) setSubmission(slackLinkFailure()); + }) + .finally(() => { + if ( + !controller.signal.aborted && + attempt.generation === generation.current + ) { + submitting.current = false; + setIsSubmitting(false); + } + }); + }; + + if (claim.kind === "loading") return ; + if (claim.kind === "terminal") + return ; + if (claim.kind === "error") { + return ( + + +

+ Slack could not be checked right now. Try again. +

+ +
+
+ ); + } + if (submission && submission.kind !== "error") + return ; + + return ( + + +
+ + + {claim.claim.email !== undefined ? ( + + ) : null} +
+
+ + {submission ? ( +

+ {submission.message} +

+ ) : null} + +
+
+ ); +} + +function LoadingPage() { + return ( + +

+ Loading Slack link… +

+
+ ); +} + +function ClaimField({ label, value }: { label: string; value: string }) { + return ( +
+
{label}
+
{value}
+
+ ); +} + +function TerminalOutcome({ outcome }: { outcome: SlackLinkCompletion }) { + return ( + + +

+ {outcome.message} +

+
+
+ ); +} diff --git a/app/src/routes/sign.tsx b/app/src/routes/sign.tsx index 5f656e8c..ee7f45ed 100644 --- a/app/src/routes/sign.tsx +++ b/app/src/routes/sign.tsx @@ -13,6 +13,10 @@ import { signInWithEmailDomain, } from "@/lib/auth/client"; import { appConfig } from "@/lib/generated/application-config"; +import { + consumePendingAuthReturn, + signedInReturnRedirect, +} from "../lib/auth/pending-return"; import { type AuthProviderId, authProvidersQueryOptions, @@ -26,11 +30,16 @@ const ENTRANCE_STAGGER_SECONDS = 0.08; const ENTRANCE_OFFSET = "translateY(12px)"; export const Route = createFileRoute("/sign")({ - beforeLoad: async ({ context }) => { + beforeLoad: async ({ context, location }) => { const user = await context.queryClient.ensureQueryData( currentUserQueryOptions(), ); if (user) { + if (typeof window !== "undefined") { + const pendingReturn = consumePendingAuthReturn(window.sessionStorage); + const returnTo = signedInReturnRedirect(location.href, pendingReturn); + if (returnTo) throw redirect({ href: returnTo }); + } throw redirect({ to: "/" }); } // Loaded here so the screen paints with its buttons rather than painting empty and then diff --git a/app/tests/auth-return.test.ts b/app/tests/auth-return.test.ts new file mode 100644 index 00000000..be553069 --- /dev/null +++ b/app/tests/auth-return.test.ts @@ -0,0 +1,88 @@ +import { expect, test } from "bun:test"; +import { + consumePendingAuthReturn, + pendingAuthReturnPath, + savePendingAuthReturn, + signedInReturnRedirect, +} from "@/lib/auth/pending-return"; + +function storage() { + const values = new Map(); + return { + getItem: (key: string) => values.get(key) ?? null, + removeItem: (key: string) => values.delete(key), + setItem: (key: string, value: string) => values.set(key, value), + }; +} + +test("accepts only the two auth-return routes with one non-empty token", () => { + expect(pendingAuthReturnPath("/link/slack?token=claim")).toBe( + "/link/slack?token=claim", + ); + expect(pendingAuthReturnPath("/assist?token=control-claim")).toBe( + "/assist?token=control-claim", + ); + expect(pendingAuthReturnPath("/link/slack?token= claim ")).toBe( + "/link/slack?token=claim", + ); + + for (const rejected of [ + "https://evil.example/link/slack?token=claim", + "//evil.example/link/slack?token=claim", + "/admin?token=claim", + "/link/slack", + "/link/slack?token=", + "/link/slack?token=one&token=two", + "/link/slack?token=claim#fragment", + "/assist?token=claim&extra=1", + "/assist?token=claim#fragment", + "https://openbot.invalid/assist?token=claim", + "/%2f%2fevil.example/assist?token=claim", + ]) { + expect(pendingAuthReturnPath(rejected)).toBeNull(); + } +}); + +test("saves an expiring one-time Slack return and consumes it after auth", () => { + const session = storage(); + expect(savePendingAuthReturn("/link/slack?token=claim", session, 100)).toBe( + true, + ); + expect(consumePendingAuthReturn(session, 100 + 60_000)).toBe( + "/link/slack?token=claim", + ); + expect(consumePendingAuthReturn(session, 100 + 60_000)).toBeNull(); +}); + +test("rejects and deletes expired or malformed saved returns", () => { + const session = storage(); + savePendingAuthReturn("/link/slack?token=claim", session, 0); + expect(consumePendingAuthReturn(session, 10 * 60_000 + 1)).toBeNull(); + expect(consumePendingAuthReturn(session, 10 * 60_000 + 1)).toBeNull(); +}); + +test("redirects a signed-in person only to a consumed Slack return", () => { + expect(signedInReturnRedirect("/", "/link/slack?token=claim")).toBe( + "/link/slack?token=claim", + ); + expect( + signedInReturnRedirect( + "https://openbot.test/link/slack?token=claim", + "/link/slack?token=claim", + ), + ).toBeNull(); + expect(signedInReturnRedirect("/", null)).toBeNull(); +}); + +test("preserves an exact assistance return across signed-out sign-in", () => { + const session = storage(); + expect( + savePendingAuthReturn("/assist?token=sealed-control", session, 100), + ).toBe(true); + + const consumed = consumePendingAuthReturn(session, 200); + expect(signedInReturnRedirect("/sign", consumed)).toBe( + "/assist?token=sealed-control", + ); + expect(consumePendingAuthReturn(session, 200)).toBeNull(); +}); diff --git a/app/tests/external-thread-route.test.ts b/app/tests/external-thread-route.test.ts new file mode 100644 index 00000000..95953fc8 --- /dev/null +++ b/app/tests/external-thread-route.test.ts @@ -0,0 +1,178 @@ +import { describe, expect, test } from "bun:test"; +import { + externalThreadKeys, + externalThreadListQueryOptions, + externalThreadPage, + externalThreadTarget, +} from "../src/lib/external/queries"; + +describe("external Slack transcript target", () => { + test("accepts the authenticated read-only target returned by OpenBot", () => { + expect( + externalThreadTarget({ + threadId: "channels-thread-1", + agentId: "risk", + agentName: "Risk Analyst", + provider: "slack", + readOnly: true, + }), + ).toEqual({ + threadId: "channels-thread-1", + agentId: "risk", + agentName: "Risk Analyst", + provider: "slack", + readOnly: true, + }); + }); + + test("rejects writable or malformed targets", () => { + for (const value of [ + null, + {}, + { threadId: "t", agentId: "a", agentName: "A", provider: "slack" }, + { + threadId: "t", + agentId: "a", + agentName: "A", + provider: "slack", + readOnly: false, + }, + ]) { + expect(() => externalThreadTarget(value)).toThrow( + "Could not load this Slack conversation", + ); + } + }); +}); + +describe("external Slack transcript list", () => { + const validThread = { + threadId: "channels-thread-1", + agentId: "risk", + agentName: "Risk Analyst", + provider: "slack", + readOnly: true, + lastMessage: "Review the queue", + lastMessageAt: "2026-08-25T12:00:00.000Z", + createdAt: "2026-08-25T11:00:00.000Z", + }; + + test("accepts a server page of authenticated read-only Slack summaries", () => { + expect( + externalThreadPage({ + threads: [validThread, { ...validThread, lastMessage: null }], + nextCursor: "opaque-next", + }), + ).toEqual({ + threads: [validThread, { ...validThread, lastMessage: null }], + nextCursor: "opaque-next", + }); + }); + + test("rejects malformed conversation pages and summaries", () => { + for (const value of [ + null, + [], + {}, + { threads: [] }, + { threads: "not-array", nextCursor: null }, + { threads: [], nextCursor: "" }, + { threads: [], nextCursor: 42 }, + { threads: [{ ...validThread, threadId: "" }], nextCursor: null }, + { threads: [{ ...validThread, agentId: "" }], nextCursor: null }, + { threads: [{ ...validThread, agentName: "" }], nextCursor: null }, + { threads: [{ ...validThread, provider: "teams" }], nextCursor: null }, + { threads: [{ ...validThread, readOnly: false }], nextCursor: null }, + { threads: [{ ...validThread, lastMessage: 123 }], nextCursor: null }, + { + threads: [{ ...validThread, lastMessageAt: "not-a-date" }], + nextCursor: null, + }, + { + threads: [{ ...validThread, lastMessageAt: "2026-08-25T12:00:00Z" }], + nextCursor: null, + }, + { + threads: [{ ...validThread, createdAt: "not-a-date" }], + nextCursor: null, + }, + ]) { + expect(() => externalThreadPage(value)).toThrow( + "Could not load Slack conversations", + ); + } + }); + + test("exposes stable external thread query keys", () => { + expect(externalThreadKeys.all).toEqual(["external-threads"]); + expect(externalThreadKeys.list()).toEqual(["external-threads", "list"]); + expect(externalThreadKeys.detail("channels-thread-1")).toEqual([ + "external-threads", + "detail", + "channels-thread-1", + ]); + }); + + test("builds a flattened cursor-based infinite query", () => { + const options = externalThreadListQueryOptions(); + const page = externalThreadPage({ + threads: [validThread], + nextCursor: "opaque-next", + }); + const finalPage = externalThreadPage({ + threads: [{ ...validThread, threadId: "channels-thread-2" }], + nextCursor: null, + }); + + expect(options.queryKey).toEqual(externalThreadKeys.list()); + expect(options.initialPageParam).toBe(""); + expect(options.getNextPageParam?.(page, [], "")).toBe("opaque-next"); + expect(options.getNextPageParam?.(finalPage, [], "")).toBeUndefined(); + expect( + options.select?.({ + pages: [page, finalPage], + pageParams: ["", "opaque-next"], + }), + ).toEqual([validThread, { ...validThread, threadId: "channels-thread-2" }]); + }); + + test("fetches cursor pages with an encoded opaque cursor and validates the response", async () => { + const requests: string[] = []; + const originalFetch = globalThis.fetch; + globalThis.fetch = async (input, init) => { + requests.push(String(input)); + expect(init?.credentials).toBe("include"); + return new Response( + JSON.stringify({ + threads: [validThread], + nextCursor: "next cursor", + }), + { + headers: { "content-type": "application/json" }, + status: 200, + }, + ); + }; + + try { + const options = externalThreadListQueryOptions(); + if (typeof options.queryFn !== "function") { + throw new Error("Expected external thread list to have a queryFn"); + } + + const page = await options.queryFn({ + pageParam: "cursor value/?", + } as Parameters[0]); + + expect(requests).toEqual([ + "/api/external-links/threads?cursor=cursor%20value%2F%3F", + ]); + expect(page).toEqual({ + threads: [validThread], + nextCursor: "next cursor", + }); + } finally { + globalThis.fetch = originalFetch; + } + }); +}); diff --git a/app/tests/sidebar-roster.test.ts b/app/tests/sidebar-roster.test.ts new file mode 100644 index 00000000..8d666f40 --- /dev/null +++ b/app/tests/sidebar-roster.test.ts @@ -0,0 +1,181 @@ +import { describe, expect, test } from "bun:test"; +import { + conversationRoster, + matchingRoster, + rosterDestination, + rosterKey, + rosterLastMessage, + rosterName, + shouldShowEmptyRoster, + shouldShowSearchEmpty, +} from "../src/components/app-sidebar/roster"; +import type { ChannelSummary } from "../src/lib/channels/queries"; +import type { ExternalThreadSummary } from "../src/lib/external/queries"; + +function channel( + id: string, + overrides: Partial = {}, +): ChannelSummary { + return { + id, + name: `Channel ${id}`, + agentIds: [`agent-${id}`], + threadId: `thread-${id}`, + active: true, + lastMessage: null, + lastMessageAt: null, + lastMessageAgentId: null, + createdAt: "2026-08-25T10:00:00.000Z", + pinned: false, + lastReadAt: null, + ...overrides, + }; +} + +function slack( + threadId: string, + overrides: Partial = {}, +): ExternalThreadSummary { + return { + threadId, + provider: "slack", + agentId: `agent-${threadId}`, + agentName: `Slack ${threadId}`, + lastMessage: null, + lastMessageAt: null, + createdAt: "2026-08-25T10:00:00.000Z", + readOnly: true, + ...overrides, + }; +} + +describe("sidebar conversation roster", () => { + test("sorts pinned native channels first, then remaining rows by activity and stable key", () => { + const rows = conversationRoster( + [ + channel("unpinned-new", { + createdAt: "2026-08-25T11:00:00.000Z", + }), + channel("pinned-old", { + createdAt: "2026-08-24T11:00:00.000Z", + pinned: true, + }), + channel("tie-z", { + createdAt: "2026-08-25T12:00:00.000Z", + }), + channel("tie-a", { + createdAt: "2026-08-25T12:00:00.000Z", + }), + ], + [ + slack("slack-newest", { + lastMessageAt: "2026-08-25T13:00:00.000Z", + createdAt: "2026-08-25T09:00:00.000Z", + }), + slack("slack-tie", { + createdAt: "2026-08-25T12:00:00.000Z", + }), + ], + ); + + expect(rows.map(rosterKey)).toEqual([ + "openbot:pinned-old", + "slack:slack-newest", + "openbot:tie-a", + "openbot:tie-z", + "slack:slack-tie", + "openbot:unpinned-new", + ]); + }); + + test("matches visible names and last-message text across native and Slack rows", () => { + const rows = conversationRoster( + [ + channel("alpha", { + name: "Roadmap", + lastMessage: "Budget review", + }), + ], + [ + slack("beta", { + agentName: "Support Slack", + lastMessage: "Incident handoff", + }), + ], + ); + + expect(matchingRoster(rows, "road").map(rosterKey)).toEqual([ + "openbot:alpha", + ]); + expect(matchingRoster(rows, "handoff").map(rosterKey)).toEqual([ + "slack:beta", + ]); + expect(matchingRoster(rows, "support").map(rosterKey)).toEqual([ + "slack:beta", + ]); + expect(matchingRoster(rows, "missing")).toEqual([]); + expect(matchingRoster(rows, " ")).toBe(rows); + }); + + test("projects names, previews, and destinations for both row sources", () => { + const nativeRow = conversationRoster([ + channel("native", { name: "Native", lastMessage: "OpenBot preview" }), + ])[0]; + const slackRow = conversationRoster( + [], + [ + slack("slack-thread", { + agentName: "Slack Agent", + lastMessage: "Slack preview", + }), + ], + )[0]; + + expect(rosterName(nativeRow)).toBe("Native"); + expect(rosterLastMessage(nativeRow)).toBe("OpenBot preview"); + expect(rosterDestination(nativeRow)).toEqual({ + to: "/channel/$channelId", + params: { channelId: "native" }, + }); + + expect(rosterName(slackRow)).toBe("Slack Agent"); + expect(rosterLastMessage(slackRow)).toBe("Slack preview"); + expect(rosterDestination(slackRow)).toEqual({ + to: "/slack/thread/$threadId", + params: { threadId: "slack-thread" }, + }); + }); + + test("shows the empty roster only after both sources have loaded empty arrays", () => { + expect(shouldShowEmptyRoster([], [], true, true)).toBe(true); + expect(shouldShowEmptyRoster([channel("native")], [], true, true)).toBe( + false, + ); + expect(shouldShowEmptyRoster([], [slack("external")], true, true)).toBe( + false, + ); + expect(shouldShowEmptyRoster([], [], false, true)).toBe(false); + expect(shouldShowEmptyRoster([], [], true, false)).toBe(false); + }); + + test("shows search-empty only once both sources successfully loaded and the merged result is empty", () => { + const match = conversationRoster([channel("native")]); + + expect(shouldShowSearchEmpty([], "missing", "success", "pending")).toBe( + false, + ); + expect(shouldShowSearchEmpty([], "missing", "pending", "success")).toBe( + false, + ); + expect(shouldShowSearchEmpty([], "missing", "success", "error")).toBe( + false, + ); + expect(shouldShowSearchEmpty([], "missing", "success", "success")).toBe( + true, + ); + expect(shouldShowSearchEmpty(match, "native", "success", "success")).toBe( + false, + ); + expect(shouldShowSearchEmpty([], " ", "success", "success")).toBe(false); + }); +}); diff --git a/app/tests/slack-assist-route.test.ts b/app/tests/slack-assist-route.test.ts new file mode 100644 index 00000000..00ea3363 --- /dev/null +++ b/app/tests/slack-assist-route.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, test } from "bun:test"; +import { + assistanceHistoryPath, + assistanceResponseOutcome, + assistanceToken, + captureAssistanceToken, +} from "../src/routes/_authed/assist"; + +describe("Slack assistance route status mapping", () => { + test("accepts only a non-empty opaque token", () => { + expect(assistanceToken({ token: " sealed " })).toBe("sealed"); + expect(assistanceToken({ token: "" })).toBeNull(); + expect(assistanceToken({ token: 123 })).toBeNull(); + }); + + test("maps only a validated agent response to a ready destination", () => { + expect(assistanceResponseOutcome(200, { agentId: "risk analyst" })).toEqual( + { + kind: "ready", + href: "/bot?agent=risk%20analyst", + }, + ); + expect(assistanceResponseOutcome(200, { agentId: "" })).toEqual({ + kind: "error", + }); + }); + + test("keeps invalid, wrong-user, and retryable failures non-navigable", () => { + expect(assistanceResponseOutcome(403, { agentId: "forged" })).toEqual({ + kind: "wrong-user", + }); + expect(assistanceResponseOutcome(410, { agentId: "stale" })).toEqual({ + kind: "invalid", + }); + expect(assistanceResponseOutcome(500, { agentId: "leaked" })).toEqual({ + kind: "error", + }); + }); + + test("removes a captured token from visible history only for its exact assistance URL", () => { + expect( + assistanceHistoryPath( + "https://openbot.test/assist?token=sealed-control", + "sealed-control", + ), + ).toBe("/assist"); + for (const href of [ + "https://openbot.test/assist?token=other", + "https://openbot.test/assist?token=sealed-control&extra=1", + "https://openbot.test/assist?token=sealed-control#fragment", + "https://openbot.test/bot?token=sealed-control", + ]) { + expect(assistanceHistoryPath(href, "sealed-control")).toBeNull(); + } + }); + + test("captures and strips the token before any network outcome, retaining it for retry", () => { + const writes: string[] = []; + const storage = new Map(); + const token = captureAssistanceToken( + "sealed-control", + "https://openbot.test/assist?token=sealed-control", + { replace: (path) => writes.push(path) }, + { + getItem: (key) => storage.get(key) ?? null, + setItem: (key, value) => storage.set(key, value), + removeItem: (key) => storage.delete(key), + }, + ); + expect(token).toBe("sealed-control"); + expect(writes).toEqual(["/assist"]); + expect([...storage.values()]).toEqual(["sealed-control"]); + }); +}); diff --git a/app/tests/slack-link-component.test.tsx b/app/tests/slack-link-component.test.tsx new file mode 100644 index 00000000..11ab4af3 --- /dev/null +++ b/app/tests/slack-link-component.test.tsx @@ -0,0 +1,147 @@ +import { afterAll, afterEach, beforeAll, expect, test } from "bun:test"; +import { GlobalRegistrator } from "@happy-dom/global-registrator"; +import { + act, + cleanup, + fireEvent, + render, + waitFor, +} from "@testing-library/react"; +import { StrictMode } from "react"; +import { SlackLinkConfirmation } from "@/routes/_authed/link/slack"; + +const originalFetch = globalThis.fetch; + +beforeAll(() => GlobalRegistrator.register()); +afterEach(() => { + cleanup(); + globalThis.fetch = originalFetch; +}); +afterAll(() => GlobalRegistrator.unregister()); + +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise((next) => { + resolve = next; + }); + return { promise, resolve }; +} + +function response(status: number, body?: unknown) { + return { + ok: status >= 200 && status < 300, + status, + json: async () => body, + } as Response; +} + +function confirmation( + token: string, + onReauthenticate = () => {}, + load?: ( + token: string, + signal: AbortSignal, + ) => Promise<{ + workspace: string; + user: string; + }>, + complete?: ( + token: string, + signal: AbortSignal, + ) => Promise<{ + kind: "linked"; + message: string; + }>, +) { + return ( + + + + ); +} + +test("a token change cannot render a delayed prior claim", async () => { + const oldClaim = deferred<{ workspace: string; user: string }>(); + const newClaim = deferred<{ workspace: string; user: string }>(); + const signals: AbortSignal[] = []; + const load = (token: string, signal: AbortSignal) => { + signals.push(signal); + return token === "token-a" ? oldClaim.promise : newClaim.promise; + }; + + const view = render(confirmation("token-a", () => {}, load)); + view.rerender(confirmation("token-b", () => {}, load)); + expect(signals.some((signal) => signal.aborted)).toBe(true); + await act(async () => { + oldClaim.resolve({ workspace: "old", user: "old" }); + newClaim.resolve({ workspace: "new", user: "new" }); + }); + + await waitFor(() => + expect(view.getAllByText("new").length).toBeGreaterThan(0), + ); + expect(view.queryByText("old")).toBeNull(); + expect(view.container.textContent).not.toContain("token-a"); + expect(view.container.textContent).not.toContain("token-b"); +}); + +test("double-click posts once and an unmounted post cannot update a new token", async () => { + const getA = deferred<{ workspace: string; user: string }>(); + const getB = deferred<{ workspace: string; user: string }>(); + const postA = deferred(); + const signals: AbortSignal[] = []; + let postCalls = 0; + const load = (token: string) => + token === "token-a" ? getA.promise : getB.promise; + const complete = (_token: string, signal: AbortSignal) => { + postCalls += 1; + signals.push(signal); + return postA.promise.then(() => ({ + kind: "linked" as const, + message: "Slack is linked to your OpenBot account.", + })); + }; + + const view = render(confirmation("token-a", () => {}, load, complete)); + await act(async () => { + getA.resolve({ workspace: "A", user: "A" }); + }); + await waitFor(() => + expect(view.getByRole("button", { name: "Link Slack" })).toBeTruthy(), + ); + const linkButton = view.getByRole("button", { name: "Link Slack" }); + fireEvent.click(linkButton); + expect( + (view.getByRole("button", { name: "Linking Slack…" }) as HTMLButtonElement) + .disabled, + ).toBe(true); + fireEvent.click(linkButton); + expect(postCalls).toBe(1); + + view.rerender(confirmation("token-b", () => {}, load, complete)); + expect(signals[0]?.aborted).toBe(true); + await act(async () => { + postA.resolve(response(200, { linked: true })); + getB.resolve({ workspace: "B", user: "B" }); + }); + await waitFor(() => expect(view.getAllByText("B").length).toBeGreaterThan(0)); + expect( + view.queryByText("Slack is linked to your OpenBot account."), + ).toBeNull(); +}); + +test("a 401 starts the auth-return recovery without rendering a token", async () => { + const reauthenticate = () => calls++; + let calls = 0; + globalThis.fetch = (() => Promise.resolve(response(401))) as typeof fetch; + + const view = render(confirmation("never-display-this", reauthenticate)); + await waitFor(() => expect(calls).toBeGreaterThan(0)); + expect(view.container.textContent).not.toContain("never-display-this"); +}); diff --git a/app/tests/slack-link-route.test.ts b/app/tests/slack-link-route.test.ts new file mode 100644 index 00000000..84938276 --- /dev/null +++ b/app/tests/slack-link-route.test.ts @@ -0,0 +1,78 @@ +import { expect, test } from "bun:test"; +import { + slackLinkClaim, + slackLinkFailure, + slackLinkResponseOutcome, + slackLinkResult, + slackLinkToken, +} from "@/routes/_authed/link/slack"; + +test("requires a token and maps completion responses", () => { + expect(slackLinkToken({})).toBeNull(); + expect(slackLinkToken({ token: " claim " })).toBe("claim"); + expect(slackLinkResult(200)).toEqual({ + kind: "linked", + message: "Slack is linked to your OpenBot account.", + }); + expect(slackLinkResult(409).kind).toBe("conflict"); +}); + +test("rejects non-string, empty, and repeated token search inputs", () => { + expect(slackLinkToken({ token: "" })).toBeNull(); + expect(slackLinkToken({ token: " " })).toBeNull(); + expect(slackLinkToken({ token: ["first", "second"] })).toBeNull(); + expect(slackLinkToken({ token: { value: "claim" } })).toBeNull(); + expect(slackLinkToken({ token: 42 })).toBeNull(); +}); + +test("maps invalid and expired completion statuses uniformly", () => { + expect(slackLinkResult(400)).toEqual({ + kind: "invalid", + message: + "This Slack link has expired or is invalid. Return to Slack and try again.", + }); + expect(slackLinkResult(410).kind).toBe("invalid"); +}); + +test("keeps unexpected server failures retryable", () => { + expect(slackLinkFailure()).toEqual({ + kind: "error", + message: "Slack could not be linked right now. Try again.", + }); +}); + +test("classifies documented token, authentication, and transient responses", () => { + for (const status of [400]) { + expect(slackLinkResponseOutcome(status).kind).toBe("invalid"); + } + expect(slackLinkResponseOutcome(409).kind).toBe("conflict"); + expect(slackLinkResponseOutcome(401).kind).toBe("reauth"); + + for (const status of [408, 418, 425, 429, 500, 502, 503]) { + expect(slackLinkResponseOutcome(status).kind).toBe("error"); + } + expect(slackLinkResponseOutcome().kind).toBe("error"); +}); + +test("maps only safe Slack identity fields for display", () => { + expect( + slackLinkClaim({ + providerTenantId: "T0123", + providerUserId: "U0456", + providerEmail: "person@example.com", + token: "must not be displayed", + }), + ).toEqual({ + workspace: "T0123", + user: "U0456", + email: "person@example.com", + }); + expect( + slackLinkClaim({ + providerTenantId: "T0123", + providerUserId: "U0456", + providerEmail: null, + }), + ).toEqual({ workspace: "T0123", user: "U0456" }); + expect(slackLinkClaim({ providerTenantId: "T0123" })).toBeNull(); +}); diff --git a/app/tests/slack-sidebar-row.test.tsx b/app/tests/slack-sidebar-row.test.tsx new file mode 100644 index 00000000..2671d03e --- /dev/null +++ b/app/tests/slack-sidebar-row.test.tsx @@ -0,0 +1,80 @@ +import { afterAll, afterEach, beforeAll, expect, test } from "bun:test"; +import { GlobalRegistrator } from "@happy-dom/global-registrator"; +import { cleanup, fireEvent, render, within } from "@testing-library/react"; +import { + SlackChannelContent, + SlackRosterProblem, +} from "@/components/app-sidebar/slack-channel"; +import type { ExternalThreadSummary } from "@/lib/external/queries"; + +beforeAll(() => GlobalRegistrator.register()); +afterEach(() => cleanup()); +afterAll(() => GlobalRegistrator.unregister()); + +function slackThread( + overrides: Partial = {}, +): ExternalThreadSummary { + return { + threadId: "slack-thread-1", + provider: "slack", + agentId: "agent-support", + agentName: "Support Agent", + lastMessage: "Can you check the customer handoff?", + lastMessageAt: "2026-08-27T18:00:00.000Z", + createdAt: "2026-08-27T17:30:00.000Z", + readOnly: true, + ...overrides, + }; +} + +test("Slack row content keeps the Slack chip beside the agent name", () => { + const view = render( + , + ); + const title = view.container.querySelector( + '[data-slot="conversation-title"]', + ); + + expect(title).toBeTruthy(); + expect(within(title as HTMLElement).getByText("Support Agent")).toBeTruthy(); + expect(within(title as HTMLElement).getByText("Slack")).toBeTruthy(); + expect(view.getByText("Can you check the customer handoff?")).toBeTruthy(); + expect(view.getByText("2 hours ago")).toBeTruthy(); + expect(view.queryByText("Pin channel")).toBeNull(); + expect(view.queryByText("Delete channel…")).toBeNull(); +}); + +test("Slack roster problem announces the loading failure and retries Slack only", () => { + let retryCalls = 0; + const view = render( + { + retryCalls += 1; + }} + />, + ); + + expect(view.getByRole("alert").textContent).toContain( + "Slack conversations could not be loaded.", + ); + fireEvent.click(view.getByRole("button", { name: "Retry" })); + expect(retryCalls).toBe(1); +}); + +test("Slack roster problem disables retry while a retry is already running", () => { + let retryCalls = 0; + const view = render( + { + retryCalls += 1; + }} + />, + ); + + const button = view.getByRole("button", { name: "Retrying…" }); + + expect((button as HTMLButtonElement).disabled).toBe(true); + fireEvent.click(button); + expect(retryCalls).toBe(0); +}); diff --git a/bun.lock b/bun.lock index 1ac087cf..403dba33 100644 --- a/bun.lock +++ b/bun.lock @@ -4,6 +4,9 @@ "workspaces": { "": { "name": "openbot", + "dependencies": { + "zod": "^4.4.3", + }, "devDependencies": { "@biomejs/biome": "2.5.10", "@copilotkit/aimock": "1.39.0", @@ -63,6 +66,7 @@ "@ag-ui/client": "0.0.57", "@better-auth/drizzle-adapter": "^1.7.1", "@better-auth/sso": "^1.7.1", + "@copilotkit/channels": "0.9.0", "@copilotkit/runtime": "1.69.0", "@modelcontextprotocol/sdk": "^1.30.0", "better-auth": "^1.7.1", @@ -265,16 +269,24 @@ "@copilotkit/aimock": ["@copilotkit/aimock@1.39.0", "", { "peerDependencies": { "jest": ">=29", "vitest": ">=3" }, "optionalPeers": ["jest", "vitest"], "bin": { "aimock": "dist/aimock-cli.js", "llmock": "dist/cli.js" } }, "sha512-AWw4vmW2hBchHoggh0G4McWGmGZD6wtXAehL6K5ncWF5lVIjlv++bPmxmRwrpQCi/K4/xK10N9Zp9srJYipEJw=="], + "@copilotkit/channels": ["@copilotkit/channels@0.9.0", "", { "dependencies": { "@copilotkit/channels-core": "0.9.0", "@copilotkit/channels-discord": "0.9.0", "@copilotkit/channels-slack": "0.9.0", "@copilotkit/channels-teams": "0.9.0", "@copilotkit/channels-telegram": "0.9.0", "@copilotkit/channels-ui": "0.9.0", "@copilotkit/channels-whatsapp": "0.9.0" }, "peerDependencies": { "vitest": "^4.0.0" }, "optionalPeers": ["vitest"] }, "sha512-phPsXReoBYIaZdUvzJX/2ngP5wX3rU7tv2y8UMlnjOqWyMUCatyIWtFwmf+ooVDMobFfnGYYiM1qq7SUL8olHQ=="], + "@copilotkit/channels-core": ["@copilotkit/channels-core@0.9.0", "", { "dependencies": { "@ag-ui/client": "0.0.57", "@ag-ui/core": "0.0.57", "@copilotkit/channels-ui": "~0.9.0", "@copilotkit/core": "^1.68.0", "@copilotkit/shared": "^1.68.0", "zod-to-json-schema": "^3.24.1" }, "peerDependencies": { "vitest": "^4.0.0" }, "optionalPeers": ["vitest"] }, "sha512-bCWLb/jb9j8O+JvOvf/jkJ/Nb8B09U/tAdTCQ221mE2AEHS2dn5PMlQih9gqyF6kbJFO0Nmf+IDTritWswmfaA=="], + "@copilotkit/channels-discord": ["@copilotkit/channels-discord@0.9.0", "", { "dependencies": { "@ag-ui/client": "0.0.57", "@copilotkit/channels-core": "^0.9.0", "@copilotkit/channels-ui": "^0.9.0", "discord.js": "^14.16.0", "zod": "^3.25.76" } }, "sha512-tiVRvl/6gJ9yyJsZCGtmWNaNZ1bCNMAKNh4PpL7MbiQYKmOowHhEHVcWFT4DUeV6lxxIVRthIaJzpqDby+IhUQ=="], + "@copilotkit/channels-intelligence": ["@copilotkit/channels-intelligence@0.9.0", "", { "dependencies": { "@ag-ui/client": "0.0.57", "@copilotkit/channels-core": "^0.9.0", "@copilotkit/channels-slack": "^0.9.0", "@copilotkit/channels-teams": "^0.9.0", "@copilotkit/channels-ui": "^0.9.0", "phoenix": "^1.8.4" } }, "sha512-w+ARYm+i30buMGJB+E3QFBze41s464MKkXGikgLYg3k9WEFHE3BgTRKz6MbHxv0yN9L6mOAjwMhR8LkTSufMcw=="], "@copilotkit/channels-slack": ["@copilotkit/channels-slack@0.9.0", "", { "dependencies": { "@ag-ui/client": "0.0.57", "@ag-ui/core": "0.0.57", "@copilotkit/channels-core": "^0.9.0", "@copilotkit/channels-ui": "^0.9.0", "@copilotkit/core": "^1.68.0", "@copilotkit/shared": "^1.68.0", "@slack/bolt": "^4.2.0", "@slack/types": "^2.21.1", "@slack/web-api": "^7.16.0", "rxjs": "^7.8.1", "zod": "^3.25.76", "zod-to-json-schema": "^3.25.1" } }, "sha512-3QkCInbQmruSP875x02YG1Ez59jPzQMDUlWRlNKPvzcAvnPfOmHxaXVVmBnS+fwcWccd4AAaeRvslTWBwU9s6g=="], "@copilotkit/channels-teams": ["@copilotkit/channels-teams@0.9.0", "", { "dependencies": { "@ag-ui/client": "0.0.57", "@ag-ui/core": "0.0.57", "@copilotkit/channels-core": "^0.9.0", "@copilotkit/channels-ui": "^0.9.0", "@copilotkit/core": "^1.68.0", "@copilotkit/shared": "^1.68.0", "@microsoft/agents-activity": "^1.5.3", "@microsoft/agents-hosting": "^1.5.3", "express": "^4.21.2", "rxjs": "^7.8.1", "zod": "^3.25.76", "zod-to-json-schema": "^3.25.1" } }, "sha512-OI1xaIg9Ho7vMUgPpxM2h4LZQRvQKRt+1yy2Er8nMBy1IJXQaNP4of4XI5pQA/WnskL/07xcGPrFiXrTRs4Hyg=="], + "@copilotkit/channels-telegram": ["@copilotkit/channels-telegram@0.9.0", "", { "dependencies": { "@ag-ui/client": "0.0.57", "@copilotkit/channels-core": "^0.9.0", "@copilotkit/channels-ui": "^0.9.0", "grammy": "^1.30.0", "zod": "^3.25.76" } }, "sha512-+qrts4W8VWEMiKVBnYTnXXsTEWid2SKMozcFSJ9rg116rhcDGw2ymXVC3u+HpF05Xlwc3p1ELmygDq3igpsdUQ=="], + "@copilotkit/channels-ui": ["@copilotkit/channels-ui@0.9.0", "", { "dependencies": { "@copilotkit/shared": "^1.68.0" } }, "sha512-6nhmIuexyW+fOBp3BE3ZWk4Mco5NOG4sr//BZ46RynSYYD36klTQQbTKA0ntSR6nIGJ/luAGc8WbVdPH8srYOA=="], + "@copilotkit/channels-whatsapp": ["@copilotkit/channels-whatsapp@0.9.0", "", { "dependencies": { "@ag-ui/client": "0.0.57", "@copilotkit/channels-core": "^0.9.0", "@copilotkit/channels-ui": "^0.9.0" } }, "sha512-nantNMFpRqWXs+58G+2RGATmcStD7w8k+UOffTsp1h1kt1GLyR1rm4552Pl0GJvovakYeZy3nBVwiV/MPUQIpA=="], + "@copilotkit/core": ["@copilotkit/core@1.69.0", "", { "dependencies": { "@ag-ui/client": "0.0.57", "@copilotkit/shared": "1.69.0", "@tanstack/pacer": "^0.20.1", "phoenix": "^1.8.4", "rxjs": "7.8.1", "zod-to-json-schema": "^3.24.6" } }, "sha512-ZfbpPZmKuDz45/z5MLnQ2YRO3nPl9WUNMjrFZUgAseE8hYWcF+ltnqSXLg2jaoggMF/+s6Z+J779IMb2AIiKPw=="], "@copilotkit/license-verifier": ["@copilotkit/license-verifier@0.5.0", "", {}, "sha512-vrwKtIpYwF0FT9ZoYASH8owa2cGV0dhDvJGaCRaRMStwDxpc6DRdydKkhx8cWZXyBRxEYcq/Vygv4JvevhQQdQ=="], @@ -291,6 +303,18 @@ "@copilotkit/web-inspector": ["@copilotkit/web-inspector@1.69.0", "", { "dependencies": { "@ag-ui/client": "0.0.57", "@copilotkit/core": "1.69.0", "@copilotkit/shared": "1.69.0", "lit": "^3.2.0", "lucide": "^0.525.0", "marked": "^12.0.2" } }, "sha512-+sE+lP4t3Fkk2lImkYRytIFI0hHh8MzK50BUZ/h/gJEq5R3ZuLo4fzuCeSdXewFlGVTARaKNcKxs+bnnUL9n6Q=="], + "@discordjs/builders": ["@discordjs/builders@1.14.1", "", { "dependencies": { "@discordjs/formatters": "^0.6.2", "@discordjs/util": "^1.2.0", "@sapphire/shapeshift": "^4.0.0", "discord-api-types": "^0.38.40", "fast-deep-equal": "^3.1.3", "ts-mixer": "^6.0.4", "tslib": "^2.6.3" } }, "sha512-gSKkhXLqs96TCzk66VZuHHl8z2bQMJFGwrXC0f33ngK+FLNau4hU1PYny3DNJfNdSH+gVMzE85/d5FQ2BpcNwQ=="], + + "@discordjs/collection": ["@discordjs/collection@1.5.3", "", {}, "sha512-SVb428OMd3WO1paV3rm6tSjM4wC+Kecaa1EUGX7vc6/fddvw/6lg90z4QtCqm21zvVe92vMMDt9+DkIvjXImQQ=="], + + "@discordjs/formatters": ["@discordjs/formatters@0.6.2", "", { "dependencies": { "discord-api-types": "^0.38.33" } }, "sha512-y4UPwWhH6vChKRkGdMB4odasUbHOUwy7KL+OVwF86PvT6QVOwElx+TiI1/6kcmcEe+g5YRXJFiXSXUdabqZOvQ=="], + + "@discordjs/rest": ["@discordjs/rest@2.6.3", "", { "dependencies": { "@discordjs/collection": "^2.1.1", "@discordjs/util": "^1.2.0", "@sapphire/async-queue": "^1.5.3", "@sapphire/snowflake": "^3.5.5", "@vladfrangu/async_event_emitter": "^2.4.6", "discord-api-types": "^0.38.50", "magic-bytes.js": "^1.13.0", "tslib": "^2.6.3", "undici": "^6.27.0" } }, "sha512-wvOylxNYJkwKjctS/Mn5GP1w9r3/rzyH+ThD1JlAca6zEdlHs8QWBBUQJpU5Q+W6DoIj/Ljh1IPlZs7hTU+UAg=="], + + "@discordjs/util": ["@discordjs/util@1.2.0", "", { "dependencies": { "discord-api-types": "^0.38.33" } }, "sha512-3LKP7F2+atl9vJFhaBjn4nOaSWahZ/yWjOvA4e5pnXkt2qyXRCHLxoBQy81GFtLGCq7K9lPm9R517M1U+/90Qg=="], + + "@discordjs/ws": ["@discordjs/ws@1.2.3", "", { "dependencies": { "@discordjs/collection": "^2.1.0", "@discordjs/rest": "^2.5.1", "@discordjs/util": "^1.1.0", "@sapphire/async-queue": "^1.5.2", "@types/ws": "^8.5.10", "@vladfrangu/async_event_emitter": "^2.2.4", "discord-api-types": "^0.38.1", "tslib": "^2.6.2", "ws": "^8.17.0" } }, "sha512-wPlQDxEmlDg5IxhJPuxXr3Vy9AjYq5xCvFWGJyD7w7Np8ZGu+Mc+97LCoEc/+AYCo2IDpKioiH0/c/mj5ZR9Uw=="], + "@dotenvx/dotenvx": ["@dotenvx/dotenvx@1.75.1", "", { "dependencies": { "@dotenvx/primitives": "^0.8.0", "commander": "^11.1.0", "conf": "^10.2.0", "dotenv": "^17.2.1", "enquirer": "^2.4.1", "env-paths": "^2.2.1", "execa": "^5.1.1", "fdir": "^6.2.0", "ignore": "^5.3.0", "object-treeify": "1.1.33", "open": "^8.4.2", "picomatch": "^4.0.4", "systeminformation": "^5.22.11", "undici": "^7.11.0", "which": "^4.0.0", "yocto-spinner": "^1.1.0" }, "bin": { "dotenvx": "src/cli/dotenvx.js" } }, "sha512-/BITOC9dmS/edY2zQwZNicQ059O6RKabtQfyEafV0nGtfYRNHYy1DIPiYVcov40+tob9hfmBnbR963dS+EQ1DQ=="], "@dotenvx/primitives": ["@dotenvx/primitives@0.8.0", "", {}, "sha512-VYJy0uhFm9zTJ1TxBaW/pA8bjbOM/OttaNMwZ1RHG4JKyRG7DhSdiqD1ipQoAyoD22olUtxbP78W9xY3Wd11bg=="], @@ -371,6 +395,8 @@ "@fontsource-variable/inter": ["@fontsource-variable/inter@5.3.0", "", {}, "sha512-OupL48va4JNofb97w6NYeF9S7W/kHNKM0Er8Dem5nqi4jeOLrVJDoE8tZEpnMJmtkvNbB1EIPPwHcdkF6b1oUA=="], + "@grammyjs/types": ["@grammyjs/types@5.0.0", "", {}, "sha512-iq1Qrq1iPKkB8yAa0qSuIURMZOCuqTY5pWy5gHpCeL1oQ+GPadGhw/cDTVE8waJwuCzacUzuIjRv1sESvk7u7A=="], + "@graphql-tools/executor": ["@graphql-tools/executor@2.0.0", "", { "dependencies": { "@graphql-tools/utils": "^12.0.0", "@graphql-typed-document-node/core": "^3.2.0", "@repeaterjs/repeater": "^3.1.0", "@whatwg-node/disposablestack": "^0.0.6", "@whatwg-node/promise-helpers": "^1.0.0", "tslib": "^2.4.0" }, "peerDependencies": { "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, "sha512-BjoqO5UfcV3BUqamGtHNm4ITBk040VOmWql/MrUefPo30x6t4v1+engfjgAjWikMsRPLU4ZC6e2QjICLucnOTA=="], "@graphql-tools/merge": ["@graphql-tools/merge@9.2.3", "", { "dependencies": { "@graphql-tools/utils": "^12.0.0", "tslib": "^2.4.0" }, "peerDependencies": { "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, "sha512-cKRoXqJGy2zSRBLvotQpkACbXlHAb0yuHLN0l0ypKGCuL3NnF0zofYalkBJqiBxxxpK0lr3s9uowKFHCKi1/ZQ=="], @@ -577,6 +603,12 @@ "@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.62.5", "", { "os": "win32", "cpu": "x64" }, "sha512-/gDJaRs4gl0NPIwqCz+6PkpmhhjRAD2j6P4rSNHBzUkO3naEx2mIU0pRle1vUNRQ7mE/+8OOeXLTv/J56FKiQg=="], + "@sapphire/async-queue": ["@sapphire/async-queue@1.5.5", "", {}, "sha512-cvGzxbba6sav2zZkH8GPf2oGk9yYoD5qrNWdu9fRehifgnFZJMV+nuy2nON2roRO4yQQ+v7MK/Pktl/HgfsUXg=="], + + "@sapphire/shapeshift": ["@sapphire/shapeshift@4.0.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "lodash": "^4.17.21" } }, "sha512-d9dUmWVA7MMiKobL3VpLF8P2aeanRTu6ypG2OIaEv/ZHH/SUQ2iHOVyi5wAPjQ+HmnMuL0whK9ez8I/raWbtIg=="], + + "@sapphire/snowflake": ["@sapphire/snowflake@3.5.5", "", {}, "sha512-xzvBr1Q1c4lCe7i6sRnrofxeO1QTP/LKQ6A6qy0iB4x5yfiSfARMEQEghojzTNALDTcv8En04qYNIco9/K9eZQ=="], + "@scarf/scarf": ["@scarf/scarf@1.4.0", "", {}, "sha512-xxeapPiUXdZAE3che6f3xogoJPeZgig6omHEy1rIY5WVsB3H2BHNnZH+gHG6x91SCWyQCzWGsuL2Hh3ClO5/qQ=="], "@sec-ant/readable-stream": ["@sec-ant/readable-stream@0.4.1", "", {}, "sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg=="], @@ -847,6 +879,8 @@ "@vitejs/plugin-react": ["@vitejs/plugin-react@5.2.0", "", { "dependencies": { "@babel/core": "^7.29.0", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-rc.3", "@types/babel__core": "^7.20.5", "react-refresh": "^0.18.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-YmKkfhOAi3wsB1PhJq5Scj3GXMn3WvtQ/JC0xoopuHoXSdmtdStOpFrYaT1kie2YgFBcIe64ROzMYRjCrYOdYw=="], + "@vladfrangu/async_event_emitter": ["@vladfrangu/async_event_emitter@2.4.7", "", {}, "sha512-Xfe6rpCTxSxfbswi/W/Pz7zp1WWSNn4A0eW4mLkQUewCrXXtMj31lCg+iQyTkh/CkusZSq9eDflu7tjEDXUY6g=="], + "@whatwg-node/disposablestack": ["@whatwg-node/disposablestack@0.0.6", "", { "dependencies": { "@whatwg-node/promise-helpers": "^1.0.0", "tslib": "^2.6.3" } }, "sha512-LOtTn+JgJvX8WfBVJtF08TGrdjuFzGJc4mkP8EdDI8ADbvO7kiexYep1o8dwnt0okb0jYclCDXF13xU7Ge4zSw=="], "@whatwg-node/events": ["@whatwg-node/events@0.1.2", "", { "dependencies": { "tslib": "^2.6.3" } }, "sha512-ApcWxkrs1WmEMS2CaLLFUEem/49erT3sxIVjpzU5f6zmVcnijtDSrhoK2zVobOIikZJdH63jdAXOrvjf6eOUNQ=="], @@ -867,7 +901,7 @@ "accepts": ["accepts@1.3.8", "", { "dependencies": { "mime-types": "~2.1.34", "negotiator": "0.6.3" } }, "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw=="], - "agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="], + "agent-base": ["agent-base@6.0.2", "", { "dependencies": { "debug": "4" } }, "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ=="], "ai": ["ai@6.0.264", "", { "dependencies": { "@ai-sdk/gateway": "3.0.179", "@ai-sdk/provider": "3.0.15", "@ai-sdk/provider-utils": "4.0.46", "@opentelemetry/api": "^1.9.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-te59gArDNXoQYWuOkZNdM7n3McETAPWFuwT/iKWRJXySV/doIuKm8Az4X6IOalaGp3K8zqGT/ICn08z5ZZNDuw=="], @@ -1149,6 +1183,10 @@ "diff": ["diff@8.0.4", "", {}, "sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw=="], + "discord-api-types": ["discord-api-types@0.38.53", "", {}, "sha512-HL1zz/UuZ+bbJjA/X8Kbxx9gk8v9rJAbTeWRNYKmIdjwJ7EovjlHgoJTxcLpATfNJ+AonOtMdy3Y5MVIJAAd/A=="], + + "discord.js": ["discord.js@14.27.0", "", { "dependencies": { "@discordjs/builders": "^1.14.1", "@discordjs/collection": "1.5.3", "@discordjs/formatters": "^0.6.2", "@discordjs/rest": "^2.6.2", "@discordjs/util": "^1.2.0", "@discordjs/ws": "^1.2.3", "@sapphire/snowflake": "3.5.5", "discord-api-types": "^0.38.49", "fast-deep-equal": "3.1.3", "lodash.snakecase": "4.1.1", "magic-bytes.js": "^1.13.0", "tslib": "^2.6.3", "undici": "^6.27.0" } }, "sha512-qHbFlFG2N7y3LjPySYsL6A1+BnX6bkTVgo842EX0CqVPk/KTMwZkojPHEXKsQUpWZNyz5BISNHK1cPpQw0+m4A=="], + "dom-accessibility-api": ["dom-accessibility-api@0.5.16", "", {}, "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg=="], "dompurify": ["dompurify@3.4.14", "", { "optionalDependencies": { "@types/trusted-types": "^2.0.7" } }, "sha512-dVoH9z+MY+C9IilgGCk3YfFqjLi3fChm2OiKJMzh6axrJ5qwxqWaZamgmHrpv22CN/KdbZJuGEGgfQoL00LTdg=="], @@ -1213,7 +1251,7 @@ "event-target-shim": ["event-target-shim@5.0.1", "", {}, "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ=="], - "eventemitter3": ["eventemitter3@4.0.7", "", {}, "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw=="], + "eventemitter3": ["eventemitter3@5.0.4", "", {}, "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw=="], "events": ["events@3.3.0", "", {}, "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q=="], @@ -1311,6 +1349,8 @@ "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="], + "grammy": ["grammy@1.46.0", "", { "dependencies": { "@grammyjs/types": "5.0.0", "abort-controller": "^3.0.0", "debug": "^4.4.3", "node-fetch": "^2.7.0" } }, "sha512-/8Qw+iisrUdOMk+p2mjEHouMm/BBdBEN1DHh16wiTpRUZkxDG3PxexdjCvR+wvK3LWPdrEvnQbdrwpU954sPhg=="], + "graphql": ["graphql@16.14.2", "", {}, "sha512-Chq1s4CY7jmh8gO2qvLIJyfCDIN+EHLFW/9iShnp1z8FjBQMoodWP1kDC36VAMXXIvAjj4ARa7ntfAV2BrjsbA=="], "graphql-query-complexity": ["graphql-query-complexity@0.12.0", "", { "dependencies": { "lodash.get": "^4.4.2" }, "peerDependencies": { "graphql": "^14.6.0 || ^15.0.0 || ^16.0.0" } }, "sha512-fWEyuSL6g/+nSiIRgIipfI6UXTI7bAxrpPlCY1c0+V3pAEUo1ybaKmSBgNr1ed2r+agm1plJww8Loig9y6s2dw=="], @@ -1373,7 +1413,7 @@ "http-proxy-agent": ["http-proxy-agent@7.0.2", "", { "dependencies": { "agent-base": "^7.1.0", "debug": "^4.3.4" } }, "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig=="], - "https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="], + "https-proxy-agent": ["https-proxy-agent@5.0.1", "", { "dependencies": { "agent-base": "6", "debug": "4" } }, "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA=="], "human-signals": ["human-signals@8.0.1", "", {}, "sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ=="], @@ -1535,6 +1575,8 @@ "locate-path": ["locate-path@3.0.0", "", { "dependencies": { "p-locate": "^3.0.0", "path-exists": "^3.0.0" } }, "sha512-7AO748wWnIhNqAuaty2ZWHkQHRSNfPVIsPIfwEOWO22AmaoVrWavlOcMR5nzTLNYvp36X220/maaRsrec1G65A=="], + "lodash": ["lodash@4.18.1", "", {}, "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q=="], + "lodash-es": ["lodash-es@4.17.21", "", {}, "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw=="], "lodash.clonedeep": ["lodash.clonedeep@4.5.0", "", {}, "sha512-H5ZhCF25riFd9uB5UCkVKo61m3S/xZk1x4wA6yp/L3RFP6Z/eHH1ymQcGLo7J3GMPfm0V/7m1tryHuGVxpqEBQ=="], @@ -1555,6 +1597,8 @@ "lodash.once": ["lodash.once@4.1.1", "", {}, "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg=="], + "lodash.snakecase": ["lodash.snakecase@4.1.1", "", {}, "sha512-QZ1d4xoBHYUeuouhEq3lk3Uq7ldgyFXGBhg04+oRLnIz8o9T65Eh+8YdroUwn846zchkA9yDsDl5CVVaV2nqYw=="], + "log-symbols": ["log-symbols@6.0.0", "", { "dependencies": { "chalk": "^5.3.0", "is-unicode-supported": "^1.3.0" } }, "sha512-i24m8rpwhmPIS4zscNzK6MSEhk0DUWa/8iYQWxhffV8jkI4Phvs3F+quL5xvS0gdQR0FyTCMMH33Y78dDTzzIw=="], "longest-streak": ["longest-streak@3.1.0", "", {}, "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g=="], @@ -1573,6 +1617,8 @@ "lz-string": ["lz-string@1.5.0", "", { "bin": { "lz-string": "bin/bin.js" } }, "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ=="], + "magic-bytes.js": ["magic-bytes.js@1.13.1", "", {}, "sha512-x5sn4UX2k5gCWlcfmoFwG4TPie8+dctESyqOBdhB5p6MsgWXdBKGmt9nXPObj/JI50TTL928lc5Yt1WntMn1bw=="], + "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], "markdown-table": ["markdown-table@3.0.4", "", {}, "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw=="], @@ -2089,6 +2135,8 @@ "ts-dedent": ["ts-dedent@2.3.0", "", {}, "sha512-JfJeIHke7y2egdGGgRAvpCwYFUsHlM2gPcrVOxFkznt/4uzQ7HFmvE63iFHVLBJNDuyDOQgijDK/tXH/f6Msjg=="], + "ts-mixer": ["ts-mixer@6.0.4", "", {}, "sha512-ufKpbmrugz5Aou4wcr5Wc1UUFWOLhq+Fm6qa6P0w0K5Qw2yhaUoiWszhCVuNQyNwrlGiscHOmqYoAox1PtvgjA=="], + "ts-morph": ["ts-morph@26.0.0", "", { "dependencies": { "@ts-morph/common": "~0.27.0", "code-block-writer": "^13.0.3" } }, "sha512-ztMO++owQnz8c/gIENcM9XfCEzgoGphTv+nKpYNM1bgsdOVC/jRZuEBf6N+mLLDNg68Kl+GgUZfOySaRiG1/Ug=="], "tsconfig-paths": ["tsconfig-paths@4.2.0", "", { "dependencies": { "json5": "^2.2.2", "minimist": "^1.2.6", "strip-bom": "^3.0.0" } }, "sha512-NoZ4roiN7LnbKn9QqE1amc9DJfzvZXxF4xDavcOWt1BPkdx+m+0gJuPM+S0vCe7zTJMYUP0R8pO2XMr+Y8oLIg=="], @@ -2253,10 +2301,14 @@ "@copilotkit/a2ui-renderer/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + "@copilotkit/channels-discord/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + "@copilotkit/channels-slack/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], "@copilotkit/channels-teams/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + "@copilotkit/channels-telegram/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + "@copilotkit/react-core/streamdown": ["streamdown@1.6.11", "", { "dependencies": { "clsx": "^2.1.1", "hast": "^1.0.0", "hast-util-to-jsx-runtime": "^2.3.6", "html-url-attributes": "^3.0.1", "katex": "^0.16.22", "lucide-react": "^0.542.0", "marked": "^16.2.1", "mermaid": "^11.11.0", "rehype-harden": "^1.1.6", "rehype-katex": "^7.0.1", "rehype-raw": "^7.0.0", "rehype-sanitize": "^6.0.0", "remark-cjk-friendly": "^1.2.3", "remark-cjk-friendly-gfm-strikethrough": "^1.2.3", "remark-gfm": "^4.0.1", "remark-math": "^6.0.0", "remark-parse": "^11.0.0", "remark-rehype": "^11.1.2", "remend": "1.0.1", "shiki": "^3.12.2", "tailwind-merge": "^3.3.1", "unified": "^11.0.5", "unist-util-visit": "^5.0.0" }, "peerDependencies": { "react": "^18.0.0 || ^19.0.0" } }, "sha512-Y38fwRx5kCKTluwM+Gf27jbbi9q6Qy+WC9YrC1YbCpMkktT3PsRBJHMWiqYeF8y/JzLpB1IzDoeaB6qkQEDnAA=="], "@copilotkit/runtime/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], @@ -2265,6 +2317,12 @@ "@copilotkit/web-inspector/marked": ["marked@12.0.2", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-qXUm7e/YKFoqFPYPa3Ukg9xlI5cyAtGmyEIzMfW//m6kXwCy2Ps9DYf5ioijFKQ8qyuscrHoY04iJGctu2Kg0Q=="], + "@discordjs/rest/@discordjs/collection": ["@discordjs/collection@2.1.1", "", {}, "sha512-LiSusze9Tc7qF03sLCujF5iZp7K+vRNEDBZ86FT9aQAv3vxMLihUvKvpsCWiQ2DJq1tVckopKm1rxomgNUc9hg=="], + + "@discordjs/rest/undici": ["undici@6.28.0", "", {}, "sha512-LIY910g9TI13YS95lrMFrs8Rm/u/irgHeTWoKCoteeJ04CUJ92eEfj0rVn+7VKMPBpUPiUoBKfhNyLI23EE/KA=="], + + "@discordjs/ws/@discordjs/collection": ["@discordjs/collection@2.1.1", "", {}, "sha512-LiSusze9Tc7qF03sLCujF5iZp7K+vRNEDBZ86FT9aQAv3vxMLihUvKvpsCWiQ2DJq1tVckopKm1rxomgNUc9hg=="], + "@dotenvx/dotenvx/commander": ["commander@11.1.0", "", {}, "sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ=="], "@dotenvx/dotenvx/execa": ["execa@5.1.1", "", { "dependencies": { "cross-spawn": "^7.0.3", "get-stream": "^6.0.0", "human-signals": "^2.1.0", "is-stream": "^2.0.0", "merge-stream": "^2.0.0", "npm-run-path": "^4.0.1", "onetime": "^5.1.2", "signal-exit": "^3.0.3", "strip-final-newline": "^2.0.0" } }, "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg=="], @@ -2299,10 +2357,6 @@ "@slack/bolt/path-to-regexp": ["path-to-regexp@8.4.2", "", {}, "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA=="], - "@slack/socket-mode/eventemitter3": ["eventemitter3@5.0.4", "", {}, "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw=="], - - "@slack/web-api/eventemitter3": ["eventemitter3@5.0.4", "", {}, "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw=="], - "@slack/web-api/is-stream": ["is-stream@2.0.1", "", {}, "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg=="], "@slack/web-api/p-retry": ["p-retry@4.6.2", "", { "dependencies": { "@types/retry": "0.12.0", "retry": "^0.13.1" } }, "sha512-312Id396EbJdvRONlngUx0NydfrIQ5lsYu0znKVUzVvArzEIt08V1qhtyESbGVd1FGX7UKtiFp5uwKZdM8wIuQ=="], @@ -2325,12 +2379,12 @@ "@types/mdast/@types/unist": ["@types/unist@3.0.3", "", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="], + "@typespec/ts-http-runtime/https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="], + "@whatwg-node/node-fetch/@fastify/busboy": ["@fastify/busboy@3.2.2", "", {}, "sha512-yXSS27qPExaXeuLvMRMXOLtpipzfQYNjG3FkunDWKGfMYjKuhFXko9CVzqxm8jcF+lmtS9Fd89QNdh9XDjnbNg=="], "accepts/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="], - "axios/https-proxy-agent": ["https-proxy-agent@5.0.1", "", { "dependencies": { "agent-base": "6", "debug": "4" } }, "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA=="], - "better-call/@better-auth/utils": ["@better-auth/utils@0.5.0", "", { "dependencies": { "@noble/hashes": "^2.0.1" } }, "sha512-BL8W4EfIZFwlu0r54m3v1ztjDhu6dDe/amLTm0xybmbZaNgYUqhD3SjpAsnq0q8YD6/ki4iwIgxJNLP/N3TxiA=="], "body-parser/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="], @@ -2359,6 +2413,8 @@ "d3-sankey/d3-shape": ["d3-shape@1.3.7", "", { "dependencies": { "d3-path": "1" } }, "sha512-EUkvKjqPFUAZyOlhY5gzCxCeI0Aep04LwIRpsZ/mLFelJiUfnK56jo5JMDSE7yyP2kLSb6LtF+S5chMk7uqPqw=="], + "discord.js/undici": ["undici@6.28.0", "", {}, "sha512-LIY910g9TI13YS95lrMFrs8Rm/u/irgHeTWoKCoteeJ04CUJ92eEfj0rVn+7VKMPBpUPiUoBKfhNyLI23EE/KA=="], + "dot-prop/is-obj": ["is-obj@2.0.0", "", {}, "sha512-drqDG3cbczxxEJRoOXcOjtdp1J/lyp1mNn0xaznRs8+muBhgQcrnbspox5X5fOw0HnMnbfDzvnEMEtqDEJEo8w=="], "drizzle-kit/esbuild": ["esbuild@0.25.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.12", "@esbuild/android-arm": "0.25.12", "@esbuild/android-arm64": "0.25.12", "@esbuild/android-x64": "0.25.12", "@esbuild/darwin-arm64": "0.25.12", "@esbuild/darwin-x64": "0.25.12", "@esbuild/freebsd-arm64": "0.25.12", "@esbuild/freebsd-x64": "0.25.12", "@esbuild/linux-arm": "0.25.12", "@esbuild/linux-arm64": "0.25.12", "@esbuild/linux-ia32": "0.25.12", "@esbuild/linux-loong64": "0.25.12", "@esbuild/linux-mips64el": "0.25.12", "@esbuild/linux-ppc64": "0.25.12", "@esbuild/linux-riscv64": "0.25.12", "@esbuild/linux-s390x": "0.25.12", "@esbuild/linux-x64": "0.25.12", "@esbuild/netbsd-arm64": "0.25.12", "@esbuild/netbsd-x64": "0.25.12", "@esbuild/openbsd-arm64": "0.25.12", "@esbuild/openbsd-x64": "0.25.12", "@esbuild/openharmony-arm64": "0.25.12", "@esbuild/sunos-x64": "0.25.12", "@esbuild/win32-arm64": "0.25.12", "@esbuild/win32-ia32": "0.25.12", "@esbuild/win32-x64": "0.25.12" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg=="], @@ -2371,6 +2427,8 @@ "form-data/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="], + "gaxios/https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="], + "gaxios/node-fetch": ["node-fetch@3.3.2", "", { "dependencies": { "data-uri-to-buffer": "^4.0.0", "fetch-blob": "^3.1.4", "formdata-polyfill": "^4.0.10" } }, "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA=="], "hast-util-from-dom/@types/hast": ["@types/hast@3.0.5", "", { "dependencies": { "@types/unist": "*" } }, "sha512-rp/ezSWaD1m44dPKICGhiskI13nVr7qTloFwDa/IYkhhf5nzwP+zIQcIJh3WIFSBOy/H1PzB40jPjMDksN4F+g=="], @@ -2429,6 +2487,8 @@ "hastscript/property-information": ["property-information@7.2.0", "", {}, "sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg=="], + "http-proxy-agent/agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="], + "is-inside-container/is-docker": ["is-docker@3.0.0", "", { "bin": { "is-docker": "cli.js" } }, "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ=="], "jsonwebtoken/semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="], @@ -2477,6 +2537,8 @@ "ora/chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="], + "p-queue/eventemitter3": ["eventemitter3@4.0.7", "", {}, "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw=="], + "parse5/entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="], "pretty-format/react-is": ["react-is@17.0.2", "", {}, "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w=="], @@ -2639,8 +2701,6 @@ "@esbuild-kit/core-utils/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.18.20", "", { "os": "win32", "cpu": "x64" }, "sha512-kTdfRcSiDfQca/y9QIkng02avJ+NCaQvrMejlsB3RRv5sE9rRoeBPISaZpKxHELzRxZyLvNts1P27W3wV+8geQ=="], - "@langchain/langgraph-sdk/p-queue/eventemitter3": ["eventemitter3@5.0.4", "", {}, "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw=="], - "@langchain/langgraph-sdk/p-queue/p-timeout": ["p-timeout@7.0.1", "", {}, "sha512-AxTM2wDGORHGEkPCt8yqxOTMgpfbEHqF51f/5fJCmwFC3C/zNcGT63SymH2ttOAaiIws2zVg4+izQCjrakcwHg=="], "@modelcontextprotocol/sdk/express/accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="], @@ -2689,9 +2749,9 @@ "@tanstack/react-router/@tanstack/react-store/@tanstack/store": ["@tanstack/store@0.9.3", "", {}, "sha512-8reSzl/qGWGGVKhBoxXPMWzATSbZLZFWhwBAFO9NAyp0TxzfBP0mIrGb8CP8KrQTmvzXlR/vFPPUrHTLBGyFyw=="], - "accepts/mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="], + "@typespec/ts-http-runtime/https-proxy-agent/agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="], - "axios/https-proxy-agent/agent-base": ["agent-base@6.0.2", "", { "dependencies": { "debug": "4" } }, "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ=="], + "accepts/mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="], "body-parser/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="], @@ -2761,6 +2821,8 @@ "form-data/mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="], + "gaxios/https-proxy-agent/agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="], + "hast-util-from-dom/@types/hast/@types/unist": ["@types/unist@3.0.3", "", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="], "hast-util-from-html-isomorphic/@types/hast/@types/unist": ["@types/unist@3.0.3", "", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="], diff --git a/docs/architecture.md b/docs/architecture.md index 46b4e2de..a9b9ef29 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -35,6 +35,31 @@ The compose file also defines optional SPIRE services. `start.sh` does not start 5. Acting browser/file/MCP calls return to the server for authorization and audit. 6. The server streams results back to the app and Intelligence thread. +### Managed Slack flow + +OpenBot declares one adapter-free CopilotKit Channel named `openbot`. CopilotKit Intelligence owns +the realtime connection, Slack provider attachment, credentialed ingress and delivery, reconnects, +and provider-side transport state. The OpenBot process runs the Channel handlers, identity checks, +thread subscriptions, tools, native component logic, and delegated coworker. Channels coordinates +streaming, files, deduplication, and continuation rendering across those two sides. The setup CLI may +temporarily receive Slack credentials to attach the provider, but the running OpenBot server does +not require or read them. + +For each Slack event, the server resolves the provider identity to an active OpenBot user before it +runs a coworker. Provider tenant and user ids stay in a server-private execution context rather than +the model prompt. The first message in a Slack thread is routed across only the coworkers that user +may access, and the selected coworker is stored as an immutable thread binding. Later replies in the +subscribed Slack thread use that coworker. Each speaker is resolved again as their own OpenBot actor, +so the coworker's current visibility, tool grants, connector identity, policy, and audit attribution +apply to the person making that turn rather than to the person who started the thread. + +Interactive approvals are durable continuations. A click is accepted only after OpenBot rechecks +the live Slack identity, linked OpenBot user, immutable thread binding, and current coworker access; +the continuation resumes once. Human control and secret entry use a sealed, ten-minute OpenBot link +instead of collecting sensitive input in Slack. + +See [Slack](slack.md) for setup, limits, readiness, and the deployment smoke test. + ## Browser action governance The computer itself does not decide policy. The server gateway is the action boundary: @@ -291,3 +316,11 @@ Connector credentials are stored through the credential vault and referenced by - Browser navigation allows `http` and `https`; cloud metadata addresses are refused under every configuration. - `AGENT_COMPUTER_ALLOW_PRIVATE_HOSTS=true` is for local development only, and a deployment running with `NODE_ENV=production` refuses to start while it is set. - Computer tokens and supervisor tokens must be long random values outside local development. +- Managed Slack credentials live in CopilotKit Intelligence after provider attachment and are never + read or persisted by the OpenBot runtime. The setup CLI may receive temporary bootstrap copies, + which operators remove before starting OpenBot. Slack provider ids are external identity + metadata, not OpenBot authorization ids. +- A Slack turn and approval recheck the linked OpenBot user and coworker access. Acting tools still + cross the existing grant, policy, and audit boundaries; Slack does not provide a privileged path. +- Passwords, one-time codes, card values, model keys, and connector credentials are entered only on + authenticated OpenBot pages, never in Slack or in a model prompt. diff --git a/docs/configuration.md b/docs/configuration.md index e9ec67d1..0fd74eb1 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -27,6 +27,64 @@ bash scripts/start.sh All four Intelligence values are required together. Missing any of them stops server startup. +## Managed Slack + +OpenBot declares one managed CopilotKit Intelligence Channel named `openbot`. It does not construct +a local Slack adapter and does not read Slack bot tokens, app tokens, or signing secrets. Configure +the four Intelligence variables above and a usable app URL, either through `OPENBOT_APP_URL` or its +documented fallback, then attach Slack to that same Intelligence project with: + +```sh +npx copilotkit@latest login +npx copilotkit@latest project select +npx copilotkit@latest channels setup +``` + +The command installs the current Channels setup guidance and prints the prompt used by the guided +flow. Sign in first and select the existing Intelligence project whose runtime configuration OpenBot +uses. In the guided flow, choose Channel name `openbot` and Slack as the provider. + +The current CLI needs the Slack bot token and signing secret while it attaches the provider. For +Channel `openbot`, its generated environment names are +`INTELLIGENCE_CHANNEL_OPENBOT_SLACK_BOT_TOKEN` and +`INTELLIGENCE_CHANNEL_OPENBOT_SLACK_SIGNING_SECRET`. Supply them only through an ignored local +`.env`, shell environment, or preferably the CLI's `--credentials-stdin` path backed by a secret +manager. Never commit them. OpenBot's server loads the complete `.env`, even though its runtime code +does not read these names, so remove local copies or unset shell variables immediately after the CLI +reports a successful attachment and before starting OpenBot. CopilotKit Intelligence stores and +uses the provider credentials after attachment; the running OpenBot process does not require them. + +Use the current CLI to inspect the declared and attached state: + +```sh +npx copilotkit@latest channels status +npx copilotkit@latest channels list +npx copilotkit@latest channels providers +``` + +The public `GET /api/capabilities` response exposes only `channels.slack.status`, +`channels.slack.transport`, and `channels.slack.provider`. It is safe for a credential-free health +check. A provider value of `not_attached` and status of `setup_required` means setup is incomplete; +`provider: attached` confirms attachment, while `status: online` confirms the complete Channel is +ready. Do not treat HTTP `/health` alone as proof that Slack is connected: the web server remains +available when managed Channel activation fails so an operator can repair setup. + +`OPENBOT_APP_URL` is also the origin of the authenticated account-link and assistance links posted +to Slack. It must be HTTPS outside local development; loopback HTTP is accepted locally. If it is +absent or unusable, Slack can connect but people who cannot be linked automatically cannot finish +linking, and secure human-assistance tools are unavailable. + +`OPENBOT_SLACK_TENANT_ID` is a non-secret, operator-owned Slack workspace/team ID for a +single-workspace deployment. Set it only when managed Channels omits canonical tenant metadata from +deliveries. OpenBot uses it for an absent, blank, or `unknown` managed tenant; it never overrides a +different known tenant, and a conflict is refused before identity linking or storage. It is not +derived from raw Slack payloads, actors, email addresses, channels, or installation IDs. One value +supports one workspace per OpenBot deployment. Remove it once Channels supplies canonical tenant +metadata for every managed delivery. + +See [Slack](slack.md) for the manifest capability review, account-link flow, operating limits, +security rules, troubleshooting, and release smoke test. + `MANAGED_AGENT_AG_UI_URL` names the Bot in the box: the default endpoint for coworkers created in the product. It needs `MANAGED_AGENT_TOKEN` beside it, or the server refuses to start. Unset, the server starts without a managed Bot, the shipped Risk Analyst coworker is omitted, and creating a @@ -42,6 +100,7 @@ at `agent-langgraph` on a laptop. | `NODE_ENV` | unset | `production` refuses the example `KEY_ENCRYPTION_KEY`. It does not decide whether sign-in is required; see `OPENBOT_SINGLE_USER`. | | `TENANT_PACKAGE_DIR` | `../examples/fintech` | Tenant package directory, resolved from `server/`. | | `DEPLOYMENT_ID` | the tenant package's id | Names this deployment inside a shared Intelligence project. | +| `OPENBOT_SLACK_TENANT_ID` | unset | Single-workspace fallback used only when managed Slack omits canonical tenant metadata. | | `OPENAI_API_KEY` | unset | Default model key for built-in agents and both shipped Bots. | | `OPENAI_BASE_URL` | unset | OpenAI-compatible endpoint that key is spent against. See below. | | `BOT_PROVIDER` | `openai` | Provider for `agent-langgraph`: `openai`, `anthropic`, or `google`. | @@ -187,11 +246,15 @@ added it. The client secret and any SAML signing material are encrypted at rest The redirect URI to register with each provider is `/api/auth/callback/`, where `` is `google`, `microsoft` or `okta`. -`OPENBOT_PUBLIC_URL` and `OPENBOT_APP_URL` matter only for a connector each person connects their own account to, such as Google Drive. +`OPENBOT_PUBLIC_URL` is used by connectors each person connects to, such as Google Drive. `OPENBOT_PUBLIC_URL` builds the redirect URI the vendor sends somebody back to after they consent, which has to match what an administrator registered with that vendor character for character — so it comes from configuration rather than from the incoming request. Most deployments never set it, because `BETTER_AUTH_URL` is already the same public address. With neither, the Plugins page says the deployment cannot complete a consent flow, and no account can be connected. -`OPENBOT_APP_URL` is where the callback sends the person afterwards. It is a separate setting because the app and the API are separate addresses: locally the app is Vite on `3010` and the API is `3001`, so a relative redirect would land on the API, which serves no pages. A deployment serving both from one origin can leave it unset. +`OPENBOT_APP_URL` is where a connector callback sends the person afterwards, and where managed Slack +sends people to link an account or complete secure assistance. It is a separate setting because the +app and the API are separate addresses: locally the app is Vite on `3010` and the API is `3001`, so +a relative redirect would land on the API, which serves no pages. A deployment serving both from +one origin can leave it unset when the fallback resolves to that same app origin. ## One Bot handing work to another diff --git a/docs/slack.md b/docs/slack.md new file mode 100644 index 00000000..34372c56 --- /dev/null +++ b/docs/slack.md @@ -0,0 +1,289 @@ +# Slack + +OpenBot can appear in Slack as one managed `@OpenBot` assistant while continuing to run the +coworkers and authorization rules already configured in OpenBot. After this setup, a person can tag +`@OpenBot`, name an accessible coworker such as Risk Analyst, and continue in the same Slack thread. + +## Before you attach Slack + +You need: + +- a running OpenBot deployment with PostgreSQL migrations applied; +- all four CopilotKit Intelligence values from [Configuration](configuration.md) for the existing + hosted project; +- an `OPENBOT_APP_URL` that reaches the browser app, using HTTPS except for loopback development; +- an OpenBot identity provider for a multi-user deployment, with users and coworker access already + configured; and +- permission to create and install a Slack app in a test workspace. + +Release one exposes one Slack bot identity, `@OpenBot`. The coworker selected behind that identity is +an existing OpenBot coworker, not another Slack app or Slack user. + +## Attach the managed Channel + +From the OpenBot checkout, run: + +```sh +npx copilotkit@latest login +npx copilotkit@latest project select +npx copilotkit@latest channels setup +``` + +The first two commands sign the CLI in and select the existing Intelligence project whose runtime +configuration OpenBot uses. `channels setup` installs the current Channels setup guidance and +prints a prompt for the guided setup. Follow that prompt, and choose: + +1. Channel name `openbot`; and +2. Slack as the provider. + +The `setup` command itself does not provision a project. The guided flow reconciles the Channel and +walks you through the provider-console steps. Follow the exact resume command it prints whenever it +stops for an operator action. Do not create a second `openbot` Channel for the same project. + +### Handle the attachment credentials + +The current CLI needs the Slack bot token and signing secret long enough to attach the provider. For +Channel `openbot`, the CLI-generated environment names are: + +- `INTELLIGENCE_CHANNEL_OPENBOT_SLACK_BOT_TOKEN` +- `INTELLIGENCE_CHANNEL_OPENBOT_SLACK_SIGNING_SECRET` + +Use the exact names and resume command printed by the current CLI. It can read them from an ignored +local `.env`, from the shell environment, or from a credential document on standard input. Prefer a +secret manager that emits the credential document directly into the exact CLI resume command with +`--credentials-stdin`. The document must use the schema requested by the CLI; do not put literal +credentials in shell history or an intermediate tracked file. No CLI flag accepts a credential +value. + +After attachment, CopilotKit Intelligence stores and uses the provider credentials. The running +OpenBot server neither requires nor reads them. However, OpenBot loads the entire `.env` into its +process, so remove the two temporary lines from a local `.env` or unset their shell variables as +soon as attachment succeeds and before starting OpenBot. Never put them in `.env.example`, a tenant +package, a deployment change record, or source control. + +### Review the generated Slack manifest + +Inspect and approve the manifest generated by the current CLI before installing it. Treat that +generated manifest as the source of truth; do not copy a hand-written scope list from this guide. +Slack and Channels evolve, and an exact list copied into documentation becomes an unsafe stale +allow-list. + +The manifest must cover only the capabilities the managed adapter needs: + +- receive app mentions and the message/thread events needed for subscribed conversations; +- read conversations in the supported channel, group, direct-message, and multi-person contexts; +- post and update threaded responses and interactive continuations; +- read attached files and upload files OpenBot intentionally shares; +- read the human user's profile; and +- receive the interactions and reactions used by the managed Slack experience. + +Compare those categories with the CLI-generated manifest and Slack's install review. If the +generated manifest requests a capability you cannot approve, stop instead of deleting it and +assuming the adapter will degrade safely. Re-run the current setup flow after a Channels upgrade so +the manifest and provider event configuration are reconciled together. + +The CLI 4.9.2 manifest grants profile read but does not grant Slack's separate email-read scope, and +the Channels 0.9 actor contract has an optional email but no verification flag. Therefore the +default generated manifest does not provide a trustworthy verified email for automatic linking. +Do not silently add a remembered email scope by hand. Use the explicit authenticated link below and +treat the next CLI-generated manifest as the source of truth after any upgrade. + +## Verify readiness without credentials + +The CLI compares local declaration, source, and provider attachment: + +```sh +npx copilotkit@latest channels status +npx copilotkit@latest channels list +``` + +OpenBot also exposes a deliberately narrow public projection: + +```sh +curl -sS https://openbot.example.com/api/capabilities +``` + +Inspect only `channels.slack`: + +```json +{ + "status": "online", + "transport": "online", + "provider": "attached" +} +``` + +The projection contains no Intelligence URL or key, licence, provider credential, installation id, +Slack workspace id, or actor id. Interpret common states as follows: + +| State | Meaning | Action | +| --- | --- | --- | +| `status: online`, `provider: attached` | Managed Slack is attached and the runtime transport is ready. | Continue to the smoke test. | +| `status: setup_required`, `provider: not_attached` | OpenBot declared `openbot`, but no Slack provider is attached. | Run `npx copilotkit@latest channels setup` and complete the guided flow. | +| `status: connecting` or `reconnecting` | Attachment exists, but the runtime has not reached a stable connection. | Wait briefly, then inspect `channels status` and server logs. | +| `status: error` or `provider: unhealthy` | Managed activation or the provider connection failed. | Inspect the CLI diagnostics and the server's `OpenBot Slack Channel activation failed` log. | +| `status: stopped`, `provider: unknown` | No managed Channel status was available to the OpenBot runtime. | Verify the Intelligence configuration, Channel declaration, and running server build. | + +`GET /health` proves only that the HTTP server is serving requests. Managed Channel activation is +observable but deliberately non-fatal, so use `/api/capabilities` and `channels status` to decide +whether Slack is ready. + +### Configure the single-workspace tenant bridge when required + +Managed Channels 0.9 may deliver Slack identity context with its canonical tenant omitted and +represented as `unknown`. A deployment serving exactly one attached Slack workspace can set +`OPENBOT_SLACK_TENANT_ID` to that workspace/team ID. The value is an operator-owned identifier, not +a Slack credential. + +OpenBot uses this setting only when the managed tenant is absent, blank, or `unknown`. A known +managed tenant remains authoritative and must exactly match configuration; a conflict fails before +identity linking, ingress persistence, thread binding, or coworker execution. OpenBot never derives +the workspace boundary from `raw` provider data, a Slack channel, actor ID, actor email, or +installation ID. Supporting multiple workspaces requires canonical tenant metadata from Channels +or a future administrator-owned authenticated mapping. Remove the bridge when Channels supplies a +canonical tenant on every managed delivery. + +## Link Slack people to OpenBot + +The reliable current path is the explicit authenticated link: `@OpenBot` posts a sealed link to the +OpenBot app, valid for ten minutes. The person signs in to OpenBot, verifies the Slack tenant and +user shown on the confirmation page, and explicitly chooses **Link Slack**. The link binds that Slack +identity to the signed-in OpenBot account; it does not grant coworker or tool access. + +Automatic linking activates only when a future CLI-generated manifest approved by the operator and +its provider contract supply a trustworthy verified email, and all of these are true: + +- the Slack event actor is a human; +- the provider returns a trustworthy verified email for that exact human profile; +- the normalized email matches exactly one verified, non-revoked OpenBot user; and +- that OpenBot user is active and has a current role. + +If any condition is absent or ambiguous, OpenBot uses the explicit link. Operators must not broaden +the generated Slack manifest merely to avoid that confirmation step. + +An expired or invalid link must be restarted from Slack. A Slack identity already linked to a +different OpenBot account, or the same OpenBot account already linked to another Slack identity in +that workspace, is refused rather than reassigned silently. + +## Talk to an OpenBot coworker + +Start a new Slack thread by tagging the one bot identity and naming an accessible coworker: + +```text +@OpenBot ask Risk Analyst to review the attached report +``` + +An exact coworker name takes precedence. If more than one accessible name matches, OpenBot asks you +to name one. Otherwise it uses the same intent router as the web composer. The chosen coworker is +pinned permanently to that Slack thread. Replies in the subscribed thread do not need another +`@OpenBot` mention and cannot switch the thread to a different coworker; start a new Slack thread to +use another one. + +Every message is a new authorization decision for its speaker. OpenBot re-resolves that person's +active account, current access to the pinned coworker, current tool grants, connector identity, and +computer policy. A second participant therefore does not inherit the first participant's grants or +connected accounts. Browser and file actions still pass through `ComputerGateway` and create the +same policy and audit records as the web surface. + +## Approvals and secure assistance + +Consequential actions can render Slack-native approve and reject buttons. A decision is accepted +only for the live linked Slack user in the original conversation, after current access to the pinned +coworker is checked. The stored continuation is claimed once before the coworker resumes. + +When a coworker needs human browser control or a secret, Slack receives only the request label and +an authenticated OpenBot link. The sealed assistance link expires after ten minutes and is bound to +the linked OpenBot user, pinned coworker, and Channels thread. Complete the handoff or enter the +secret in OpenBot. Never paste any of the following into Slack: + +- passwords or passphrases; +- one-time passwords or verification codes; +- payment card values; +- model or API keys; or +- connector credentials. + +OpenBot does not return the secret value to Slack or to the model. It reports only whether the +assistance request completed, expired, was cancelled, or needs operator checking. + +## Release-one limits + +- One Slack identity, `@OpenBot`, serves all existing OpenBot coworkers. +- One immutable coworker is assigned to each Slack thread. +- Every speaker acts with their own current OpenBot access and grants. +- Slack's app and user roster is not mirrored into OpenBot's web roster, and OpenBot coworker + changes do not create separate Slack identities. +- Arbitrary React and sandbox components cannot render natively in Slack. They fall back to text and + an authenticated OpenBot link when interaction needs the web surface. +- Separate Slack identities for each coworker would require separate Slack app installations and + credentials. That is future work. + +## Troubleshooting + +- **The bot never responds:** confirm `channels.slack` is `online` and `attached`, then run + `npx copilotkit@latest channels status`. A healthy `/health` response is not sufficient. +- **The CLI reports setup required:** re-run `npx copilotkit@latest channels setup` and finish the + exact provider-console and resume steps it prints. +- **A person always receives a link:** this is expected with the CLI 4.9.2 generated manifest. Use + the explicit authenticated link. Automatic linking is only available when the provider supplies + trustworthy verified email and exactly one active, verified, non-revoked OpenBot user matches it. +- **The link opens the wrong host or is refused:** set `OPENBOT_APP_URL` to the public HTTPS browser + app origin, restart OpenBot, and request a new link. Old links expire after ten minutes. +- **A mention returns `slack_identity_tenant_invalid`:** for a deployment attached to exactly one + workspace, set `OPENBOT_SLACK_TENANT_ID` to that operator-verified workspace/team ID and restart. + Do not copy a tenant out of raw event data. If Channels supplied a different known tenant, stop: + the configured workspace and attached provider conflict. +- **A reply names a different coworker but the old one runs:** this is expected. Coworker assignment + is immutable per Slack thread; start a new thread. +- **A second participant is refused:** link that participant, then grant their OpenBot account access + to the pinned coworker and required tools. Do not broaden the first participant's grants. +- **A file is not shared:** OpenBot refuses truncated workspace reads, and Slack may reject a file's + size or type. Inspect the safe tool result and audit event; do not assume upload success. +- **Assistance times out:** return to Slack and ask again only after confirming the earlier request is + no longer pending. Never move the secret into Slack as a workaround. + +## Deployment smoke test + +Run this against a test Slack workspace only after readiness reports `online` and `attached`: + +1. Install the generated Slack app and invite `@OpenBot` where required. +2. Tag `@OpenBot` from one Slack user and complete account linking if it was not automatic. +3. Send `@OpenBot ask Risk Analyst to review the attached report` with a harmless test file. +4. Confirm the response streams in a Slack thread. +5. Reply in that thread without tagging `@OpenBot`; confirm Risk Analyst still runs. +6. Link a second user and confirm their own coworker visibility, tool grants, and connector access + apply rather than the first user's. +7. Run one allowed browser action and one action the configured policy refuses. +8. Request browser control and secret assistance; complete both only in authenticated OpenBot. +9. Confirm the audit trail contains `channel.routed`, governed tool/policy, and assistance events + attributed to the linked OpenBot user and pinned coworker, without Slack message bodies or secret + values. For a button decision, verify the one-use outcome in durable `approval_decisions` state; + approval decisions do not currently have a separate audit event. + +Record the date, test workspace label, OpenBot commit, installed `@copilotkit/channels` version, and +pass or fail for every item in the deployment change record. Do not record a Slack workspace id, +provider credentials, account-link token, assistance link, message transcript, or secret. A release +must say explicitly when this real-provider smoke test was not run; local tests and a healthy server +do not make Slack live. + +### Smoke record: 2026-08-27 + +- Workspace: CopilotKit; no workspace id recorded. +- OpenBot commit: `09dcade` (configured checkout, not a deployed runtime). +- `@copilotkit/channels`: `0.9.0`. +- Provider setup: `@OpenBot` installed and the managed `openbot` Channel reports Slack attached. +- Result: **PARTIAL — PROVIDER ONLY**. Slack is not claimed live. +- Blocker: this checkout has no running deployment, model-provider key, licence token, database + configuration, or public `OPENBOT_APP_URL`, so runtime readiness, account linking, replies, tools, + and audit behavior have not been exercised. No credential or provider identifier is recorded. + +| Step | Result | +| --- | --- | +| 1. Install and invite `@OpenBot` | PARTIAL — installed; channel invite not run | +| 2. Link the first Slack user | NOT RUN | +| 3. Ask Risk Analyst to review the test attachment | NOT RUN | +| 4. Confirm a streamed threaded response | NOT RUN | +| 5. Confirm an unmentioned thread reply uses the pinned coworker | NOT RUN | +| 6. Confirm a second user's own access and grants | NOT RUN | +| 7. Exercise one allowed and one policy-refused browser action | NOT RUN | +| 8. Complete control and secret assistance only in OpenBot | NOT RUN | +| 9. Inspect routing, governed tool/policy, assistance, and approval state | NOT RUN | diff --git a/package.json b/package.json index 84c4ee66..75e00949 100644 --- a/package.json +++ b/package.json @@ -33,5 +33,7 @@ "typescript": "^5.9.3", "yaml": "^2.9.0" }, - "dependencies": {} + "dependencies": { + "zod": "^4.4.3" + } } diff --git a/server/drizzle/0024_slack_channels.sql b/server/drizzle/0024_slack_channels.sql new file mode 100644 index 00000000..851e4b13 --- /dev/null +++ b/server/drizzle/0024_slack_channels.sql @@ -0,0 +1,72 @@ +CREATE TABLE "approval_decisions" ( + "presentation_id" uuid PRIMARY KEY NOT NULL, + "channels_thread_id" text NOT NULL, + "conversation_key" text NOT NULL, + "agent_id" text NOT NULL, + "created_by_user_id" text NOT NULL, + "action_id" text, + "approved" boolean, + "decided_by_user_id" text, + "completed_at" timestamp with time zone, + "created_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "external_thread_bindings" ( + "channels_thread_id" text PRIMARY KEY NOT NULL, + "provider" text NOT NULL, + "provider_tenant_id" text NOT NULL, + "provider_conversation_id" text NOT NULL, + "provider_thread_id" text NOT NULL, + "agent_id" text NOT NULL, + "created_by_user_id" text NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "external_thread_bindings_provider_slack_check" CHECK ("external_thread_bindings"."provider" = 'slack') +); +--> statement-breakpoint +CREATE TABLE "external_thread_messages" ( + "sequence" bigserial PRIMARY KEY NOT NULL, + "channels_thread_id" text NOT NULL, + "message_id" text NOT NULL, + "role" text NOT NULL, + "content" text NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "external_thread_messages_role_check" CHECK ("external_thread_messages"."role" IN ('user', 'assistant')) +); +--> statement-breakpoint +CREATE TABLE "external_user_links" ( + "provider" text NOT NULL, + "provider_tenant_id" text NOT NULL, + "provider_user_id" text NOT NULL, + "openbot_user_id" text NOT NULL, + "provider_email" text, + "linked_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "external_user_links_provider_provider_tenant_id_provider_user_id_pk" PRIMARY KEY("provider","provider_tenant_id","provider_user_id") +); +--> statement-breakpoint +ALTER TABLE "approval_decisions" ADD CONSTRAINT "approval_decisions_channels_thread_id_external_thread_bindings_channels_thread_id_fk" FOREIGN KEY ("channels_thread_id") REFERENCES "public"."external_thread_bindings"("channels_thread_id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "approval_decisions" ADD CONSTRAINT "approval_decisions_agent_id_agents_id_fk" FOREIGN KEY ("agent_id") REFERENCES "public"."agents"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "approval_decisions" ADD CONSTRAINT "approval_decisions_created_by_user_id_users_id_fk" FOREIGN KEY ("created_by_user_id") REFERENCES "public"."users"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "approval_decisions" ADD CONSTRAINT "approval_decisions_decided_by_user_id_users_id_fk" FOREIGN KEY ("decided_by_user_id") REFERENCES "public"."users"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "external_thread_bindings" ADD CONSTRAINT "external_thread_bindings_agent_id_agents_id_fk" FOREIGN KEY ("agent_id") REFERENCES "public"."agents"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "external_thread_bindings" ADD CONSTRAINT "external_thread_bindings_created_by_user_id_users_id_fk" FOREIGN KEY ("created_by_user_id") REFERENCES "public"."users"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "external_thread_messages" ADD CONSTRAINT "external_thread_messages_channels_thread_id_external_thread_bindings_channels_thread_id_fk" FOREIGN KEY ("channels_thread_id") REFERENCES "public"."external_thread_bindings"("channels_thread_id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "external_user_links" ADD CONSTRAINT "external_user_links_openbot_user_id_users_id_fk" FOREIGN KEY ("openbot_user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +CREATE INDEX "approval_decisions_created_at_idx" ON "approval_decisions" USING btree ("created_at");--> statement-breakpoint +CREATE UNIQUE INDEX "external_thread_bindings_provider_thread_idx" ON "external_thread_bindings" USING btree ("provider","provider_tenant_id","provider_conversation_id","provider_thread_id");--> statement-breakpoint +CREATE INDEX "external_thread_bindings_creator_thread_idx" ON "external_thread_bindings" USING btree ("created_by_user_id","channels_thread_id");--> statement-breakpoint +CREATE UNIQUE INDEX "external_thread_messages_thread_message_idx" ON "external_thread_messages" USING btree ("channels_thread_id","message_id");--> statement-breakpoint +CREATE INDEX "external_thread_messages_thread_sequence_idx" ON "external_thread_messages" USING btree ("channels_thread_id","sequence" DESC NULLS LAST);--> statement-breakpoint +CREATE UNIQUE INDEX "external_user_links_openbot_workspace_idx" ON "external_user_links" USING btree ("provider","provider_tenant_id","openbot_user_id");--> statement-breakpoint +CREATE FUNCTION "reject_external_thread_binding_mutation"() +RETURNS trigger +LANGUAGE plpgsql +AS $$ +BEGIN + RAISE EXCEPTION 'External thread bindings are append-only'; +END; +$$;--> statement-breakpoint +CREATE TRIGGER "external_thread_bindings_append_only" +BEFORE UPDATE OR DELETE ON "external_thread_bindings" +FOR EACH ROW +EXECUTE FUNCTION "reject_external_thread_binding_mutation"(); diff --git a/server/drizzle/meta/0024_snapshot.json b/server/drizzle/meta/0024_snapshot.json new file mode 100644 index 00000000..7a454d9e --- /dev/null +++ b/server/drizzle/meta/0024_snapshot.json @@ -0,0 +1,3519 @@ +{ + "id": "d7679792-fc02-4462-945d-253f69957fd6", + "prevId": "a1053424-96eb-4ace-98e4-63ac0ea99060", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.accounts": { + "name": "accounts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "issuer": { + "name": "issuer", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "accounts_provider_account_idx": { + "name": "accounts_provider_account_idx", + "columns": [ + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "accounts_user_id_users_id_fk": { + "name": "accounts_user_id_users_id_fk", + "tableFrom": "accounts", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.agents": { + "name": "agents", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "agent_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "configuration": { + "name": "configuration", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "package_id": { + "name": "package_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "override": { + "name": "override", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "agents_package_id_deployment_packages_id_fk": { + "name": "agents_package_id_deployment_packages_id_fk", + "tableFrom": "agents", + "tableTo": "deployment_packages", + "columnsFrom": [ + "package_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.approval_decisions": { + "name": "approval_decisions", + "schema": "", + "columns": { + "presentation_id": { + "name": "presentation_id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "channels_thread_id": { + "name": "channels_thread_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "conversation_key": { + "name": "conversation_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "action_id": { + "name": "action_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "approved": { + "name": "approved", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "decided_by_user_id": { + "name": "decided_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "approval_decisions_created_at_idx": { + "name": "approval_decisions_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "approval_decisions_channels_thread_id_external_thread_bindings_channels_thread_id_fk": { + "name": "approval_decisions_channels_thread_id_external_thread_bindings_channels_thread_id_fk", + "tableFrom": "approval_decisions", + "tableTo": "external_thread_bindings", + "columnsFrom": [ + "channels_thread_id" + ], + "columnsTo": [ + "channels_thread_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "approval_decisions_agent_id_agents_id_fk": { + "name": "approval_decisions_agent_id_agents_id_fk", + "tableFrom": "approval_decisions", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "approval_decisions_created_by_user_id_users_id_fk": { + "name": "approval_decisions_created_by_user_id_users_id_fk", + "tableFrom": "approval_decisions", + "tableTo": "users", + "columnsFrom": [ + "created_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "approval_decisions_decided_by_user_id_users_id_fk": { + "name": "approval_decisions_decided_by_user_id_users_id_fk", + "tableFrom": "approval_decisions", + "tableTo": "users", + "columnsFrom": [ + "decided_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.audit_events": { + "name": "audit_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "actor_user_id": { + "name": "actor_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "event_type": { + "name": "event_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_type": { + "name": "target_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_id": { + "name": "target_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "audit_events_created_at_idx": { + "name": "audit_events_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_events_type_time_idx": { + "name": "audit_events_type_time_idx", + "columns": [ + { + "expression": "event_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_events_actor_time_idx": { + "name": "audit_events_actor_time_idx", + "columns": [ + { + "expression": "actor_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_events_target_time_idx": { + "name": "audit_events_target_time_idx", + "columns": [ + { + "expression": "target_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.channel_agents": { + "name": "channel_agents", + "schema": "", + "columns": { + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "channel_agents_channel_id_channels_id_fk": { + "name": "channel_agents_channel_id_channels_id_fk", + "tableFrom": "channel_agents", + "tableTo": "channels", + "columnsFrom": [ + "channel_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "channel_agents_agent_id_agents_id_fk": { + "name": "channel_agents_agent_id_agents_id_fk", + "tableFrom": "channel_agents", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "channel_agents_channel_id_agent_id_pk": { + "name": "channel_agents_channel_id_agent_id_pk", + "columns": [ + "channel_id", + "agent_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.channel_memberships": { + "name": "channel_memberships", + "schema": "", + "columns": { + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "pinned_at": { + "name": "pinned_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_read_at": { + "name": "last_read_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "channel_memberships_channel_id_channels_id_fk": { + "name": "channel_memberships_channel_id_channels_id_fk", + "tableFrom": "channel_memberships", + "tableTo": "channels", + "columnsFrom": [ + "channel_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "channel_memberships_user_id_users_id_fk": { + "name": "channel_memberships_user_id_users_id_fk", + "tableFrom": "channel_memberships", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "channel_memberships_channel_id_user_id_pk": { + "name": "channel_memberships_channel_id_user_id_pk", + "columns": [ + "channel_id", + "user_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.channels": { + "name": "channels", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "suggested_prompts": { + "name": "suggested_prompts", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "allowed_groups": { + "name": "allowed_groups", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "package_id": { + "name": "package_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "override": { + "name": "override", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "last_message": { + "name": "last_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_message_at": { + "name": "last_message_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_message_agent_id": { + "name": "last_message_agent_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "channels_recent_activity_idx": { + "name": "channels_recent_activity_idx", + "columns": [ + { + "expression": "COALESCE(\"last_message_at\", \"created_at\") DESC", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "channels_package_id_deployment_packages_id_fk": { + "name": "channels_package_id_deployment_packages_id_fk", + "tableFrom": "channels", + "tableTo": "deployment_packages", + "columnsFrom": [ + "package_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "channels_last_message_agent_id_agents_id_fk": { + "name": "channels_last_message_agent_id_agents_id_fk", + "tableFrom": "channels", + "tableTo": "agents", + "columnsFrom": [ + "last_message_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.credentials": { + "name": "credentials", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "kind": { + "name": "kind", + "type": "credential_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "encrypted_value": { + "name": "encrypted_value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key_id": { + "name": "key_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "credentials_active_key_idx": { + "name": "credentials_active_key_idx", + "columns": [ + { + "expression": "kind", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"credentials\".\"revoked_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.deployment_packages": { + "name": "deployment_packages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_path": { + "name": "source_path", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "checksum": { + "name": "checksum", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "loaded_at": { + "name": "loaded_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "deployment_packages_tenant_id_unique": { + "name": "deployment_packages_tenant_id_unique", + "nullsNotDistinct": false, + "columns": [ + "tenant_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.external_thread_bindings": { + "name": "external_thread_bindings", + "schema": "", + "columns": { + "channels_thread_id": { + "name": "channels_thread_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_tenant_id": { + "name": "provider_tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_conversation_id": { + "name": "provider_conversation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_thread_id": { + "name": "provider_thread_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "external_thread_bindings_provider_thread_idx": { + "name": "external_thread_bindings_provider_thread_idx", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_thread_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "external_thread_bindings_creator_thread_idx": { + "name": "external_thread_bindings_creator_thread_idx", + "columns": [ + { + "expression": "created_by_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "channels_thread_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "external_thread_bindings_agent_id_agents_id_fk": { + "name": "external_thread_bindings_agent_id_agents_id_fk", + "tableFrom": "external_thread_bindings", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "external_thread_bindings_created_by_user_id_users_id_fk": { + "name": "external_thread_bindings_created_by_user_id_users_id_fk", + "tableFrom": "external_thread_bindings", + "tableTo": "users", + "columnsFrom": [ + "created_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "external_thread_bindings_provider_slack_check": { + "name": "external_thread_bindings_provider_slack_check", + "value": "\"external_thread_bindings\".\"provider\" = 'slack'" + } + }, + "isRLSEnabled": false + }, + "public.external_thread_messages": { + "name": "external_thread_messages", + "schema": "", + "columns": { + "sequence": { + "name": "sequence", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "channels_thread_id": { + "name": "channels_thread_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "message_id": { + "name": "message_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "external_thread_messages_thread_message_idx": { + "name": "external_thread_messages_thread_message_idx", + "columns": [ + { + "expression": "channels_thread_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "external_thread_messages_thread_sequence_idx": { + "name": "external_thread_messages_thread_sequence_idx", + "columns": [ + { + "expression": "channels_thread_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sequence", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "external_thread_messages_channels_thread_id_external_thread_bindings_channels_thread_id_fk": { + "name": "external_thread_messages_channels_thread_id_external_thread_bindings_channels_thread_id_fk", + "tableFrom": "external_thread_messages", + "tableTo": "external_thread_bindings", + "columnsFrom": [ + "channels_thread_id" + ], + "columnsTo": [ + "channels_thread_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "external_thread_messages_role_check": { + "name": "external_thread_messages_role_check", + "value": "\"external_thread_messages\".\"role\" IN ('user', 'assistant')" + } + }, + "isRLSEnabled": false + }, + "public.external_user_links": { + "name": "external_user_links", + "schema": "", + "columns": { + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_tenant_id": { + "name": "provider_tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_user_id": { + "name": "provider_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "openbot_user_id": { + "name": "openbot_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_email": { + "name": "provider_email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "linked_at": { + "name": "linked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "external_user_links_openbot_workspace_idx": { + "name": "external_user_links_openbot_workspace_idx", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "openbot_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "external_user_links_openbot_user_id_users_id_fk": { + "name": "external_user_links_openbot_user_id_users_id_fk", + "tableFrom": "external_user_links", + "tableTo": "users", + "columnsFrom": [ + "openbot_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "external_user_links_provider_provider_tenant_id_provider_user_id_pk": { + "name": "external_user_links_provider_provider_tenant_id_provider_user_id_pk", + "columns": [ + "provider", + "provider_tenant_id", + "provider_user_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.intelligence_channel_mappings": { + "name": "intelligence_channel_mappings", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "intelligence_channel_mappings_thread_idx": { + "name": "intelligence_channel_mappings_thread_idx", + "columns": [ + { + "expression": "thread_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "intelligence_channel_mappings_user_id_users_id_fk": { + "name": "intelligence_channel_mappings_user_id_users_id_fk", + "tableFrom": "intelligence_channel_mappings", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "intelligence_channel_mappings_channel_id_channels_id_fk": { + "name": "intelligence_channel_mappings_channel_id_channels_id_fk", + "tableFrom": "intelligence_channel_mappings", + "tableTo": "channels", + "columnsFrom": [ + "channel_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "intelligence_channel_mappings_user_id_channel_id_pk": { + "name": "intelligence_channel_mappings_user_id_channel_id_pk", + "columns": [ + "user_id", + "channel_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.revoked_access": { + "name": "revoked_access", + "schema": "", + "columns": { + "email": { + "name": "email", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "revoked_by": { + "name": "revoked_by", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sessions": { + "name": "sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "sessions_user_id_users_id_fk": { + "name": "sessions_user_id_users_id_fk", + "tableFrom": "sessions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "sessions_token_unique": { + "name": "sessions_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sso_providers": { + "name": "sso_providers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "issuer": { + "name": "issuer", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "oidc_config": { + "name": "oidc_config", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "saml_config": { + "name": "saml_config", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "domain": { + "name": "domain", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "sso_providers_user_id_users_id_fk": { + "name": "sso_providers_user_id_users_id_fk", + "tableFrom": "sso_providers", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "sso_providers_provider_id_unique": { + "name": "sso_providers_provider_id_unique", + "nullsNotDistinct": false, + "columns": [ + "provider_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_roles": { + "name": "user_roles", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "user_roles_user_id_users_id_fk": { + "name": "user_roles_user_id_users_id_fk", + "tableFrom": "user_roles", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "user_roles_user_id_role_pk": { + "name": "user_roles_user_id_role_pk", + "columns": [ + "user_id", + "role" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "groups": { + "name": "groups", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_email_unique": { + "name": "users_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verifications": { + "name": "verifications", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.action_policy": { + "name": "action_policy", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "mode": { + "name": "mode", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deny": { + "name": "deny", + "type": "text[]", + "primaryKey": false, + "notNull": true + }, + "allow": { + "name": "allow", + "type": "text[]", + "primaryKey": false, + "notNull": true + }, + "updated_by": { + "name": "updated_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.computer_page_frame": { + "name": "computer_page_frame", + "schema": "", + "columns": { + "computer_id": { + "name": "computer_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tool_call_id": { + "name": "tool_call_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "frame": { + "name": "frame", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "captured_at": { + "name": "captured_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "computer_page_frame_captured_idx": { + "name": "computer_page_frame_captured_idx", + "columns": [ + { + "expression": "captured_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "computer_page_frame_computer_id_tool_call_id_pk": { + "name": "computer_page_frame_computer_id_tool_call_id_pk", + "columns": [ + "computer_id", + "tool_call_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.computer_snapshot": { + "name": "computer_snapshot", + "schema": "", + "columns": { + "computer_id": { + "name": "computer_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "snapshot_id": { + "name": "snapshot_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "elements": { + "name": "elements", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "taken_at": { + "name": "taken_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "session": { + "name": "session", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.agent_preferences": { + "name": "agent_preferences", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "hidden_at": { + "name": "hidden_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "agent_preferences_user_id_users_id_fk": { + "name": "agent_preferences_user_id_users_id_fk", + "tableFrom": "agent_preferences", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "agent_preferences_agent_id_agents_id_fk": { + "name": "agent_preferences_agent_id_agents_id_fk", + "tableFrom": "agent_preferences", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "agent_preferences_user_id_agent_id_pk": { + "name": "agent_preferences_user_id_agent_id_pk", + "columns": [ + "user_id", + "agent_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.agent_profiles": { + "name": "agent_profiles", + "schema": "", + "columns": { + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role_description": { + "name": "role_description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "avatar_seed": { + "name": "avatar_seed", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "visibility": { + "name": "visibility", + "type": "agent_visibility", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "callback_token_hash": { + "name": "callback_token_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "callback_token_issued_at": { + "name": "callback_token_issued_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "agent_profiles_visibility_deleted_idx": { + "name": "agent_profiles_visibility_deleted_idx", + "columns": [ + { + "expression": "visibility", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "agent_profiles_agent_id_agents_id_fk": { + "name": "agent_profiles_agent_id_agents_id_fk", + "tableFrom": "agent_profiles", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "agent_profiles_owner_user_id_users_id_fk": { + "name": "agent_profiles_owner_user_id_users_id_fk", + "tableFrom": "agent_profiles", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.routine_runs": { + "name": "routine_runs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "routine_id": { + "name": "routine_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "finished_at": { + "name": "finished_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "routine_run_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "routine_runs_by_routine_idx": { + "name": "routine_runs_by_routine_idx", + "columns": [ + { + "expression": "routine_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "routine_runs_routine_id_routines_id_fk": { + "name": "routine_runs_routine_id_routines_id_fk", + "tableFrom": "routine_runs", + "tableTo": "routines", + "columnsFrom": [ + "routine_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.routines": { + "name": "routines", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "instruction": { + "name": "instruction", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "cron": { + "name": "cron", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "timezone": { + "name": "timezone", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'UTC'" + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "next_run_at": { + "name": "next_run_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "last_run_at": { + "name": "last_run_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "routines_due_idx": { + "name": "routines_due_idx", + "columns": [ + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "next_run_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "routines_by_owner_idx": { + "name": "routines_by_owner_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "routines_owner_user_id_users_id_fk": { + "name": "routines_owner_user_id_users_id_fk", + "tableFrom": "routines", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "routines_agent_id_agents_id_fk": { + "name": "routines_agent_id_agents_id_fk", + "tableFrom": "routines", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.component_exclusions": { + "name": "component_exclusions", + "schema": "", + "columns": { + "component_name": { + "name": "component_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "withheld_by": { + "name": "withheld_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "component_exclusions_component_name_components_name_fk": { + "name": "component_exclusions_component_name_components_name_fk", + "tableFrom": "component_exclusions", + "tableTo": "components", + "columnsFrom": [ + "component_name" + ], + "columnsTo": [ + "name" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "component_exclusions_agent_id_agents_id_fk": { + "name": "component_exclusions_agent_id_agents_id_fk", + "tableFrom": "component_exclusions", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "component_exclusions_component_name_agent_id_pk": { + "name": "component_exclusions_component_name_agent_id_pk", + "columns": [ + "component_name", + "agent_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.component_functions": { + "name": "component_functions", + "schema": "", + "columns": { + "component_name": { + "name": "component_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "function_name": { + "name": "function_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "granted_by": { + "name": "granted_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "component_functions_component_name_components_name_fk": { + "name": "component_functions_component_name_components_name_fk", + "tableFrom": "component_functions", + "tableTo": "components", + "columnsFrom": [ + "component_name" + ], + "columnsTo": [ + "name" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "component_functions_component_name_function_name_pk": { + "name": "component_functions_component_name_function_name_pk", + "columns": [ + "component_name", + "function_name" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.components": { + "name": "components", + "schema": "", + "columns": { + "name": { + "name": "name", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "draft_description": { + "name": "draft_description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "published_description": { + "name": "published_description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "published": { + "name": "published", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "published_at": { + "name": "published_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "updated_by": { + "name": "updated_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mcp_servers": { + "name": "mcp_servers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "vendor": { + "name": "vendor", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provenance": { + "name": "provenance", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'first-party'" + }, + "credential_id": { + "name": "credential_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "tools_refreshed_at": { + "name": "tools_refreshed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "added_by": { + "name": "added_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "mcp_servers_credential_id_credentials_id_fk": { + "name": "mcp_servers_credential_id_credentials_id_fk", + "tableFrom": "mcp_servers", + "tableTo": "credentials", + "columnsFrom": [ + "credential_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mcp_tools": { + "name": "mcp_tools", + "schema": "", + "columns": { + "server_id": { + "name": "server_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "input_schema": { + "name": "input_schema", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "mcp_tools_server_id_mcp_servers_id_fk": { + "name": "mcp_tools_server_id_mcp_servers_id_fk", + "tableFrom": "mcp_tools", + "tableTo": "mcp_servers", + "columnsFrom": [ + "server_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "mcp_tools_server_id_name_pk": { + "name": "mcp_tools_server_id_name_pk", + "columns": [ + "server_id", + "name" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mcp_user_credentials": { + "name": "mcp_user_credentials", + "schema": "", + "columns": { + "server_id": { + "name": "server_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "credential_id": { + "name": "credential_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "connected_at": { + "name": "connected_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "mcp_user_credentials_user_idx": { + "name": "mcp_user_credentials_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mcp_user_credentials_server_id_mcp_servers_id_fk": { + "name": "mcp_user_credentials_server_id_mcp_servers_id_fk", + "tableFrom": "mcp_user_credentials", + "tableTo": "mcp_servers", + "columnsFrom": [ + "server_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mcp_user_credentials_user_id_users_id_fk": { + "name": "mcp_user_credentials_user_id_users_id_fk", + "tableFrom": "mcp_user_credentials", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mcp_user_credentials_credential_id_credentials_id_fk": { + "name": "mcp_user_credentials_credential_id_credentials_id_fk", + "tableFrom": "mcp_user_credentials", + "tableTo": "credentials", + "columnsFrom": [ + "credential_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "mcp_user_credentials_server_id_user_id_pk": { + "name": "mcp_user_credentials_server_id_user_id_pk", + "columns": [ + "server_id", + "user_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.plugin_grants": { + "name": "plugin_grants", + "schema": "", + "columns": { + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "ref": { + "name": "ref", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "granted_by": { + "name": "granted_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "plugin_grants_agent_idx": { + "name": "plugin_grants_agent_idx", + "columns": [ + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "plugin_grants_agent_id_agents_id_fk": { + "name": "plugin_grants_agent_id_agents_id_fk", + "tableFrom": "plugin_grants", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "plugin_grants_kind_ref_agent_id_pk": { + "name": "plugin_grants_kind_ref_agent_id_pk", + "columns": [ + "kind", + "ref", + "agent_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sandboxed_components": { + "name": "sandboxed_components", + "schema": "", + "columns": { + "name": { + "name": "name", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "draft_description": { + "name": "draft_description", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "draft_html": { + "name": "draft_html", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "draft_css": { + "name": "draft_css", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "draft_js_functions": { + "name": "draft_js_functions", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "draft_argument_schema": { + "name": "draft_argument_schema", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "published_description": { + "name": "published_description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "published_html": { + "name": "published_html", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "published_css": { + "name": "published_css", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "published_js_functions": { + "name": "published_js_functions", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "published_argument_schema": { + "name": "published_argument_schema", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "sample_arguments": { + "name": "sample_arguments", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "published": { + "name": "published", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "published_at": { + "name": "published_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "authored_by": { + "name": "authored_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.skill_tools": { + "name": "skill_tools", + "schema": "", + "columns": { + "skill_id": { + "name": "skill_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "ref": { + "name": "ref", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "declared_by": { + "name": "declared_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "skill_tools_ref_idx": { + "name": "skill_tools_ref_idx", + "columns": [ + { + "expression": "ref", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "skill_tools_skill_id_skills_id_fk": { + "name": "skill_tools_skill_id_skills_id_fk", + "tableFrom": "skill_tools", + "tableTo": "skills", + "columnsFrom": [ + "skill_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "skill_tools_skill_id_ref_pk": { + "name": "skill_tools_skill_id_ref_pk", + "columns": [ + "skill_id", + "ref" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.skills": { + "name": "skills", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "instructions": { + "name": "instructions", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "origin": { + "name": "origin", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'yours'" + }, + "installed_by": { + "name": "installed_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "skills_slug_key": { + "name": "skills_slug_key", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "skills_owner_idx": { + "name": "skills_owner_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "skills_owner_user_id_users_id_fk": { + "name": "skills_owner_user_id_users_id_fk", + "tableFrom": "skills", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.work_items": { + "name": "work_items", + "schema": "", + "columns": { + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "run_at": { + "name": "run_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "claimed_by": { + "name": "claimed_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lease_until": { + "name": "lease_until", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "finished_at": { + "name": "finished_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "work_items_claimable_idx": { + "name": "work_items_claimable_idx", + "columns": [ + { + "expression": "kind", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "run_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "work_items_kind_key_pk": { + "name": "work_items_kind_key_pk", + "columns": [ + "kind", + "key" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.agent_type": { + "name": "agent_type", + "schema": "public", + "values": [ + "built_in", + "remote_ag_ui" + ] + }, + "public.credential_kind": { + "name": "credential_kind", + "schema": "public", + "values": [ + "model", + "connector", + "agent", + "mcp", + "mcp_oauth_client", + "mcp_user_token" + ] + }, + "public.role": { + "name": "role", + "schema": "public", + "values": [ + "admin", + "user" + ] + }, + "public.agent_visibility": { + "name": "agent_visibility", + "schema": "public", + "values": [ + "public", + "private" + ] + }, + "public.routine_run_status": { + "name": "routine_run_status", + "schema": "public", + "values": [ + "succeeded", + "failed", + "skipped" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/server/drizzle/meta/_journal.json b/server/drizzle/meta/_journal.json index b6330597..8a6d041a 100644 --- a/server/drizzle/meta/_journal.json +++ b/server/drizzle/meta/_journal.json @@ -169,6 +169,13 @@ "when": 1787841174859, "tag": "0023_routines_owner_index", "breakpoints": true + }, + { + "idx": 24, + "version": "7", + "when": 1788094798440, + "tag": "0024_slack_channels", + "breakpoints": true } ] -} +} \ No newline at end of file diff --git a/server/package.json b/server/package.json index 4b6fa4c1..168273c3 100644 --- a/server/package.json +++ b/server/package.json @@ -15,6 +15,7 @@ "@ag-ui/client": "0.0.57", "@better-auth/drizzle-adapter": "^1.7.1", "@better-auth/sso": "^1.7.1", + "@copilotkit/channels": "0.9.0", "@copilotkit/runtime": "1.69.0", "@modelcontextprotocol/sdk": "^1.30.0", "better-auth": "^1.7.1", diff --git a/server/src/agents/agent-resolver.ts b/server/src/agents/agent-resolver.ts new file mode 100644 index 00000000..db7d1e96 --- /dev/null +++ b/server/src/agents/agent-resolver.ts @@ -0,0 +1,101 @@ +import type { AbstractAgent } from "@ag-ui/client"; +import type { AgentFetch, StallGuard } from "../channels/stall-guard"; +import { + type HandoffForRun, + type LoadAgentsForActor, + type LoadToolsForBot, + type RuntimeModel, + resolveRuntimeAgents, + type SignRun, + type ToolSelection, +} from "../copilot"; +import type { AgentActor } from "./profile-types"; + +export type ActorAgentResolver = { + resolveAgentsForActor( + actor: AgentActor, + ): Promise>; + resolveAgentForActor( + actor: AgentActor, + agentId: string, + ): Promise; +}; + +export type ActorAgentResolverDependencies = { + loadAgents: LoadAgentsForActor; + model: RuntimeModel; + resolveModelApiKey: () => Promise; + stallGuard?: StallGuard; + loadToolsForActor?: (actorId: string) => LoadToolsForBot; + signRunForActor?: (actorId: string) => SignRun; + computerGuidance?: string; + loadVendors?: () => Promise; + selectionForActor?: (actorId: string) => ToolSelection; + agentFetch?: AgentFetch; + /** + * What a Bot may reach past itself for, resolved for whoever is asking. + * + * Per actor for the same reason the tools are: which Bots may be reached is decided against the + * roster that person can see, so a Bot must never be able to address one they cannot. + */ + handoffForActor?: (actorId: string) => HandoffForRun; +}; + +/** + * Resolves the coworkers available to one OpenBot actor. + * + * Every surface enters through this boundary so it shares the same visibility, grants, assertions, + * skill selection, and endpoint dial policy for a person. + */ +export function createActorAgentResolver( + deps: ActorAgentResolverDependencies, +): ActorAgentResolver { + const resolveRegisteredAgents = ( + actor: AgentActor, + registered: Awaited>, + /** + * Build only this Bot, when the caller already knows which one it wants. + * + * The roster is still read in full, so a Bot this person cannot see is still absent. The others + * are simply neither built nor asked what they hold, which is a query per Bot a headless turn + * or a Slack thread has no use for. + */ + onlyAgentId?: string, + ) => + resolveRuntimeAgents( + () => Promise.resolve(registered), + deps.model, + deps.resolveModelApiKey, + deps.stallGuard, + deps.loadToolsForActor?.(actor.id), + deps.signRunForActor?.(actor.id), + deps.computerGuidance, + deps.loadVendors, + deps.selectionForActor?.(actor.id), + deps.agentFetch, + deps.handoffForActor?.(actor.id), + onlyAgentId, + ); + + const resolveAgentsForActor = async (actor: AgentActor) => + resolveRegisteredAgents(actor, await deps.loadAgents(actor)); + + return { + resolveAgentsForActor, + async resolveAgentForActor(actor, agentId) { + const registered = await deps.loadAgents(actor); + if (!registered.some((agent) => agent.id === agentId)) { + throw new Error(`Coworker ${agentId} is unavailable to this user.`); + } + + const agents = await resolveRegisteredAgents(actor, registered, agentId); + const agent = Object.hasOwn(agents, agentId) + ? agents[agentId] + : undefined; + if (!agent) { + throw new Error(`Coworker ${agentId} is unavailable to this user.`); + } + return agent; + }, + }; +} diff --git a/server/src/agents/profile-store.ts b/server/src/agents/profile-store.ts index 8d14bdd4..90a5d648 100644 --- a/server/src/agents/profile-store.ts +++ b/server/src/agents/profile-store.ts @@ -32,6 +32,7 @@ export type ProfileReadExecutor = DatabaseExecutor; export type AgentProfileStore = { list(actor: AgentActor, hidden?: boolean): Promise; + listAccessibleIds(actor: AgentActor): Promise; get(actor: AgentActor, id: string): Promise; /** * `get`, but on the caller's own transaction and holding the profile against deletion until that @@ -292,6 +293,14 @@ export function createAgentProfileStore( return rows.map(mapProfile); }, + async listAccessibleIds(actor) { + const rows = await database + .select({ id: agentProfiles.agentId }) + .from(agentProfiles) + .where(and(isNull(agentProfiles.deletedAt), accessFilter(actor))); + return rows.map((row) => row.id); + }, + get(actor, id) { return findAccessibleProfile(database, actor, id); }, diff --git a/server/src/app.ts b/server/src/app.ts index 20561446..fb3e6c0d 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -44,6 +44,8 @@ import { createRoutineRoutes, type RoutineStore } from "./routines/routes"; import type { RoutineRunner } from "./routines/runner"; import type { IntentRouter } from "./routing/classify"; import { createRoutingRoutes } from "./routing/routes"; +import { createCoworkerRoutingService } from "./routing/service"; +import type { SlackStatus } from "./slack/status"; import type { PackageStatusReader } from "./tenant-package"; /** @@ -190,6 +192,19 @@ export function createApp( * has no door for this at all, not a locked one. */ routineStore?: RoutineStore, + /** + * Authenticated confirmation routes for identities that arrived from an external provider. + * + * Appended last: callers build these routes with the deployment's encryption key, the shared + * user guard, and its audit store before handing the completed surface to the app. + */ + externalLinkRoutes?: HonoApp<{ Variables: AppVariables }>, + /** Credential-free managed Slack readiness, appended to preserve positional callers. */ + slackStatus: () => SlackStatus = () => ({ + status: "stopped", + transport: "stopped", + provider: "unknown", + }), ) { const app = new Hono<{ Variables: AppVariables }>(); @@ -197,8 +212,9 @@ export function createApp( // Projected, never the raw runtime. config.runtime carries the Intelligence contract, including // INTELLIGENCE_API_KEY and the licence token, and this endpoint is reachable by anyone. Returning // the object wholesale would serve deployment secrets to the browser. Add fields here explicitly. - app.get("/api/capabilities", async (context) => - context.json({ + app.get("/api/capabilities", async (context) => { + const slack = slackStatus(); + return context.json({ mode: config.runtime.mode, durableHistory: config.runtime.durableHistory, /* @@ -220,8 +236,15 @@ export function createApp( * companies use this deployment, which is not theirs to have before they sign in. */ ssoConfigured: ((await identityProviders?.list()) ?? []).length > 0, - }), - ); + channels: { + slack: { + status: slack.status, + transport: slack.transport, + provider: slack.provider, + }, + }, + }); + }); /* * Registering an identity provider is an administrator's decision, not a signed-in one. * @@ -788,29 +811,31 @@ export function createApp( app.route( "/api/route", createRoutingRoutes( - agentProfileStore, - intentRouter, - requireUser, - auditStore, - /* - * Which vendors each coworker holds tools for, so the router weighs what a coworker can - * reach and not only what somebody wrote it was for. Only when there is a plugin store to - * ask: a deployment with no connectors routes exactly as it did. - */ - pluginStore - ? async (agentId) => { - const granted = await pluginStore.listForAgent(agentId); - return [ - ...new Set( - granted.tools.map( - (tool) => - tool.toolName.replace(/^mcp__/, "").split("__")[0] ?? - tool.toolName, + createCoworkerRoutingService({ + store: agentProfileStore, + router: intentRouter, + auditStore, + /* + * Which vendors each coworker holds tools for, so the router weighs what a coworker can + * reach and not only what somebody wrote it was for. Only when there is a plugin store to + * ask: a deployment with no connectors routes exactly as it did. + */ + reachableSystems: pluginStore + ? async (agentId) => { + const granted = await pluginStore.listForAgent(agentId); + return [ + ...new Set( + granted.tools.map( + (tool) => + tool.toolName.replace(/^mcp__/, "").split("__")[0] ?? + tool.toolName, + ), ), - ), - ]; - } - : undefined, + ]; + } + : undefined, + }), + requireUser, ), ); } @@ -827,6 +852,10 @@ export function createApp( app.route("/api/routines", createRoutineRoutes(routineStore, requireUser)); } + if (externalLinkRoutes) { + app.route("/api/external-links", externalLinkRoutes); + } + if (componentStore) { app.route( "/api/components", diff --git a/server/src/audit.ts b/server/src/audit.ts index c92f89fd..dd6c2728 100644 --- a/server/src/audit.ts +++ b/server/src/audit.ts @@ -196,6 +196,7 @@ export const auditEventTypes = [ // useful fact for an investigator is that a human drove this browser between these two times, and // logging every click a person made would bury it while telling nobody anything. "computer.help_requested", + "computer.assistance_cancelled", "computer.control_taken", "computer.control_released", // A credential a person entered by hand. The row records that it happened, what it was called and @@ -318,6 +319,7 @@ export const auditEventTypes = [ */ "identity_provider.registered", "identity_provider.removed", + "external_identity.linked", /* * What a Bot is and what it may reach. * @@ -402,6 +404,15 @@ export type AuditStore = { insert: (event: AuditEventInput) => Promise; }; +export type AuditTransaction = Parameters< + Parameters[0] +>[0]; + +/** An audit writer rebound to a caller's transaction, so the event commits with its subject. */ +export type TransactionalAuditStore = AuditStore & { + inTransaction: (transaction: AuditTransaction) => AuditStore; +}; + export type AuditEvent = { id: string; actorUserId: string | null; @@ -480,11 +491,16 @@ export async function recordAuditEvent( }); } -export function createAuditStore(database: Database): AuditStore { +export function createAuditStore(database: Database): TransactionalAuditStore { return { insert: async (event) => { await database.insert(auditEvents).values(event); }, + inTransaction: (transaction) => ({ + insert: async (event) => { + await transaction.insert(auditEvents).values(event); + }, + }), }; } diff --git a/server/src/computer/gateway.ts b/server/src/computer/gateway.ts index 21c7d985..6a70d246 100644 --- a/server/src/computer/gateway.ts +++ b/server/src/computer/gateway.ts @@ -45,6 +45,8 @@ import { import type { ComputerProvider } from "./provider"; import type { ActionResult, + AssistanceCancellationResult, + AssistanceStatus, ClickInput, ComputerStatus, ControlState, @@ -183,13 +185,22 @@ export interface ComputerGateway { botId: string, actor: ActionActor, reason: string, + requestId?: string, ): Promise; + cancelAssistance( + botId: string, + actor: ActionActor, + requestId: string, + signal?: AbortSignal, + ): Promise; + assistanceStatus(botId: string, requestId: string): Promise; takeControl(botId: string, actor: ActionActor): Promise; releaseControl(botId: string, actor: ActionActor): Promise; requestSecret( botId: string, actor: ActionActor, input: SecretRequest, + requestId?: string, ): Promise; supplySecret( botId: string, @@ -634,9 +645,15 @@ export function createComputerGateway( * row and do not ask. What IS recorded is the period: who, when, and why the Bot asked, the fact * an investigator wants is that a human drove this browser between two times. */ - async requestHelp(botId: string, actor: ActionActor, reason: string) { + async requestHelp( + botId: string, + actor: ActionActor, + reason: string, + requestId = crypto.randomUUID(), + ) { const state = await post(botId, "/control/request", { reason, + requestId, }); await writeControlEvent(auditStore, "computer.help_requested", { botId, @@ -646,6 +663,37 @@ export function createComputerGateway( return state; }, + async cancelAssistance( + botId: string, + actor: ActionActor, + requestId: string, + signal?: AbortSignal, + ) { + const result = await post( + botId, + "/control/assistance/cancel", + { requestId }, + signal, + ); + if (result.cancelled) { + await writeControlEvent(auditStore, "computer.assistance_cancelled", { + botId, + actor, + reason: "the pending assistance request was cancelled", + }); + } + return result; + }, + + async assistanceStatus(botId: string, requestId: string) { + const result = await post<{ status: AssistanceStatus }>( + botId, + "/control/assistance/status", + { requestId }, + ); + return result.status; + }, + async takeControl(botId: string, actor: ActionActor) { const state = await post(botId, "/control/take", {}); await writeControlEvent(auditStore, "computer.control_taken", { @@ -747,8 +795,12 @@ export function createComputerGateway( botId: string, actor: ActionActor, input: SecretRequest, + requestId = crypto.randomUUID(), ) { - const state = await post(botId, "/control/secret", input); + const state = await post(botId, "/control/secret", { + ...input, + requestId, + }); await writeControlEvent(auditStore, "computer.secret_requested", { botId, actor, @@ -1159,6 +1211,7 @@ async function writeControlEvent( auditStore: AuditStore, eventType: | "computer.help_requested" + | "computer.assistance_cancelled" | "computer.control_taken" | "computer.control_released" | "computer.secret_requested" diff --git a/server/src/computer/schema.ts b/server/src/computer/schema.ts index 70e88ac9..f2b22b36 100644 --- a/server/src/computer/schema.ts +++ b/server/src/computer/schema.ts @@ -274,6 +274,27 @@ export type ControlState = { reason?: string; /** The Bot has asked and nobody has taken over yet. */ requested: boolean; + helpRequestId?: string; + secretWanted?: string; + secretRef?: string; + secretSnapshotId?: number; + secretRequestedAt?: string; + secretRequestId?: string; +}; + +export type AssistanceStatus = + | "pending" + | "human" + | "completed" + | "expired" + | "cancelled" + | "superseded" + | "unknown"; + +export type AssistanceCancellationResult = { + cancelled: boolean; + state: ControlState; + status: AssistanceStatus; }; /** diff --git a/server/src/config.ts b/server/src/config.ts index dbe95e5d..f874c9a0 100644 --- a/server/src/config.ts +++ b/server/src/config.ts @@ -163,6 +163,11 @@ export type DeploymentConfig = { * packages but not a copy of one running alongside the original. See channels/thread-identity.ts. */ deploymentId: string | undefined; + /** + * Operator-owned Slack workspace ID used only when managed Channels omits its canonical tenant. + * A known managed tenant must still match this value; see slack/tenant-context.ts. + */ + slackTenantId: string | undefined; /** * Where this deployment is reached from outside, with no trailing slash. * @@ -770,6 +775,17 @@ function accessibilityEnabled(environment: Environment): boolean { return off !== "true" && off !== "1"; } +function slackTenantId(environment: Environment): string | undefined { + const tenantId = optional(environment, "OPENBOT_SLACK_TENANT_ID"); + if (!tenantId) return undefined; + if (tenantId.toLowerCase() === "unknown") { + throw new Error( + "OPENBOT_SLACK_TENANT_ID must be a canonical Slack workspace ID, not unknown", + ); + } + return tenantId; +} + /** * How long the audit trail is kept. * @@ -819,6 +835,7 @@ export function loadConfig( ...(managedAgent ? { managedAgent } : {}), agentEndpointAllowedHosts: agentEndpointAllowedHosts(environment), deploymentId: optional(environment, "DEPLOYMENT_ID"), + slackTenantId: slackTenantId(environment), publicUrl: ( optional(environment, "OPENBOT_PUBLIC_URL") ?? auth?.baseUrl )?.replace(/\/+$/, ""), diff --git a/server/src/copilot.ts b/server/src/copilot.ts index 891c53ee..22a2fbe8 100644 --- a/server/src/copilot.ts +++ b/server/src/copilot.ts @@ -1,5 +1,6 @@ import type { BaseEvent, RunAgentInput } from "@ag-ui/client"; import { AbstractAgent, HttpAgent } from "@ag-ui/client"; +import type { Channel } from "@copilotkit/channels"; import type { BuiltInAgentConfiguration } from "@copilotkit/runtime/v2"; import { BuiltInAgent, @@ -8,12 +9,10 @@ import { } from "@copilotkit/runtime/v2"; import { createCopilotHonoHandler } from "@copilotkit/runtime/v2/hono"; import type { Observable } from "rxjs"; -import { defer, from, switchMap } from "rxjs"; +import { defer, finalize, from, switchMap } from "rxjs"; import { z } from "zod"; -import { - COMPUTER_GUIDANCE, - PROVENANCE_GUIDANCE, -} from "../../shared/bot-prompt"; +import { PROVENANCE_GUIDANCE } from "../../shared/bot-prompt"; +import type { ActorAgentResolver } from "./agents/agent-resolver"; import type { AgentActor } from "./agents/profile-types"; import type { AgentFetch, StallGuard } from "./channels/stall-guard"; import type { DeploymentConfig } from "./config"; @@ -398,6 +397,9 @@ async function buildAgent( * announce and never invoke. Granting one is refused at the door rather than stored dead: see * `enablementRefusal` in plugins/routes.ts. * + * The wrapper composes every direct `run(input)` too, which is the path a channel delegation + * takes, so a Slack turn reaches the same composition a browser turn does. + * * Making this work is a feature rather than a fix: the callback would have to carry a run * assertion the endpoint cannot forge, and execute a hop on its behalf. Worth doing; not done * here, and worth knowing it is missing rather than assuming it is not. @@ -419,7 +421,7 @@ async function buildAgent( * what keeps a narrowed run from being told it holds something it was not offered. */ const withTools = (tools: GrantedTool[]) => - new BuiltInAgent( + new GovernedBuiltInAgent( builtInAgentConfiguration( agent, model, @@ -428,6 +430,9 @@ async function buildAgent( computerGuidance, connectedVendors, ), + agent, + tools, + signRun, ); const whole = withTools(granted); @@ -520,22 +525,14 @@ function remoteAgentWithStandingRole( /** As for the built-in path: what this deployment connects to, held or not. */ connectedVendors: readonly string[] = [], /** - * Which of those tools this run is about, decided once the message is known. - * - * NARROWED HERE RATHER THAN BY WRAPPING THE AGENT, and the difference is not cosmetic. Middleware - * registered with `.use()` is applied by `runAgent`, not by `run`: an outer agent that delegated - * to `remote.run(input)` would skip this whole function's work, and the endpoint would receive a - * run with no standing role, no holdings message, no tools and no signed assertion. Every one of - * those is silent — the Bot simply answers worse — so the narrowing goes inside the middleware - * that is already here. - * - * Absent means no narrowing, which is the behaviour every deployment had before this existed. + * Which of those tools this run is about, decided once the message is known. The composed wrapper + * invokes it on subscription for both web `runAgent()` and direct delegated `run()` calls. */ narrow?: (input: RunAgentInput) => Promise, /** The fetch this agent is dialled with. See {@link buildAgents}. */ agentFetch?: AgentFetch, ) { - const remote = new HttpAgent({ + const raw = new HttpAgent({ url: agent.endpoint, agentId: agent.id, // The customer's own key, if their agent sits behind one. `HttpAgentConfig` is @@ -580,13 +577,9 @@ function remoteAgentWithStandingRole( : null; }; - const runWith = ( - tools: GrantedTool[], - input: RunAgentInput, - next: AbstractAgent, - ) => { + const compose = (tools: GrantedTool[], input: RunAgentInput) => { const holdingsMessage = holdingsMessageFor(tools); - return next.run({ + return { ...input, messages: [ agent.standingMessage, @@ -615,55 +608,165 @@ function remoteAgentWithStandingRole( >, })), ], - // Who the Bot is calling back as, so the audit row names it rather than "an agent". - forwardedProps: { - ...(input.forwardedProps ?? {}), - openbotBotId: agent.id, - /* - * Which of those tools this deployment runs, as opposed to the surface. - * - * `tools` mixes two kinds that a name cannot tell apart: the Bot's grants, which execute - * here through the policy and the audit trail, and the components the browser draws. A Bot - * that ran the second kind through this deployment asked it to execute a chart, was told it - * could not, and then apologised to the person for not showing the chart that was on screen - * in front of them. Only this side knows which is which, so only this side can say. - */ - openbotDeploymentTools: tools.map((tool) => tool.name), - /* - * This deployment's own statement of what this run is. - * - * Signed, short-lived, and naming the Bot and the person. The agent hands it back when it - * calls a tool, and that is where the Bot and the actor come from: its own token says which - * agent is calling, and this says who it is calling for. Neither is taken from the request - * body any more, which is what used to make the audit trail forgeable by anything holding - * one shared secret. - */ - ...(signRun - ? { openbotRun: signRun(agent.id, input.runId, input.threadId) } - : /* - * Absent means this deployment cannot sign, so the agent is given nothing to hand back - * and its tool calls will be refused. That is the right direction to fail: a Bot that - * cannot prove whose run it is should not be spending anybody's grants. - */ - {}), - }, - } as never); + forwardedProps: governedRunForwardedProps( + input, + agent.id, + tools, + signRun, + ), + } as RunAgentInput; }; - /* - * Deferred, because choosing the tools is a model call and middleware has to answer with a stream - * straight away. `defer` puts the work on the subscription, which is where the run actually - * begins, so nothing happens until somebody is listening and a retried run chooses again. - */ - remote.use((input, next) => - defer(() => - from(narrow ? narrow(input) : Promise.resolve(tools)).pipe( - switchMap((offered) => runWith(offered, input, next)), - ), - ), + return new ComposedRemoteAgent( + { agentId: agent.id, description: agent.name }, + raw, + async (input) => + compose(await (narrow ? narrow(input) : Promise.resolve(tools)), input), ); +} + +function governedRunForwardedProps( + input: RunAgentInput, + botId: string, + tools: GrantedTool[], + signRun?: SignRun, +): Record { + return { + ...(input.forwardedProps ?? {}), + // Who the Bot is calling back as, so the audit row names it rather than "an agent". + openbotBotId: botId, + /* + * Which of those tools this deployment runs, as opposed to the surface. + * + * `tools` mixes two kinds that a name cannot tell apart: the Bot's grants, which execute + * here through the policy and the audit trail, and the components the browser draws. A Bot + * that ran the second kind through this deployment asked it to execute a chart, was told it + * could not, and then apologised to the person for not showing the chart that was on screen + * in front of them. Only this side knows which is which, so only this side can say. + */ + openbotDeploymentTools: tools.map((tool) => tool.name), + /* + * This deployment's own statement of what this run is. + * + * Signed, short-lived, and naming the Bot and the person. The agent hands it back when it + * calls a tool, and that is where the Bot and the actor come from: its own token says which + * agent is calling, and this says who it is calling for. Neither is taken from the request + * body any more, which is what used to make the audit trail forgeable by anything holding + * one shared secret. + * + * Absent means this deployment cannot sign, so the agent is given nothing to hand back and its + * tool calls fail closed. Built-ins carry the same assertion in their private AG-UI input even + * though their granted tools execute locally rather than through the callback endpoint. + */ + ...(signRun + ? { openbotRun: signRun(botId, input.runId, input.threadId) } + : {}), + }; +} + +/** Keep built-in and remote coworkers on the same actor-scoped AG-UI run boundary. */ +class GovernedBuiltInAgent extends BuiltInAgent { + private configuration: BuiltInAgentConfiguration; + private registeredAgent: RegisteredBuiltInAgent; + private botId: string; + private deploymentTools: GrantedTool[]; + private signRun?: SignRun; + private governedMiddlewares: Parameters = []; + + constructor( + configuration: BuiltInAgentConfiguration, + agent: RegisteredBuiltInAgent, + tools: GrantedTool[], + signRun?: SignRun, + ) { + super(configuration); + this.configuration = configuration; + this.registeredAgent = agent; + this.botId = agent.id; + this.deploymentTools = tools; + this.signRun = signRun; + } + + use(...middlewares: Parameters): this { + this.governedMiddlewares.push(...middlewares); + return super.use(...middlewares); + } + + run(input: RunAgentInput): Observable { + return super.run({ + ...input, + forwardedProps: governedRunForwardedProps( + input, + this.botId, + this.deploymentTools, + this.signRun, + ), + }); + } + + clone(): GovernedBuiltInAgent { + const cloned = new GovernedBuiltInAgent( + this.configuration, + this.registeredAgent, + this.deploymentTools, + this.signRun, + ); + if (this.governedMiddlewares.length > 0) { + cloned.use(...this.governedMiddlewares); + } + return cloned; + } +} + +/** + * A remote AG-UI agent whose direct `run` path is the full OpenBot composition boundary. + * + * `AbstractAgent` only applies `.use()` middleware in `runAgent()`, while channel delegation calls + * `run(input)` to forward AG-UI events unchanged. Keeping composition here makes both entrances + * equivalent without trying to reconstruct a run from `runAgent()` output. + */ +class ComposedRemoteAgent extends AbstractAgent { + private raw: HttpAgent; + private compose: (input: RunAgentInput) => Promise; + private active?: HttpAgent; + + constructor( + identity: { agentId: string; description: string }, + raw: HttpAgent, + compose: (input: RunAgentInput) => Promise, + ) { + super(identity); + this.raw = raw; + this.compose = compose; + } + + run(input: RunAgentInput): Observable { + return defer(() => { + // `HttpAgent.abortRun()` aborts its current controller permanently. A fresh raw clone per + // wrapper run gives a cancelled turn its own controller and leaves the next turn runnable. + const raw = this.raw.clone() as HttpAgent; + this.active = raw; + return from(this.compose(input)).pipe( + switchMap((composed) => raw.run(composed)), + finalize(() => { + if (this.active === raw) this.active = undefined; + }), + ); + }); + } + + clone(): ComposedRemoteAgent { + const cloned = super.clone() as ComposedRemoteAgent; + cloned.raw = this.raw.clone() as HttpAgent; + cloned.compose = this.compose; + cloned.active = undefined; + return cloned; + } - return remote; + abortRun(): void { + this.active?.abortRun(); + super.abortRun(); + } } /** @@ -856,55 +959,10 @@ export type LoadAgentsForActor = ( */ export function createRequestAgents( identifyActor: IdentifyActor, - loadAgents: LoadAgentsForActor, - model: RuntimeModel, - resolveModelApiKey: () => Promise, - /** - * Shared across every request rather than built per run, because it is the thing that has to - * outlive one: the sweep that notices a silent stream has to still be running after the request - * that opened it has been answered. - */ - stallGuard?: StallGuard, - /** What each Bot may call, resolved for whoever is asking. Absent means no tools. */ - loadToolsForActor?: (actorId: string) => LoadToolsForBot, - /** Resolved per request, because what it signs is who this request turned out to be. */ - signRunForActor?: (actorId: string) => SignRun, - /** What every built-in Bot is told about the computer. Absent means this deployment has none. */ - computerGuidance?: string, - /** Which vendors this deployment connects to, held by a Bot or not. Absent means none. */ - loadVendors?: () => Promise, - /** - * How a run's tools are narrowed, resolved for whoever is asking. - * - * Per actor like the tools themselves, because the skills a Bot holds are read through the same - * grants, and because the discovery row has to name the person the run belongs to. - */ - selectionForActor?: (actorId: string) => ToolSelection, - /** The fetch remote agents are dialled with. See {@link buildAgents}. */ - agentFetch?: AgentFetch, - /** - * How a run gets its tool for handing work to another Bot, resolved for whoever is asking. - * - * Per actor for the same reason the tools are: which Bots may be reached is decided against the - * roster that person can see, so a Bot must never be able to address one they cannot. - */ - handoffForActor?: (actorId: string) => HandoffForRun, + resolver: ActorAgentResolver, ) { return async ({ request }: { request: Request }) => { - const actor = await identifyActor(request); - return resolveRuntimeAgents( - () => loadAgents(actor), - model, - resolveModelApiKey, - stallGuard, - loadToolsForActor?.(actor.id), - signRunForActor?.(actor.id), - computerGuidance, - loadVendors, - selectionForActor?.(actor.id), - agentFetch, - handoffForActor?.(actor.id), - ); + return resolver.resolveAgentsForActor(await identifyActor(request)); }; } @@ -994,26 +1052,11 @@ const THREAD_LOCK_TTL_SECONDS = 120; export function mountCopilotRuntime( config: DeploymentConfig, - model: RuntimeModel, - loadAgents: LoadAgentsForActor, - resolveModelApiKey: () => Promise, + resolver: ActorAgentResolver, identifyUser: IdentifyUser, identifyActor: IdentifyActor, - /** - * The watch on Bot streams. Not optional, unlike the parameter it forwards to: a guard built from - * a timeout of zero already watches nothing, so an unconfigured deployment has one to hand and - * there is no reason for a caller to have to say `undefined` here to reach `basePath`. - */ - stallGuard: StallGuard, - loadToolsForActor?: (actorId: string) => LoadToolsForBot, - signRunForActor?: (actorId: string) => SignRun, basePath = "/api/copilotkit", - loadVendors?: () => Promise, - selectionForActor?: (actorId: string) => ToolSelection, - /** The fetch remote agents are dialled with. See {@link buildAgents}. */ - agentFetch?: AgentFetch, - /** How a run gets its tool for handing work on. Absent means no Bot is offered one. */ - handoffForActor?: (actorId: string) => HandoffForRun, + channels: Channel[] = [], ) { const { intelligence } = config.runtime; @@ -1039,25 +1082,12 @@ export function mountCopilotRuntime( actor: AgentActor; botId: string; }): Promise => { - const { actor } = input; - const agents = await resolveRuntimeAgents( - () => loadAgents(actor), - model, - resolveModelApiKey, - stallGuard, - loadToolsForActor?.(actor.id), - signRunForActor?.(actor.id), - config.computer ? COMPUTER_GUIDANCE : undefined, - loadVendors, - selectionForActor?.(actor.id), - agentFetch, - handoffForActor?.(actor.id), - // Only the Bot this hop is for. The roster is still read in full, so a Bot this person cannot - // see is still absent; what this skips is constructing the other Bots and asking the database - // what each of them was granted, on every delivery and again on every retry. - input.botId, - ); - return agents[input.botId] ?? null; + // Only the Bot this hop is for. The roster is still read in full, so a Bot this person cannot + // see is still absent; what this skips is constructing the other Bots and asking the database + // what each of them was granted, on every delivery and again on every retry. + return await resolver + .resolveAgentForActor(input.actor, input.botId) + .catch(() => null); }; /* @@ -1077,6 +1107,7 @@ export function mountCopilotRuntime( // returns, so omitting it puts every person in the deployment in the same thread space and one // person's conversations become another's. identifyUser, + channels, // The subclass, not the base: a thread nobody has run yet reads as empty rather than as a 500. // See IntelligenceKnowingANewThread. intelligence: intelligenceClient, @@ -1088,30 +1119,21 @@ export function mountCopilotRuntime( : {}), // `identifyUser` is the Intelligence projection of the same person `identifyActor` returns: // one resolver decides both whose threads these are and whose coworkers exist. - agents: createRequestAgents( - identifyActor, - loadAgents, - model, - resolveModelApiKey, - stallGuard, - loadToolsForActor, - signRunForActor, - /* - * Only when a computer exists. The tools themselves are registered by the surface, so a Bot is - * offered them without this and the guidance is what tells it how they go together: snapshot - * before acting, and ask a person to take the wheel at a sign-in rather than reporting the task - * as impossible. Absent computer, absent guidance: a Bot is not told about hands it has not got. - */ - config.computer ? COMPUTER_GUIDANCE : undefined, - loadVendors, - selectionForActor, - agentFetch, - handoffForActor, - ) as never, + agents: createRequestAgents(identifyActor, resolver) as never, }); + const honoHandler = createCopilotHonoHandler({ runtime, basePath }); + return { - handler: createCopilotHonoHandler({ runtime, basePath }), + handler: honoHandler, + /** + * The managed channel host, when a Channel was declared, and otherwise nothing. + * + * Handed back rather than started here: an outbound socket that has to be up before a Slack + * turn can reach a coworker, and down before this process exits, is process lifecycle rather + * than a route, and the caller is the only place that already owns the HTTP listener beside it. + */ + channels: honoHandler.channels, /** * How to reach the platform's runner, exactly as the runtime reaches it. * diff --git a/server/src/db/schema/core.ts b/server/src/db/schema/core.ts index f8869b26..1bb5cc64 100644 --- a/server/src/db/schema/core.ts +++ b/server/src/db/schema/core.ts @@ -1,6 +1,8 @@ import { sql } from "drizzle-orm"; import { + bigserial, boolean, + check, index, pgEnum, pgTable, @@ -203,6 +205,34 @@ export const revokedAccess = pgTable("revoked_access", { revokedBy: text("revoked_by").notNull(), }); +/** An external workspace identity, permanently associated with one OpenBot user. */ +export const externalUserLinks = pgTable( + "external_user_links", + { + provider: text("provider").notNull(), + providerTenantId: text("provider_tenant_id").notNull(), + providerUserId: text("provider_user_id").notNull(), + openbotUserId: text("openbot_user_id") + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + providerEmail: text("provider_email"), + linkedAt: timestamp("linked_at", { withTimezone: true }) + .notNull() + .defaultNow(), + updatedAt: updatedAt(), + }, + (table) => [ + primaryKey({ + columns: [table.provider, table.providerTenantId, table.providerUserId], + }), + uniqueIndex("external_user_links_openbot_workspace_idx").on( + table.provider, + table.providerTenantId, + table.openbotUserId, + ), + ], +); + export const deploymentPackages = pgTable("deployment_packages", { id: uuid("id").primaryKey().defaultRandom(), tenantId: text("tenant_id").notNull().unique(), @@ -226,6 +256,72 @@ export const agents = pgTable("agents", { updatedAt: updatedAt(), }); +/** A provider thread is permanently assigned to the Channels thread and agent that first claims it. */ +export const externalThreadBindings = pgTable( + "external_thread_bindings", + { + channelsThreadId: text("channels_thread_id").primaryKey(), + provider: text("provider").notNull(), + providerTenantId: text("provider_tenant_id").notNull(), + providerConversationId: text("provider_conversation_id").notNull(), + providerThreadId: text("provider_thread_id").notNull(), + agentId: text("agent_id") + .notNull() + .references(() => agents.id, { onDelete: "restrict" }), + createdByUserId: text("created_by_user_id") + .notNull() + .references(() => users.id, { onDelete: "restrict" }), + createdAt: createdAt(), + }, + (table) => [ + check( + "external_thread_bindings_provider_slack_check", + sql`${table.provider} = 'slack'`, + ), + uniqueIndex("external_thread_bindings_provider_thread_idx").on( + table.provider, + table.providerTenantId, + table.providerConversationId, + table.providerThreadId, + ), + index("external_thread_bindings_creator_thread_idx").on( + table.createdByUserId, + table.channelsThreadId, + ), + ], +); + +/** OpenBot-owned copy of provider-visible turns for the read-only cross-surface transcript. */ +export const externalThreadMessages = pgTable( + "external_thread_messages", + { + sequence: bigserial("sequence", { mode: "number" }).primaryKey(), + channelsThreadId: text("channels_thread_id") + .notNull() + .references(() => externalThreadBindings.channelsThreadId, { + onDelete: "cascade", + }), + messageId: text("message_id").notNull(), + role: text("role").notNull(), + content: text("content").notNull(), + createdAt: createdAt(), + }, + (table) => [ + check( + "external_thread_messages_role_check", + sql`${table.role} IN ('user', 'assistant')`, + ), + uniqueIndex("external_thread_messages_thread_message_idx").on( + table.channelsThreadId, + table.messageId, + ), + index("external_thread_messages_thread_sequence_idx").on( + table.channelsThreadId, + table.sequence.desc(), + ), + ], +); + export const channels = pgTable( "channels", { @@ -405,6 +501,34 @@ export const auditEvents = pgTable( ], ); +/** One durable winner for the two actions rendered by an approval presentation. */ +export const approvalDecisions = pgTable( + "approval_decisions", + { + presentationId: uuid("presentation_id").primaryKey(), + channelsThreadId: text("channels_thread_id") + .notNull() + .references(() => externalThreadBindings.channelsThreadId, { + onDelete: "cascade", + }), + conversationKey: text("conversation_key").notNull(), + agentId: text("agent_id") + .notNull() + .references(() => agents.id, { onDelete: "restrict" }), + createdByUserId: text("created_by_user_id") + .notNull() + .references(() => users.id, { onDelete: "restrict" }), + actionId: text("action_id"), + approved: boolean("approved"), + decidedByUserId: text("decided_by_user_id").references(() => users.id, { + onDelete: "restrict", + }), + completedAt: timestamp("completed_at", { withTimezone: true }), + createdAt: createdAt(), + }, + (table) => [index("approval_decisions_created_at_idx").on(table.createdAt)], +); + export const intelligenceChannelMappings = pgTable( "intelligence_channel_mappings", { diff --git a/server/src/external/link-store.ts b/server/src/external/link-store.ts new file mode 100644 index 00000000..e65cc19b --- /dev/null +++ b/server/src/external/link-store.ts @@ -0,0 +1,235 @@ +import { and, eq, isNull, sql } from "drizzle-orm"; +import type { AuditTransaction } from "../audit"; +import type { Database } from "../db/client"; +import { + externalUserLinks, + revokedAccess, + userRoles, + users, +} from "../db/schema"; +import type { + ExternalProvider, + ExternalProviderIdentity, + ExternalUserLink, +} from "./schema-types"; + +export type ExternalLinkResult = { + link: ExternalUserLink; + created: boolean; +}; + +export type ExternalLinkStore = { + find: ( + provider: ExternalProvider, + tenantId: string, + providerUserId: string, + ) => Promise; + findVerifiedUserByEmail: ( + email: string, + ) => Promise<{ id: string; name: string } | null>; + link: ( + input: ExternalProviderIdentity & { openbotUserId: string }, + ) => Promise; +}; + +/** Fresh OpenBot authorization state for an external identity already stored in the database. */ +export type ExternalLinkAuthorizationStore = ExternalLinkStore & { + resolveActiveUser: (openbotUserId: string) => Promise<{ + id: string; + name: string; + role: "admin" | "user"; + } | null>; +}; + +export type ExternalLinkCreationStore = ExternalLinkStore & { + linkWithStatus: ( + input: ExternalProviderIdentity & { openbotUserId: string }, + ) => Promise; + linkWithStatusAndAudit: ( + input: ExternalProviderIdentity & { openbotUserId: string }, + recordAudit: (transaction: AuditTransaction) => Promise, + ) => Promise; +}; + +function normalizeEmail(email: string): string { + return email.trim().toLowerCase(); +} + +function asLink(row: typeof externalUserLinks.$inferSelect): ExternalUserLink { + return { + provider: row.provider as ExternalProvider, + providerTenantId: row.providerTenantId, + providerUserId: row.providerUserId, + providerEmail: row.providerEmail, + openbotUserId: row.openbotUserId, + linkedAt: row.linkedAt, + updatedAt: row.updatedAt, + }; +} + +export function createExternalLinkStore( + database: Database, +): ExternalLinkCreationStore & ExternalLinkAuthorizationStore { + async function find( + provider: ExternalProvider, + tenantId: string, + providerUserId: string, + ): Promise { + const [row] = await database + .select() + .from(externalUserLinks) + .where( + and( + eq(externalUserLinks.provider, provider), + eq(externalUserLinks.providerTenantId, tenantId), + eq(externalUserLinks.providerUserId, providerUserId), + ), + ) + .limit(1); + return row ? asLink(row) : null; + } + + async function findVerifiedUserByEmail(email: string) { + const rows = await database + .select({ + id: users.id, + // User names predate the not-null requirement but callers need a stable display value. + name: sql`coalesce(${users.name}, '')`, + }) + .from(users) + .leftJoin( + revokedAccess, + eq(revokedAccess.email, sql`lower(${users.email})`), + ) + .where( + and( + eq(sql`lower(${users.email})`, normalizeEmail(email)), + eq(users.emailVerified, true), + isNull(revokedAccess.email), + ), + ) + .limit(2); + + return rows.length === 1 ? rows[0] : null; + } + + async function resolveActiveUser(openbotUserId: string): Promise<{ + id: string; + name: string; + role: "admin" | "user"; + } | null> { + const rows = await database + .select({ + id: users.id, + name: sql`coalesce(${users.name}, '')`, + role: userRoles.role, + }) + .from(users) + .leftJoin( + revokedAccess, + eq(revokedAccess.email, sql`lower(${users.email})`), + ) + .leftJoin(userRoles, eq(userRoles.userId, users.id)) + .where(and(eq(users.id, openbotUserId), isNull(revokedAccess.email))); + + const user = rows[0]; + if (!user) return null; + const roles = rows.map((row) => row.role); + const role: "admin" | "user" | null = roles.includes("admin") + ? "admin" + : roles.includes("user") + ? "user" + : null; + return role ? { id: user.id, name: user.name, role } : null; + } + + async function linkWithStatusWithin( + transaction: AuditTransaction, + input: ExternalProviderIdentity & { openbotUserId: string }, + ): Promise { + const [inserted] = await transaction + .insert(externalUserLinks) + .values(input) + .onConflictDoNothing() + .returning(); + if (inserted) { + return { link: asLink(inserted), created: true }; + } + + const [row] = await transaction + .select() + .from(externalUserLinks) + .where( + and( + eq(externalUserLinks.provider, input.provider), + eq(externalUserLinks.providerTenantId, input.providerTenantId), + eq(externalUserLinks.providerUserId, input.providerUserId), + ), + ) + .limit(1); + const existing = row ? asLink(row) : null; + if (existing && existing.openbotUserId === input.openbotUserId) { + return { link: existing, created: false }; + } + if (existing) { + throw new Error("That Slack identity is already linked."); + } + + /* + * `onConflictDoNothing` also covers the one-OpenBot-user-per-workspace key. When that key + * won, the lookup above has no row because it is deliberately by provider identity; read the + * other key before reporting the public conflict. Each statement observes committed work, so + * this is also the answer after a concurrent insert has completed. + */ + const [existingForUser] = await transaction + .select({ openbotUserId: externalUserLinks.openbotUserId }) + .from(externalUserLinks) + .where( + and( + eq(externalUserLinks.provider, input.provider), + eq(externalUserLinks.providerTenantId, input.providerTenantId), + eq(externalUserLinks.openbotUserId, input.openbotUserId), + ), + ) + .limit(1); + if (existingForUser) { + throw new Error("That Slack identity is already linked."); + } + + throw new Error("External user link was not found after insertion."); + } + + async function linkWithStatus( + input: ExternalProviderIdentity & { openbotUserId: string }, + ): Promise { + return database.transaction((transaction) => + linkWithStatusWithin(transaction, input), + ); + } + + async function linkWithStatusAndAudit( + input: ExternalProviderIdentity & { openbotUserId: string }, + recordAudit: (transaction: AuditTransaction) => Promise, + ): Promise { + return database.transaction(async (transaction) => { + const result = await linkWithStatusWithin(transaction, input); + if (result.created) await recordAudit(transaction); + return result; + }); + } + + async function link( + input: ExternalProviderIdentity & { openbotUserId: string }, + ): Promise { + return (await linkWithStatus(input)).link; + } + + return { + find, + findVerifiedUserByEmail, + resolveActiveUser, + link, + linkWithStatus, + linkWithStatusAndAudit, + }; +} diff --git a/server/src/external/link-token.ts b/server/src/external/link-token.ts new file mode 100644 index 00000000..14bb07a2 --- /dev/null +++ b/server/src/external/link-token.ts @@ -0,0 +1,119 @@ +import { seal, unseal } from "../auth/signed-value"; +import type { ExternalProviderIdentity } from "./schema-types"; + +export const EXTERNAL_LINK_TTL_MS = 10 * 60_000; + +const EXTERNAL_LINK_LABEL = "external-link:v1"; +const INVALID_LINK_MESSAGE = "This Slack link has expired or is invalid."; +const UUID_PATTERN = + /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; +const CLAIM_KEYS = new Set([ + "provider", + "providerTenantId", + "providerUserId", + "providerEmail", + "issuedAt", + "expiresAt", + "nonce", +]); + +type ExternalLinkClaim = ExternalProviderIdentity & { + issuedAt: number; + expiresAt: number; + nonce: string; +}; + +function invalidLink(): never { + throw new Error(INVALID_LINK_MESSAGE); +} + +function isNonEmptyString(value: unknown): value is string { + return typeof value === "string" && value.trim().length > 0; +} + +function isUuid(value: unknown): value is string { + return typeof value === "string" && UUID_PATTERN.test(value); +} + +function asClaim(value: unknown): ExternalLinkClaim | null { + if ( + !value || + typeof value !== "object" || + Array.isArray(value) || + Object.getPrototypeOf(value) !== Object.prototype + ) { + return null; + } + + const keys = Object.keys(value); + if ( + keys.length !== CLAIM_KEYS.size || + keys.some((key) => !CLAIM_KEYS.has(key)) + ) { + return null; + } + + const claim = value as Partial; + const { issuedAt, expiresAt } = claim; + if ( + claim.provider !== "slack" || + !isNonEmptyString(claim.providerTenantId) || + !isNonEmptyString(claim.providerUserId) || + (claim.providerEmail !== null && typeof claim.providerEmail !== "string") || + typeof issuedAt !== "number" || + !Number.isSafeInteger(issuedAt) || + typeof expiresAt !== "number" || + !Number.isSafeInteger(expiresAt) || + expiresAt !== issuedAt + EXTERNAL_LINK_TTL_MS || + !isUuid(claim.nonce) + ) { + return null; + } + + return claim as ExternalLinkClaim; +} + +export async function mintExternalLinkToken( + identity: ExternalProviderIdentity, + key: string, + now = Date.now(), +): Promise { + return seal( + JSON.stringify({ + provider: identity.provider, + providerTenantId: identity.providerTenantId, + providerUserId: identity.providerUserId, + providerEmail: identity.providerEmail, + issuedAt: now, + expiresAt: now + EXTERNAL_LINK_TTL_MS, + nonce: crypto.randomUUID(), + } satisfies ExternalLinkClaim), + key, + EXTERNAL_LINK_LABEL, + ); +} + +export async function readExternalLinkToken( + token: string | undefined, + key: string, + now = Date.now(), +): Promise { + try { + const unsealed = await unseal(token, key, EXTERNAL_LINK_LABEL); + if (!unsealed) return invalidLink(); + + const claim = asClaim(JSON.parse(unsealed)); + if (!claim || now < claim.issuedAt || now > claim.expiresAt) { + return invalidLink(); + } + + return { + provider: claim.provider, + providerTenantId: claim.providerTenantId, + providerUserId: claim.providerUserId, + providerEmail: claim.providerEmail, + }; + } catch { + return invalidLink(); + } +} diff --git a/server/src/external/routes.ts b/server/src/external/routes.ts new file mode 100644 index 00000000..ce0c5617 --- /dev/null +++ b/server/src/external/routes.ts @@ -0,0 +1,257 @@ +import type { Context, MiddlewareHandler } from "hono"; +import { Hono } from "hono"; +import type { AgentProfileStore } from "../agents/profile-store"; +import { recordAuditEvent, type TransactionalAuditStore } from "../audit"; +import type { AppVariables } from "../auth/guards"; +import { + type AssistanceClaim, + readAssistanceToken, +} from "../slack/assistance-token"; +import type { ExternalLinkCreationStore } from "./link-store"; +import { readExternalLinkToken } from "./link-token"; +import type { ExternalProviderIdentity } from "./schema-types"; +import type { ExternalThreadStore } from "./thread-store"; + +const INVALID_LINK_MESSAGE = "This Slack link has expired or is invalid."; +const LINK_CONFLICT_MESSAGE = "That Slack identity is already linked."; +const INVALID_ASSISTANCE_MESSAGE = + "This assistance link has expired or is invalid."; +const ASSISTANCE_FORBIDDEN_MESSAGE = + "This assistance request is not available to this account."; +const INVALID_CONVERSATION_PAGE_MESSAGE = "Invalid conversation page."; + +type ExternalLinkRoutesOptions = { + store: ExternalLinkCreationStore; + encryptionKey: string; + requireUser: MiddlewareHandler<{ Variables: AppVariables }>; + auditStore: TransactionalAuditStore; + agentProfileStore: Pick; + threadStore: ExternalThreadStore; +}; + +function tokenFrom(value: unknown): string | undefined { + if (!value || typeof value !== "object" || Array.isArray(value)) { + return undefined; + } + const token = (value as { token?: unknown }).token; + return typeof token === "string" ? token : undefined; +} + +function invalidLinkResponse(context: Context<{ Variables: AppVariables }>) { + return context.json({ error: INVALID_LINK_MESSAGE }, 400); +} + +function externalThreadLimit(value: string | undefined): number | undefined { + if (value === undefined) return undefined; + if (!/^\d+$/.test(value)) { + throw new Error(INVALID_CONVERSATION_PAGE_MESSAGE); + } + const limit = Number(value); + if (limit < 1 || limit > 200) { + throw new Error(INVALID_CONVERSATION_PAGE_MESSAGE); + } + return limit; +} + +type ExternalThreadSummary = Awaited< + ReturnType +>["threads"][number]; + +function externalThreadSummary(thread: ExternalThreadSummary) { + return { + threadId: thread.threadId, + provider: thread.provider, + agentId: thread.agentId, + agentName: thread.agentName, + lastMessage: thread.lastMessage, + lastMessageAt: thread.lastMessageAt?.toISOString() ?? null, + createdAt: thread.createdAt.toISOString(), + readOnly: true, + }; +} + +export function createExternalLinkRoutes({ + store, + encryptionKey, + requireUser, + auditStore, + agentProfileStore, + threadStore, +}: ExternalLinkRoutesOptions) { + const routes = new Hono<{ Variables: AppVariables }>(); + + routes.get("/slack", requireUser, async (context) => { + try { + const claim = await readExternalLinkToken( + context.req.query("token"), + encryptionKey, + ); + return context.json({ + providerTenantId: claim.providerTenantId, + providerUserId: claim.providerUserId, + providerEmail: claim.providerEmail, + }); + } catch { + return invalidLinkResponse(context); + } + }); + + routes.post("/slack", requireUser, async (context) => { + const body = await context.req.json().catch(() => null); + let claim: ExternalProviderIdentity; + try { + claim = await readExternalLinkToken(tokenFrom(body), encryptionKey); + } catch { + return invalidLinkResponse(context); + } + + const actor = context.var.actor; + try { + await store.linkWithStatusAndAudit( + { ...claim, openbotUserId: actor.id }, + async (transaction) => { + await recordAuditEvent(auditStore.inTransaction(transaction), { + eventType: "external_identity.linked", + targetType: "user", + targetId: actor.id, + actorUserId: actor.id, + payload: { + provider: claim.provider, + providerTenantId: claim.providerTenantId, + providerUserId: claim.providerUserId, + }, + }); + }, + ); + } catch (error) { + if (error instanceof Error && error.message === LINK_CONFLICT_MESSAGE) { + return context.json({ error: LINK_CONFLICT_MESSAGE }, 409); + } + throw error; + } + + return context.json({ linked: true }); + }); + + routes.use("/threads/*", async (context, next) => { + context.header("Cache-Control", "no-store"); + await next(); + }); + + routes.use("/threads", async (context, next) => { + context.header("Cache-Control", "no-store"); + await next(); + }); + + routes.get("/threads", requireUser, async (context) => { + let limit: number | undefined; + try { + limit = externalThreadLimit(context.req.query("limit")); + } catch { + return context.json({ error: INVALID_CONVERSATION_PAGE_MESSAGE }, 400); + } + + const actor = context.var.actor; + const requestedLimit = limit ?? 50; + const agentIds = await agentProfileStore.listAccessibleIds({ + id: actor.id, + role: actor.role, + }); + const page = await threadStore.listForCreator(actor.id, { + agentIds, + cursor: context.req.query("cursor"), + limit: requestedLimit, + }); + + return context.json({ + threads: page.threads.map(externalThreadSummary), + nextCursor: page.nextCursor, + }); + }); + + routes.get("/threads/:threadId", requireUser, async (context) => { + const actor = context.var.actor; + const binding = await threadStore.getByChannelsThreadId( + context.req.param("threadId"), + ); + if (!binding || binding.createdByUserId !== actor.id) { + return context.json({ error: "Conversation not found." }, 404); + } + + const profile = await agentProfileStore.get( + { id: actor.id, role: actor.role }, + binding.agentId, + ); + if (!profile) { + return context.json({ error: "Conversation not found." }, 404); + } + + return context.json({ + threadId: binding.channelsThreadId, + agentId: profile.id, + agentName: profile.name, + provider: binding.provider, + readOnly: true, + }); + }); + + routes.get("/threads/:threadId/messages", requireUser, async (context) => { + const actor = context.var.actor; + const threadId = context.req.param("threadId"); + const binding = await threadStore.getByChannelsThreadId(threadId); + if (!binding || binding.createdByUserId !== actor.id) { + return context.json({ error: "Conversation not found." }, 404); + } + const profile = await agentProfileStore.get( + { id: actor.id, role: actor.role }, + binding.agentId, + ); + if (!profile) { + return context.json({ error: "Conversation not found." }, 404); + } + return context.json({ + messages: await threadStore.getTranscript(threadId), + }); + }); + + routes.use("/assistance", async (context, next) => { + context.header("Cache-Control", "no-store"); + await next(); + }); + + routes.get("/assistance", requireUser, async (context) => { + let claim: AssistanceClaim; + try { + claim = await readAssistanceToken( + context.req.query("token"), + encryptionKey, + ); + } catch { + return context.json({ error: INVALID_ASSISTANCE_MESSAGE }, 410); + } + + const actor = context.var.actor; + if (claim.openbotUserId !== actor.id) { + return context.json({ error: ASSISTANCE_FORBIDDEN_MESSAGE }, 403); + } + + let profile: Awaited>; + try { + profile = await agentProfileStore.get( + { id: actor.id, role: actor.role }, + claim.agentId, + ); + } catch { + return context.json( + { error: "This assistance request could not be checked right now." }, + 503, + ); + } + if (!profile) { + return context.json({ error: ASSISTANCE_FORBIDDEN_MESSAGE }, 403); + } + return context.json({ agentId: profile.id }); + }); + + return routes; +} diff --git a/server/src/external/schema-types.ts b/server/src/external/schema-types.ts new file mode 100644 index 00000000..4cdcef06 --- /dev/null +++ b/server/src/external/schema-types.ts @@ -0,0 +1,14 @@ +export type ExternalProvider = "slack"; + +export type ExternalProviderIdentity = { + provider: ExternalProvider; + providerTenantId: string; + providerUserId: string; + providerEmail: string | null; +}; + +export type ExternalUserLink = ExternalProviderIdentity & { + openbotUserId: string; + linkedAt: Date; + updatedAt: Date; +}; diff --git a/server/src/external/thread-store.ts b/server/src/external/thread-store.ts new file mode 100644 index 00000000..de18c20a --- /dev/null +++ b/server/src/external/thread-store.ts @@ -0,0 +1,504 @@ +import { and, asc, desc, eq, inArray, or, sql } from "drizzle-orm"; +import type { Database } from "../db/client"; +import { + agents, + externalThreadBindings, + externalThreadMessages, +} from "../db/schema"; + +export type ExternalTranscriptMessage = { + id: string; + role: "user" | "assistant"; + content: string; +}; + +export type ExternalThreadBindingInput = { + channelsThreadId: string; + provider: "slack"; + providerTenantId: string; + providerConversationId: string; + providerThreadId: string; + agentId: string; + /** Required by the public contract, but never trusted or persisted. Reads join the current name. */ + agentName: string; + createdByUserId: string; +}; + +export type ExternalThreadBinding = Omit< + ExternalThreadBindingInput, + "agentName" +> & { + agentName: string; + createdAt: Date; +}; + +export type ExternalThreadSummary = { + threadId: string; + provider: "slack"; + agentId: string; + agentName: string; + lastMessage: string | null; + lastMessageAt: Date | null; + createdAt: Date; +}; + +export type ExternalThreadPage = { + threads: ExternalThreadSummary[]; + nextCursor: string | null; +}; + +export type ExternalThreadListQuery = { + agentIds?: readonly string[]; + cursor?: string; + limit?: number; +}; + +/** An established Slack thread cannot be switched to another coworker. */ +export class ExternalThreadConflictError extends Error { + readonly agentName: string; + + constructor(agentName: string) { + super(`This Slack thread is already assigned to ${agentName}.`); + this.name = "ExternalThreadConflictError"; + this.agentName = agentName; + } +} + +export type ExternalThreadStore = { + listForCreator: ( + creatorId: string, + query?: ExternalThreadListQuery, + ) => Promise; + getByChannelsThreadId: (id: string) => Promise; + getByProviderThread: ( + identity: Pick< + ExternalThreadBindingInput, + | "provider" + | "providerTenantId" + | "providerConversationId" + | "providerThreadId" + >, + ) => Promise; + bind: (input: ExternalThreadBindingInput) => Promise; + appendTranscriptTurn: (input: { + channelsThreadId: string; + user: ExternalTranscriptMessage & { role: "user" }; + assistant: ExternalTranscriptMessage & { role: "assistant" }; + }) => Promise; + getTranscript: (id: string) => Promise; +}; + +type BindingReader = Pick; +type BindingWriter = BindingReader & Pick; + +const bindingColumns = { + channelsThreadId: externalThreadBindings.channelsThreadId, + provider: externalThreadBindings.provider, + providerTenantId: externalThreadBindings.providerTenantId, + providerConversationId: externalThreadBindings.providerConversationId, + providerThreadId: externalThreadBindings.providerThreadId, + agentId: externalThreadBindings.agentId, + agentName: agents.name, + createdByUserId: externalThreadBindings.createdByUserId, + createdAt: externalThreadBindings.createdAt, +}; + +const DEFAULT_EXTERNAL_THREAD_PAGE = 50; +const MAX_EXTERNAL_THREAD_PAGE = 200; +const MAX_PREVIEW_CODE_POINTS = 200; + +type ExternalThreadCursor = { recency: string; threadId: string }; + +const latestMessageAt = sql`( + select ${externalThreadMessages.createdAt} + from ${externalThreadMessages} + where ${externalThreadMessages.channelsThreadId} = ${externalThreadBindings.channelsThreadId} + order by ${externalThreadMessages.sequence} desc + limit 1 +)`; +const latestMessageContent = sql`( + select ${externalThreadMessages.content} + from ${externalThreadMessages} + where ${externalThreadMessages.channelsThreadId} = ${externalThreadBindings.channelsThreadId} + order by ${externalThreadMessages.sequence} desc + limit 1 +)`; +const externalRecency = sql`coalesce(${latestMessageAt}, ${externalThreadBindings.createdAt})`; + +function asBinding( + row: Omit & { provider: string }, +): ExternalThreadBinding { + if (row.provider !== "slack") { + throw new Error("External thread binding has an unsupported provider."); + } + return { ...row, provider: "slack" }; +} + +function isSameBinding( + binding: ExternalThreadBinding, + input: ExternalThreadBindingInput, +): boolean { + return ( + binding.channelsThreadId === input.channelsThreadId && + binding.provider === input.provider && + binding.providerTenantId === input.providerTenantId && + binding.providerConversationId === input.providerConversationId && + binding.providerThreadId === input.providerThreadId && + binding.agentId === input.agentId && + binding.createdByUserId === input.createdByUserId + ); +} + +function hasSameIdentityAndCreator( + binding: ExternalThreadBinding, + input: ExternalThreadBindingInput, +): boolean { + return ( + binding.channelsThreadId === input.channelsThreadId && + binding.provider === input.provider && + binding.providerTenantId === input.providerTenantId && + binding.providerConversationId === input.providerConversationId && + binding.providerThreadId === input.providerThreadId && + binding.createdByUserId === input.createdByUserId + ); +} + +function assignedError( + binding: ExternalThreadBinding, +): ExternalThreadConflictError { + return new ExternalThreadConflictError(binding.agentName); +} + +function sqlState(error: unknown): string | undefined { + let current: unknown = error; + for (let depth = 0; depth < 5 && current; depth += 1) { + const candidate = current as { + cause?: unknown; + code?: unknown; + errno?: unknown; + }; + if ( + typeof candidate.code === "string" && + /^[0-9A-Z]{5}$/.test(candidate.code) + ) { + return candidate.code; + } + if ( + typeof candidate.errno === "string" && + /^[0-9A-Z]{5}$/.test(candidate.errno) + ) { + return candidate.errno; + } + current = candidate.cause; + } + return undefined; +} + +function integrityError(): Error { + return new Error("External thread bindings have conflicting identities."); +} + +function encodeExternalThreadCursor(cursor: ExternalThreadCursor): string { + return Buffer.from(JSON.stringify(cursor), "utf8").toString("base64url"); +} + +function decodeExternalThreadCursor( + value: string | undefined, +): ExternalThreadCursor | undefined { + if (!value) return undefined; + try { + const parsed = JSON.parse( + Buffer.from(value, "base64url").toString("utf8"), + ) as ExternalThreadCursor; + if ( + typeof parsed?.recency !== "string" || + !/^\d{4}-/.test(parsed.recency) || + typeof parsed?.threadId !== "string" + ) { + return undefined; + } + const year = Number(parsed.recency.slice(0, 4)); + if (year < 1) return undefined; + const recency = new Date(parsed.recency); + if ( + Number.isNaN(recency.getTime()) || + recency.toISOString() !== parsed.recency + ) { + return undefined; + } + return parsed; + } catch { + return undefined; + } +} + +function previewOf(text: string): string { + // biome-ignore lint/suspicious/noControlCharactersInRegex: stripping them is the point. + const flattened = text.replace(/[\u0000-\u001f\u007f-\u009f]+/g, " ").trim(); + const collapsed = flattened.replace(/\s+/g, " "); + const codePoints = Array.from(collapsed); + if (codePoints.length <= MAX_PREVIEW_CODE_POINTS) return collapsed; + return `${codePoints.slice(0, MAX_PREVIEW_CODE_POINTS - 1).join("")}\u2026`; +} + +export function createExternalThreadStore( + database: Database, +): ExternalThreadStore { + async function listForCreator( + creatorId: string, + query: ExternalThreadListQuery = {}, + ): Promise { + if (query.agentIds?.length === 0) { + return { threads: [], nextCursor: null }; + } + + const limit = Math.min( + Math.max(query.limit ?? DEFAULT_EXTERNAL_THREAD_PAGE, 1), + MAX_EXTERNAL_THREAD_PAGE, + ); + const cursor = decodeExternalThreadCursor(query.cursor); + const rows = await database + .select({ + threadId: externalThreadBindings.channelsThreadId, + agentId: externalThreadBindings.agentId, + agentName: agents.name, + lastMessage: latestMessageContent, + lastMessageAt: latestMessageAt, + createdAt: externalThreadBindings.createdAt, + recency: externalRecency, + }) + .from(externalThreadBindings) + .innerJoin(agents, eq(externalThreadBindings.agentId, agents.id)) + .where( + and( + eq(externalThreadBindings.createdByUserId, creatorId), + query.agentIds + ? inArray(externalThreadBindings.agentId, query.agentIds) + : undefined, + cursor + ? sql`(${externalRecency}, ${externalThreadBindings.channelsThreadId}) < (${cursor.recency}::timestamptz, ${cursor.threadId})` + : undefined, + ), + ) + .orderBy( + sql`${externalRecency} desc`, + desc(externalThreadBindings.channelsThreadId), + ) + .limit(limit + 1); + + const wanted = rows.slice(0, limit); + const last = wanted.at(-1); + return { + threads: wanted.map((row) => ({ + threadId: row.threadId, + provider: "slack" as const, + agentId: row.agentId, + agentName: row.agentName, + lastMessage: + row.lastMessage === null ? null : previewOf(row.lastMessage), + lastMessageAt: row.lastMessageAt, + createdAt: row.createdAt, + })), + nextCursor: + rows.length > limit && last + ? encodeExternalThreadCursor({ + recency: new Date(last.recency).toISOString(), + threadId: last.threadId, + }) + : null, + }; + } + + async function lookup( + reader: BindingReader, + input: Pick< + ExternalThreadBindingInput, + | "channelsThreadId" + | "provider" + | "providerTenantId" + | "providerConversationId" + | "providerThreadId" + >, + ): Promise { + const rows = await reader + .select(bindingColumns) + .from(externalThreadBindings) + .innerJoin(agents, eq(externalThreadBindings.agentId, agents.id)) + .where( + or( + eq(externalThreadBindings.channelsThreadId, input.channelsThreadId), + and( + eq(externalThreadBindings.provider, input.provider), + eq(externalThreadBindings.providerTenantId, input.providerTenantId), + eq( + externalThreadBindings.providerConversationId, + input.providerConversationId, + ), + eq(externalThreadBindings.providerThreadId, input.providerThreadId), + ), + ), + ) + .limit(2); + return rows.map(asBinding); + } + + function oneOrIntegrity( + bindings: ExternalThreadBinding[], + ): ExternalThreadBinding | null { + if (bindings.length > 1) throw integrityError(); + return bindings[0] ?? null; + } + + async function getByChannelsThreadId( + id: string, + ): Promise { + const rows = await database + .select(bindingColumns) + .from(externalThreadBindings) + .innerJoin(agents, eq(externalThreadBindings.agentId, agents.id)) + .where(eq(externalThreadBindings.channelsThreadId, id)) + .limit(1); + return rows[0] ? asBinding(rows[0]) : null; + } + + async function getByProviderThread( + identity: Pick< + ExternalThreadBindingInput, + | "provider" + | "providerTenantId" + | "providerConversationId" + | "providerThreadId" + >, + ): Promise { + const rows = await database + .select(bindingColumns) + .from(externalThreadBindings) + .innerJoin(agents, eq(externalThreadBindings.agentId, agents.id)) + .where( + and( + eq(externalThreadBindings.provider, identity.provider), + eq( + externalThreadBindings.providerTenantId, + identity.providerTenantId, + ), + eq( + externalThreadBindings.providerConversationId, + identity.providerConversationId, + ), + eq( + externalThreadBindings.providerThreadId, + identity.providerThreadId, + ), + ), + ) + .limit(1); + return rows[0] ? asBinding(rows[0]) : null; + } + + async function bindInSerializableTransaction( + input: ExternalThreadBindingInput, + ): Promise { + return database.transaction( + async (transaction) => { + const existing = oneOrIntegrity(await lookup(transaction, input)); + if (existing) { + if (isSameBinding(existing, input)) return existing; + throw assignedError(existing); + } + + const [inserted] = await (transaction as BindingWriter) + .insert(externalThreadBindings) + .values({ + channelsThreadId: input.channelsThreadId, + provider: input.provider, + providerTenantId: input.providerTenantId, + providerConversationId: input.providerConversationId, + providerThreadId: input.providerThreadId, + agentId: input.agentId, + createdByUserId: input.createdByUserId, + }) + .onConflictDoNothing() + .returning(); + if (!inserted) { + throw new Error("External thread binding was not inserted."); + } + + const binding = oneOrIntegrity(await lookup(transaction, input)); + if (!binding) { + throw new Error( + "External thread binding was not found after insertion.", + ); + } + return binding; + }, + { isolationLevel: "serializable" }, + ); + } + + async function bind( + input: ExternalThreadBindingInput, + ): Promise { + try { + return await bindInSerializableTransaction(input); + } catch (error) { + if (sqlState(error) !== "40001") throw error; + + /* + * An overlapping, still-uncommitted first delivery is concurrency: both serializable snapshots + * may see no binding, and one may lose validation. After rollback, one combined read sees the + * committed winner. Only the exact canonical/provider identity and creator may converge; a + * call that starts after the winner commits sees an established row and takes the conflict path. + */ + const winner = oneOrIntegrity(await lookup(database, input)); + if (winner && hasSameIdentityAndCreator(winner, input)) return winner; + if (winner) throw assignedError(winner); + throw error; + } + } + + async function appendTranscriptTurn(input: { + channelsThreadId: string; + user: ExternalTranscriptMessage & { role: "user" }; + assistant: ExternalTranscriptMessage & { role: "assistant" }; + }): Promise { + await database + .insert(externalThreadMessages) + .values( + [input.user, input.assistant].map((message) => ({ + channelsThreadId: input.channelsThreadId, + messageId: message.id, + role: message.role, + content: message.content, + })), + ) + .onConflictDoNothing(); + } + + async function getTranscript( + id: string, + ): Promise { + const rows = await database + .select({ + id: externalThreadMessages.messageId, + role: externalThreadMessages.role, + content: externalThreadMessages.content, + }) + .from(externalThreadMessages) + .where(eq(externalThreadMessages.channelsThreadId, id)) + .orderBy(asc(externalThreadMessages.sequence)); + return rows.flatMap((row) => + row.role === "user" || row.role === "assistant" + ? [{ ...row, role: row.role }] + : [], + ); + } + + return { + listForCreator, + getByChannelsThreadId, + getByProviderThread, + bind, + appendTranscriptTurn, + getTranscript, + }; +} diff --git a/server/src/index.ts b/server/src/index.ts index a3b18ef6..7e69a3fe 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -5,6 +5,7 @@ import { } from "@copilotkit/runtime/v2"; import { serve } from "bun"; import { COMPUTER_GUIDANCE } from "../../shared/bot-prompt"; +import { createActorAgentResolver } from "./agents/agent-resolver"; import { mintRunAssertion, readRunAssertion } from "./agents/callback-token"; import { createAgentFetch } from "./agents/endpoint"; import { askTheirOwnPerson, escalationTool } from "./agents/escalation"; @@ -19,8 +20,12 @@ import { createApp } from "./app"; import { createAuditReader, createAuditStore, recordAuditEvent } from "./audit"; import { startRetentionSweeps } from "./audit-retention"; import { createAuth } from "./auth"; -import { DEV_ACTOR, initializeDevActorUser } from "./auth/dev-actor"; -import { createRoleRepository } from "./auth/guards"; +import { + createDevRequireUser, + DEV_ACTOR, + initializeDevActorUser, +} from "./auth/dev-actor"; +import { createRequireUser, createRoleRepository } from "./auth/guards"; import { createIdentityProviderStore } from "./auth/identity-provider-store"; import type { OpenBotRole } from "./auth/roles"; import { @@ -47,10 +52,10 @@ import { import { createSnapshotStore } from "./computer/snapshot-store"; import { loadConfig } from "./config"; import { + type HandoffForRun, type IdentifyActor, type IdentifyUser, mountCopilotRuntime, - resolveRuntimeAgents, type ToolSelection, } from "./copilot"; import { @@ -59,6 +64,9 @@ import { resolveModelApiKey, } from "./credentials"; import { createDatabase } from "./db/client"; +import { createExternalLinkStore } from "./external/link-store"; +import { createExternalLinkRoutes } from "./external/routes"; +import { createExternalThreadStore } from "./external/thread-store"; import { createPeopleStore } from "./people/store"; import { useRoutineTools } from "./plugins/builtin-routines"; import { redirectUriFor } from "./plugins/oauth"; @@ -69,6 +77,14 @@ import { createRoutineRunner } from "./routines/runner"; import { createRoutineStore } from "./routines/store"; import { createIntentRouter } from "./routing/classify"; import { createModelCompleter } from "./routing/model"; +import { createCoworkerRoutingService } from "./routing/service"; +import { createApprovalAuthorizer } from "./slack/approval-authorizer"; +import { createApprovalDecisionStore } from "./slack/approval-store"; +import { createOpenBotSlackChannel } from "./slack/channel"; +import { configureApprovalDecisionStore } from "./slack/components"; +import { SlackIdentityLinker } from "./slack/identity-linker"; +import { SlackIngressRegistry } from "./slack/ingress-registry"; +import { projectSlackStatus, startManagedChannelHost } from "./slack/status"; import { createPackageStatusReader, loadTenantPackage, @@ -152,6 +168,23 @@ const agentProfileStore = createAgentProfileStore( config.managedAgent?.endpoint, agentVault, ); +const externalLinkStore = createExternalLinkStore(database); +const externalThreadStore = createExternalThreadStore(database); +/* + * Who a Slack approval belongs to, decided here rather than by the card that renders it. + * + * The components are drawn inside a Slack surface and are handed a presentation id and nothing + * else, so a person clicking one proves only that they can reach the message. The authoriser reads + * the link, the thread binding and the coworker's visibility for whoever actually clicked, so an + * approval is refused for anybody the coworker is not visible to. + */ +configureApprovalDecisionStore(createApprovalDecisionStore(database), { + authorize: createApprovalAuthorizer({ + links: externalLinkStore, + threads: externalThreadStore, + profiles: agentProfileStore, + }), +}); // Read here rather than beside the synchronise below, because the package names the deployment and // the channel store needs that name before it can mint a thread id. const tenantPackage = await loadTenantPackage(config.tenantPackageDirectory); @@ -595,6 +628,114 @@ const agentFetch = createAgentFetch({ }, }); +/* + * What a Bot may reach past itself for: another Bot, and a person. Made per run and per person. + * + * Per person because which Bots may be reached is decided against the roster that person can + * see: a Bot must never be able to address one they cannot, or this becomes a way around agent + * visibility. Per run because the caps need to know how deep the chain already is and where an + * answer belongs, and both of those are the deployment's own statement about the run rather than + * anything the model can edit. + */ +const handoffForActor = + (actorId: string): HandoffForRun => + async (botId, input) => { + const from = readRunAssertion( + (input.forwardedProps as { openbotRun?: unknown } | undefined) + ?.openbotRun, + config.keyEncryptionKey, + ); + const run = { + botId, + actorId, + runId: input.runId, + threadId: input.threadId, + depth: from?.depth ?? 0, + }; + /* + * The caps are checked BEFORE the grants query, not inside the tool that would discard it. + * + * `handoffTool` short-circuits on all three of these, but only after being handed a + * `hasSomebodyToAsk` that costs a query. So a deployment which switched the capability off + * still paid one grants read per run of every Bot, for a tool it was never going to be offered, + * and a run already at the cap paid it again. + */ + const couldHandOn = + config.handoff.maxDepth > 0 && + config.handoff.maxPerRun > 0 && + run.depth < config.handoff.maxDepth; + + const passing = couldHandOn + ? handoffTool({ + desk: handoffDesk, + /* + * How deep this run already is comes from the assertion the deployment signed when it handed + * this work on. A run a person started carries none, and none means zero. + * + * NOT `from.botId`. The assertion proves what this run is, and the Bot is whichever one the + * runtime is building right now: on a hop those agree, and taking the id from the signed + * value rather than from the build would let a stale assertion aim the next hop at the + * wrong Bot's grants. + */ + from: run, + // Read now rather than at boot, so a grant made a minute ago counts and one revoked a + // minute ago stops counting. + hasSomebodyToAsk: + ( + await pluginStore + .botsReachableFrom(botId) + .catch(() => [] as string[]) + ).length > 0, + maxDepth: config.handoff.maxDepth, + maxPerRun: config.handoff.maxPerRun, + }) + : null; + /* + * The way to stop and ask is offered whether or not there is a Bot to hand to. + * + * It is the cheaper of the two and the one a Bot should reach for first: asking the person who + * is already in the conversation spends nothing and cannot be aimed anywhere they cannot see. + * A deployment that offered only the expensive exit would push every unanswerable question + * sideways into another run. + */ + const asking = escalationTool({ + from: run, + route: askTheirOwnPerson, + auditStore: bootAuditStore, + }); + return passing ? [passing, asking] : [asking]; + }; + +/** + * One place a coworker is built for one person, for every surface that runs one. + * + * The named constants above exist because two callers had to build the SAME Bot. There are now + * four — a person's chat request, a routine's headless turn, a hop delivered to another Bot, and a + * Slack thread — and passing eleven collaborators to each of them in the right order is a drift + * waiting to happen: a surface that got one argument wrong would run, and quietly hold different + * tools or a different role from the Bot the person is talking to. So the collaborators are bound + * once here, and every surface asks this for a coworker instead. + */ +const actorAgentResolver = createActorAgentResolver({ + loadAgents: loadAgentsForActor, + model: tenantPackage.model, + resolveModelApiKey: resolveRuntimeModelApiKey, + stallGuard, + loadToolsForActor, + signRunForActor, + /* + * Only when a computer exists. The tools themselves are registered by the surface, so a Bot is + * offered them without this and the guidance is what tells it how they go together: snapshot + * before acting, and ask a person to take the wheel at a sign-in rather than reporting the task + * as impossible. Absent computer, absent guidance: a Bot is not told about hands it has not got. + */ + computerGuidance: config.computer ? COMPUTER_GUIDANCE : undefined, + loadVendors, + selectionForActor, + agentFetch, + handoffForActor, +}); + /** * Who a routine acts as, resolved the way {@link resolveRequestActor} resolves it. * @@ -636,24 +777,12 @@ const buildAgentFor = async ({ agentId: string; }) => { const actor = await actorFor(ownerUserId); - const agents = await resolveRuntimeAgents( - () => loadAgentsForActor(actor), - tenantPackage.model, - resolveRuntimeModelApiKey, - stallGuard, - loadToolsForActor(actor.id), - signRunForActor(actor.id), - config.computer ? COMPUTER_GUIDANCE : undefined, - loadVendors, - selectionForActor(actor.id), - agentFetch, - undefined, - // Only the Bot this routine names. Same reason as the hop delivery: the roster is still read in - // full so a Bot this owner cannot see is still absent, but the other Bots are neither built nor - // asked what they hold. - agentId, - ); - const agent = agents[agentId]; + // Only the Bot this routine names. Same reason as the hop delivery: the roster is still read in + // full so a Bot this owner cannot see is still absent, but the other Bots are neither built nor + // asked what they hold. + const agent = await actorAgentResolver + .resolveAgentForActor(actor, agentId) + .catch(() => null); if (!agent) { /* * Named, and raised rather than swallowed. The routine's Bot was deleted, or made private by @@ -706,6 +835,60 @@ const routineRunner = createRoutineRunner({ }), }); +/** + * The Slack surface, and the runtime both surfaces share. + * + * One Channel declaration, handed to the same `CopilotRuntime` the browser talks to, so a Slack + * turn and a web turn resolve the same coworker for the same person through the same resolver, + * policy and audit store. Nothing about Slack reaches further in than this: the channel is handed + * a routing service, a thread store and the resolver, and builds no Bot of its own. + */ +const slackRouting = createCoworkerRoutingService({ + store: agentProfileStore, + router: intentRouter, + auditStore: bootAuditStore, + /* + * Which systems a coworker can reach, for the router to weigh alongside what it is for. + * + * Read from the grants rather than held, because a grant made a minute ago has to count. A read + * that fails stops the routing rather than routing on a false statement about what a coworker can + * reach: see `CoworkerReachabilityUnavailableError`. + */ + reachableSystems: async (agentId) => { + const granted = await pluginStore.listForAgent(agentId); + return [ + ...new Set( + granted.tools.map( + (tool) => + tool.toolName.replace(/^mcp__/, "").split("__")[0] ?? tool.toolName, + ), + ), + ]; + }, +}); +const slackIngress = new SlackIngressRegistry(); +const openbotSlackChannel = createOpenBotSlackChannel({ + appUrl: config.appUrl, + configuredTenantId: config.slackTenantId, + identityLinker: new SlackIdentityLinker({ + store: externalLinkStore, + encryptionKey: config.keyEncryptionKey, + appUrl: config.appUrl, + }), + ingressRegistry: slackIngress, + agentDeps: { + routing: slackRouting, + store: externalThreadStore, + resolver: actorAgentResolver, + }, + computerGateway, + // Secrets, sign-in control and 2FA are asked for on OpenBot's own surface, never in Slack. Absent + // an app URL there is nowhere to send somebody, so the channel offers no assistance at all. + assistance: config.appUrl + ? { appUrl: config.appUrl, encryptionKey: config.keyEncryptionKey } + : undefined, +}); + /** * The runtime, and the two things beside it a hop needs. * @@ -717,95 +900,36 @@ const routineRunner = createRoutineRunner({ */ const copilotRuntime = mountCopilotRuntime( config, - tenantPackage.model, - loadAgentsForActor, - resolveRuntimeModelApiKey, + actorAgentResolver, identifyUser, identifyActor, - stallGuard, - loadToolsForActor, - signRunForActor, - undefined, - loadVendors, - selectionForActor, - agentFetch, - /* - * What a Bot may reach past itself for: another Bot, and a person. Made per run and per person. - * - * Per person because which Bots may be reached is decided against the roster that person can - * see: a Bot must never be able to address one they cannot, or this becomes a way around agent - * visibility. Per run because the caps need to know how deep the chain already is and where an - * answer belongs, and both of those are the deployment's own statement about the run rather than - * anything the model can edit. - */ - (actorId) => async (botId, input) => { - const from = readRunAssertion( - (input.forwardedProps as { openbotRun?: unknown } | undefined) - ?.openbotRun, - config.keyEncryptionKey, - ); - const run = { - botId, - actorId, - runId: input.runId, - threadId: input.threadId, - depth: from?.depth ?? 0, - }; - /* - * The caps are checked BEFORE the grants query, not inside the tool that would discard it. - * - * `handoffTool` short-circuits on all three of these, but only after being handed a - * `hasSomebodyToAsk` that costs a query. So a deployment which switched the capability off - * still paid one grants read per run of every Bot, for a tool it was never going to be offered, - * and a run already at the cap paid it again. - */ - const couldHandOn = - config.handoff.maxDepth > 0 && - config.handoff.maxPerRun > 0 && - run.depth < config.handoff.maxDepth; - - const passing = couldHandOn - ? handoffTool({ - desk: handoffDesk, - /* - * How deep this run already is comes from the assertion the deployment signed when it handed - * this work on. A run a person started carries none, and none means zero. - * - * NOT `from.botId`. The assertion proves what this run is, and the Bot is whichever one the - * runtime is building right now: on a hop those agree, and taking the id from the signed - * value rather than from the build would let a stale assertion aim the next hop at the - * wrong Bot's grants. - */ - from: run, - // Read now rather than at boot, so a grant made a minute ago counts and one revoked a - // minute ago stops counting. - hasSomebodyToAsk: - ( - await pluginStore - .botsReachableFrom(botId) - .catch(() => [] as string[]) - ).length > 0, - maxDepth: config.handoff.maxDepth, - maxPerRun: config.handoff.maxPerRun, - }) - : null; - /* - * The way to stop and ask is offered whether or not there is a Bot to hand to. - * - * It is the cheaper of the two and the one a Bot should reach for first: asking the person who - * is already in the conversation spends nothing and cannot be aimed anywhere they cannot see. - * A deployment that offered only the expensive exit would push every unanswerable question - * sideways into another run. - */ - const asking = escalationTool({ - from: run, - route: askTheirOwnPerson, - auditStore: bootAuditStore, - }); - return passing ? [passing, asking] : [asking]; - }, + "/api/copilotkit", + [openbotSlackChannel], ); +/* + * The guard the account-link confirmation runs behind: a person's own session, never Slack's word. + * + * `loadConfig` permits no-provider operation only in explicit single-user mode. The invariant is + * checked again here so this route can never be handed an undefined auth service. + */ +const requireExternalUser = config.singleUser + ? createDevRequireUser() + : (() => { + if (!auth) { + throw new Error("Slack account linking requires authentication."); + } + return createRequireUser(auth, roleRepository); + })(); +const externalLinkRoutes = createExternalLinkRoutes({ + store: externalLinkStore, + encryptionKey: config.keyEncryptionKey, + requireUser: requireExternalUser, + auditStore: bootAuditStore, + agentProfileStore, + threadStore: externalThreadStore, +}); + /** * Delivering hops, on every replica. * @@ -1026,6 +1150,10 @@ const app = createApp( routineRunner, // A person's own standing instructions: the list, and a switch to stop one. routineStore, + // Where somebody confirms that a Slack account is theirs, behind their own OpenBot session. + externalLinkRoutes, + // A narrow public projection: never hand `/api/capabilities` the runtime snapshot itself. + () => projectSlackStatus(copilotRuntime.channels?.status()), ); /** @@ -1074,7 +1202,7 @@ const isProxiedStream = (data: SocketData): data is StreamData => const asChannelSocket = (ws: { data: SocketData }) => ws as unknown as ChannelSocket; -serve({ +const serverOptions = { port, async fetch(request, server) { const url = new URL(request.url); @@ -1172,6 +1300,31 @@ serve({ ws.data.inward?.close(); }, }, +} satisfies Bun.Serve.Options; +const startWeb = () => serve(serverOptions); + +/** + * The listener, started and stopped by the managed Slack host rather than at import. + * + * Managed Slack delivery arrives over an outbound socket this process opens, so the channel host + * has to be running before a Slack turn can reach a coworker, and the HTTP listener has to be up + * before the host so that setup and health stay reachable while attachment settles. Ownership of + * both is handed to one place so a signal stops them in that order, rather than a signal handler + * here racing an attachment that is still in progress. + */ +const managedHost = startManagedChannelHost({ + startWeb, + stopWeb: (server) => server.stop(true), + channels: copilotRuntime.channels, + signals: process, + // Each listener holds a connection of its own for the life of the process. Released on the way + // out, so a watch-mode restart does not leave two behind on every reload. + stopOthers: [ + () => channelActivityListener.stop(), + () => policyListener.stop(), + () => retentionSweeps.stop(), + ], + exit: (code) => process.exit(code), }); if (config.singleUser) { @@ -1183,16 +1336,8 @@ if (config.singleUser) { ); } -// Each listener holds a connection of its own for the life of the process. Released on the way out, -// so a watch-mode restart does not leave two behind on every reload. -for (const signal of ["SIGINT", "SIGTERM"] as const) { - process.on(signal, () => { - void Promise.allSettled([ - channelActivityListener.stop(), - policyListener.stop(), - Promise.resolve(retentionSweeps.stop()), - ]).finally(() => process.exit(0)); - }); -} - console.info(`OpenBot server listening on http://localhost:${port}`); + +// Activation is allowed to settle after HTTP starts. A missing provider or gateway outage must +// leave the setup and health surfaces reachable, with the projected status explaining why. +await managedHost; diff --git a/server/src/routing/routes.ts b/server/src/routing/routes.ts index 637713a3..c37b9754 100644 --- a/server/src/routing/routes.ts +++ b/server/src/routing/routes.ts @@ -1,96 +1,21 @@ import type { MiddlewareHandler } from "hono"; import { Hono } from "hono"; -import type { AgentProfileStore } from "../agents/profile-store"; -import type { AuditStore } from "../audit"; -import { recordAuditEvent } from "../audit"; import type { AppVariables } from "../auth/guards"; -import type { - IntentRouter, - RoutingCandidate, - RoutingUndecided, -} from "./classify"; - -const DEV_ACTOR_EMAIL = "dev@openbot.local"; +import type { HttpCoworkerRoutingService } from "./service"; /** - * Who to record the routing against, or nobody. + * Translate the shared coworker-routing result into the established HTTP contract. * - * The single-user development actor is not a real person and has no row to point at, so it is left - * off rather than written as a user id that resolves to nothing. - */ -function actorId( - actor: - | { - id?: string; - email?: string; - } - | null - | undefined, -): string | undefined { - return actor?.id && actor.email !== DEV_ACTOR_EMAIL ? actor.id : undefined; -} - -/** - * Decide which coworker a message is for, before a channel is pinned to one. - * - * The roster is read for the person asking, so this can only ever land on a coworker they are - * already allowed to reach. The decision is recorded like every other one in the product: a - * `channel.routed` row names where it went and why, and carries the candidate ids but never the - * message itself, which the audit payload redaction would drop anyway. - * - * A person who named a coworker with `@` has already decided, so nothing is inferred and no model - * is called. It is still recorded, with `viaMention` true and the person as the reason. Without - * that the trail answered "why did this go to Risk Analyst" for routed conversations and said - * nothing at all for chosen ones, which reads exactly like a row that failed to write. + * Choosing a coworker, applying visibility, invoking the intent model, and recording the canonical + * audit row are deliberately owned by CoworkerRoutingService. This layer only validates HTTP input + * and turns its outcome into status codes and JSON. */ export function createRoutingRoutes( - store: AgentProfileStore, - router: IntentRouter, + routing: HttpCoworkerRoutingService, requireUser: MiddlewareHandler<{ Variables: AppVariables }>, - auditStore?: AuditStore, - /** - * Which systems a coworker can reach, for the router to weigh alongside what it is for. - * - * Optional, and absent leaves routing exactly as it was: a deployment with no connectors has - * nothing to add here, and one that cannot answer the question should not have routing fail over - * it. Asked per request rather than held, because a grant added a minute ago has to count. - */ - reachableSystems?: (agentId: string) => Promise, ) { const routes = new Hono<{ Variables: AppVariables }>(); - /* - * The one place a `channel.routed` row is written, for both ways a message finds a coworker. - * - * Two call sites writing the same event is two payloads that drift, and a trail whose rows mean - * slightly different things depending on which branch produced them cannot be read at all. - */ - async function record( - actorUserId: string | undefined, - chosen: string, - reason: string, - fallback: boolean, - viaMention: boolean, - candidates: readonly string[], - /* - * Why the router did not decide, when it did not. - * - * On the row rather than only in the sentence, because this is the field a deployment counts. A - * router that has been unreachable for a week produced rows that read like ordinary - * "no confident match" ones, which is how #178 went unnoticed for as long as it did. - */ - undecided: RoutingUndecided | null, - ): Promise { - if (!auditStore) return; - await recordAuditEvent(auditStore, { - eventType: "channel.routed", - targetType: "agent", - targetId: chosen, - ...(actorUserId ? { actorUserId } : {}), - payload: { chosen, reason, fallback, viaMention, candidates, undecided }, - }); - } - routes.post("/", requireUser, async (context) => { const body = (await context.req.json().catch(() => null)) as { text?: unknown; @@ -98,104 +23,48 @@ export function createRoutingRoutes( } | null; const text = typeof body?.text === "string" ? body.text.trim() : ""; if (!text) return context.json({ error: "A message is required." }, 400); - const named = + const agentId = typeof body?.agentId === "string" && body.agentId.trim() ? body.agentId.trim() : null; - const actor = context.var.actor; - const roster = await store.list(actor, false); - // The same default the composer shows: the first public coworker, else the first at all. - const preferred = - roster.find((a) => a.visibility === "public") ?? roster[0]; - if (!preferred) { - return context.json({ error: "No coworker is available." }, 409); + const detail = await routing.routeDetailed({ + actor: context.var.actor, + text, + agentId, + }); + const { result } = detail; + if (result.kind === "none") { + return context.json( + { + error: agentId + ? "That coworker is not on your roster." + : "No coworker is available.", + }, + agentId ? 404 : 409, + ); } - - /* - * A named coworker is an instruction, not a question, so it is honoured as given. - * - * Checked against the same roster the router picks from, so `@` cannot reach further than - * routing can: a name that is not on it is refused rather than quietly turned into somebody - * else, because silently redirecting a message the person addressed by hand is the worst - * available answer. - */ - if (named) { - const chosen = roster.find((a) => a.id === named); - if (!chosen) { - return context.json( - { error: "That coworker is not on your roster." }, - 404, - ); - } - /* - * Third person, because the audit page is not read by the person who chose. - * - * This said "you chose them yourself", which is true in the conversation and false on an - * administrator's screen, where every row is somebody else's. The person is already on the - * row as `actorUserId`; the reason only has to say what kind of decision it was. - * - * @zopeVaibhav had this right in #134. - */ - const reason = "named by the person asking"; - // The person chose. Nothing was left to the router, so nothing about it was undecided. - await record( - actorId(actor), - chosen.id, - reason, - false, - true, - [chosen.id], - null, + if (result.kind === "ambiguous") { + return context.json( + { + error: "More than one coworker matches that name.", + names: result.names, + }, + 409, ); - return context.json({ - agentId: chosen.id, - name: chosen.name, - reason, - fallback: false, - viaMention: true, - }); } - const candidates: RoutingCandidate[] = await Promise.all( - roster.map(async (a) => ({ - id: a.id, - name: a.name, - roleDescription: a.roleDescription, - /* - * Never allowed to break routing. A connector store that is slow or unhappy must not turn - * "who is this for" into an error, so a failure here is the same as holding nothing: the - * router falls back to matching on purpose alone, which is what it did before. - */ - ...(reachableSystems - ? { - reaches: await reachableSystems(a.id).catch( - () => [] as readonly string[], - ), - } - : {}), - })), - ); - - const decision = await router.route(text, candidates, preferred.id); - - await record( - actorId(actor), - decision.agentId, - decision.reason, - decision.fallback, - false, - candidates.map((c) => c.id), - decision.undecided, + const response = { + agentId: result.agentId, + name: result.name, + reason: result.reason, + fallback: result.fallback, + viaMention: result.viaMention, + }; + // The composer chose this coworker directly, and the legacy response did not expose a model + // fallback cause for that path. Keep the model-routed response shape unchanged below. + return context.json( + agentId ? response : { ...response, undecided: detail.undecided }, ); - - return context.json({ - agentId: decision.agentId, - name: decision.name, - reason: decision.reason, - fallback: decision.fallback, - undecided: decision.undecided, - viaMention: false, - }); }); return routes; diff --git a/server/src/routing/service.ts b/server/src/routing/service.ts new file mode 100644 index 00000000..c9dadf78 --- /dev/null +++ b/server/src/routing/service.ts @@ -0,0 +1,382 @@ +import { canAccessAgent } from "../agents/profile-policy"; +import type { AgentProfileStore } from "../agents/profile-store"; +import type { AgentActor, AgentProfile } from "../agents/profile-types"; +import type { AuditStore } from "../audit"; +import { recordAuditEvent } from "../audit"; +import type { + IntentRouter, + RoutingCandidate, + RoutingUndecided, +} from "./classify"; + +const DEV_ACTOR_EMAIL = "dev@openbot.local"; +const WORD_CHARACTER = /[\p{L}\p{N}\p{M}_]/u; + +export type CoworkerRouteResult = + | { + kind: "selected"; + agentId: string; + name: string; + reason: string; + fallback: boolean; + viaMention: boolean; + } + | { kind: "ambiguous"; names: string[] } + | { kind: "none" }; + +type RoutingActor = AgentActor & { email?: string }; + +export type CoworkerRoutingInput = { + actor: RoutingActor; + text: string; + /** An explicit picker selection from a surface such as the web composer. */ + agentId?: string | null; +}; + +export type CoworkerRouteDetail = { + result: CoworkerRouteResult; + /** Kept for surfaces that have historically returned the model's fallback cause. */ + undecided: RoutingUndecided | null; +}; + +export type CoworkerRoutingService = { + route(input: CoworkerRoutingInput): Promise; +}; + +export type HttpCoworkerRoutingService = CoworkerRoutingService & { + routeDetailed(input: CoworkerRoutingInput): Promise; +}; + +export type CreateCoworkerRoutingServiceOptions = { + store: AgentProfileStore; + router: IntentRouter; + auditStore?: AuditStore; + reachableSystems?: (agentId: string) => Promise; +}; + +/** A safe categorical failure for a roster whose connector reachability could not be checked. */ +export class CoworkerReachabilityUnavailableError extends Error { + readonly code = "coworker_reachability_unavailable"; + + constructor() { + super("Coworker reachability is temporarily unavailable"); + this.name = "CoworkerReachabilityUnavailableError"; + } +} + +/** Normalize people-facing names before matching, without making matching fuzzy. */ +export function normalizeCoworkerName(value: string): string { + return value.normalize("NFKC").toLowerCase().trim().replace(/\s+/gu, " "); +} + +function hasTokenBoundaries(text: string, start: number, end: number): boolean { + const before = [...text.slice(0, start)].at(-1); + const after = [...text.slice(end)][0]; + return !before || !WORD_CHARACTER.test(before) + ? !after || !WORD_CHARACTER.test(after) + : false; +} + +type AliasOccurrence = { + start: number; + end: number; + profiles: ReadonlyMap; +}; + +function occurrencesOf( + text: string, + alias: string, + profiles: ReadonlyMap, +): AliasOccurrence[] { + const occurrences: AliasOccurrence[] = []; + let start = text.indexOf(alias); + while (start >= 0) { + const end = start + alias.length; + if (hasTokenBoundaries(text, start, end)) { + occurrences.push({ start, end, profiles }); + } + start = text.indexOf(alias, start + alias.length); + } + return occurrences; +} + +function actorId(actor: RoutingActor): string | undefined { + return actor.id && actor.email !== DEV_ACTOR_EMAIL ? actor.id : undefined; +} + +function suffixes(name: string): string[] { + const tokens = name.split(" "); + return tokens.slice(1).map((_, index) => tokens.slice(index + 1).join(" ")); +} + +function displayName(name: string): string { + return name.normalize("NFKC").trim().replace(/\s+/gu, " "); +} + +function utf8Hex(value: string): string { + return [...new TextEncoder().encode(value)] + .map((byte) => byte.toString(16).padStart(2, "0")) + .join(""); +} + +function codePointCompare(left: string, right: string): number { + const leftPoints = [...left]; + const rightPoints = [...right]; + for (let index = 0; index < leftPoints.length; index += 1) { + const leftPoint = leftPoints[index]?.codePointAt(0); + const rightPoint = rightPoints[index]?.codePointAt(0); + if (leftPoint === undefined) return -1; + if (rightPoint === undefined) return 1; + if (leftPoint !== rightPoint) return leftPoint - rightPoint; + } + return leftPoints.length - rightPoints.length; +} + +type AliasIndex = { + aliases: Map>; + labels: Map; +}; + +function addAlias( + aliases: AliasIndex["aliases"], + alias: string, + profile: AgentProfile, +): void { + if (!alias) return; + const profiles = aliases.get(alias) ?? new Map(); + profiles.set(profile.id, profile); + aliases.set(alias, profiles); +} + +function buildAliasIndex(roster: readonly AgentProfile[]): AliasIndex { + const byNormalizedName = new Map(); + for (const profile of roster) { + const normalized = normalizeCoworkerName(profile.name); + const profiles = byNormalizedName.get(normalized); + if (profiles) profiles.push(profile); + else byNormalizedName.set(normalized, [profile]); + } + + const aliases = new Map>(); + const labels = new Map(); + for (const profile of roster) { + const normalized = normalizeCoworkerName(profile.name); + const duplicates = byNormalizedName.get(normalized) ?? []; + const label = + duplicates.length > 1 + ? `${displayName(profile.name)} (id ${utf8Hex(profile.id)})` + : profile.name; + labels.set(profile.id, label); + addAlias(aliases, normalized, profile); + for (const suffix of suffixes(normalized)) + addAlias(aliases, suffix, profile); + if (duplicates.length > 1) { + addAlias(aliases, normalizeCoworkerName(label), profile); + } + } + return { aliases, labels }; +} + +type ExplicitOccurrence = { + start: number; + end: number; + profiles: Map; +}; + +/** + * Discard only aliases that a strictly longer explicit occurrence fully contains. + * + * Intervals are ordered by start, then widest first. A running maximum end therefore proves that a + * prior interval starts no later and reaches at least as far as the current one, which is exactly + * containment. Partial overlaps extend the maximum only for later contained intervals; they never + * suppress each other. + */ +function withoutContainedOccurrences( + occurrences: readonly AliasOccurrence[], +): ExplicitOccurrence[] { + const bySpan = new Map(); + for (const occurrence of occurrences) { + const key = `${occurrence.start}:${occurrence.end}`; + const merged = + bySpan.get(key) ?? + ({ + start: occurrence.start, + end: occurrence.end, + profiles: new Map(), + } satisfies ExplicitOccurrence); + for (const profile of occurrence.profiles.values()) { + merged.profiles.set(profile.id, profile); + } + bySpan.set(key, merged); + } + + const sorted = [...bySpan.values()].sort( + (left, right) => left.start - right.start || right.end - left.end, + ); + let maximumEnd = -1; + return sorted.filter((occurrence) => { + const contained = maximumEnd >= occurrence.end; + maximumEnd = Math.max(maximumEnd, occurrence.end); + return !contained; + }); +} + +function labelsFor( + profiles: Iterable, + labels: ReadonlyMap, +): string[] { + return [...profiles] + .map((profile) => labels.get(profile.id) ?? profile.name) + .sort(codePointCompare); +} + +function explicitNameRoute( + text: string, + roster: readonly AgentProfile[], +): CoworkerRouteResult | null { + const normalizedText = normalizeCoworkerName(text); + const { aliases, labels } = buildAliasIndex(roster); + const occurrences = [...aliases.entries()].flatMap(([alias, profiles]) => + occurrencesOf(normalizedText, alias, profiles), + ); + const explicitOccurrences = withoutContainedOccurrences(occurrences); + const profiles = new Map(); + for (const occurrence of explicitOccurrences) { + for (const profile of occurrence.profiles.values()) { + profiles.set(profile.id, profile); + } + } + if (profiles.size === 1) { + const chosen = profiles.values().next().value as AgentProfile; + return { + kind: "selected", + agentId: chosen.id, + name: chosen.name, + reason: "named by the person asking", + fallback: false, + viaMention: true, + }; + } + if (profiles.size > 1) { + return { kind: "ambiguous", names: labelsFor(profiles.values(), labels) }; + } + return null; +} + +function auditReason( + selected: Extract, + undecided: RoutingUndecided | null, +): string { + if (selected.viaMention) return "named by the person asking"; + if (selected.fallback) + return undecided ? `fallback: ${undecided}` : "fallback"; + return "intent match"; +} + +export function createCoworkerRoutingService( + options: CreateCoworkerRoutingServiceOptions, +): HttpCoworkerRoutingService { + async function record( + actor: RoutingActor, + selected: Extract, + candidates: readonly string[], + undecided: RoutingUndecided | null, + ): Promise { + if (!options.auditStore) return; + await recordAuditEvent(options.auditStore, { + eventType: "channel.routed", + targetType: "agent", + targetId: selected.agentId, + ...(actorId(actor) ? { actorUserId: actorId(actor) } : {}), + payload: { + chosen: selected.agentId, + reason: auditReason(selected, undecided), + fallback: selected.fallback, + viaMention: selected.viaMention, + candidates, + undecided, + }, + }); + } + + async function routeDetailed( + input: CoworkerRoutingInput, + ): Promise { + // The store applies this same policy in SQL; keep this canonical policy check at the service + // boundary so a broader store implementation cannot leak a coworker into routing. + const roster = (await options.store.list(input.actor, false)).filter( + (profile) => canAccessAgent(input.actor, profile), + ); + const namedId = input.agentId?.trim() || null; + if (namedId) { + const chosen = roster.find(({ id }) => id === namedId); + if (!chosen) return { result: { kind: "none" }, undecided: null }; + const result: Extract = { + kind: "selected", + agentId: chosen.id, + name: chosen.name, + reason: "named by the person asking", + fallback: false, + viaMention: true, + }; + await record(input.actor, result, [chosen.id], null); + return { result, undecided: null }; + } + + if (roster.length === 0) + return { result: { kind: "none" }, undecided: null }; + + const explicit = explicitNameRoute(input.text, roster); + if (explicit) { + if (explicit.kind === "selected") { + await record(input.actor, explicit, [explicit.agentId], null); + } + return { result: explicit, undecided: null }; + } + + const preferred = + roster.find(({ visibility }) => visibility === "public") ?? roster[0]; + if (!preferred) return { result: { kind: "none" }, undecided: null }; + const candidates: RoutingCandidate[] = await Promise.all( + roster.map(async (profile) => ({ + id: profile.id, + name: profile.name, + roleDescription: profile.roleDescription, + ...(options.reachableSystems + ? { + reaches: await options.reachableSystems(profile.id).catch(() => { + throw new CoworkerReachabilityUnavailableError(); + }), + } + : {}), + })), + ); + const decision = await options.router.route( + input.text, + candidates, + preferred.id, + ); + const result: Extract = { + kind: "selected", + agentId: decision.agentId, + name: decision.name, + reason: decision.reason, + fallback: decision.fallback, + viaMention: false, + }; + await record( + input.actor, + result, + candidates.map(({ id }) => id), + decision.undecided, + ); + return { result, undecided: decision.undecided }; + } + + return { + async route(input) { + return (await routeDetailed(input)).result; + }, + routeDetailed, + }; +} diff --git a/server/src/slack/approval-authorizer.ts b/server/src/slack/approval-authorizer.ts new file mode 100644 index 00000000..ec00ed2a --- /dev/null +++ b/server/src/slack/approval-authorizer.ts @@ -0,0 +1,76 @@ +import type { AgentProfileStore } from "../agents/profile-store"; +import type { ExternalLinkAuthorizationStore } from "../external/link-store"; +import type { ExternalThreadStore } from "../external/thread-store"; +import type { ApprovalPresentation } from "./approval-store"; +import { + type LinkedSlackIngress, + providerThreadIdFromIdentity, +} from "./ingress-registry"; + +export type SlackApprovalAuthorization = { + actor: { id: string; role: "admin" | "user" }; + applicationUser: { id: string; name: string }; + provider: "slack"; + providerTenantId: string; + providerConversationId: string; + providerThreadId: string; +}; + +export type ApprovalAuthorizerDependencies = { + links: ExternalLinkAuthorizationStore; + threads: ExternalThreadStore; + profiles: Pick; +}; + +/** Recheck canonical user, immutable Slack thread binding, and coworker visibility per click. */ +export function createApprovalAuthorizer( + dependencies: ApprovalAuthorizerDependencies, +) { + return async (input: { + userId: string; + presentation: ApprovalPresentation; + liveIdentity: LinkedSlackIngress | null; + }): Promise => { + if (!input.liveIdentity) return false; + const { identityContext: live, identityResult } = input.liveIdentity; + if ( + live.provider !== "slack" || + live.actor.kind !== "human" || + live.actor.id !== identityResult.identity.providerUserId || + live.tenant.id !== identityResult.identity.providerTenantId || + identityResult.user.id !== input.userId || + identityResult.actor.id !== input.userId + ) { + return false; + } + const active = await dependencies.links.resolveActiveUser(input.userId); + if (!active || active.id !== input.userId) return false; + const binding = await dependencies.threads.getByChannelsThreadId( + input.presentation.channelsThreadId, + ); + if ( + !binding || + binding.agentId !== input.presentation.agentId || + binding.provider !== "slack" || + binding.providerTenantId !== live.tenant.id || + binding.providerConversationId !== live.conversation.id || + binding.providerThreadId !== providerThreadIdFromIdentity(live) + ) + return false; + const actor = { id: active.id, role: active.role }; + if ( + (await dependencies.profiles.get(actor, input.presentation.agentId)) === + null + ) { + return false; + } + return { + actor, + applicationUser: { id: active.id, name: active.name }, + provider: "slack", + providerTenantId: binding.providerTenantId, + providerConversationId: binding.providerConversationId, + providerThreadId: binding.providerThreadId, + }; + }; +} diff --git a/server/src/slack/approval-store.ts b/server/src/slack/approval-store.ts new file mode 100644 index 00000000..9436660c --- /dev/null +++ b/server/src/slack/approval-store.ts @@ -0,0 +1,134 @@ +import { and, eq, isNull, lt } from "drizzle-orm"; +import type { Database } from "../db/client"; +import { approvalDecisions } from "../db/schema"; + +export type ApprovalPresentation = { + presentationId: string; + channelsThreadId: string; + conversationKey: string; + agentId: string; + createdByUserId: string; + createdAt: Date; +}; + +export type ApprovalDecisionClaim = { + presentationId: string; + actionId: string; + approved: boolean; + decidedByUserId: string; +}; + +export type ApprovalClaimResult = "first" | "retry" | "rejected"; + +export interface ApprovalDecisionStore { + present(input: Omit): Promise; + get(presentationId: string): Promise; + begin(input: ApprovalDecisionClaim): Promise; + complete(presentationId: string, actionId: string): Promise; + cleanup(before: Date): Promise; +} + +export function createApprovalDecisionStore( + database: Database, +): ApprovalDecisionStore { + const get = async ( + presentationId: string, + ): Promise => { + const [row] = await database + .select({ + presentationId: approvalDecisions.presentationId, + channelsThreadId: approvalDecisions.channelsThreadId, + conversationKey: approvalDecisions.conversationKey, + agentId: approvalDecisions.agentId, + createdByUserId: approvalDecisions.createdByUserId, + createdAt: approvalDecisions.createdAt, + }) + .from(approvalDecisions) + .where(eq(approvalDecisions.presentationId, presentationId)) + .limit(1); + return row ?? null; + }; + + return { + async present(input) { + const inserted = await database + .insert(approvalDecisions) + .values(input) + .onConflictDoNothing({ target: approvalDecisions.presentationId }) + .returning({ presentationId: approvalDecisions.presentationId }); + if (inserted.length > 0) return; + + const existing = await get(input.presentationId); + if ( + !existing || + existing.channelsThreadId !== input.channelsThreadId || + existing.conversationKey !== input.conversationKey || + existing.agentId !== input.agentId || + existing.createdByUserId !== input.createdByUserId + ) { + throw new Error( + "Approval presentation conflicts with its authorization subject.", + ); + } + }, + + get, + + async begin(input) { + return database.transaction(async (transaction) => { + const [row] = await transaction + .select({ + actionId: approvalDecisions.actionId, + approved: approvalDecisions.approved, + decidedByUserId: approvalDecisions.decidedByUserId, + completedAt: approvalDecisions.completedAt, + }) + .from(approvalDecisions) + .where(eq(approvalDecisions.presentationId, input.presentationId)) + .for("update"); + if (!row) return "rejected"; + if (row.actionId === null) { + await transaction + .update(approvalDecisions) + .set({ + actionId: input.actionId, + approved: input.approved, + decidedByUserId: input.decidedByUserId, + }) + .where(eq(approvalDecisions.presentationId, input.presentationId)); + return "first"; + } + if ( + row.actionId === input.actionId && + row.approved === input.approved && + row.decidedByUserId === input.decidedByUserId && + row.completedAt === null + ) { + return "retry"; + } + return "rejected"; + }); + }, + + async complete(presentationId, actionId) { + await database + .update(approvalDecisions) + .set({ completedAt: new Date() }) + .where( + and( + eq(approvalDecisions.presentationId, presentationId), + eq(approvalDecisions.actionId, actionId), + isNull(approvalDecisions.completedAt), + ), + ); + }, + + async cleanup(before) { + const removed = await database + .delete(approvalDecisions) + .where(lt(approvalDecisions.createdAt, before)) + .returning({ presentationId: approvalDecisions.presentationId }); + return removed.length; + }, + }; +} diff --git a/server/src/slack/assistance-token.ts b/server/src/slack/assistance-token.ts new file mode 100644 index 00000000..7d43f59d --- /dev/null +++ b/server/src/slack/assistance-token.ts @@ -0,0 +1,112 @@ +import { seal, unseal } from "../auth/signed-value"; + +const ASSISTANCE_LABEL = "slack-assistance:v1"; +const INVALID_ASSISTANCE_MESSAGE = + "This assistance link has expired or is invalid."; +export const ASSISTANCE_TTL_MS = 10 * 60_000; + +const UUID_PATTERN = + /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; +const CLAIM_KEYS = new Set([ + "openbotUserId", + "agentId", + "channelsThreadId", + "issuedAt", + "expiresAt", + "nonce", +]); + +export type AssistanceClaim = { + openbotUserId: string; + agentId: string; + channelsThreadId: string; + issuedAt: number; + expiresAt: number; + nonce: string; +}; + +function invalidAssistance(): never { + throw new Error(INVALID_ASSISTANCE_MESSAGE); +} + +function nonEmpty(value: unknown): value is string { + return typeof value === "string" && value.trim().length > 0; +} + +function parseAssistanceClaim(raw: string | null): AssistanceClaim | null { + if (!raw) return null; + try { + const value: unknown = JSON.parse(raw); + if ( + !value || + typeof value !== "object" || + Array.isArray(value) || + Object.getPrototypeOf(value) !== Object.prototype + ) { + return null; + } + const keys = Object.keys(value); + if ( + keys.length !== CLAIM_KEYS.size || + keys.some((key) => !CLAIM_KEYS.has(key)) + ) { + return null; + } + const claim = value as Partial; + if ( + !nonEmpty(claim.openbotUserId) || + !nonEmpty(claim.agentId) || + !nonEmpty(claim.channelsThreadId) || + typeof claim.issuedAt !== "number" || + !Number.isSafeInteger(claim.issuedAt) || + typeof claim.expiresAt !== "number" || + !Number.isSafeInteger(claim.expiresAt) || + claim.expiresAt !== claim.issuedAt + ASSISTANCE_TTL_MS || + typeof claim.nonce !== "string" || + !UUID_PATTERN.test(claim.nonce) + ) { + return null; + } + return claim as AssistanceClaim; + } catch { + return null; + } +} + +export async function mintAssistanceToken( + input: Pick< + AssistanceClaim, + "openbotUserId" | "agentId" | "channelsThreadId" + >, + key: string, + now = Date.now(), +): Promise { + return seal( + JSON.stringify({ + ...input, + issuedAt: now, + expiresAt: now + ASSISTANCE_TTL_MS, + nonce: crypto.randomUUID(), + } satisfies AssistanceClaim), + key, + ASSISTANCE_LABEL, + ); +} + +export async function readAssistanceToken( + token: string | undefined, + key: string, + now = Date.now(), +): Promise { + try { + const claim = parseAssistanceClaim( + await unseal(token, key, ASSISTANCE_LABEL), + ); + if (!claim || now < claim.issuedAt || now > claim.expiresAt) { + return invalidAssistance(); + } + return claim; + } catch { + return invalidAssistance(); + } +} diff --git a/server/src/slack/assistance.ts b/server/src/slack/assistance.ts new file mode 100644 index 00000000..f48d38e0 --- /dev/null +++ b/server/src/slack/assistance.ts @@ -0,0 +1,556 @@ +import type { ChannelToolContext } from "@copilotkit/channels"; +import { Actions, Button, Message, Section } from "@copilotkit/channels/ui"; +import type { ActionActor, ComputerGateway } from "../computer/gateway"; +import type { + AssistanceStatus, + ControlState, + SecretRequest, +} from "../computer/schema"; +import { ASSISTANCE_TTL_MS, mintAssistanceToken } from "./assistance-token"; +import { currentSlackExecution } from "./execution-context"; + +export const ASSISTANCE_POLL_MS = 1_000; +const COMPENSATION_TIMEOUT_MS = 10_000; +const STOPPED = { ok: false, stopped: true, reason: "Stopped." } as const; +const DELIVERY_CLEARED = { + ok: false, + reason: + "The Slack handoff could not be delivered. Its OpenBot assistance request was cleared; ask again if help is still needed.", +} as const; +const MAY_STILL_BE_PENDING = { + ok: false, + assistanceMayBePending: true, + reason: + "The Slack assistance flow could not be completed, and its OpenBot assistance request may still be pending. Open the coworker directly to clear it before asking again.", +} as const; +const DELIVERY_UNKNOWN = { + ok: false, + deliveryMayBePending: true, + assistanceMayBePending: true, + reason: + "Slack may still deliver the secure assistance link, and its OpenBot request is still pending. Do not send another request until this one is checked.", +} as const; +const REQUEST_NOT_PENDING = { + ok: false, + reason: + "The OpenBot assistance request could not be created safely. Its exact request generation is no longer pending; ask again if help is still needed.", +} as const; +const EXACT_CANCELLED = { + ok: false, + reason: + "This exact OpenBot assistance request was already cancelled. Ask again only if help is still needed.", +} as const; +const EXACT_EXPIRED = { + ok: false, + reason: + "This exact OpenBot assistance request expired without completion. Ask again only if help is still needed.", +} as const; +const EXACT_SUPERSEDED = { + ok: false, + reason: + "This exact OpenBot assistance request was replaced by a newer request and did not complete.", +} as const; +const EXACT_UNKNOWN = { + ok: false, + assistanceMayBePending: true, + reason: + "OpenBot no longer knows the exact assistance request outcome. Check the coworker before asking again.", +} as const; + +type WaitOutcome = "answered" | "cancelled" | "expired"; +type SleepOutcome = "elapsed" | "aborted"; +type SettledOperation = + | { kind: "value"; value: T } + | { kind: "error"; error: unknown } + | { kind: "aborted" } + | { kind: "expired" }; + +export type WaitForAssistanceOptions = { + control: () => Promise; + done: (state: ControlState) => boolean; + signal?: AbortSignal; + timeoutMs?: number; + pollMs?: number; + now?: () => number; + sleep?: (milliseconds: number, signal?: AbortSignal) => Promise; +}; + +function abortAwareSleep( + milliseconds: number, + signal?: AbortSignal, +): Promise { + if (signal?.aborted) return Promise.resolve("aborted"); + return new Promise((resolve) => { + let timer: ReturnType | undefined; + const finish = (outcome: SleepOutcome) => { + if (timer !== undefined) clearTimeout(timer); + signal?.removeEventListener("abort", onAbort); + resolve(outcome); + }; + const onAbort = () => finish("aborted"); + timer = setTimeout(() => finish("elapsed"), milliseconds); + signal?.addEventListener("abort", onAbort, { once: true }); + }); +} + +/** Settle a possibly hung operation without abandoning an unobserved rejection or live timer. */ +function settleOperation( + run: () => Promise, + remainingMs: number, + signal?: AbortSignal, +): Promise> { + if (signal?.aborted) return Promise.resolve({ kind: "aborted" }); + if (remainingMs <= 0) return Promise.resolve({ kind: "expired" }); + + return new Promise((resolve) => { + let settled = false; + const finish = (outcome: SettledOperation) => { + if (settled) return; + settled = true; + clearTimeout(timer); + signal?.removeEventListener("abort", onAbort); + resolve(outcome); + }; + const onAbort = () => finish({ kind: "aborted" }); + const timer = setTimeout(() => finish({ kind: "expired" }), remainingMs); + signal?.addEventListener("abort", onAbort, { once: true }); + Promise.resolve() + .then(run) + .then( + (value) => finish({ kind: "value", value }), + (error: unknown) => finish({ kind: "error", error }), + ); + }); +} + +/** Poll one already-created assistance request for a finite, abortable window. */ +export async function waitForAssistance({ + control, + done, + signal, + timeoutMs = ASSISTANCE_TTL_MS, + pollMs = ASSISTANCE_POLL_MS, + now = Date.now, + sleep = abortAwareSleep, +}: WaitForAssistanceOptions): Promise { + const deadline = now() + timeoutMs; + while (now() < deadline) { + if (signal?.aborted) return "cancelled"; + const polled = await settleOperation(control, deadline - now(), signal); + if (polled.kind === "aborted") return "cancelled"; + if (polled.kind === "expired") return "expired"; + if (polled.kind === "error") throw polled.error; + if (signal?.aborted) return "cancelled"; + if (done(polled.value)) return "answered"; + const remaining = deadline - now(); + if (remaining <= 0) break; + const slept = await sleep(Math.min(pollMs, remaining), signal); + if (slept === "aborted" || signal?.aborted) return "cancelled"; + } + return "expired"; +} + +export function computerControlUrl(appUrl: string, token: string): string { + let configured: URL; + try { + configured = new URL(appUrl); + const hostname = configured.hostname.toLowerCase(); + const loopback = + hostname === "localhost" || + hostname === "127.0.0.1" || + hostname === "::1" || + hostname === "[::1]"; + if ( + configured.username || + configured.password || + (configured.protocol !== "https:" && + !(configured.protocol === "http:" && loopback)) + ) { + throw new Error(); + } + } catch { + throw new Error( + "OpenBot app URL must be HTTPS (or loopback HTTP for local development).", + ); + } + const url = new URL("/assist", configured); + url.searchParams.set("token", token); + return url.toString(); +} + +export type SlackAssistanceOptions = { + appUrl: string; + encryptionKey: string; + now?: () => number; +}; + +async function assistanceUrl( + options: SlackAssistanceOptions, + issuedAt: number, +): Promise { + const execution = currentSlackExecution(); + if (!execution.agentId || !execution.channelsThreadId) { + throw new Error("Slack assistance requires a pinned coworker thread."); + } + const token = await mintAssistanceToken( + { + openbotUserId: execution.actor.id, + agentId: execution.agentId, + channelsThreadId: execution.channelsThreadId, + }, + options.encryptionKey, + issuedAt, + ); + return computerControlUrl(options.appUrl, token); +} + +function assistanceMessage(reason: string, url: string) { + return Message({ + fallbackText: `${reason} Open coworker control: ${url}`, + children: [ + Section({ children: reason }), + Actions({ + children: Button({ url, children: "Open coworker control" }), + }), + ], + }); +} + +function actorAndAgent() { + const execution = currentSlackExecution(); + if (!execution.agentId) { + throw new Error("Slack assistance requires a pinned coworker."); + } + return { + agentId: execution.agentId, + actor: { id: execution.actor.id, userId: execution.actor.id }, + }; +} + +type AssistanceOutcome = Record & { ok: boolean }; + +function hasExactHelpRequest(state: ControlState, requestId: string): boolean { + return ( + state.holder === "bot" && + state.requested && + state.helpRequestId === requestId + ); +} + +function hasExactSecretRequest( + state: ControlState, + requestId: string, +): boolean { + return ( + state.holder === "bot" && + state.secretWanted !== undefined && + state.secretRequestId === requestId + ); +} + +async function cancelCommittedRequest( + gateway: ComputerGateway, + agentId: string, + actor: ActionActor, + requestId: string, + clearedOutcome: AssistanceOutcome, + completedOutcome: AssistanceOutcome, +): Promise { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), COMPENSATION_TIMEOUT_MS); + try { + const observed = await settleOperation( + () => gateway.assistanceStatus(agentId, requestId), + COMPENSATION_TIMEOUT_MS, + ); + if (observed.kind === "value") { + if (observed.value === "completed") return completedOutcome; + if (observed.value === "human") return MAY_STILL_BE_PENDING; + if (observed.value === "expired") return EXACT_EXPIRED; + if (observed.value === "cancelled") return EXACT_CANCELLED; + if (observed.value === "superseded") return EXACT_SUPERSEDED; + } + const cleared = await settleOperation( + () => + gateway.cancelAssistance(agentId, actor, requestId, controller.signal), + COMPENSATION_TIMEOUT_MS, + ); + if (cleared.kind !== "value") return MAY_STILL_BE_PENDING; + if (cleared.value.cancelled) return clearedOutcome; + if (cleared.value.status === "completed") return completedOutcome; + if (cleared.value.status === "expired") return EXACT_EXPIRED; + if (cleared.value.status === "cancelled") return EXACT_CANCELLED; + if (cleared.value.status === "superseded") return EXACT_SUPERSEDED; + if (cleared.value.status === "unknown") return EXACT_UNKNOWN; + return MAY_STILL_BE_PENDING; + } finally { + clearTimeout(timer); + controller.abort(); + } +} + +async function deliverCommittedRequest( + gateway: ComputerGateway, + agentId: string, + actor: ActionActor, + requestId: string, + context: ChannelToolContext, + message: ReturnType, + remainingMs: number, + completedOutcome: AssistanceOutcome, +): Promise { + if (context.signal?.aborted) { + return cancelCommittedRequest( + gateway, + agentId, + actor, + requestId, + STOPPED, + completedOutcome, + ); + } + const posted = await settleOperation( + () => context.thread.post(message), + remainingMs, + context.signal, + ); + if (posted.kind === "aborted" || posted.kind === "expired") { + return DELIVERY_UNKNOWN; + } + if (posted.kind === "error") { + return cancelCommittedRequest( + gateway, + agentId, + actor, + requestId, + DELIVERY_CLEARED, + completedOutcome, + ); + } + return null; +} + +async function waitForExactAssistance(options: { + status: () => Promise; + signal?: AbortSignal; + deadline: number; + now: () => number; +}): Promise { + while (options.now() < options.deadline) { + if (options.signal?.aborted) return "stopped"; + const polled = await settleOperation( + options.status, + options.deadline - options.now(), + options.signal, + ); + if (polled.kind === "aborted") return "stopped"; + if (polled.kind === "expired") return "expired"; + if (polled.kind === "error") throw polled.error; + if (polled.value !== "pending" && polled.value !== "human") { + return polled.value; + } + const remaining = options.deadline - options.now(); + if (remaining <= 0) return "expired"; + const slept = await abortAwareSleep( + Math.min(ASSISTANCE_POLL_MS, remaining), + options.signal, + ); + if (slept === "aborted") return "stopped"; + } + return "expired"; +} + +export async function requestSlackHelp( + gateway: ComputerGateway, + reason: string, + context: ChannelToolContext, + options: SlackAssistanceOptions, +) { + const now = options.now ?? Date.now; + const startedAt = now(); + const deadline = startedAt + ASSISTANCE_TTL_MS; + const { agentId, actor } = actorAndAgent(); + const url = await assistanceUrl(options, startedAt); + if (context.signal?.aborted) return STOPPED; + const requestId = crypto.randomUUID(); + const answered = { + ok: true, + result: + "The person has finished and handed control back. Take a fresh snapshot: the page may have changed while they were driving.", + }; + let state: ControlState; + try { + state = await gateway.requestHelp(agentId, actor, reason, requestId); + } catch { + return cancelCommittedRequest( + gateway, + agentId, + actor, + requestId, + REQUEST_NOT_PENDING, + answered, + ); + } + if (!hasExactHelpRequest(state, requestId)) { + return MAY_STILL_BE_PENDING; + } + if (now() >= deadline) { + return cancelCommittedRequest( + gateway, + agentId, + actor, + requestId, + { + ok: true, + result: + "Nobody took control. Say what you still need rather than trying to do it yourself.", + }, + answered, + ); + } + const deliveryFailure = await deliverCommittedRequest( + gateway, + agentId, + actor, + requestId, + context, + assistanceMessage(reason, url), + deadline - now(), + answered, + ); + if (deliveryFailure) return deliveryFailure; + const expired = { + ok: true, + result: + "Nobody took control. Say what you still need rather than trying to do it yourself.", + }; + try { + const outcome = await waitForExactAssistance({ + status: () => gateway.assistanceStatus(agentId, requestId), + signal: context.signal, + deadline, + now, + }); + if (outcome === "completed") return answered; + if (outcome === "superseded") return EXACT_SUPERSEDED; + if (outcome === "cancelled") return EXACT_CANCELLED; + if (outcome === "unknown") return EXACT_UNKNOWN; + return cancelCommittedRequest( + gateway, + agentId, + actor, + requestId, + outcome === "stopped" ? STOPPED : expired, + answered, + ); + } catch { + return cancelCommittedRequest( + gateway, + agentId, + actor, + requestId, + { + ok: false, + reason: + "OpenBot could not confirm the assistance result. Its request was cleared; ask again if help is still needed.", + }, + answered, + ); + } +} + +export async function requestSlackSecret( + gateway: ComputerGateway, + input: SecretRequest, + context: ChannelToolContext, + options: SlackAssistanceOptions, +) { + const now = options.now ?? Date.now; + const startedAt = now(); + const deadline = startedAt + ASSISTANCE_TTL_MS; + const { agentId, actor } = actorAndAgent(); + const url = await assistanceUrl(options, startedAt); + if (context.signal?.aborted) return STOPPED; + const requestId = crypto.randomUUID(); + const answered = { + ok: true, + result: `The person has entered ${input.label} into the field. It was typed straight into the page and you were not told what it is.`, + }; + let state: ControlState; + try { + state = await gateway.requestSecret(agentId, actor, input, requestId); + } catch { + return cancelCommittedRequest( + gateway, + agentId, + actor, + requestId, + REQUEST_NOT_PENDING, + answered, + ); + } + if (!hasExactSecretRequest(state, requestId)) { + return MAY_STILL_BE_PENDING; + } + if (now() >= deadline) { + return cancelCommittedRequest( + gateway, + agentId, + actor, + requestId, + { + ok: true, + result: `Nobody entered ${input.label}. Do not ask for it another way.`, + }, + answered, + ); + } + const deliveryFailure = await deliverCommittedRequest( + gateway, + agentId, + actor, + requestId, + context, + assistanceMessage(`Open OpenBot to enter ${input.label}.`, url), + deadline - now(), + answered, + ); + if (deliveryFailure) return deliveryFailure; + const expired = { + ok: true, + result: `Nobody entered ${input.label}. Do not ask for it another way.`, + }; + try { + const outcome = await waitForExactAssistance({ + status: () => gateway.assistanceStatus(agentId, requestId), + signal: context.signal, + deadline, + now, + }); + if (outcome === "completed") return answered; + if (outcome === "superseded") return EXACT_SUPERSEDED; + if (outcome === "cancelled") return EXACT_CANCELLED; + if (outcome === "unknown") return EXACT_UNKNOWN; + return cancelCommittedRequest( + gateway, + agentId, + actor, + requestId, + outcome === "stopped" ? STOPPED : expired, + answered, + ); + } catch { + return cancelCommittedRequest( + gateway, + agentId, + actor, + requestId, + { + ok: false, + reason: + "OpenBot could not confirm the secret request result. Its request was cleared; ask again if it is still needed.", + }, + answered, + ); + } +} diff --git a/server/src/slack/channel-agent.ts b/server/src/slack/channel-agent.ts new file mode 100644 index 00000000..dd806472 --- /dev/null +++ b/server/src/slack/channel-agent.ts @@ -0,0 +1,287 @@ +import type { BaseEvent } from "@ag-ui/client"; +import { AbstractAgent } from "@ag-ui/client"; +import { + defer, + EMPTY, + finalize, + from, + Observable, + Subject, + switchMap, + takeUntil, + throwError, +} from "rxjs"; +import type { ActorAgentResolver } from "../agents/agent-resolver"; +import type { + ExternalThreadBinding, + ExternalThreadStore, +} from "../external/thread-store"; +import type { + CoworkerRouteResult, + CoworkerRoutingService, +} from "../routing/service"; +import { + maybeCurrentSlackExecution, + runWithSlackExecution, + type SlackExecution, +} from "./execution-context"; + +type RunAgentInput = Parameters[0]; + +export type OpenBotChannelAgentDependencies = { + routing: CoworkerRoutingService; + store: ExternalThreadStore; + resolver: ActorAgentResolver; +}; + +type ActiveRun = { + inner?: AbstractAgent; + started: boolean; + cancelled: boolean; + cancellation: Subject; +}; + +/** + * A Channels-facing agent that pins a Slack thread to its first selected coworker. + * + * Slack identity stays in server-private execution state: the delegated AG-UI input is exactly the + * one that Channels gave us, so none of the provider identity is exposed to a coworker or remote + * endpoint. + */ +export class OpenBotChannelAgent extends AbstractAgent { + private channelsConversationKey: string; + private routing: CoworkerRoutingService; + private store: ExternalThreadStore; + private resolver: ActorAgentResolver; + private execution?: SlackExecution; + private executionForRun?: () => SlackExecution | undefined; + private active?: ActiveRun; + + constructor( + channelsConversationKey: string, + deps: OpenBotChannelAgentDependencies, + execution?: SlackExecution, + executionForRun?: () => SlackExecution | undefined, + ) { + super({ agentId: "openbot-slack", description: "OpenBot Slack router" }); + this.channelsConversationKey = channelsConversationKey; + this.routing = deps.routing; + this.store = deps.store; + this.resolver = deps.resolver; + this.execution = execution; + this.executionForRun = executionForRun; + } + + run(input: RunAgentInput): Observable { + // Channels caches this agent by conversation. Resolve execution for every run so a detached + // managed-delivery boundary can bridge the active turn without retaining the first turn's + // mutable context on the cached agent. + const execution = + maybeCurrentSlackExecution() ?? + this.executionForRun?.() ?? + this.execution; + if (!execution) { + throw new Error( + "A Slack agent run requires a private execution context.", + ); + } + const work = defer(() => { + if (this.active) { + return throwError( + () => new Error("OpenBot Slack agent is already running."), + ); + } + const channelsThreadId = input.threadId; + execution.channelsThreadId = channelsThreadId; + const active: ActiveRun = { + started: false, + cancelled: false, + cancellation: new Subject(), + }; + this.active = active; + + const work = from(this.resolve(execution, channelsThreadId)).pipe( + switchMap((target) => { + if (active.cancelled || this.active !== active) return EMPTY; + active.inner = target; + active.started = true; + return this.runAndRemember( + target, + input, + execution, + channelsThreadId, + ); + }), + takeUntil(active.cancellation), + finalize(() => { + active.inner = undefined; + if (this.active === active) this.active = undefined; + active.cancellation.complete(); + }), + ); + + return new Observable((subscriber) => { + let settled = false; + const subscription = work.subscribe({ + next: (event) => subscriber.next(event), + error: (error) => { + settled = true; + subscriber.error(error); + }, + complete: () => { + settled = true; + subscriber.complete(); + }, + }); + return () => { + if (!settled) this.cancel(active); + subscription.unsubscribe(); + }; + }); + }); + return new Observable((subscriber) => + runWithSlackExecution(execution, () => { + const subscription = work.subscribe(subscriber); + return () => subscription.unsubscribe(); + }), + ); + } + + private runAndRemember( + target: AbstractAgent, + input: RunAgentInput, + execution: SlackExecution, + channelsThreadId: string, + ): Observable { + return new Observable((subscriber) => { + let assistantId: string | undefined; + let assistantContent = ""; + const subscription = target.run(input).subscribe({ + next: (event) => { + const candidate = event as BaseEvent & { + messageId?: unknown; + role?: unknown; + delta?: unknown; + }; + if ( + candidate.type === "TEXT_MESSAGE_START" && + candidate.role === "assistant" && + typeof candidate.messageId === "string" + ) { + assistantId = candidate.messageId; + } else if ( + (candidate.type === "TEXT_MESSAGE_CONTENT" || + candidate.type === "TEXT_MESSAGE_CHUNK") && + typeof candidate.delta === "string" && + (assistantId === undefined || + candidate.messageId === undefined || + candidate.messageId === assistantId) + ) { + assistantContent += candidate.delta; + } + subscriber.next(event); + }, + error: (error) => subscriber.error(error), + complete: () => { + if (assistantContent.length === 0) { + subscriber.complete(); + return; + } + const userMessage = [...input.messages] + .reverse() + .find((message) => message.role === "user"); + void this.store + .appendTranscriptTurn({ + channelsThreadId, + user: { + id: userMessage?.id ?? crypto.randomUUID(), + role: "user", + content: execution.messageText, + }, + assistant: { + id: assistantId ?? crypto.randomUUID(), + role: "assistant", + content: assistantContent, + }, + }) + .then( + () => subscriber.complete(), + (error) => subscriber.error(error), + ); + }, + }); + return () => subscription.unsubscribe(); + }); + } + + clone(): OpenBotChannelAgent { + const cloned = super.clone() as OpenBotChannelAgent; + cloned.channelsConversationKey = this.channelsConversationKey; + cloned.routing = this.routing; + cloned.store = this.store; + cloned.resolver = this.resolver; + cloned.execution = this.execution; + cloned.executionForRun = this.executionForRun; + cloned.active = undefined; + return cloned; + } + + abortRun(): void { + const active = this.active; + if (active) this.cancel(active); + super.abortRun(); + } + + private cancel(active: ActiveRun): void { + if (active.cancelled) return; + active.cancelled = true; + if (active.started) active.inner?.abortRun(); + active.cancellation.next(); + active.cancellation.complete(); + } + + private async resolve( + execution: SlackExecution, + channelsThreadId: string, + ): Promise { + let binding = await this.store.getByChannelsThreadId(channelsThreadId); + if (!binding) { + const route = await this.routing.route({ + actor: execution.actor, + text: execution.messageText, + }); + binding = await this.bindSelectedRoute( + execution, + channelsThreadId, + route, + ); + } + + execution.agentId = binding.agentId; + return this.resolver.resolveAgentForActor(execution.actor, binding.agentId); + } + + private async bindSelectedRoute( + execution: SlackExecution, + channelsThreadId: string, + route: CoworkerRouteResult, + ): Promise { + if (route.kind === "none") { + throw new Error("No coworker is available to you."); + } + if (route.kind === "ambiguous") { + throw new Error(`Name one coworker: ${route.names.join(", ")}.`); + } + + return this.store.bind({ + channelsThreadId, + provider: execution.provider, + providerTenantId: execution.providerTenantId, + providerConversationId: execution.providerConversationId, + providerThreadId: execution.providerThreadId, + agentId: route.agentId, + agentName: route.name, + createdByUserId: execution.actor.id, + }); + } +} diff --git a/server/src/slack/channel.tsx b/server/src/slack/channel.tsx new file mode 100644 index 00000000..5914ad9a --- /dev/null +++ b/server/src/slack/channel.tsx @@ -0,0 +1,348 @@ +/** @jsxImportSource @copilotkit/channels */ +import { + type Channel, + type ChannelIdentityContext, + createChannel, +} from "@copilotkit/channels"; +import { Actions, Button, Message, Section } from "@copilotkit/channels/ui"; +import type { ComputerGateway } from "../computer/gateway"; +import type { SlackAssistanceOptions } from "./assistance"; +import { + OpenBotChannelAgent, + type OpenBotChannelAgentDependencies, +} from "./channel-agent"; +import { + ApprovalCard, + configureApprovalExecutionBridge, + configureApprovalInteractionBridge, +} from "./components"; +import { + createSlackComputerTools, + type SlackComputerTool, +} from "./computer-tools"; +import { + currentSlackExecution, + runWithSlackExecution, + type SlackExecution, +} from "./execution-context"; +import type { + SlackIdentityLinker, + SlackIdentityResult, +} from "./identity-linker"; +import { + providerThreadIdFromIdentity, + SlackIngressRegistry, +} from "./ingress-registry"; +import { normalizeSlackTenantContext } from "./tenant-context"; +import { + defaultSlackTurnFailureLogger, + runSlackPhase, + type SlackTurnFailureLogger, +} from "./turn-phase"; + +export type OpenBotSlackChannelDependencies = { + identityLinker: Pick; + appUrl?: string; + configuredTenantId?: string; + agentDeps: OpenBotChannelAgentDependencies; + ingressRegistry?: SlackIngressRegistry; + computerGateway?: ComputerGateway; + assistance?: SlackAssistanceOptions; + logTurnFailure?: SlackTurnFailureLogger; + prepareExecution?: SlackExecutionPreparer; +}; + +function linkCard(linkUrl: string) { + return ( + +
+ Link your Slack identity to OpenBot before asking a coworker to run. +
+ + + +
+ ); +} + +function transcriptCard(transcriptUrl: string) { + return ( + +
+ View this canonical Slack conversation on the OpenBot domain. Continue + chatting here in Slack. +
+ + + +
+ ); +} + +function transcriptUrl(appUrl: string, channelsThreadId: string): string { + return new URL( + `/slack/thread/${encodeURIComponent(channelsThreadId)}`, + appUrl, + ).toString(); +} + +function eventId(context: ChannelIdentityContext): string | undefined { + const id = context.event.id; + return typeof id === "string" ? id : undefined; +} + +function isNewMessage(message: { operation?: { kind?: string } }): boolean { + return message.operation?.kind === "created"; +} + +function validRememberedPrincipal( + remembered: Parameters[1], + message: Parameters[0]>[0]["message"], +): boolean { + const { identityContext: context, identityResult: result } = remembered; + const identity = result.identity; + return ( + context.provider === "slack" && + context.actor.kind === "human" && + context.actor.id === message.actor.id && + identity.provider === "slack" && + identity.providerTenantId === context.tenant.id && + identity.providerUserId === context.actor.id && + (result.kind === "unlinked" || + (!!message.user && + result.user.id === message.user.id && + result.actor.id === message.user.id)) + ); +} + +function executionFor( + identityContext: ChannelIdentityContext, + result: Extract, + conversationKey: string, + messageText: string, +): SlackExecution { + return { + actor: result.actor, + applicationUser: result.user, + provider: "slack", + providerTenantId: identityContext.tenant.id, + providerConversationId: identityContext.conversation.id, + providerThreadId: providerThreadIdFromIdentity(identityContext), + channelsConversationKey: conversationKey, + messageText, + }; +} + +type SlackExecutionPreparer = typeof executionFor; + +/** Declare the managed OpenBot Slack Channel. The Copilot runtime attaches its adapter. */ +export function createOpenBotSlackChannel( + deps: OpenBotSlackChannelDependencies, +): Channel { + const ingress = deps.ingressRegistry ?? new SlackIngressRegistry(); + const logTurnFailure = deps.logTurnFailure ?? defaultSlackTurnFailureLogger; + const prepareExecution = deps.prepareExecution ?? executionFor; + const pendingExecutions = new Map(); + function pendingExecutionFor( + conversationKey: string, + ): SlackExecution | undefined { + return pendingExecutions.get(conversationKey)?.[0]; + } + async function runWithPendingExecution( + conversationKey: string, + execution: SlackExecution, + work: () => Promise, + ): Promise { + return runWithSlackExecution(execution, async () => { + const protectedExecution = currentSlackExecution(); + const queue = pendingExecutions.get(conversationKey) ?? []; + queue.push(protectedExecution); + pendingExecutions.set(conversationKey, queue); + try { + return await work(); + } finally { + const remaining = pendingExecutions.get(conversationKey); + const index = remaining?.indexOf(protectedExecution) ?? -1; + if (remaining && index >= 0) remaining.splice(index, 1); + if (remaining?.length === 0) pendingExecutions.delete(conversationKey); + } + }); + } + configureApprovalInteractionBridge(ingress); + configureApprovalExecutionBridge({ run: runWithPendingExecution }); + const tools: SlackComputerTool[] = deps.computerGateway + ? createSlackComputerTools( + deps.computerGateway, + deps.assistance, + pendingExecutionFor, + ) + : []; + const channel = createChannel({ + name: "openbot", + identifyUser: async (context) => { + if (context.actor.kind !== "human") return null; + const { identityContext, identityResult } = await runSlackPhase( + "identity.resolve", + async () => { + const identityContext = normalizeSlackTenantContext( + context, + deps.configuredTenantId, + ); + const identityResult = + await deps.identityLinker.resolve(identityContext); + return { identityContext, identityResult }; + }, + logTurnFailure, + ); + await runSlackPhase( + "ingress.remember", + () => + ingress.remember(eventId(identityContext), { + identityContext, + identityResult, + }), + logTurnFailure, + ); + return identityResult.kind === "linked" ? identityResult.user : null; + }, + // Channels caches an agent by conversation, so execution lookup must happen on every run. The + // queue bridges detached managed-delivery operations where AsyncLocalStorage is unavailable. + agent: (threadId) => + new OpenBotChannelAgent(threadId, deps.agentDeps, undefined, () => + pendingExecutionFor(threadId), + ), + tools, + components: [ApprovalCard], + // Managed Slack native streams can strand the final reply when a task chunk + // opens the stream before any assistant text. Composer status remains active. + showToolStatus: false, + store: { concurrency: "serial", actionRetentionMs: 10 * 60_000 }, + }); + + async function runLinked({ + message, + thread, + subscribe, + }: { + message: Parameters[0]>[0]["message"]; + thread: Parameters[0]>[0]["thread"]; + subscribe: boolean; + }): Promise { + if (message.actor.kind !== "human" || !isNewMessage(message)) return; + const remembered = await runSlackPhase( + "ingress.take", + () => + ingress.take(message.eventId, { + provider: "slack", + providerActorId: message.actor.id, + applicationUserId: message.user?.id ?? null, + }), + logTurnFailure, + ); + await runSlackPhase( + "identity.validate", + () => { + if (!remembered || !validRememberedPrincipal(remembered, message)) { + throw new Error( + "Managed Slack ingress identity is no longer available.", + ); + } + }, + logTurnFailure, + ); + if (!remembered) return; + const identityResult = remembered.identityResult; + if (identityResult.kind === "unlinked") { + await runSlackPhase( + "link_card.post", + () => thread.post(linkCard(identityResult.linkUrl)), + logTurnFailure, + ); + return; + } + if (!message.user) { + await runSlackPhase( + "identity.validate", + () => { + throw new Error( + "Managed Slack ingress identity did not match its user.", + ); + }, + logTurnFailure, + ); + return; + } + if (subscribe) { + await runSlackPhase( + "thread.subscribe", + () => thread.subscribe(), + logTurnFailure, + ); + } + const execution = await runSlackPhase( + "execution.prepare", + () => + prepareExecution( + remembered.identityContext, + identityResult, + thread.conversationKey, + message.text, + ), + logTurnFailure, + ); + await runSlackPhase( + "agent.run", + () => + runWithPendingExecution(thread.conversationKey, execution, () => + thread.runAgent( + message.contentParts?.length + ? { prompt: message.contentParts } + : undefined, + ), + ), + logTurnFailure, + ); + const appUrl = deps.appUrl; + if (subscribe && appUrl) { + try { + await runSlackPhase( + "transcript_link.post", + async () => { + const binding = await deps.agentDeps.store.getByProviderThread({ + provider: execution.provider, + providerTenantId: execution.providerTenantId, + providerConversationId: execution.providerConversationId, + providerThreadId: execution.providerThreadId, + }); + if (!binding) { + throw new Error( + "The canonical Slack conversation binding is unavailable.", + ); + } + await thread.post( + transcriptCard(transcriptUrl(appUrl, binding.channelsThreadId)), + ); + }, + logTurnFailure, + ); + } catch { + // The agent reply already succeeded. The dedicated phase log keeps this optional demo link + // observable without turning a completed Slack answer into a provider-visible failure. + } + } + } + + channel.onMention(({ message, thread }) => + runLinked({ message, thread, subscribe: true }), + ); + channel.onMessage(async ({ message, thread }) => { + if (!isNewMessage(message) || !(await thread.isSubscribed())) return; + await runLinked({ message, thread, subscribe: false }); + }); + + return channel; +} diff --git a/server/src/slack/components.tsx b/server/src/slack/components.tsx new file mode 100644 index 00000000..f6660caa --- /dev/null +++ b/server/src/slack/components.tsx @@ -0,0 +1,269 @@ +/** @jsxImportSource @copilotkit/channels */ +import { defineChannelComponent } from "@copilotkit/channels"; +import { Actions, Button, Message, Section } from "@copilotkit/channels/ui"; +import { z } from "zod"; +import type { SlackApprovalAuthorization } from "./approval-authorizer"; +import type { + ApprovalDecisionStore, + ApprovalPresentation, +} from "./approval-store"; +import { + maybeCurrentSlackExecution, + runWithSlackExecution, + type SlackExecution, +} from "./execution-context"; +import type { + LinkedSlackIngress, + SlackIngressRegistry, +} from "./ingress-registry"; + +type ApprovalDependencies = { + store: ApprovalDecisionStore; + authorize(input: { + userId: string; + presentation: ApprovalPresentation; + liveIdentity: LinkedSlackIngress | null; + }): Promise; + now(): number; + retentionMs: number; +}; + +let approvalDependencies: ApprovalDependencies | undefined; +let approvalInteractionBridge: + | Pick + | undefined; +let approvalExecutionBridge: + | { + run( + conversationKey: string, + execution: SlackExecution, + work: () => Promise, + ): Promise; + } + | undefined; + +/** Connect the channel's live identifyUser handoff to durable approval actions. */ +export function configureApprovalInteractionBridge( + bridge: Pick, +): void { + approvalInteractionBridge = bridge; +} + +/** Carry private execution across the deferred Channels resume boundary. */ +export function configureApprovalExecutionBridge( + bridge: NonNullable, +): void { + approvalExecutionBridge = bridge; +} + +/** Wire the durable decision store before registering ApprovalCard with a Channel runtime. */ +export function configureApprovalDecisionStore( + store: ApprovalDecisionStore, + options: Partial> = {}, +): void { + approvalDependencies = { + store, + authorize: options.authorize ?? (async () => false), + now: options.now ?? Date.now, + // Channels keeps actions for seven days by default. Keep the authorization row one day longer + // so cleanup can never outpace the continuation it protects. + retentionMs: options.retentionMs ?? 8 * 24 * 60 * 60_000, + }; +} + +const approvalAction = z + .object({ + presentationId: z.string().uuid(), + approved: z.boolean(), + }) + .strict(); + +/* + * Channels 0.9 persists each non-undefined button actionValue in its ActionRegistry and replaces + * the provider callback value with that stored value on hot and cold dispatch. Initial buttons use + * an opaque presentation UUID plus the decision; cold renders use null, never undefined, so the + * provider callback is never trusted as a fallback. + */ + +async function claimAndResume( + value: unknown, + actionId: string, + userId: string | null, + providerActorId: string | null, + platform: string, + conversationKey: string | null, + initialExecution: SlackExecution | null, + resume: (decision: { approved: boolean }) => Promise, +): Promise { + const dependencies = approvalDependencies; + if (!dependencies) { + throw new Error("ApprovalCard requires a durable approval decision store."); + } + if (!userId || !providerActorId || platform !== "slack" || !conversationKey) { + throw new Error("This approval interaction could not be authorized."); + } + const decision = approvalAction.parse(value); + const presentation = await dependencies.store.get(decision.presentationId); + if (!presentation || presentation.conversationKey !== conversationKey) { + throw new Error("This approval interaction could not be authorized."); + } + const liveIdentity = + approvalInteractionBridge?.takeInteraction({ + provider: "slack", + providerActorId, + applicationUserId: userId, + }) ?? null; + const authorization = await dependencies.authorize({ + userId, + presentation, + liveIdentity, + }); + if (!authorization) { + throw new Error("This approval interaction could not be authorized."); + } + const claimed = await dependencies.store.begin({ + ...decision, + actionId, + decidedByUserId: userId, + }); + if (claimed === "rejected") return; + const execution = approvalExecution( + authorization, + presentation, + initialExecution, + userId, + ); + if (execution) { + const work = () => resume({ approved: decision.approved }); + await (approvalExecutionBridge + ? approvalExecutionBridge.run(conversationKey, execution, work) + : runWithSlackExecution(execution, work)); + } else { + // Non-OpenBot Channels may use the component with a boolean authorizer and an agent that does + // not require Slack execution facts. OpenBot's authorizer always returns the complete subject. + await resume({ approved: decision.approved }); + } + await dependencies.store.complete(decision.presentationId, actionId); +} + +function approvalExecution( + authorization: true | SlackApprovalAuthorization, + presentation: ApprovalPresentation, + initial: SlackExecution | null, + userId: string, +): SlackExecution | null { + if (authorization !== true) { + return { + ...authorization, + channelsThreadId: presentation.channelsThreadId, + channelsConversationKey: presentation.conversationKey, + messageText: "", + agentId: presentation.agentId, + }; + } + if (!initial || initial.actor.id !== userId) return null; + return { ...initial }; +} + +function threadConversationKey(thread: unknown): string | null { + if (!thread || typeof thread !== "object") return null; + const value = (thread as { conversationKey?: unknown }).conversationKey; + return typeof value === "string" && value ? value : null; +} + +export const ApprovalCard = defineChannelComponent({ + name: "approval_card", + description: + "Ask the person to approve or reject a consequential action before continuing.", + parameters: z.object({ + question: z.string().min(1).describe("The decision the person must make"), + }), + async render({ question }) { + const execution = maybeCurrentSlackExecution(); + const subject = + execution?.channelsThreadId && + execution.channelsConversationKey && + execution.agentId + ? { + channelsThreadId: execution.channelsThreadId, + conversationKey: execution.channelsConversationKey, + agentId: execution.agentId, + createdByUserId: execution.actor.id, + } + : null; + const presentation = subject + ? { presentationId: crypto.randomUUID(), ...subject } + : null; + if (presentation) { + const dependencies = approvalDependencies; + if (!dependencies) { + throw new Error( + "ApprovalCard requires a durable approval decision store.", + ); + } + const now = dependencies.now(); + await dependencies.store.cleanup( + new Date(now - dependencies.retentionMs), + ); + await dependencies.store.present(presentation); + } + return ( + +
{question}
+ + + + +
+ ); + }, +}); diff --git a/server/src/slack/computer-tools.ts b/server/src/slack/computer-tools.ts new file mode 100644 index 00000000..e98f8d27 --- /dev/null +++ b/server/src/slack/computer-tools.ts @@ -0,0 +1,647 @@ +import { + type ChannelTool, + type ChannelToolContext, + defineChannelTool, +} from "@copilotkit/channels"; +import { + computerClickContract, + computerKeyContract, + computerListFilesContract, + computerNavigateContract, + computerOpenAndShareScreenshotContract, + computerReadContract, + computerReadFileContract, + computerRequestHelpContract, + computerRequestSecretContract, + computerRunCommandContract, + computerScreenshotContract, + computerScrollContract, + computerShareFileContract, + computerSnapshotContract, + computerTypeContract, + computerWriteFileContract, +} from "../../../shared/computer-tool-contracts"; +import { + type ActionActor, + ActionRefusedError, + type ComputerGateway, + ComputerUnavailableError, + ElementNotFoundError, + HumanHasControlError, + NavigationRefusedError, + StaleSnapshotError, + WorkspaceRefusedError, + WorkspaceRequestError, +} from "../computer/gateway"; +import { + requestSlackHelp, + requestSlackSecret, + type SlackAssistanceOptions, +} from "./assistance"; +import { + maybeCurrentSlackExecution, + runWithSlackExecution, + type SlackExecution, +} from "./execution-context"; + +const COMPUTER_UNAVAILABLE = + "The assistant's computer could not be reached." as const; +const COMPUTER_CONTEXT_UNAVAILABLE = + "The computer action could not start because its Slack context was unavailable." as const; +const COMPUTER_ACTION_FAILED = "The computer action failed." as const; + +class SlackComputerContextError extends Error { + constructor() { + super("The Slack computer tool is missing its private execution context."); + this.name = "SlackComputerContextError"; + } +} + +type ToolOutcome = Record & { ok: boolean }; + +/** The public common type consumed by Channels when registering this heterogeneous tool list. */ +export type SlackComputerTool = ChannelTool; + +type SlackExecutionForConversation = ( + conversationKey: string, +) => SlackExecution | undefined; + +function stopped(): ToolOutcome { + return { ok: false, stopped: true, reason: "Stopped." }; +} + +function throwIfStopped(signal: AbortSignal | undefined): void { + if (signal?.aborted) throw new DOMException("Stopped.", "AbortError"); +} + +function isAbortError(error: unknown): error is DOMException { + return error instanceof DOMException && error.name === "AbortError"; +} + +function success(result: unknown): ToolOutcome { + const serializable = serializableValue(result); + if (isPlainRecord(serializable)) { + const record = serializable; + // A compound operation such as file sharing may already have a truthful explicit outcome. + if (typeof record.ok === "boolean") return record as ToolOutcome; + return { ...record, ok: true }; + } + return { ok: true, result: serializable }; +} + +function isPlainRecord(value: unknown): value is Record { + if (value === null || typeof value !== "object" || Array.isArray(value)) { + return false; + } + const prototype = Object.getPrototypeOf(value); + return prototype === Object.prototype || prototype === null; +} + +function serializableValue( + value: unknown, + ancestors = new WeakSet(), +): unknown { + if ( + value === null || + typeof value === "string" || + typeof value === "boolean" || + (typeof value === "number" && Number.isFinite(value)) + ) { + return value; + } + if (typeof value === "bigint") return value.toString(); + if (typeof value !== "object") { + throw new TypeError("ComputerGateway returned a non-serializable result."); + } + if (value instanceof Date) return value.toISOString(); + if (ancestors.has(value)) { + throw new TypeError("ComputerGateway returned a circular result."); + } + + ancestors.add(value); + try { + if (Array.isArray(value)) { + return value.map((item) => serializableValue(item, ancestors)); + } + if (value instanceof Map) { + return [...value.entries()].map(([key, item]) => [ + serializableValue(key, ancestors), + serializableValue(item, ancestors), + ]); + } + if (value instanceof Set) { + return [...value.values()].map((item) => + serializableValue(item, ancestors), + ); + } + if (isPlainRecord(value)) { + return Object.fromEntries( + Object.entries(value).map(([key, item]) => [ + key, + serializableValue(item, ancestors), + ]), + ); + } + } finally { + ancestors.delete(value); + } + throw new TypeError("ComputerGateway returned a non-serializable result."); +} + +/** + * Normalize only the control-plane failures whose meaning is safe for an agent to act on. + * Everything else is deliberately opaque: transport details, paths, and secrets never cross into + * the Slack transcript or model result. + */ +async function governed( + signal: AbortSignal | undefined, + run: () => Promise, + options: { checkStoppedAfter?: boolean } = {}, +): Promise { + try { + throwIfStopped(signal); + const result = await run(); + // Some gateway methods predate caller-signal plumbing. This checkpoint prevents their result + // from being reported or used after Stop, even though work already in flight cannot be cancelled. + if (options.checkStoppedAfter !== false) throwIfStopped(signal); + return success(result); + } catch (error) { + // The computer transport deliberately wraps fetch aborts as unavailable. The caller signal is + // the typed evidence that this particular unavailable response means Stop, not an outage. + if (signal?.aborted || isAbortError(error)) return stopped(); + if (error instanceof ActionRefusedError) { + return { + ok: false, + refused: true, + reason: error.message, + rule: error.rule, + }; + } + if ( + error instanceof NavigationRefusedError || + error instanceof WorkspaceRefusedError + ) { + return { ok: false, refused: true, reason: error.message }; + } + if (error instanceof WorkspaceRequestError) { + return { ok: false, reason: error.message }; + } + if ( + error instanceof StaleSnapshotError || + error instanceof ElementNotFoundError + ) { + return { ok: false, staleRefs: true, reason: error.message }; + } + if (error instanceof HumanHasControlError) { + return { ok: false, humanHasControl: true, reason: error.message }; + } + if (error instanceof ComputerUnavailableError) { + return { ok: false, reason: COMPUTER_UNAVAILABLE }; + } + // Do not serialize the error: gateway and configuration failures can contain transport details, + // paths, or secrets. The category is enough to distinguish a lost Slack turn from a real host + // outage while preserving an opaque tool result. + const contextUnavailable = error instanceof SlackComputerContextError; + const errorCategory = contextUnavailable ? "execution-context" : "unknown"; + console.error( + JSON.stringify({ + type: "slack-computer-tool-failed", + error: contextUnavailable + ? "SlackComputerContextError" + : "UnknownError", + context: { + integration: "slack", + operation: "computer-tool", + errorCategory, + }, + timestamp: new Date().toISOString(), + }), + ); + return { + ok: false, + reason: contextUnavailable + ? COMPUTER_CONTEXT_UNAVAILABLE + : COMPUTER_ACTION_FAILED, + }; + } +} + +function currentComputer(): { agentId: string; actor: ActionActor } { + const execution = maybeCurrentSlackExecution(); + if (!execution?.agentId) { + throw new SlackComputerContextError(); + } + return { + agentId: execution.agentId, + // This is the linked OpenBot principal from private ALS. The provider actor in Channel context is + // deliberately not authorization identity and never reaches ComputerGateway. + actor: { id: execution.actor.id, userId: execution.actor.id }, + }; +} + +function bindSlackExecution( + tool: SlackComputerTool, + executionForConversation?: SlackExecutionForConversation, +): SlackComputerTool { + return { + ...tool, + handler: (input, context) => { + const run = () => tool.handler(input, context); + if (maybeCurrentSlackExecution()) return run(); + const conversationKey = + "conversationKey" in context.thread && + typeof context.thread.conversationKey === "string" + ? context.thread.conversationKey + : undefined; + const execution = conversationKey + ? executionForConversation?.(conversationKey) + : undefined; + return execution ? runWithSlackExecution(execution, run) : run(); + }, + }; +} + +function safeFilename(pathOrName: string): string { + const basename = pathOrName.split(/[\\/]/).pop()?.trim(); + if (!basename || basename === "." || basename === "..") return "file.txt"; + // Slack filenames cannot safely carry controls. Removing them does not expose the workspace path. + const clean = [...basename] + .filter((character) => !/[\p{Cc}\p{Cf}\p{Zl}\p{Zp}]/u.test(character)) + .join("") + .trim(); + if (!clean || clean === "." || clean === "..") return "file.txt"; + return limitUtf8Filename(clean, 255); +} + +function limitUtf8Filename(filename: string, maxBytes: number): string { + if (utf8Bytes(filename) <= maxBytes) return filename; + + const dot = filename.lastIndexOf("."); + const extension = dot > 0 ? filename.slice(dot) : ""; + const extensionBytes = utf8Bytes(extension); + if (extension && extensionBytes < maxBytes) { + const stem = takeUtf8Bytes( + filename.slice(0, dot), + maxBytes - extensionBytes, + ); + if (stem) return `${stem}${extension}`; + } + return takeUtf8Bytes(filename, maxBytes) || "file.txt"; +} + +function takeUtf8Bytes(value: string, maxBytes: number): string { + let used = 0; + let result = ""; + for (const character of value) { + const bytes = utf8Bytes(character); + if (used + bytes > maxBytes) break; + result += character; + used += bytes; + } + return result; +} + +function utf8Bytes(value: string): number { + return new TextEncoder().encode(value).byteLength; +} + +/** + * Computer tools for Slack turns. + * + * Every operation enters through ComputerGateway, preserving its policy decision and audit record. + * Assistance operations are added only with a configured secure handoff; without one, exposing + * them would falsely claim that a person in Slack had been reached. + */ +export function createSlackComputerTools( + gateway: ComputerGateway, + assistance?: SlackAssistanceOptions, + executionForConversation?: SlackExecutionForConversation, +): SlackComputerTool[] { + const tools: SlackComputerTool[] = [ + defineChannelTool({ + ...computerNavigateContract, + description: + computerNavigateContract.description + + " In Slack, if the request also asks for a screenshot or picture, use " + + "computer_open_and_share_screenshot instead.", + handler: ({ url }, { signal }) => + governed( + signal, + async () => { + const { agentId, actor } = currentComputer(); + return gateway.navigate(agentId, actor, url); + }, + { checkStoppedAfter: false }, + ), + }), + defineChannelTool({ + ...computerOpenAndShareScreenshotContract, + handler: (input, context) => + openAndShareScreenshot(gateway, input, context), + }), + defineChannelTool({ + ...computerScreenshotContract, + handler: (input, context) => shareScreenshot(gateway, input, context), + }), + defineChannelTool({ + ...computerReadContract, + handler: (_input, { signal }) => + governed( + signal, + async () => { + const { agentId } = currentComputer(); + return gateway.read(agentId); + }, + { checkStoppedAfter: true }, + ), + }), + defineChannelTool({ + ...computerSnapshotContract, + handler: (_input, { signal }) => + governed( + signal, + async () => { + const { agentId } = currentComputer(); + return gateway.snapshot(agentId); + }, + { checkStoppedAfter: true }, + ), + }), + defineChannelTool({ + ...computerTypeContract, + handler: (input, { signal }) => + governed( + signal, + async () => { + const { agentId, actor } = currentComputer(); + return gateway.type(agentId, actor, input, signal); + }, + { checkStoppedAfter: false }, + ), + }), + defineChannelTool({ + ...computerClickContract, + handler: (input, { signal }) => + governed( + signal, + async () => { + const { agentId, actor } = currentComputer(); + return gateway.click(agentId, actor, input, signal); + }, + { checkStoppedAfter: false }, + ), + }), + defineChannelTool({ + ...computerKeyContract, + handler: (input, { signal }) => + governed( + signal, + async () => { + const { agentId, actor } = currentComputer(); + return gateway.key(agentId, actor, input, signal); + }, + { checkStoppedAfter: false }, + ), + }), + defineChannelTool({ + ...computerListFilesContract, + handler: (input, { signal }) => + governed( + signal, + async () => { + const { agentId, actor } = currentComputer(); + return gateway.listFiles(agentId, actor, input); + }, + { checkStoppedAfter: true }, + ), + }), + defineChannelTool({ + ...computerReadFileContract, + handler: (input, { signal }) => + governed( + signal, + async () => { + const { agentId, actor } = currentComputer(); + return gateway.readFile(agentId, actor, input); + }, + { checkStoppedAfter: true }, + ), + }), + defineChannelTool({ + ...computerRunCommandContract, + handler: (input, { signal }) => + governed( + signal, + async () => { + const { agentId, actor } = currentComputer(); + return gateway.runCommand(agentId, actor, input, signal); + }, + { checkStoppedAfter: false }, + ), + }), + defineChannelTool({ + ...computerWriteFileContract, + handler: (input, { signal }) => + governed( + signal, + async () => { + const { agentId, actor } = currentComputer(); + return gateway.writeFile(agentId, actor, input); + }, + { checkStoppedAfter: false }, + ), + }), + defineChannelTool({ + ...computerScrollContract, + handler: (input, { signal }) => + governed( + signal, + async () => { + const { agentId, actor } = currentComputer(); + return gateway.scroll(agentId, actor, input); + }, + { checkStoppedAfter: false }, + ), + }), + defineChannelTool({ + ...computerShareFileContract, + handler: (input, context) => shareFile(gateway, input, context), + }), + ]; + if (assistance) { + tools.push( + defineChannelTool({ + ...computerRequestHelpContract, + handler: ({ reason }, context) => + governed( + context.signal, + () => requestSlackHelp(gateway, reason, context, assistance), + { checkStoppedAfter: false }, + ), + }), + defineChannelTool({ + ...computerRequestSecretContract, + handler: (input, context) => + governed( + context.signal, + () => requestSlackSecret(gateway, input, context, assistance), + { checkStoppedAfter: false }, + ), + }), + ); + } + return tools.map((tool) => + bindSlackExecution(tool, executionForConversation), + ); +} + +async function openAndShareScreenshot( + gateway: ComputerGateway, + input: { url: string; filename?: string }, + context: ChannelToolContext, +): Promise { + return governed( + context.signal, + async () => { + const { agentId, actor } = currentComputer(); + const navigation = await gateway.navigate(agentId, actor, input.url); + throwIfStopped(context.signal); + const screenshot = await gateway.screenshot(agentId); + throwIfStopped(context.signal); + + const filename = safeFilename(input.filename ?? "screenshot.png"); + // Intelligence currently accepts a Slack file effect but can reject a later text stream in + // the same managed delivery. Put the useful page text first so the person receives both the + // answer and the image even when that provider ordering limitation is present. + await context.thread.post(pageSummaryMessage(navigation)); + throwIfStopped(context.signal); + const posted = await context.thread.postFile({ + bytes: Buffer.from(screenshot.base64, "base64"), + filename, + }); + if (!posted.ok) { + return { + ok: false, + reason: posted.error ?? "Slack could not share that screenshot.", + }; + } + return { + ok: true, + ...navigation, + summaryShared: true, + screenshotShared: true, + screenshotFilename: filename, + screenshotWidth: screenshot.width, + screenshotHeight: screenshot.height, + ...(posted.fileId ? { fileId: posted.fileId } : {}), + ...(posted.assetId ? { assetId: posted.assetId } : {}), + }; + }, + { checkStoppedAfter: false }, + ); +} + +function pageSummaryMessage(navigation: { + url: string; + title: string; + text: string; + truncated: boolean; +}): string { + const readable = navigation.text.replace(/\s+/g, " ").trim(); + const excerpt = takeUtf8Bytes(readable, 1_200).trim(); + const summary = + excerpt || navigation.title || "The page did not expose readable text."; + return [ + `I opened ${navigation.title || navigation.url}.`, + `Summary: ${summary}${navigation.truncated ? " (The page extract was truncated.)" : ""}`, + `Source: ${navigation.url}, read just now.`, + ].join("\n\n"); +} + +async function shareScreenshot( + gateway: ComputerGateway, + input: { filename?: string }, + context: ChannelToolContext, +): Promise { + return governed( + context.signal, + async () => { + const { agentId } = currentComputer(); + // screenshot has no signal parameter today. Check both sides so Stop prevents the upload even + // when it arrived while the capture was in flight. + throwIfStopped(context.signal); + const screenshot = await gateway.screenshot(agentId); + throwIfStopped(context.signal); + + const filename = safeFilename(input.filename ?? "screenshot.png"); + const bytes = Buffer.from(screenshot.base64, "base64"); + throwIfStopped(context.signal); + const posted = await context.thread.postFile({ bytes, filename }); + // Upload is the irreversible commit point. Once Slack answers, report that exact outcome even + // if cancellation raced the response. + if (!posted.ok) { + return { + ok: false, + reason: posted.error ?? "Slack could not share that screenshot.", + }; + } + return { + ok: true, + shared: true, + filename, + width: screenshot.width, + height: screenshot.height, + ...(screenshot.url ? { url: screenshot.url } : {}), + ...(posted.fileId ? { fileId: posted.fileId } : {}), + ...(posted.assetId ? { assetId: posted.assetId } : {}), + }; + }, + { checkStoppedAfter: false }, + ); +} + +async function shareFile( + gateway: ComputerGateway, + input: { path: string; filename?: string }, + context: ChannelToolContext, +): Promise { + return governed( + context.signal, + async () => { + const { agentId, actor } = currentComputer(); + // readFile has no signal parameter today. Check both sides so Stop prevents the upload even + // when it arrived while the governed read was in flight. + throwIfStopped(context.signal); + const file = await gateway.readFile(agentId, actor, { path: input.path }); + throwIfStopped(context.signal); + if (file.truncated) { + return { + ok: false, + reason: + "That file is too large to read completely, so it was not shared.", + }; + } + + const filename = safeFilename(input.filename ?? input.path); + const bytes = new TextEncoder().encode(file.text); + throwIfStopped(context.signal); + const posted = await context.thread.postFile({ bytes, filename }); + // Upload is the irreversible commit point. Once the adapter has answered, its explicit result + // is more truthful than replacing a success with "Stopped" because cancellation raced it. + if (!posted.ok) { + return { + ok: false, + reason: posted.error ?? "Slack could not share that file.", + }; + } + return { + ok: true, + shared: true, + filename, + ...(posted.fileId ? { fileId: posted.fileId } : {}), + ...(posted.assetId ? { assetId: posted.assetId } : {}), + }; + }, + { checkStoppedAfter: false }, + ); +} diff --git a/server/src/slack/execution-context.ts b/server/src/slack/execution-context.ts new file mode 100644 index 00000000..8743199e --- /dev/null +++ b/server/src/slack/execution-context.ts @@ -0,0 +1,65 @@ +import { AsyncLocalStorage } from "node:async_hooks"; +import type { AgentActor } from "../agents/profile-types"; + +export type SlackExecution = { + readonly actor: Readonly; + readonly applicationUser: Readonly<{ id: string; name: string }>; + readonly provider: "slack"; + readonly providerTenantId: string; + readonly providerConversationId: string; + readonly providerThreadId: string; + channelsThreadId?: string; + channelsConversationKey?: string; + readonly messageText: string; + agentId?: string; +}; + +const executionStorage = new AsyncLocalStorage(); +const PROTECTED_FIELDS = [ + "actor", + "applicationUser", + "provider", + "providerTenantId", + "providerConversationId", + "providerThreadId", + "messageText", +] as const; + +function protect(execution: SlackExecution): SlackExecution { + const protectedExecution: SlackExecution = { + ...execution, + actor: Object.freeze({ ...execution.actor }), + applicationUser: Object.freeze({ ...execution.applicationUser }), + }; + for (const field of PROTECTED_FIELDS) { + Object.defineProperty(protectedExecution, field, { + value: protectedExecution[field], + writable: false, + enumerable: true, + configurable: false, + }); + } + return protectedExecution; +} + +/** Runs server-side Slack work without placing its private facts in agent inputs. */ +export function runWithSlackExecution( + execution: SlackExecution, + run: () => T, +): T { + return executionStorage.run(protect(execution), run); +} + +/** Reads the server-private execution facts for the current Slack turn. */ +export function currentSlackExecution(): SlackExecution { + const execution = executionStorage.getStore(); + if (!execution) { + throw new Error("A Slack agent run requires a private execution context."); + } + return execution; +} + +/** Reads an execution when rendering inside a Slack run; cold Channels recovery has none. */ +export function maybeCurrentSlackExecution(): SlackExecution | null { + return executionStorage.getStore() ?? null; +} diff --git a/server/src/slack/identity-linker.ts b/server/src/slack/identity-linker.ts new file mode 100644 index 00000000..1e52a435 --- /dev/null +++ b/server/src/slack/identity-linker.ts @@ -0,0 +1,248 @@ +import type { ChannelIdentityContext } from "@copilotkit/channels"; +import type { AgentActor } from "../agents/profile-types"; +import type { ExternalLinkAuthorizationStore } from "../external/link-store"; +import { mintExternalLinkToken } from "../external/link-token"; +import type { + ExternalProviderIdentity, + ExternalUserLink, +} from "../external/schema-types"; + +export type SlackIdentityResult = + | { + kind: "linked"; + user: { id: string; name: string }; + actor: AgentActor; + identity: ExternalProviderIdentity; + } + | { + kind: "unlinked"; + linkUrl: string; + identity: ExternalProviderIdentity; + }; + +export type SlackIdentityLinkerOptions = { + store: ExternalLinkAuthorizationStore; + encryptionKey: string; + appUrl: string | undefined; +}; + +const APP_URL_ERROR = "Slack link setup requires an absolute OPENBOT_APP_URL."; +const LINK_CONFLICT_ERROR = "That Slack identity is already linked."; +const IDENTITY_ERROR = "Slack identity requires a known tenant and actor id."; + +type SlackIdentityFailureCode = + | "slack_identity_provider_invalid" + | "slack_identity_actor_kind_invalid" + | "slack_identity_tenant_invalid" + | "slack_identity_actor_invalid" + | "slack_identity_link_lookup_failed" + | "slack_identity_user_lookup_failed" + | "slack_identity_email_lookup_failed" + | "slack_identity_link_write_failed" + | "slack_identity_link_token_failed"; + +function identityFailure( + code: SlackIdentityFailureCode, + message: string, + cause?: unknown, +): Error & { code: SlackIdentityFailureCode } { + return Object.assign( + new Error(message, cause === undefined ? undefined : { cause }), + { + code, + }, + ); +} + +async function resolutionStep( + code: SlackIdentityFailureCode, + message: string, + operation: () => T | Promise, +): Promise { + try { + return await operation(); + } catch (error) { + throw identityFailure(code, message, error); + } +} + +function canonicalId(value: unknown): string | null { + if (typeof value !== "string") return null; + const normalized = value.trim(); + return normalized && normalized.toLowerCase() !== "unknown" + ? normalized + : null; +} + +async function adapterEmail( + context: ChannelIdentityContext, + actorId: string, +): Promise { + try { + const profile = await context.lookupProfile?.(); + if (profile?.kind !== "human" || canonicalId(profile.id) !== actorId) { + return null; + } + const email = profile?.email?.trim().toLowerCase(); + return email || null; + } catch { + // Profile enrichment is optional; a failed lookup must only disable auto-linking. + return null; + } +} + +function identityFor( + context: ChannelIdentityContext, + providerEmail: string | null, +): ExternalProviderIdentity { + const tenantId = canonicalId(context.tenant.id); + const actorId = canonicalId(context.actor.id); + if (context.provider !== "slack") + throw identityFailure("slack_identity_provider_invalid", IDENTITY_ERROR); + if (context.actor.kind !== "human") + throw identityFailure("slack_identity_actor_kind_invalid", IDENTITY_ERROR); + if (!tenantId) + throw identityFailure("slack_identity_tenant_invalid", IDENTITY_ERROR); + if (!actorId) + throw identityFailure("slack_identity_actor_invalid", IDENTITY_ERROR); + return { + provider: "slack", + providerTenantId: tenantId, + providerUserId: actorId, + providerEmail, + }; +} + +function linkedResult( + identity: ExternalProviderIdentity, + user: { id: string; name: string; role: AgentActor["role"] }, +): SlackIdentityResult { + return { + kind: "linked", + user: { id: user.id, name: user.name }, + actor: { id: user.id, role: user.role }, + identity, + }; +} + +function configuredAppUrl(appUrl: string | undefined): URL { + try { + const url = new URL(appUrl ?? ""); + const hostname = url.hostname.toLowerCase(); + const loopback = + hostname === "localhost" || + hostname === "127.0.0.1" || + hostname === "::1" || + hostname === "[::1]"; + if ( + url.username || + url.password || + (url.protocol !== "https:" && !(url.protocol === "http:" && loopback)) + ) + throw new Error(); + return url; + } catch { + throw new Error(APP_URL_ERROR); + } +} + +function isLinkConflict(error: unknown): boolean { + return error instanceof Error && error.message === LINK_CONFLICT_ERROR; +} + +export class SlackIdentityLinker { + constructor(private readonly options: SlackIdentityLinkerOptions) {} + + async resolve(context: ChannelIdentityContext): Promise { + const baseIdentity = identityFor(context, null); + const existing = await resolutionStep( + "slack_identity_link_lookup_failed", + "Slack identity link lookup failed.", + () => + this.options.store.find( + baseIdentity.provider, + baseIdentity.providerTenantId, + baseIdentity.providerUserId, + ), + ); + if (existing) { + const active = await resolutionStep( + "slack_identity_user_lookup_failed", + "Slack identity user lookup failed.", + () => this.options.store.resolveActiveUser(existing.openbotUserId), + ); + if (active) return linkedResult(existing, active); + return this.unlinked(existing); + } + + const profileEmail = await adapterEmail( + context, + baseIdentity.providerUserId, + ); + const identity = { ...baseIdentity, providerEmail: profileEmail }; + if (!profileEmail) return this.unlinked(identity); + + const matched = await resolutionStep( + "slack_identity_email_lookup_failed", + "Slack identity email lookup failed.", + () => this.options.store.findVerifiedUserByEmail(profileEmail), + ); + if (!matched) return this.unlinked(identity); + const matchedActive = await resolutionStep( + "slack_identity_user_lookup_failed", + "Slack identity user lookup failed.", + () => this.options.store.resolveActiveUser(matched.id), + ); + if (!matchedActive) return this.unlinked(identity); + + let linked: ExternalUserLink | null = null; + try { + linked = await this.options.store.link({ + ...identity, + openbotUserId: matched.id, + }); + } catch (error) { + if (!isLinkConflict(error)) { + throw identityFailure( + "slack_identity_link_write_failed", + "Slack identity link write failed.", + error, + ); + } + } + + const winner = await resolutionStep( + "slack_identity_link_lookup_failed", + "Slack identity link lookup failed.", + () => + this.options.store.find( + identity.provider, + identity.providerTenantId, + identity.providerUserId, + ), + ); + const current = winner ?? linked; + if (!current) return this.unlinked(identity); + + const active = await resolutionStep( + "slack_identity_user_lookup_failed", + "Slack identity user lookup failed.", + () => this.options.store.resolveActiveUser(current.openbotUserId), + ); + if (!active) return this.unlinked(identity); + return linkedResult(current, active); + } + + private async unlinked( + identity: ExternalProviderIdentity, + ): Promise { + const url = new URL("/link/slack", configuredAppUrl(this.options.appUrl)); + const token = await resolutionStep( + "slack_identity_link_token_failed", + "Slack identity link token creation failed.", + () => mintExternalLinkToken(identity, this.options.encryptionKey), + ); + url.searchParams.set("token", token); + return { kind: "unlinked", linkUrl: url.toString(), identity }; + } +} diff --git a/server/src/slack/ingress-registry.ts b/server/src/slack/ingress-registry.ts new file mode 100644 index 00000000..eaedcac9 --- /dev/null +++ b/server/src/slack/ingress-registry.ts @@ -0,0 +1,174 @@ +import type { ChannelIdentityContext } from "@copilotkit/channels"; +import type { SlackIdentityResult } from "./identity-linker"; + +export type Timer = { cancel(): void }; + +export type SlackIngress = { + identityContext: ChannelIdentityContext; + identityResult: SlackIdentityResult; +}; + +export type SlackIngressSelector = { + provider: "slack"; + providerActorId: string; + applicationUserId: string | null; +}; + +export type SlackInteractionSelector = SlackIngressSelector; +export type LinkedSlackIngress = SlackIngress & { + identityResult: Extract; +}; + +export type SlackIngressTimer = { + after(milliseconds: number, callback: () => void): Timer; +}; + +const INGRESS_TTL_MS = 30_000; +const EVENT_ID_ERROR = "Managed Slack ingress requires an event id."; + +export function providerThreadIdFromIdentity( + context: ChannelIdentityContext, +): string { + const eventThreadId = context.event.threadId; + return typeof eventThreadId === "string" && eventThreadId.trim() + ? eventThreadId + : context.conversation.id; +} + +type RememberedIngress = { + ingress: SlackIngress; + timer: Timer; +}; + +type RememberedLinkedInteraction = RememberedIngress & { + ingress: LinkedSlackIngress; +}; + +const systemTimer: SlackIngressTimer = { + after(milliseconds, callback) { + const timeout = setTimeout(callback, milliseconds); + return { cancel: () => clearTimeout(timeout) }; + }, +}; + +function requiredEventId(eventId: string | undefined): string { + if (!eventId?.trim()) throw new Error(EVENT_ID_ERROR); + return eventId.trim(); +} + +/** One-use, short-lived identity facts bridging managed Channels ingress to agent execution. */ +export class SlackIngressRegistry { + private readonly entries = new Map(); + + constructor(private readonly timer: SlackIngressTimer = systemTimer) {} + + remember(eventId: string | undefined, ingress: SlackIngress): void { + const id = requiredEventId(eventId); + const rememberedForEvent = this.entries.get(id) ?? []; + const priorIndex = rememberedForEvent.findIndex(({ ingress: prior }) => + samePrincipal(prior, ingress), + ); + const prior = rememberedForEvent[priorIndex]; + prior?.timer.cancel(); + + let remembered: RememberedIngress; + const timer = this.timer.after(INGRESS_TTL_MS, () => { + this.remove(id, remembered); + }); + remembered = { ingress, timer }; + if (priorIndex >= 0) rememberedForEvent[priorIndex] = remembered; + else rememberedForEvent.push(remembered); + this.entries.set(id, rememberedForEvent); + } + + take( + eventId: string | undefined, + selector: SlackIngressSelector, + ): SlackIngress | null { + const id = requiredEventId(eventId); + const matches = (this.entries.get(id) ?? []).filter(({ ingress }) => + matchesSelector(ingress, selector), + ); + if (matches.length !== 1) return null; + const [remembered] = matches; + if (!remembered) return null; + this.remove(id, remembered); + remembered.timer.cancel(); + return remembered.ingress; + } + + /** Consume the one live interaction principal that Channels resolved immediately before click dispatch. */ + takeInteraction( + selector: SlackInteractionSelector, + ): LinkedSlackIngress | null { + const matches = [...this.entries.entries()].flatMap(([id, entries]) => + entries + .filter(isLinkedInteraction) + .filter(({ ingress }) => matchesSelector(ingress, selector)) + .map((remembered) => ({ id, remembered })), + ); + // Ambiguity is itself untrusted. Burn every candidate so none can be replayed after expiry or + // after another overlapping interaction disappears. + if (matches.length !== 1) { + for (const { id, remembered } of matches) { + this.remove(id, remembered); + remembered.timer.cancel(); + } + return null; + } + const [{ id, remembered }] = matches; + this.remove(id, remembered); + remembered.timer.cancel(); + return remembered.ingress; + } + + private remove(id: string, remembered: RememberedIngress): void { + const remaining = (this.entries.get(id) ?? []).filter( + (entry) => entry !== remembered, + ); + if (remaining.length > 0) this.entries.set(id, remaining); + else this.entries.delete(id); + } +} + +function isLinkedInteraction( + remembered: RememberedIngress, +): remembered is RememberedLinkedInteraction { + return ( + remembered.ingress.identityContext.trigger === "interaction" && + remembered.ingress.identityResult.kind === "linked" + ); +} + +function applicationUserId(ingress: SlackIngress): string | null { + return ingress.identityResult.kind === "linked" + ? ingress.identityResult.user.id + : null; +} + +function samePrincipal(left: SlackIngress, right: SlackIngress): boolean { + return ( + left.identityContext.provider === right.identityContext.provider && + left.identityContext.tenant.id === right.identityContext.tenant.id && + left.identityContext.installation.id === + right.identityContext.installation.id && + left.identityContext.conversation.id === + right.identityContext.conversation.id && + providerThreadIdFromIdentity(left.identityContext) === + providerThreadIdFromIdentity(right.identityContext) && + left.identityContext.actor.id === right.identityContext.actor.id && + applicationUserId(left) === applicationUserId(right) + ); +} + +function matchesSelector( + ingress: SlackIngress, + selector: SlackIngressSelector, +): boolean { + const context = ingress.identityContext; + return ( + context.provider === selector.provider && + context.actor.id === selector.providerActorId && + applicationUserId(ingress) === selector.applicationUserId + ); +} diff --git a/server/src/slack/status.ts b/server/src/slack/status.ts new file mode 100644 index 00000000..f6280e52 --- /dev/null +++ b/server/src/slack/status.ts @@ -0,0 +1,307 @@ +export type ChannelStatus = + | "connecting" + | "online" + | "setup_required" + | "reconnecting" + | "stopped" + | "error"; + +export type ChannelProviderStatus = + | "attached" + | "unhealthy" + | "not_attached" + | "disabled" + | "channel_not_declared" + | "unknown"; + +export type ChannelsStatusSnapshot = { + overall: ChannelStatus; + channels: Record; + detail: Record< + string, + { + status: ChannelStatus; + transport: ChannelStatus; + provider: ChannelProviderStatus; + } + >; +}; + +/** The credential-free Slack state exposed to unauthenticated deployment checks. */ +export type SlackStatus = { + status: ChannelStatus; + transport: ChannelStatus; + provider: ChannelProviderStatus; +}; + +export function projectSlackStatus( + snapshot?: ChannelsStatusSnapshot, +): SlackStatus { + const leg = snapshot?.detail.openbot; + if (!leg) { + return { status: "stopped", transport: "stopped", provider: "unknown" }; + } + return { + status: leg.status, + transport: leg.transport, + provider: leg.provider, + }; +} + +type ChannelsActivation = { + ready(options?: { timeoutMs?: number }): Promise; +}; + +function reportActivationFailure(error: unknown): void { + console.error("OpenBot Slack Channel activation failed", error); +} + +/** Activation is observable but non-fatal: the HTTP application remains available for setup. */ +export async function activateManagedChannels( + channels: ChannelsActivation | undefined, + reportFailure: (error: unknown) => void = reportActivationFailure, +): Promise { + if (!channels) return; + try { + await channels.ready({ timeoutMs: 30_000 }); + } catch (error) { + reportFailure(error); + } +} + +type StoppableChannels = { stop(): Promise }; + +export type ShutdownFailure = { + code: + | "shutdown_stop_failed" + | "shutdown_stop_timeout" + | "shutdown_promise_rejected"; + component: string; +}; + +function reportShutdownFailure(failure: ShutdownFailure): void { + console.error("OpenBot shutdown failed", failure); +} + +type ShutdownFailureReporter = ( + failure: ShutdownFailure, +) => void | Promise; + +function reportBestEffort( + reportFailure: ShutdownFailureReporter, + failure: ShutdownFailure, +): void { + try { + void Promise.resolve(reportFailure(failure)).catch(() => {}); + } catch { + // Reporting cannot be allowed to keep the process alive during shutdown. + } +} + +type StartShutdownTimeout = ( + callback: () => void, + timeoutMs: number, +) => () => void; + +const startShutdownTimeout: StartShutdownTimeout = (callback, timeoutMs) => { + const timeout = setTimeout(callback, timeoutMs); + return () => clearTimeout(timeout); +}; + +export type GracefulShutdownOptions = { + channels?: StoppableChannels; + stopOthers: ReadonlyArray<() => void | Promise>; + exit: (code: 0 | 1) => void; + reportFailure?: ShutdownFailureReporter; + timeoutMs?: number; + startTimeout?: StartShutdownTimeout; +}; + +/** Build one idempotent signal handler so SIGINT and SIGTERM cannot tear down twice. */ +export function createGracefulShutdown({ + channels, + stopOthers, + exit, + reportFailure = reportShutdownFailure, + timeoutMs = 10_000, + startTimeout = startShutdownTimeout, +}: GracefulShutdownOptions): () => Promise { + let shutdown: Promise | undefined; + let exited = false; + const exitOnce = (code: 0 | 1) => { + if (exited) return; + exited = true; + try { + exit(code); + } catch { + // There is no recovery path after shutdown; an exit adapter must not restart teardown. + } + }; + return () => { + const stops = [ + ...(channels + ? [{ component: "channels", stop: () => channels.stop() }] + : []), + ...stopOthers.map((stop, index) => ({ + component: `background_${index}`, + stop, + })), + ]; + shutdown ??= (async () => { + const tracked = stops.map(({ component, stop }) => { + const state: { + component: string; + outcome: + | { status: "pending" } + | { status: "fulfilled" } + | { status: "rejected"; reason: unknown }; + } = { component, outcome: { status: "pending" } }; + const promise = Promise.resolve() + .then(stop) + .then( + (value) => { + state.outcome = { status: "fulfilled" }; + return value; + }, + (error) => { + state.outcome = { status: "rejected", reason: error }; + throw error; + }, + ); + return { state, promise }; + }); + const allStops = Promise.allSettled( + tracked.map(({ promise }) => promise), + ); + let cancelTimeout = () => {}; + const timeout = new Promise<"timeout">((resolve) => { + cancelTimeout = startTimeout(() => resolve("timeout"), timeoutMs); + }); + const outcome = await Promise.race([ + allStops.then((results) => ({ kind: "settled" as const, results })), + timeout.then(() => ({ kind: "timeout" as const })), + ]); + + if (outcome.kind === "timeout") { + for (const { state } of tracked) { + if (state.outcome.status === "rejected") { + reportBestEffort(reportFailure, { + code: "shutdown_stop_failed", + component: state.component, + }); + } else if (state.outcome.status === "pending") { + reportBestEffort(reportFailure, { + code: "shutdown_stop_timeout", + component: state.component, + }); + } + } + exitOnce(1); + return; + } + + cancelTimeout(); + const failures = outcome.results.flatMap((result, index) => + result.status === "rejected" + ? [ + { + code: "shutdown_stop_failed" as const, + component: stops[index]?.component ?? "unknown", + }, + ] + : [], + ); + for (const failure of failures) { + reportBestEffort(reportFailure, failure); + } + exitOnce(failures.length > 0 ? 1 : 0); + })().catch(() => { + reportBestEffort(reportFailure, { + code: "shutdown_promise_rejected", + component: "shutdown", + }); + exitOnce(1); + }); + return shutdown; + }; +} + +type ShutdownSignal = "SIGINT" | "SIGTERM"; + +export type ShutdownSignalSource = { + on(signal: ShutdownSignal, listener: () => void): unknown; + off(signal: ShutdownSignal, listener: () => void): unknown; +}; + +/** Register exactly one shared callback for each supported process signal. */ +export function registerShutdownSignals( + signals: ShutdownSignalSource, + shutdown: () => Promise, + reportFailure: ShutdownFailureReporter = reportShutdownFailure, + exit: (code: 1) => void = (code) => { + process.exitCode = code; + }, +): () => void { + let registered = true; + let exited = false; + const unregister = () => { + if (!registered) return; + registered = false; + signals.off("SIGINT", onSignal); + signals.off("SIGTERM", onSignal); + }; + const onSignal = () => { + unregister(); + void Promise.resolve() + .then(shutdown) + .catch(() => { + reportBestEffort(reportFailure, { + code: "shutdown_promise_rejected", + component: "signal_handler", + }); + if (exited) return; + exited = true; + try { + exit(1); + } catch { + // A failing exit adapter cannot safely restart the shutdown path. + } + }); + }; + signals.on("SIGINT", onSignal); + signals.on("SIGTERM", onSignal); + return unregister; +} + +type ManagedChannelsControl = ChannelsActivation & StoppableChannels; + +export type ManagedChannelHostOptions = { + startWeb(): WebHost; + stopWeb(host: WebHost): void | Promise; + channels?: ManagedChannelsControl; + signals: ShutdownSignalSource; + stopOthers: ReadonlyArray<() => void | Promise>; + exit(code: 0 | 1): void; + reportActivationFailure?: (error: unknown) => void; +}; + +/** Start HTTP first, install shutdown handling, then wait for non-fatal Channel activation. */ +export async function startManagedChannelHost({ + startWeb, + stopWeb, + channels, + signals, + stopOthers, + exit, + reportActivationFailure, +}: ManagedChannelHostOptions): Promise { + const web = startWeb(); + const shutdown = createGracefulShutdown({ + channels, + stopOthers: [() => stopWeb(web), ...stopOthers], + exit, + }); + registerShutdownSignals(signals, shutdown, undefined, exit); + await activateManagedChannels(channels, reportActivationFailure); + return web; +} diff --git a/server/src/slack/tenant-context.ts b/server/src/slack/tenant-context.ts new file mode 100644 index 00000000..0325e1f5 --- /dev/null +++ b/server/src/slack/tenant-context.ts @@ -0,0 +1,57 @@ +import type { ChannelIdentityContext } from "@copilotkit/channels"; + +export const MANAGED_SLACK_TENANT_ERROR = + "Managed Slack delivery did not provide the configured canonical tenant."; +const MANAGED_SLACK_TENANT_CODE = "slack_identity_tenant_invalid" as const; + +function managedSlackTenantError(): Error & { + code: typeof MANAGED_SLACK_TENANT_CODE; +} { + return Object.assign(new Error(MANAGED_SLACK_TENANT_ERROR), { + code: MANAGED_SLACK_TENANT_CODE, + }); +} + +function canonicalTenantId(value: unknown): string | undefined { + if (typeof value !== "string") return undefined; + const tenantId = value.trim(); + return tenantId && tenantId.toLowerCase() !== "unknown" + ? tenantId + : undefined; +} + +function withTenantId( + context: ChannelIdentityContext, + tenantId: string, +): ChannelIdentityContext { + return Object.freeze({ + ...context, + tenant: Object.freeze({ ...context.tenant, id: tenantId }), + }); +} + +/** + * Supply the operator-owned workspace only when managed Channels omitted its canonical tenant. + * Every other identity fact remains the adapter's immutable value. + */ +export function normalizeSlackTenantContext( + context: ChannelIdentityContext, + configuredTenantId?: string, +): ChannelIdentityContext { + const managedTenantId = canonicalTenantId(context.tenant?.id); + const fallbackTenantId = canonicalTenantId(configuredTenantId); + + if (managedTenantId) { + if (fallbackTenantId && managedTenantId !== fallbackTenantId) { + throw managedSlackTenantError(); + } + return context.tenant.id === managedTenantId + ? context + : withTenantId(context, managedTenantId); + } + if (!fallbackTenantId) { + throw managedSlackTenantError(); + } + + return withTenantId(context, fallbackTenantId); +} diff --git a/server/src/slack/turn-phase.ts b/server/src/slack/turn-phase.ts new file mode 100644 index 00000000..630cc018 --- /dev/null +++ b/server/src/slack/turn-phase.ts @@ -0,0 +1,78 @@ +export const SLACK_TURN_PHASES = [ + "identity.resolve", + "ingress.remember", + "ingress.take", + "identity.validate", + "link_card.post", + "thread.subscribe", + "execution.prepare", + "agent.run", + "transcript_link.post", +] as const; + +export type SlackTurnPhase = (typeof SLACK_TURN_PHASES)[number]; +export type SlackTurnFailureEvent = { + type: "slack-turn-failed"; + phase: SlackTurnPhase; + reason?: SlackTurnFailureReason; +}; +export type SlackTurnFailureLogger = (event: SlackTurnFailureEvent) => void; + +const SLACK_TURN_FAILURE_REASONS = new Set([ + "slack_identity_provider_invalid", + "slack_identity_actor_kind_invalid", + "slack_identity_tenant_invalid", + "slack_identity_actor_invalid", + "slack_identity_link_lookup_failed", + "slack_identity_user_lookup_failed", + "slack_identity_email_lookup_failed", + "slack_identity_link_write_failed", + "slack_identity_link_token_failed", +]); + +type SlackTurnFailureReason = + | "slack_identity_provider_invalid" + | "slack_identity_actor_kind_invalid" + | "slack_identity_tenant_invalid" + | "slack_identity_actor_invalid" + | "slack_identity_link_lookup_failed" + | "slack_identity_user_lookup_failed" + | "slack_identity_email_lookup_failed" + | "slack_identity_link_write_failed" + | "slack_identity_link_token_failed"; + +function safeFailureReason(error: unknown): SlackTurnFailureReason | undefined { + if (!error || typeof error !== "object" || !("code" in error)) return; + const code = error.code; + return typeof code === "string" && SLACK_TURN_FAILURE_REASONS.has(code) + ? (code as SlackTurnFailureReason) + : undefined; +} + +export const defaultSlackTurnFailureLogger: SlackTurnFailureLogger = ( + event, +) => { + console.error(JSON.stringify(event)); +}; + +export async function runSlackPhase( + phase: SlackTurnPhase, + operation: () => T | Promise, + logger: SlackTurnFailureLogger, +): Promise { + try { + return await operation(); + } catch (error) { + try { + const reason = safeFailureReason(error); + logger({ + type: "slack-turn-failed", + phase, + ...(reason ? { reason } : {}), + }); + } catch { + // Observability must never replace the application failure. + } + throw error; + } +} diff --git a/server/tests/agent-profile-store.integration.test.ts b/server/tests/agent-profile-store.integration.test.ts index 01dc052e..cc29c88d 100644 --- a/server/tests/agent-profile-store.integration.test.ts +++ b/server/tests/agent-profile-store.integration.test.ts @@ -280,6 +280,42 @@ describe("agent profile store integration", () => { expect(preference?.hiddenAt).toBeNull(); }); + test("lists canonical accessible ids without treating hidden as revoked access", async () => { + const owner = await createUser(); + const other = await createUser(); + const admin = await createUser("admin"); + const ownedPrivate = await createProfileFixture({ + owner, + visibility: "private", + }); + const publicHidden = await createProfileFixture({ + owner: other, + visibility: "public", + }); + const inaccessiblePrivate = await createProfileFixture({ + owner: other, + visibility: "private", + }); + const deletedPublic = await createProfileFixture({ + owner: other, + visibility: "public", + }); + await store.setHidden(owner, publicHidden.agentId, true); + await store.softDelete(other, deletedPublic.agentId); + + const ownerIds = await store.listAccessibleIds(owner); + const adminIds = await store.listAccessibleIds(admin); + + expect(ownerIds).toContain(ownedPrivate.agentId); + expect(ownerIds).toContain(publicHidden.agentId); + expect(ownerIds).not.toContain(inaccessiblePrivate.agentId); + expect(ownerIds).not.toContain(deletedPublic.agentId); + expect(adminIds).toContain(ownedPrivate.agentId); + expect(adminIds).toContain(publicHidden.agentId); + expect(adminIds).toContain(inaccessiblePrivate.agentId); + expect(adminIds).not.toContain(deletedPublic.agentId); + }); + test("takes the endpoint and ignores every field a caller must not set", async () => { const owner = await createUser(); const deploymentPackage = await createPackage(); diff --git a/server/tests/agent-resolver.test.ts b/server/tests/agent-resolver.test.ts new file mode 100644 index 00000000..53bfd746 --- /dev/null +++ b/server/tests/agent-resolver.test.ts @@ -0,0 +1,112 @@ +import { describe, expect, test } from "bun:test"; +import { BuiltInAgent } from "@copilotkit/runtime/v2"; +import { createActorAgentResolver } from "../src/agents/agent-resolver"; + +describe("actor-scoped agent resolver", () => { + test("uses the same actor for web maps and individual agent resolution", async () => { + const seenActorIds: string[] = []; + const resolver = createActorAgentResolver({ + loadAgents: async (actor) => { + seenActorIds.push(actor.id); + return [ + { + id: "risk", + name: "Risk Analyst", + type: "built_in" as const, + systemPrompt: "Assess operational risk.", + }, + ]; + }, + model: { provider: "openai", defaultModel: "gpt-5.6-terra" }, + resolveModelApiKey: async () => "openai-secret", + }); + const actor = { id: "u1", role: "user" as const }; + + const visibleAgents = await resolver.resolveAgentsForActor(actor); + const risk = await resolver.resolveAgentForActor(actor, "risk"); + + expect(seenActorIds).toEqual(["u1", "u1"]); + expect(visibleAgents.risk).toBeInstanceOf(BuiltInAgent); + expect(risk).toBeInstanceOf(BuiltInAgent); + }); + + test("rejects an agent absent from the actor's visible map", async () => { + const resolver = createActorAgentResolver({ + loadAgents: async () => [ + { + id: "risk", + name: "Risk Analyst", + type: "built_in" as const, + systemPrompt: "Assess operational risk.", + }, + ], + model: { provider: "openai", defaultModel: "gpt-5.6-terra" }, + resolveModelApiKey: async () => "openai-secret", + }); + + let rejection: unknown; + try { + await resolver.resolveAgentForActor( + { id: "u1", role: "user" }, + "private-risk", + ); + } catch (error) { + rejection = error; + } + + expect(rejection).toBeInstanceOf(Error); + expect((rejection as Error).message).toBe( + "Coworker private-risk is unavailable to this user.", + ); + }); + + test("rejects an agent when the actor has no visible coworkers", async () => { + const resolver = createActorAgentResolver({ + loadAgents: async () => [], + model: { provider: "openai", defaultModel: "gpt-5.6-terra" }, + resolveModelApiKey: async () => "openai-secret", + }); + + expect( + await rejectionMessage(() => + resolver.resolveAgentForActor( + { id: "u1", role: "user" }, + "private-risk", + ), + ), + ).toBe("Coworker private-risk is unavailable to this user."); + }); + + test("rejects inherited object keys as unavailable coworkers", async () => { + const resolver = createActorAgentResolver({ + loadAgents: async () => [ + { + id: "risk", + name: "Risk Analyst", + type: "built_in" as const, + systemPrompt: "Assess operational risk.", + }, + ], + model: { provider: "openai", defaultModel: "gpt-5.6-terra" }, + resolveModelApiKey: async () => "openai-secret", + }); + + for (const agentId of ["constructor", "toString", "__proto__"]) { + expect( + await rejectionMessage(() => + resolver.resolveAgentForActor({ id: "u1", role: "user" }, agentId), + ), + ).toBe(`Coworker ${agentId} is unavailable to this user.`); + } + }); +}); + +async function rejectionMessage(run: () => Promise) { + try { + await run(); + } catch (error) { + if (error instanceof Error) return error.message; + throw error; + } + throw new Error("Expected the run to reject."); +} diff --git a/server/tests/audit.test.ts b/server/tests/audit.test.ts index 22a7dedf..4109a7d1 100644 --- a/server/tests/audit.test.ts +++ b/server/tests/audit.test.ts @@ -2,6 +2,7 @@ import { describe, expect, test } from "bun:test"; import { readdir, readFile } from "node:fs/promises"; import { createApp } from "../src/app"; import { + type AuditEventType, auditEventTypes, recordAuditEvent, redactAuditPayload, @@ -45,10 +46,17 @@ describe("audit payload redaction", () => { "agent.invoked", "mcp.call_succeeded", "mcp.call_rejected", + "external_identity.linked", ]), ); }); + test("types an external identity link as a canonical audit event", () => { + const eventType: AuditEventType = "external_identity.linked"; + + expect(eventType).toBe("external_identity.linked"); + }); + test("removes secret values and document content recursively", () => { expect( redactAuditPayload({ diff --git a/server/tests/computer-gateway.test.ts b/server/tests/computer-gateway.test.ts index e2b4c255..959c678e 100644 --- a/server/tests/computer-gateway.test.ts +++ b/server/tests/computer-gateway.test.ts @@ -163,6 +163,16 @@ function fakeComputer(options?: { case "/control/secret": calls.push("requestSecret"); return Response.json({ mode: "secret", ref: "e1" }); + case "/control/assistance/cancel": + calls.push("cancelAssistance"); + return Response.json({ + cancelled: true, + status: "cancelled", + state: { holder: "bot", since: "now", requested: false }, + }); + case "/control/assistance/status": + calls.push("assistanceStatus"); + return Response.json({ status: "completed" }); case "/human/secret": calls.push("supplySecret"); return Response.json({ supplied: true }); @@ -772,6 +782,15 @@ describe("the computer gateway", () => { ref: "e1", snapshotId: 7, }); + await gateway.cancelAssistance( + "bot-1", + ACTOR, + "11111111-1111-4111-8111-111111111111", + ); + await gateway.assistanceStatus( + "bot-1", + "11111111-1111-4111-8111-111111111111", + ); await gateway.supplySecret("bot-1", ACTOR, "secret"); await gateway.humanInput("bot-1", { kind: "click", x: 10, y: 20 }); @@ -786,10 +805,35 @@ describe("the computer gateway", () => { "/control/take", "/control/release", "/control/secret", + "/control/assistance/cancel", + "/control/assistance/status", "/human/secret", "/human/click", ]); }); + + test("sends one opaque request generation and audits only a matching assistance cancellation", async () => { + const { gateway, requests, rows } = await gatewayWith(PERMISSIVE); + const requestId = "11111111-1111-4111-8111-111111111111"; + + await gateway.requestHelp("bot-1", ACTOR, "Sign in", requestId); + const result = await gateway.cancelAssistance("bot-1", ACTOR, requestId); + + const requestBody = await new Response( + requests.find(({ url }) => url.endsWith("/control/request"))?.init?.body, + ).json(); + const cancelBody = await new Response( + requests.find(({ url }) => url.endsWith("/control/assistance/cancel")) + ?.init?.body, + ).json(); + expect(requestBody).toEqual({ reason: "Sign in", requestId }); + expect(cancelBody).toEqual({ requestId }); + expect(result.cancelled).toBe(true); + expect(rows.map(({ eventType }) => eventType)).toEqual([ + "computer.help_requested", + "computer.assistance_cancelled", + ]); + }); /* * Through `gatewayWith`, deliberately, rather than by handing `evaluateActionPolicy` a context * written here. The bug these cover was that the context the gateway builds and the context the diff --git a/server/tests/config.test.ts b/server/tests/config.test.ts index 0c887811..ace49758 100644 --- a/server/tests/config.test.ts +++ b/server/tests/config.test.ts @@ -71,6 +71,39 @@ describe("deployment configuration", () => { expect(config.tenantPackageDirectory).toBe("../examples/fintech"); }); + test("loads a canonical managed Slack tenant fallback", () => { + expect( + loadConfig({ + ...baseEnvironment, + OPENBOT_SLACK_TENANT_ID: " T05QFA4BW9X ", + }).slackTenantId, + ).toBe("T05QFA4BW9X"); + }); + + test.each(["unknown", " UNKNOWN "])( + "rejects the non-canonical managed Slack tenant %j", + (tenantId) => { + expect(() => + loadConfig({ + ...baseEnvironment, + OPENBOT_SLACK_TENANT_ID: tenantId, + }), + ).toThrow( + "OPENBOT_SLACK_TENANT_ID must be a canonical Slack workspace ID, not unknown", + ); + }, + ); + + test("leaves managed Slack tenant fallback disabled when unset or blank", () => { + expect(loadConfig(baseEnvironment).slackTenantId).toBeUndefined(); + expect( + loadConfig({ + ...baseEnvironment, + OPENBOT_SLACK_TENANT_ID: " ", + }).slackTenantId, + ).toBeUndefined(); + }); + test("allows deployment without an authentication provider, when asked to", () => { const config = loadConfig({ DATABASE_URL: baseEnvironment.DATABASE_URL, diff --git a/server/tests/copilot.test.ts b/server/tests/copilot.test.ts index 6d439464..38636851 100644 --- a/server/tests/copilot.test.ts +++ b/server/tests/copilot.test.ts @@ -1,17 +1,25 @@ import { describe, expect, spyOn, test } from "bun:test"; -import { HttpAgent } from "@ag-ui/client"; +import type { AbstractAgent, RunAgentInput } from "@ag-ui/client"; import { BuiltInAgent } from "@copilotkit/runtime/v2"; import { PROVENANCE_GUIDANCE } from "../../shared/bot-prompt"; +import { createActorAgentResolver } from "../src/agents/agent-resolver"; import { buildAgents, builtInAgentConfiguration, createRequestAgents, + type mountCopilotRuntime, registeredAgentFromRow, resolveRuntimeAgents, standingRoleMessage, } from "../src/copilot"; import { grantedToolGuidance } from "../src/plugins/tools"; +test("mountCopilotRuntime preserves the positional custom base path", () => { + const customBasePath: Parameters[4] = + "/custom/copilotkit"; + expect(customBasePath).toBe("/custom/copilotkit"); +}); + // Every agent row now joins its profile, so the row a coworker is built from always names it. const assistantRow = { id: "general-assistant", @@ -164,7 +172,7 @@ describe("registered Copilot agents", () => { ); expect(agents["general-assistant"]).toBeInstanceOf(BuiltInAgent); - expect(agents.risk).toBeInstanceOf(HttpAgent); + expect(agents.risk?.agentId).toBe("risk"); }); /* @@ -206,7 +214,7 @@ describe("registered Copilot agents", () => { ); expect(watched).toEqual([{ id: "risk", name: "Risk" }]); - expect(agents.risk).toBeInstanceOf(HttpAgent); + expect(agents.risk?.agentId).toBe("risk"); }); /* @@ -218,7 +226,11 @@ describe("registered Copilot agents", () => { * goes. */ test("dials a remote Bot with the fetch it was given, guarded or not", async () => { - const dialler = async () => new Response(null); + let dialled = 0; + const dialler = async (_url: string, request: RequestInit) => { + dialled += 1; + return completeAgUiResponse(request); + }; const registered = [ { id: "risk", @@ -243,9 +255,9 @@ describe("registered Copilot agents", () => { dialler, ) ).risk; - if (!(plain instanceof HttpAgent)) - throw new Error("Expected the remote agent"); - expect(plain.fetch).toBe(dialler); + if (!plain) throw new Error("Expected the remote agent"); + await runDirect(plain); + expect(dialled).toBe(1); // With a timeout configured the watch wraps it, so the guard is handed the dialling fetch rather // than replacing it. A deployment gets both, not whichever was wired last. @@ -270,8 +282,7 @@ describe("registered Copilot agents", () => { dialler, ) ).risk; - if (!(watched instanceof HttpAgent)) - throw new Error("Expected the remote agent"); + expect(watched?.agentId).toBe("risk"); expect(handed).toBe(dialler); }); @@ -284,7 +295,11 @@ describe("registered Copilot agents", () => { * redirect anywhere. */ test("carries the dialling fetch through resolveRuntimeAgents", async () => { - const dialler = async () => new Response(null); + let dialled = 0; + const dialler = async (_url: string, request: RequestInit) => { + dialled += 1; + return completeAgUiResponse(request); + }; const agents = await resolveRuntimeAgents( async () => [ { @@ -306,9 +321,9 @@ describe("registered Copilot agents", () => { ); const risk = agents.risk; - if (!(risk instanceof HttpAgent)) - throw new Error("Expected the remote agent"); - expect(risk.fetch).toBe(dialler); + if (!risk) throw new Error("Expected the remote agent"); + await runDirect(risk); + expect(dialled).toBe(1); }); /* @@ -320,7 +335,16 @@ describe("registered Copilot agents", () => { * could have produced and then without one, and the two are compared. */ test("leaves a remote Bot's fetch alone when no timeout is configured", async () => { - const sentinel = async () => new Response(null); + let guardedCalls = 0; + let unguardedCalls = 0; + const sentinel = async (_url: string, request: RequestInit) => { + guardedCalls += 1; + return completeAgUiResponse(request); + }; + const ordinary = async (_url: string, request: RequestInit) => { + unguardedCalls += 1; + return completeAgUiResponse(request); + }; const registered = [ { id: "risk", @@ -340,13 +364,25 @@ describe("registered Copilot agents", () => { stop: () => undefined, }) ).risk; - const unguarded = (await buildAgents(registered, model, null)).risk; - if (!(guarded instanceof HttpAgent) || !(unguarded instanceof HttpAgent)) { - throw new Error("Expected the remote agent"); - } - - expect(guarded.fetch).toBe(sentinel); - expect(unguarded.fetch).not.toBe(sentinel); + const unguarded = ( + await buildAgents( + registered, + model, + null, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + ordinary, + ) + ).risk; + if (!guarded || !unguarded) throw new Error("Expected remote agents"); + await runDirect(guarded); + await runDirect(unguarded); + expect(guardedCalls).toBe(1); + expect(unguardedCalls).toBe(1); }); test("resolves fresh built-in agents and credentials for every request", async () => { @@ -405,7 +441,7 @@ describe("registered Copilot agents", () => { }, ); - expect(agents.risk).toBeInstanceOf(HttpAgent); + expect(agents.risk?.agentId).toBe("risk"); expect(resolverInvoked).toBe(false); }); }); @@ -518,12 +554,14 @@ describe("standing agent roles", () => { seen.request = request; return { id: "user-7", role: "user" as const }; }, - async (actor) => { - seen.actors.push(actor); - return [remoteAgent("http://coworker.internal/ag-ui")]; - }, - { provider: "openai", defaultModel: "gpt-5.6-terra" }, - async () => null, + createActorAgentResolver({ + loadAgents: async (actor) => { + seen.actors.push(actor); + return [remoteAgent("http://coworker.internal/ag-ui")]; + }, + model: { provider: "openai", defaultModel: "gpt-5.6-terra" }, + resolveModelApiKey: async () => null, + }), ); const request = new Request("http://openbot.test/api/copilotkit"); @@ -531,18 +569,20 @@ describe("standing agent roles", () => { expect(seen.request).toBe(request); expect(seen.actors).toEqual([{ id: "user-7", role: "user" }]); - expect(resolved.agent_expense).toBeInstanceOf(HttpAgent); + expect(resolved.agent_expense?.agentId).toBe("agent_expense"); }); test("rebuilds each agent from the loader so an edited role applies to the next run", async () => { let roleDescription = "Review receipts."; const factory = createRequestAgents( async () => ({ id: "user-7", role: "user" as const }), - async () => [ - remoteAgent("http://coworker.internal/ag-ui", { roleDescription }), - ], - { provider: "openai", defaultModel: "gpt-5.6-terra" }, - async () => null, + createActorAgentResolver({ + loadAgents: async () => [ + remoteAgent("http://coworker.internal/ag-ui", { roleDescription }), + ], + model: { provider: "openai", defaultModel: "gpt-5.6-terra" }, + resolveModelApiKey: async () => null, + }), ); const request = new Request("http://openbot.test/api/copilotkit"); @@ -580,6 +620,37 @@ function userMessage(content: string) { return { id: `user-${content}`, role: "user" as const, content }; } +async function runDirect(agent: AbstractAgent) { + const input: RunAgentInput = { + threadId: "direct-thread", + runId: "direct-run", + state: {}, + messages: [], + tools: [], + context: [], + forwardedProps: {}, + }; + await new Promise((resolve, reject) => { + agent.run(input).subscribe({ complete: resolve, error: reject }); + }); +} + +function completeAgUiResponse(request: RequestInit) { + const input = JSON.parse(String(request.body)) as { + threadId: string; + runId: string; + }; + return new Response( + [ + { type: "RUN_STARTED", threadId: input.threadId, runId: input.runId }, + { type: "RUN_FINISHED", threadId: input.threadId, runId: input.runId }, + ] + .map((event) => `data: ${JSON.stringify(event)}\n\n`) + .join(""), + { headers: { "content-type": "text/event-stream" } }, + ); +} + /** * An AG-UI server that records what it was sent and answers with a complete run, so the standing * role can be asserted on the wire rather than on the object that was supposed to send it. diff --git a/server/tests/external-link-routes.test.ts b/server/tests/external-link-routes.test.ts new file mode 100644 index 00000000..7e0912a3 --- /dev/null +++ b/server/tests/external-link-routes.test.ts @@ -0,0 +1,633 @@ +import { describe, expect, test } from "bun:test"; +import type { MiddlewareHandler } from "hono"; +import { Hono } from "hono"; +import type { AgentProfileStore } from "../src/agents/profile-store"; +import { createApp } from "../src/app"; +import type { AuditEventInput, TransactionalAuditStore } from "../src/audit"; +import type { AppVariables } from "../src/auth/guards"; +import { loadConfig } from "../src/config"; +import type { + ExternalLinkCreationStore, + ExternalLinkStore, +} from "../src/external/link-store"; +import { mintExternalLinkToken } from "../src/external/link-token"; +import { createExternalLinkRoutes } from "../src/external/routes"; +import type { + ExternalThreadBinding, + ExternalThreadPage, + ExternalThreadStore, +} from "../src/external/thread-store"; +import { testEnvironment } from "./support/environment"; + +const KEY = "external-link-routes-test-key"; +const NOW = 1_700_000_000_000; +const INVALID = "This Slack link has expired or is invalid."; +const CONFLICT = "That Slack identity is already linked."; +const identity = { + provider: "slack" as const, + providerTenantId: "T1", + providerUserId: "U1", + providerEmail: "person@example.com", +}; +const actor = { + id: "openbot-user-1", + email: "member@openbot.test", + role: "user", +} as const; + +function fakeAgentProfileStore( + accessibleIds: readonly string[] = ["risk"], +): Pick & { + getCalls: Parameters[]; + listAccessibleIdsCalls: Parameters[]; +} { + const getCalls: Parameters[] = []; + const listAccessibleIdsCalls: Parameters< + AgentProfileStore["listAccessibleIds"] + >[] = []; + return { + getCalls, + listAccessibleIdsCalls, + get: async (...args) => { + getCalls.push(args); + const id = args[1]; + return id === "risk" + ? ({ id: "risk", name: "Risk Analyst" } as Awaited< + ReturnType + >) + : null; + }, + listAccessibleIds: async (...args) => { + listAccessibleIdsCalls.push(args); + return accessibleIds; + }, + }; +} + +const externalThread: ExternalThreadBinding = { + channelsThreadId: "channels-thread-1", + provider: "slack", + providerTenantId: "T1", + providerConversationId: "C1", + providerThreadId: "1712345.6789", + agentId: "risk", + agentName: "Risk Analyst", + createdByUserId: actor.id, + createdAt: new Date(NOW), +}; + +const externalThreadSummary: ExternalThreadPage["threads"][number] = { + threadId: externalThread.channelsThreadId, + provider: "slack", + agentId: externalThread.agentId, + agentName: externalThread.agentName, + lastMessage: "Review the queue", + lastMessageAt: new Date(NOW + 1_000), + createdAt: externalThread.createdAt, +}; + +function fakeThreadStore( + found: ExternalThreadBinding | null = externalThread, + messages: Awaited> = [], + page: ExternalThreadPage = { threads: [], nextCursor: null }, +): ExternalThreadStore & { + listCalls: Parameters[]; +} { + const listCalls: Parameters[] = []; + return { + listCalls, + listForCreator: async (...args) => { + listCalls.push(args); + return page; + }, + getByChannelsThreadId: async (id) => + id === found?.channelsThreadId ? found : null, + getByProviderThread: async () => null, + bind: async () => { + throw new Error("unused"); + }, + appendTranscriptTurn: async () => undefined, + getTranscript: async () => messages, + }; +} + +function authenticatedAs( + authenticatedActor = actor, +): MiddlewareHandler<{ Variables: AppVariables }> { + return async (context, next) => { + context.set("actor", authenticatedActor); + await next(); + }; +} + +const unauthenticated: MiddlewareHandler<{ Variables: AppVariables }> = async ( + context, +) => context.json({ error: "Authentication required." }, 401); + +function linkFor(openbotUserId: string) { + return { + ...identity, + openbotUserId, + linkedAt: new Date(NOW), + updatedAt: new Date(NOW), + }; +} + +function fakeStore( + overrides: Partial = {}, +): ExternalLinkCreationStore & { links: ReturnType[] } { + const links: ReturnType[] = []; + async function linkWithStatus( + input: Parameters[0], + ) { + const found = links.find( + (link) => + link.provider === input.provider && + link.providerTenantId === input.providerTenantId && + link.providerUserId === input.providerUserId, + ); + if (found?.openbotUserId === input.openbotUserId) { + return { link: found, created: false }; + } + if (found) throw new Error(CONFLICT); + const link = linkFor(input.openbotUserId); + links.push(link); + return { link, created: true }; + } + async function linkWithStatusAndAudit( + input: Parameters[0], + recordAudit: () => Promise, + ) { + const found = links.find( + (link) => + link.provider === input.provider && + link.providerTenantId === input.providerTenantId && + link.providerUserId === input.providerUserId, + ); + if (found?.openbotUserId === input.openbotUserId) { + return { link: found, created: false }; + } + if (found) throw new Error(CONFLICT); + const link = linkFor(input.openbotUserId); + await recordAudit(); + links.push(link); + return { link, created: true }; + } + return Object.assign( + { + links, + async find() { + return null; + }, + async findVerifiedUserByEmail() { + return null; + }, + linkWithStatus, + linkWithStatusAndAudit, + async link(input) { + return (await linkWithStatus(input)).link; + }, + } satisfies ExternalLinkCreationStore, + overrides, + ); +} + +function appFor( + store = fakeStore(), + requireUser = authenticatedAs(), + rows: AuditEventInput[] = [], + auditStore: TransactionalAuditStore = { + insert: async (event) => void rows.push(event), + inTransaction: () => ({ insert: async (event) => void rows.push(event) }), + }, + threadStore: ExternalThreadStore = fakeThreadStore(), + agentProfileStore = fakeAgentProfileStore(), +) { + const app = new Hono<{ Variables: AppVariables }>(); + app.route( + "/api/external-links", + createExternalLinkRoutes({ + store, + encryptionKey: KEY, + requireUser, + auditStore, + agentProfileStore, + threadStore, + }), + ); + return { app, agentProfileStore, rows, store }; +} + +const baseStore: ExternalLinkStore = { + find: async () => null, + findVerifiedUserByEmail: async () => null, + link: async (input) => linkFor(input.openbotUserId), +}; + +function requestToken(token: string) { + return `?token=${encodeURIComponent(token)}`; +} + +async function liveToken() { + return mintExternalLinkToken(identity, KEY); +} + +describe("external Slack link confirmation routes", () => { + test("GET /threads returns safe authorized Slack thread summaries", async () => { + const unsafeSummary = { + ...externalThreadSummary, + providerTenantId: "T1", + providerConversationId: "C1", + providerThreadId: "1712345.6789", + createdByUserId: actor.id, + }; + const { app } = appFor( + fakeStore(), + authenticatedAs(), + [], + undefined, + fakeThreadStore(externalThread, [], { + threads: [unsafeSummary], + nextCursor: "opaque-next", + }), + ); + + const response = await app.request( + "http://openbot.test/api/external-links/threads?limit=1&cursor=opaque-cursor", + ); + + expect(response.status).toBe(200); + expect(response.headers.get("cache-control")).toBe("no-store"); + expect(await response.json()).toEqual({ + threads: [ + { + threadId: "channels-thread-1", + provider: "slack", + agentId: "risk", + agentName: "Risk Analyst", + lastMessage: "Review the queue", + lastMessageAt: new Date(NOW + 1_000).toISOString(), + createdAt: new Date(NOW).toISOString(), + readOnly: true, + }, + ], + nextCursor: "opaque-next", + }); + }); + + test("GET /threads passes actor, cursor, limit, and accessible agent ids in one store call", async () => { + const threadStore = fakeThreadStore(); + const profileStore = fakeAgentProfileStore(["risk", "helper"]); + const { app } = appFor( + fakeStore(), + authenticatedAs(), + [], + undefined, + threadStore, + profileStore, + ); + + const response = await app.request( + "http://openbot.test/api/external-links/threads?limit=200&cursor=opaque-cursor", + ); + + expect(response.status).toBe(200); + expect(threadStore.listCalls).toEqual([ + [ + actor.id, + { agentIds: ["risk", "helper"], cursor: "opaque-cursor", limit: 200 }, + ], + ]); + expect(profileStore.listAccessibleIdsCalls).toEqual([ + [{ id: actor.id, role: actor.role }], + ]); + expect(profileStore.getCalls).toEqual([]); + }); + + test("GET /threads rejects unauthenticated callers", async () => { + const threadStore = fakeThreadStore(externalThread, [], { + threads: [externalThreadSummary], + nextCursor: null, + }); + const { app } = appFor( + fakeStore(), + unauthenticated, + [], + undefined, + threadStore, + ); + + const response = await app.request( + "http://openbot.test/api/external-links/threads", + ); + + expect(response.status).toBe(401); + expect(response.headers.get("cache-control")).toBe("no-store"); + expect(threadStore.listCalls).toEqual([]); + }); + + test("GET /threads rejects invalid limit values", async () => { + const { app } = appFor(); + + for (const limit of ["0", "abc", "201"]) { + const response = await app.request( + `http://openbot.test/api/external-links/threads?limit=${limit}`, + ); + + expect(response.status).toBe(400); + expect(response.headers.get("cache-control")).toBe("no-store"); + expect(await response.json()).toEqual({ + error: "Invalid conversation page.", + }); + } + }); + + test("GET returns an authenticated creator's canonical Slack transcript target", async () => { + const { app } = appFor(); + + const response = await app.request( + "http://openbot.test/api/external-links/threads/channels-thread-1", + ); + + expect(response.status).toBe(200); + expect(response.headers.get("cache-control")).toBe("no-store"); + expect(await response.json()).toEqual({ + threadId: "channels-thread-1", + agentId: "risk", + agentName: "Risk Analyst", + provider: "slack", + readOnly: true, + }); + }); + + test("GET returns the OpenBot-owned durable transcript for an authorized creator", async () => { + const messages = [ + { id: "user-1", role: "user" as const, content: "Hello" }, + { id: "reply-1", role: "assistant" as const, content: "Hi" }, + ]; + const { app } = appFor( + fakeStore(), + authenticatedAs(), + [], + undefined, + fakeThreadStore(externalThread, messages), + ); + + const response = await app.request( + "http://openbot.test/api/external-links/threads/channels-thread-1/messages", + ); + + expect(response.status).toBe(200); + expect(response.headers.get("cache-control")).toBe("no-store"); + expect(await response.json()).toEqual({ messages }); + }); + + test("GET hides external transcripts from other users and revoked coworkers", async () => { + const otherUser = { ...actor, id: "openbot-user-2" } as const; + const otherUserApp = appFor(fakeStore(), authenticatedAs(otherUser)).app; + expect( + ( + await otherUserApp.request( + "http://openbot.test/api/external-links/threads/channels-thread-1", + ) + ).status, + ).toBe(404); + + const revoked = { ...externalThread, agentId: "revoked" }; + const revokedApp = appFor( + fakeStore(), + authenticatedAs(), + [], + undefined, + fakeThreadStore(revoked), + ).app; + expect( + ( + await revokedApp.request( + "http://openbot.test/api/external-links/threads/channels-thread-1", + ) + ).status, + ).toBe(404); + }); + + test("GET shows only the safe Slack display metadata after authentication", async () => { + const { app } = appFor(); + const token = await liveToken(); + + const response = await app.request( + `http://openbot.test/api/external-links/slack${requestToken(token)}`, + ); + + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ + providerTenantId: "T1", + providerUserId: "U1", + providerEmail: "person@example.com", + }); + }); + + test("GET maps a missing or invalid claim to one stable public refusal", async () => { + const { app } = appFor(); + const expired = await mintExternalLinkToken(identity, KEY, NOW); + + for (const suffix of ["", "?token=invalid", requestToken(expired)]) { + const response = await app.request( + `http://openbot.test/api/external-links/slack${suffix}`, + ); + expect(response.status).toBe(400); + expect(await response.json()).toEqual({ error: INVALID }); + } + }); + + test("POST uses the authenticated actor rather than a body-supplied user id", async () => { + const { app, rows, store } = appFor(); + const token = await liveToken(); + + const response = await app.request( + "http://openbot.test/api/external-links/slack", + { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ token, openbotUserId: "forged-user" }), + }, + ); + + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ linked: true }); + expect(store.links).toEqual([linkFor(actor.id)]); + expect(rows).toEqual([ + { + eventType: "external_identity.linked", + targetType: "user", + targetId: actor.id, + actorUserId: actor.id, + payload: { + provider: "slack", + providerTenantId: "T1", + providerUserId: "U1", + }, + }, + ]); + }); + + test("POST is idempotent without duplicating audit when the store returns an existing same-actor link", async () => { + const { app, store, rows } = appFor(); + const token = await liveToken(); + + for (let call = 0; call < 2; call += 1) { + const response = await app.request( + "http://openbot.test/api/external-links/slack", + { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ token }), + }, + ); + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ linked: true }); + } + + expect(store.links).toEqual([linkFor(actor.id)]); + expect(rows).toHaveLength(1); + }); + + test("rolls back an unauditable creation so a successful retry writes exactly one audit event", async () => { + const rows: AuditEventInput[] = []; + let failuresRemaining = 1; + const auditStore: TransactionalAuditStore = { + insert: async (event) => { + if (failuresRemaining > 0) { + failuresRemaining -= 1; + throw new Error("audit table unavailable"); + } + rows.push(event); + }, + inTransaction: () => auditStore, + }; + const { app, store } = appFor( + fakeStore(), + authenticatedAs(), + rows, + auditStore, + ); + const token = await liveToken(); + const request = () => + app.request("http://openbot.test/api/external-links/slack", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ token }), + }); + + expect((await request()).status).toBe(500); + expect(store.links).toEqual([]); + + expect((await request()).status).toBe(200); + expect((await request()).status).toBe(200); + expect(store.links).toEqual([linkFor(actor.id)]); + expect(rows).toHaveLength(1); + }); + + test("POST refuses a replay for another actor without reassigning the link", async () => { + const { app, store, rows } = appFor(); + const token = await liveToken(); + await app.request("http://openbot.test/api/external-links/slack", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ token }), + }); + + const differentActor = { ...actor, id: "openbot-user-2" } as const; + const replay = appFor(store, authenticatedAs(differentActor), rows).app; + const response = await replay.request( + "http://openbot.test/api/external-links/slack", + { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ token }), + }, + ); + + expect(response.status).toBe(409); + expect(await response.json()).toEqual({ error: CONFLICT }); + expect(store.links).toEqual([linkFor(actor.id)]); + expect(rows).toHaveLength(1); + }); + + test("GET and POST are both rejected without authentication", async () => { + const { app, store } = appFor(fakeStore(), unauthenticated); + const token = await liveToken(); + + const get = await app.request( + `http://openbot.test/api/external-links/slack${requestToken(token)}`, + ); + const post = await app.request( + "http://openbot.test/api/external-links/slack", + { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ token }), + }, + ); + + expect(get.status).toBe(401); + expect(post.status).toBe(401); + expect(store.links).toEqual([]); + }); + + test("POST maps missing, malformed, and expired tokens to the same public refusal", async () => { + const { app } = appFor(); + const expired = await mintExternalLinkToken(identity, KEY, NOW); + const requests = [ + {}, + { body: "{not-json" }, + { body: JSON.stringify({ token: expired }) }, + ]; + + for (const request of requests) { + const response = await app.request( + "http://openbot.test/api/external-links/slack", + { + method: "POST", + headers: { "content-type": "application/json" }, + ...request, + }, + ); + expect(response.status).toBe(400); + expect(await response.json()).toEqual({ error: INVALID }); + } + }); + + test("createApp mounts the optional external link routes under its authenticated API prefix", async () => { + const token = await liveToken(); + const externalLinkRoutes = createExternalLinkRoutes({ + store: fakeStore(), + encryptionKey: KEY, + requireUser: authenticatedAs(), + auditStore: { + insert: async () => undefined, + inTransaction: () => ({ insert: async () => undefined }), + }, + agentProfileStore: fakeAgentProfileStore(), + threadStore: fakeThreadStore(), + }); + const app = createApp( + loadConfig(testEnvironment()), + undefined, + undefined, + ...(Array.from({ length: 20 }) as never[]), + externalLinkRoutes, + ); + + const response = await app.request( + `http://openbot.test/api/external-links/slack${requestToken(token)}`, + ); + + expect(response.status).toBe(200); + }); + + test("keeps the base external link store usable without confirmation-only methods", async () => { + await expect( + baseStore.link({ ...identity, openbotUserId: actor.id }), + ).resolves.toEqual(linkFor(actor.id)); + }); +}); diff --git a/server/tests/external-link-store.integration.test.ts b/server/tests/external-link-store.integration.test.ts new file mode 100644 index 00000000..3d3593aa --- /dev/null +++ b/server/tests/external-link-store.integration.test.ts @@ -0,0 +1,476 @@ +import { afterAll, describe, expect, test } from "bun:test"; +import { randomUUID } from "node:crypto"; +import { and, eq, inArray } from "drizzle-orm"; +import { createDatabase } from "../src/db/client"; +import { + externalUserLinks, + revokedAccess, + userRoles, + users, +} from "../src/db/schema"; +import { createExternalLinkStore } from "../src/external/link-store"; +import { TEST_POOL } from "./support/database"; + +function testDatabaseUrl(): string { + return ( + process.env.DATABASE_URL ?? + "postgres://openbot:openbot@localhost:5432/openbot" + ); +} + +const database = createDatabase(testDatabaseUrl(), TEST_POOL); +const store = createExternalLinkStore(database); +const suite = randomUUID().slice(0, 8); +const createdUsers: string[] = []; +const createdRevocations: string[] = []; +const LINK_CONFLICT_MESSAGE = "That Slack identity is already linked."; + +function userId(label: string): string { + const id = `external_link_${label}_${suite}`; + createdUsers.push(id); + return id; +} + +function email(label: string): string { + return `${label}_${suite}@example.test`; +} + +async function createUser(input: { + id: string; + email: string; + name: string; + emailVerified?: boolean; +}) { + await database.insert(users).values({ + ...input, + emailVerified: input.emailVerified ?? true, + }); +} + +function expectLinkConflict(error: unknown): void { + expect(error).toBeInstanceOf(Error); + if (error instanceof Error) { + expect(error.message).toBe(LINK_CONFLICT_MESSAGE); + } +} + +afterAll(async () => { + if (createdRevocations.length) { + await database + .delete(revokedAccess) + .where(inArray(revokedAccess.email, createdRevocations)); + } + if (createdUsers.length) { + await database.delete(users).where(inArray(users.id, createdUsers)); + } + await database.$client.end(); +}); + +describe("external user links", () => { + test("links one Slack identity to one OpenBot user and reads its provider email", async () => { + const openbotUserId = userId("linked"); + const teamId = `T${suite}`; + await createUser({ + id: openbotUserId, + email: email("linked"), + name: "Linked person", + }); + + await store.link({ + provider: "slack", + providerTenantId: teamId, + providerUserId: "U123", + openbotUserId, + providerEmail: "person@example.com", + }); + + expect(await store.find("slack", teamId, "U123")).toMatchObject({ + provider: "slack", + providerTenantId: teamId, + providerUserId: "U123", + openbotUserId, + providerEmail: "person@example.com", + }); + }); + + test("idempotently links the same provider identity to the same OpenBot user", async () => { + const openbotUserId = userId("idempotent"); + const teamId = `T${suite}`; + await createUser({ + id: openbotUserId, + email: email("idempotent"), + name: "Idempotent person", + }); + + const input = { + provider: "slack" as const, + providerTenantId: teamId, + providerUserId: "U456", + openbotUserId, + providerEmail: "person@example.com", + }; + const linked = await store.link(input); + const repeated = await store.link(input); + + expect(repeated).toEqual(linked); + }); + + test("reports whether this call created an external link without changing link compatibility", async () => { + const openbotUserId = userId("creation_status"); + const teamId = `T${suite}`; + await createUser({ + id: openbotUserId, + email: email("creation_status"), + name: "Creation status person", + }); + const input = { + provider: "slack" as const, + providerTenantId: teamId, + providerUserId: "U457", + openbotUserId, + providerEmail: "person@example.com", + }; + + const first = await store.linkWithStatus(input); + const repeated = await store.linkWithStatus(input); + + expect(first).toMatchObject({ created: true, link: input }); + expect(repeated).toEqual({ link: first.link, created: false }); + await expect(store.link(input)).resolves.toEqual(first.link); + }); + + test("reports exactly one creator when identical confirmations race", async () => { + const openbotUserId = userId("creation_status_race"); + const teamId = `T${suite}`; + await createUser({ + id: openbotUserId, + email: email("creation_status_race"), + name: "Creation status race person", + }); + const input = { + provider: "slack" as const, + providerTenantId: teamId, + providerUserId: "U458", + openbotUserId, + providerEmail: "person@example.com", + }; + + const results = await Promise.all([ + store.linkWithStatus(input), + store.linkWithStatus(input), + ]); + + expect(results.filter((result) => result.created)).toHaveLength(1); + expect(results.map((result) => result.link)).toEqual([ + results[0]?.link, + results[0]?.link, + ]); + }); + + test("rolls back a new link when its atomic audit write fails", async () => { + const openbotUserId = userId("audit_rollback"); + const teamId = `T${suite}`; + await createUser({ + id: openbotUserId, + email: email("audit_rollback"), + name: "Audit rollback person", + }); + const input = { + provider: "slack" as const, + providerTenantId: teamId, + providerUserId: "U459", + openbotUserId, + providerEmail: "person@example.com", + }; + + await expect( + store.linkWithStatusAndAudit(input, async () => { + throw new Error("audit table unavailable"); + }), + ).rejects.toThrow("audit table unavailable"); + await expect(store.find("slack", teamId, "U459")).resolves.toBeNull(); + + await expect( + store.linkWithStatusAndAudit(input, async () => undefined), + ).resolves.toMatchObject({ created: true, link: input }); + }); + + test("never silently reassigns an existing provider identity to another user", async () => { + const firstUserId = userId("first"); + const secondUserId = userId("second"); + const teamId = `T${suite}`; + await createUser({ + id: firstUserId, + email: email("first"), + name: "First person", + }); + await createUser({ + id: secondUserId, + email: email("second"), + name: "Second person", + }); + await store.link({ + provider: "slack", + providerTenantId: teamId, + providerUserId: "U789", + openbotUserId: firstUserId, + providerEmail: "first@example.com", + }); + + const error = await store + .link({ + provider: "slack", + providerTenantId: teamId, + providerUserId: "U789", + openbotUserId: secondUserId, + providerEmail: "second@example.com", + }) + .catch((reason: unknown) => reason); + expectLinkConflict(error); + expect(await store.find("slack", teamId, "U789")).toMatchObject({ + openbotUserId: firstUserId, + providerEmail: "first@example.com", + }); + }); + + test("rejects a second Slack identity claiming an OpenBot user in the same workspace", async () => { + const openbotUserId = userId("one_identity"); + const teamId = `T${suite}`; + await createUser({ + id: openbotUserId, + email: email("one_identity"), + name: "One identity person", + }); + await store.link({ + provider: "slack", + providerTenantId: teamId, + providerUserId: "U901", + openbotUserId, + providerEmail: "first@example.com", + }); + + const error = await store + .link({ + provider: "slack", + providerTenantId: teamId, + providerUserId: "U902", + openbotUserId, + providerEmail: "second@example.com", + }) + .catch((reason: unknown) => reason); + + expectLinkConflict(error); + const links = await database + .select() + .from(externalUserLinks) + .where( + and( + eq(externalUserLinks.provider, "slack"), + eq(externalUserLinks.providerTenantId, teamId), + eq(externalUserLinks.openbotUserId, openbotUserId), + ), + ); + expect(links).toHaveLength(1); + expect(links[0]?.providerUserId).toBe("U901"); + }); + + test("returns one public conflict when Slack identities race for one OpenBot user", async () => { + const openbotUserId = userId("racing_identities"); + const teamId = `T${suite}`; + await createUser({ + id: openbotUserId, + email: email("racing_identities"), + name: "Racing identity person", + }); + + const results = await Promise.allSettled([ + store.link({ + provider: "slack", + providerTenantId: teamId, + providerUserId: "U903", + openbotUserId, + providerEmail: "first@example.com", + }), + store.link({ + provider: "slack", + providerTenantId: teamId, + providerUserId: "U904", + openbotUserId, + providerEmail: "second@example.com", + }), + ]); + + const successes = results.filter((result) => result.status === "fulfilled"); + const failures = results.filter( + (result): result is PromiseRejectedResult => result.status === "rejected", + ); + expect(successes).toHaveLength(1); + expect(failures).toHaveLength(1); + expectLinkConflict(failures[0]?.reason); + + const links = await database + .select() + .from(externalUserLinks) + .where( + and( + eq(externalUserLinks.provider, "slack"), + eq(externalUserLinks.providerTenantId, teamId), + eq(externalUserLinks.openbotUserId, openbotUserId), + ), + ); + expect(links).toHaveLength(1); + }); + + test("returns one public conflict when OpenBot users race for one Slack identity", async () => { + const firstUserId = userId("racing_identity_first"); + const secondUserId = userId("racing_identity_second"); + const teamId = `T${suite}`; + await createUser({ + id: firstUserId, + email: email("racing_identity_first"), + name: "First racing identity person", + }); + await createUser({ + id: secondUserId, + email: email("racing_identity_second"), + name: "Second racing identity person", + }); + + const results = await Promise.allSettled([ + store.link({ + provider: "slack", + providerTenantId: teamId, + providerUserId: "U905", + openbotUserId: firstUserId, + providerEmail: "first@example.com", + }), + store.link({ + provider: "slack", + providerTenantId: teamId, + providerUserId: "U905", + openbotUserId: secondUserId, + providerEmail: "second@example.com", + }), + ]); + + const successes = results.filter((result) => result.status === "fulfilled"); + const failures = results.filter( + (result): result is PromiseRejectedResult => result.status === "rejected", + ); + expect(successes).toHaveLength(1); + expect(failures).toHaveLength(1); + expectLinkConflict(failures[0]?.reason); + + const links = await database + .select() + .from(externalUserLinks) + .where( + and( + eq(externalUserLinks.provider, "slack"), + eq(externalUserLinks.providerTenantId, teamId), + eq(externalUserLinks.providerUserId, "U905"), + ), + ); + expect(links).toHaveLength(1); + }); + + test("finds exactly one active verified OpenBot user by a normalized email", async () => { + const openbotUserId = userId("normalized"); + const address = email("normalized"); + await createUser({ + id: openbotUserId, + email: address, + name: "Normalized person", + }); + + await expect( + store.findVerifiedUserByEmail(` ${address.toUpperCase()} `), + ).resolves.toEqual({ id: openbotUserId, name: "Normalized person" }); + }); + + test("does not find an unverified OpenBot user by email", async () => { + const openbotUserId = userId("unverified"); + const address = email("unverified"); + await createUser({ + id: openbotUserId, + email: address, + name: "Unverified person", + emailVerified: false, + }); + + await expect(store.findVerifiedUserByEmail(address)).resolves.toBeNull(); + }); + + test("excludes a user whose lower-cased email is revoked", async () => { + const openbotUserId = userId("revoked"); + const address = email("revoked"); + await createUser({ + id: openbotUserId, + email: address.toUpperCase(), + name: "Revoked person", + }); + const normalized = address.toLowerCase(); + createdRevocations.push(normalized); + await database + .insert(revokedAccess) + .values({ email: normalized, revokedBy: "test" }); + + await expect(store.findVerifiedUserByEmail(address)).resolves.toBeNull(); + }); + + test("returns null for ambiguous active verified users", async () => { + const firstUserId = userId("ambiguous_first"); + const secondUserId = userId("ambiguous_second"); + const address = email("ambiguous"); + await createUser({ + id: firstUserId, + email: address, + name: "First ambiguous person", + }); + await createUser({ + id: secondUserId, + email: address.toUpperCase(), + name: "Second ambiguous person", + }); + + await expect(store.findVerifiedUserByEmail(address)).resolves.toBeNull(); + }); + + test("reloads an active user with its effective role and refuses revoked or roleless accounts", async () => { + const adminId = userId("active_admin"); + const rolelessId = userId("active_roleless"); + const revokedId = userId("active_revoked"); + const revokedEmail = email("active_revoked"); + await Promise.all([ + createUser({ + id: adminId, + email: email("active_admin"), + name: "Active admin", + }), + createUser({ + id: rolelessId, + email: email("active_roleless"), + name: "Roleless", + }), + createUser({ id: revokedId, email: revokedEmail, name: "Revoked" }), + ]); + await database.insert(userRoles).values([ + { userId: adminId, role: "user" }, + { userId: adminId, role: "admin" }, + { userId: revokedId, role: "user" }, + ]); + const normalizedRevokedEmail = revokedEmail.toLowerCase(); + createdRevocations.push(normalizedRevokedEmail); + await database + .insert(revokedAccess) + .values({ email: normalizedRevokedEmail, revokedBy: "test" }); + + await expect(store.resolveActiveUser(adminId)).resolves.toEqual({ + id: adminId, + name: "Active admin", + role: "admin", + }); + await expect(store.resolveActiveUser(rolelessId)).resolves.toBeNull(); + await expect(store.resolveActiveUser(revokedId)).resolves.toBeNull(); + }); +}); diff --git a/server/tests/external-link-token.test.ts b/server/tests/external-link-token.test.ts new file mode 100644 index 00000000..b8c62a91 --- /dev/null +++ b/server/tests/external-link-token.test.ts @@ -0,0 +1,193 @@ +import { describe, expect, test } from "bun:test"; +import { seal } from "../src/auth/signed-value"; +import { + EXTERNAL_LINK_TTL_MS, + mintExternalLinkToken, + readExternalLinkToken, +} from "../src/external/link-token"; + +const KEY = "external-link-token-test-key"; +const NOW = 1_700_000_000_000; +const INVALID = "This Slack link has expired or is invalid."; +const identity = { + provider: "slack" as const, + providerTenantId: "T1", + providerUserId: "U1", + providerEmail: "person@example.com", +}; +const VALID_NONCE = "f47ac10b-58cc-4372-a567-0e02b2c3d479"; + +async function expectInvalid(token: string, key = KEY, now = NOW) { + await expect(readExternalLinkToken(token, key, now)).rejects.toThrow(INVALID); + await expect(readExternalLinkToken(token, key, now)).rejects.toThrowError( + new Error(INVALID), + ); +} + +function forgedClaim(overrides: Record = {}) { + return { + ...identity, + issuedAt: NOW, + expiresAt: NOW + EXTERNAL_LINK_TTL_MS, + nonce: VALID_NONCE, + ...overrides, + }; +} + +async function sealForgedClaim(overrides: Record = {}) { + return seal(JSON.stringify(forgedClaim(overrides)), KEY, "external-link:v1"); +} + +describe("external Slack link tokens", () => { + test("opens a live claim to only its provider identity", async () => { + const token = await mintExternalLinkToken(identity, KEY, NOW); + + expect(await readExternalLinkToken(token, KEY, NOW)).toEqual(identity); + }); + + test("mints a structurally wider identity as the exact approved claim shape", async () => { + const identityWithDisplayName = { + ...identity, + displayName: "Slack Person", + }; + const token = await mintExternalLinkToken( + identityWithDisplayName, + KEY, + NOW, + ); + + expect(await readExternalLinkToken(token, KEY, NOW)).toEqual(identity); + }); + + test("expires after its ten minute TTL", async () => { + const token = await mintExternalLinkToken(identity, KEY, NOW); + + await expectInvalid(token, KEY, NOW + EXTERNAL_LINK_TTL_MS + 1); + }); + + test("remains valid exactly at its expiry boundary", async () => { + const token = await mintExternalLinkToken(identity, KEY, NOW); + + expect( + await readExternalLinkToken(token, KEY, NOW + EXTERNAL_LINK_TTL_MS), + ).toEqual(identity); + }); + + test("refuses an altered claim", async () => { + const token = await mintExternalLinkToken(identity, KEY, NOW); + const altered = `${token.slice(0, -1)}${token.endsWith("A") ? "B" : "A"}`; + + await expectInvalid(altered); + }); + + test("refuses a claim sealed with another key", async () => { + const token = await mintExternalLinkToken(identity, KEY, NOW); + + await expectInvalid(token, "another-external-link-token-test-key"); + }); + + test.each([ + ["unsealed", "not a sealed value"], + ["malformed JSON", "{not-json"], + ["{}", "a sealed value with no claim fields"], + ["[]", "a sealed array"], + ])("refuses %s", async (value) => { + const token = + value === "unsealed" + ? "not-a-sealed-value" + : await seal(value, KEY, "external-link:v1"); + + await expectInvalid(token); + }); + + test("requires the Slack provider binding", async () => { + await expectInvalid(await sealForgedClaim({ provider: "github" })); + }); + + test.each([ + ["providerTenantId", ""], + ["providerTenantId", " "], + ["providerUserId", ""], + ["providerUserId", " "], + ])("refuses an empty %s", async (field, value) => { + await expectInvalid(await sealForgedClaim({ [field]: value })); + }); + + test.each([ + ["a string issued timestamp", { issuedAt: "now" }], + ["a string expiry timestamp", { expiresAt: "later" }], + ["an empty nonce", { nonce: "" }], + ["a non-string nonce", { nonce: 42 }], + ["a non-string provider email", { providerEmail: 42 }], + ])("refuses a malformed claim with %s", async (_reason, overrides) => { + await expectInvalid(await sealForgedClaim(overrides)); + }); + + test("refuses a claim that expires before it was issued", async () => { + await expectInvalid(await sealForgedClaim({ expiresAt: NOW - 1 })); + }); + + test.each([ + ["a non-UUID nonce", { nonce: "nonce" }, NOW], + [ + "an issued time after the reader clock", + { issuedAt: NOW + 1, expiresAt: NOW + EXTERNAL_LINK_TTL_MS + 1 }, + NOW, + ], + [ + "a lifetime longer than ten minutes", + { expiresAt: NOW + EXTERNAL_LINK_TTL_MS + 1 }, + NOW, + ], + [ + "a lifetime shorter than ten minutes", + { expiresAt: NOW + EXTERNAL_LINK_TTL_MS - 1 }, + NOW, + ], + [ + "a fractional issued time", + { issuedAt: NOW + 0.5, expiresAt: NOW + EXTERNAL_LINK_TTL_MS + 0.5 }, + NOW + 1, + ], + [ + "a fractional expiry time", + { expiresAt: NOW + EXTERNAL_LINK_TTL_MS + 0.5 }, + NOW, + ], + ])("refuses a forged claim with %s", async (_reason, overrides, readNow) => { + await expectInvalid(await sealForgedClaim(overrides), KEY, readNow); + }); + + test.each(["openbotUserId", "unexpected"])( + "refuses a claim with an extra %s key", + async (key) => { + await expectInvalid(await sealForgedClaim({ [key]: "forged" })); + }, + ); + + test.each([ + "provider", + "providerTenantId", + "providerUserId", + "providerEmail", + "issuedAt", + "expiresAt", + "nonce", + ])("refuses a claim missing %s", async (key) => { + const claim: Record = forgedClaim(); + delete claim[key]; + const token = await seal(JSON.stringify(claim), KEY, "external-link:v1"); + + await expectInvalid(token); + }); + + test("refuses a claim sealed for another domain label", async () => { + const token = await seal( + JSON.stringify(forgedClaim()), + KEY, + "agent-callback", + ); + + await expectInvalid(token); + }); +}); diff --git a/server/tests/external-thread-store.integration.test.ts b/server/tests/external-thread-store.integration.test.ts new file mode 100644 index 00000000..3cd9972d --- /dev/null +++ b/server/tests/external-thread-store.integration.test.ts @@ -0,0 +1,844 @@ +import { afterAll, beforeAll, describe, expect, test } from "bun:test"; +import { randomUUID } from "node:crypto"; +import { eq, sql } from "drizzle-orm"; +import { createDatabase } from "../src/db/client"; +import { agents, externalThreadMessages, users } from "../src/db/schema"; +import { + createExternalThreadStore, + type ExternalThreadBindingInput, + ExternalThreadConflictError, +} from "../src/external/thread-store"; +import { TEST_POOL } from "./support/database"; + +const database = createDatabase( + process.env.DATABASE_URL ?? + "postgres://openbot:openbot@localhost:5432/openbot", + TEST_POOL, +); +const store = createExternalThreadStore(database); +const suite = randomUUID().slice(0, 8); +const creatorId = `external_thread_creator_${suite}`; +const otherCreatorId = `external_thread_other_creator_${suite}`; +const paginationCreatorId = `external_thread_page_creator_${suite}`; +const accessFilterCreatorId = `external_thread_access_creator_${suite}`; +const previewCreatorId = `external_thread_preview_creator_${suite}`; +const foreignKeyCreatorId = `external_thread_fk_creator_${suite}`; +const riskAgentId = `external_thread_risk_${suite}`; +const knowledgeAgentId = `external_thread_knowledge_${suite}`; +const foreignKeyAgentId = `external_thread_fk_agent_${suite}`; + +function encodeCursor(cursor: { recency: string; threadId: string }): string { + return Buffer.from(JSON.stringify(cursor), "utf8").toString("base64url"); +} + +function binding( + label: string, + overrides: Partial = {}, +): ExternalThreadBindingInput { + return { + channelsThreadId: `channels_${label}_${suite}`, + provider: "slack", + providerTenantId: `T${suite}`, + providerConversationId: `C${label}_${suite}`, + providerThreadId: `P${label}_${suite}`, + agentId: riskAgentId, + agentName: "Risk Analyst", + createdByUserId: creatorId, + ...overrides, + }; +} + +function expectAssignedToRisk(error: unknown): void { + expect(error).toBeInstanceOf(ExternalThreadConflictError); + if (error instanceof ExternalThreadConflictError) { + expect(error.agentName).toBe("Risk Analyst"); + expect(error.message).toBe( + "This Slack thread is already assigned to Risk Analyst.", + ); + } +} + +async function concurrentBinds( + left: ExternalThreadBindingInput, + right: ExternalThreadBindingInput, +) { + const databaseUrl = + process.env.DATABASE_URL ?? + "postgres://openbot:openbot@localhost:5432/openbot"; + const leftDatabase = createDatabase(databaseUrl, { max: 1 }); + const rightDatabase = createDatabase(databaseUrl, { max: 1 }); + try { + return await Promise.allSettled([ + createExternalThreadStore(leftDatabase).bind(left), + createExternalThreadStore(rightDatabase).bind(right), + ]); + } finally { + await leftDatabase.$client.end(); + await rightDatabase.$client.end(); + } +} + +function sqlState(error: unknown): string | undefined { + let current: unknown = error; + for (let depth = 0; depth < 5 && current; depth += 1) { + const candidate = current as { + cause?: unknown; + code?: unknown; + errno?: unknown; + }; + if ( + typeof candidate.code === "string" && + /^[0-9A-Z]{5}$/.test(candidate.code) + ) { + return candidate.code; + } + if ( + typeof candidate.errno === "string" && + /^[0-9A-Z]{5}$/.test(candidate.errno) + ) { + return candidate.errno; + } + current = candidate.cause; + } + return undefined; +} + +function errorText(error: unknown): string { + const messages: string[] = []; + let current: unknown = error; + for (let depth = 0; depth < 5 && current; depth += 1) { + const candidate = current as { cause?: unknown; message?: unknown }; + if (typeof candidate.message === "string") messages.push(candidate.message); + current = candidate.cause; + } + return messages.join("\n"); +} + +async function expectSqlState( + work: () => Promise, + state: string, +): Promise { + const error = await work().catch((reason: unknown) => reason); + expect(error).toBeInstanceOf(Error); + expect(sqlState(error)).toBe(state); +} + +beforeAll(async () => { + await database.insert(users).values([ + { id: creatorId, email: `${creatorId}@example.test` }, + { id: otherCreatorId, email: `${otherCreatorId}@example.test` }, + { + id: paginationCreatorId, + email: `${paginationCreatorId}@example.test`, + }, + { + id: accessFilterCreatorId, + email: `${accessFilterCreatorId}@example.test`, + }, + { + id: previewCreatorId, + email: `${previewCreatorId}@example.test`, + }, + { + id: foreignKeyCreatorId, + email: `${foreignKeyCreatorId}@example.test`, + }, + ]); + await database.insert(agents).values([ + { + id: riskAgentId, + name: "Risk Analyst", + type: "remote_ag_ui", + configuration: {}, + }, + { + id: knowledgeAgentId, + name: "Knowledge Analyst", + type: "remote_ag_ui", + configuration: {}, + }, + { + id: foreignKeyAgentId, + name: "Foreign Key Analyst", + type: "remote_ag_ui", + configuration: {}, + }, + ]); +}); + +afterAll(async () => { + /* The migration makes production rows append-only; test fixtures are removed only with the user + * trigger temporarily disabled, after direct DELETE rejection has already been proved below. */ + await database.transaction(async (transaction) => { + await transaction.execute( + sql`ALTER TABLE "external_thread_bindings" DISABLE TRIGGER USER`, + ); + await transaction.execute(sql` + DELETE FROM "external_thread_bindings" + WHERE "created_by_user_id" IN ( + ${creatorId}, + ${otherCreatorId}, + ${paginationCreatorId}, + ${accessFilterCreatorId}, + ${previewCreatorId}, + ${foreignKeyCreatorId} + ) + `); + await transaction.execute( + sql`ALTER TABLE "external_thread_bindings" ENABLE TRIGGER USER`, + ); + }); + await database.delete(agents).where(eq(agents.id, riskAgentId)); + await database.delete(agents).where(eq(agents.id, knowledgeAgentId)); + await database.delete(agents).where(eq(agents.id, foreignKeyAgentId)); + await database.delete(users).where(eq(users.id, creatorId)); + await database.delete(users).where(eq(users.id, otherCreatorId)); + await database.delete(users).where(eq(users.id, paginationCreatorId)); + await database.delete(users).where(eq(users.id, accessFilterCreatorId)); + await database.delete(users).where(eq(users.id, previewCreatorId)); + await database.delete(users).where(eq(users.id, foreignKeyCreatorId)); + await database.$client.end(); +}); + +describe("external thread bindings", () => { + test("binds and reloads a canonical Channels thread id", async () => { + const input = binding("canonical"); + const bound = await store.bind(input); + + await expect( + store.getByChannelsThreadId(input.channelsThreadId), + ).resolves.toEqual(bound); + }); + + test("appends provider-visible turns idempotently and reads them in order", async () => { + const input = binding("transcript"); + await store.bind(input); + const turn = { + channelsThreadId: input.channelsThreadId, + user: { id: "user-1", role: "user" as const, content: "Hello" }, + assistant: { + id: "reply-1", + role: "assistant" as const, + content: "Hi there", + }, + }; + + await store.appendTranscriptTurn(turn); + await store.appendTranscriptTurn(turn); + + await expect(store.getTranscript(input.channelsThreadId)).resolves.toEqual([ + turn.user, + turn.assistant, + ]); + }); + + test("lists only the creator's Slack threads with latest activity first", async () => { + const older = binding("list_older"); + const newer = binding("list_newer"); + const foreign = binding("list_foreign", { + createdByUserId: otherCreatorId, + }); + await store.bind(older); + await store.bind(newer); + await store.bind(foreign); + await database.insert(externalThreadMessages).values([ + { + channelsThreadId: older.channelsThreadId, + messageId: "older-user", + role: "user", + content: "old line", + createdAt: new Date("2099-08-28T10:00:00.000Z"), + }, + { + channelsThreadId: newer.channelsThreadId, + messageId: "newer-lower-sequence-later-time", + role: "user", + content: "wrong by timestamp", + createdAt: new Date("2099-08-28T12:00:00.000Z"), + }, + { + channelsThreadId: newer.channelsThreadId, + messageId: "newer-higher-sequence-earlier-time", + role: "assistant", + content: "newest\nline", + createdAt: new Date("2099-08-28T11:00:00.000Z"), + }, + { + channelsThreadId: foreign.channelsThreadId, + messageId: "foreign-user", + role: "user", + content: "must stay private", + createdAt: new Date("2099-08-28T12:00:00.000Z"), + }, + ]); + + const page = await store.listForCreator(creatorId, { limit: 20 }); + + const listed = page.threads.filter((thread) => + [older.channelsThreadId, newer.channelsThreadId].includes( + thread.threadId, + ), + ); + expect(listed.map((thread) => thread.threadId)).toEqual([ + newer.channelsThreadId, + older.channelsThreadId, + ]); + expect(listed[0]?.lastMessage).toBe("newest line"); + expect(listed[0]?.lastMessageAt).toEqual( + new Date("2099-08-28T11:00:00.000Z"), + ); + expect( + page.threads.some( + (thread) => thread.threadId === foreign.channelsThreadId, + ), + ).toBe(false); + }); + + test("paginates Slack thread summaries with an opaque malformed-safe cursor", async () => { + const first = binding("page_first", { + createdByUserId: paginationCreatorId, + }); + const second = binding("page_second", { + createdByUserId: paginationCreatorId, + }); + const third = binding("page_third", { + createdByUserId: paginationCreatorId, + }); + await store.bind(first); + await store.bind(second); + await store.bind(third); + await database.insert(externalThreadMessages).values([ + { + channelsThreadId: first.channelsThreadId, + messageId: "page-first-user", + role: "user", + content: "first page first", + createdAt: new Date("2099-08-29T13:00:00.000Z"), + }, + { + channelsThreadId: second.channelsThreadId, + messageId: "page-second-user", + role: "user", + content: "first page second", + createdAt: new Date("2099-08-29T12:00:00.000Z"), + }, + { + channelsThreadId: third.channelsThreadId, + messageId: "page-third-user", + role: "user", + content: "second page", + createdAt: new Date("2099-08-29T11:00:00.000Z"), + }, + ]); + + const pageOne = await store.listForCreator(paginationCreatorId, { + limit: 2, + }); + const pageTwo = await store.listForCreator(paginationCreatorId, { + limit: 2, + cursor: pageOne.nextCursor ?? undefined, + }); + const malformedCursorPage = await store.listForCreator( + paginationCreatorId, + { + limit: 2, + cursor: "malformed", + }, + ); + + expect(pageOne.threads.map((thread) => thread.threadId)).toEqual([ + first.channelsThreadId, + second.channelsThreadId, + ]); + expect(pageOne.nextCursor).not.toBeNull(); + expect(pageTwo.threads.map((thread) => thread.threadId)).toEqual([ + third.channelsThreadId, + ]); + expect( + new Set([ + ...pageOne.threads.map((thread) => thread.threadId), + ...pageTwo.threads.map((thread) => thread.threadId), + ]).size, + ).toBe(pageOne.threads.length + pageTwo.threads.length); + expect( + malformedCursorPage.threads.map((thread) => thread.threadId), + ).toEqual(pageOne.threads.map((thread) => thread.threadId)); + }); + + test("filters allowed agent ids before applying the page limit", async () => { + const allowed = binding("allowed_agent_before_limit", { + createdByUserId: accessFilterCreatorId, + agentId: riskAgentId, + agentName: "Risk Analyst", + }); + const blocked = binding("blocked_agent_before_limit", { + createdByUserId: accessFilterCreatorId, + agentId: knowledgeAgentId, + agentName: "Knowledge Analyst", + }); + await store.bind(allowed); + await store.bind(blocked); + await database.insert(externalThreadMessages).values([ + { + channelsThreadId: blocked.channelsThreadId, + messageId: "blocked-agent-before-limit-user", + role: "user", + content: "blocked newest", + createdAt: new Date("2099-09-02T12:00:00.000Z"), + }, + { + channelsThreadId: allowed.channelsThreadId, + messageId: "allowed-agent-before-limit-user", + role: "user", + content: "allowed older", + createdAt: new Date("2099-09-02T11:00:00.000Z"), + }, + ]); + + const page = await store.listForCreator(accessFilterCreatorId, { + agentIds: [riskAgentId], + limit: 1, + }); + + expect(page.threads.map((thread) => thread.threadId)).toEqual([ + allowed.channelsThreadId, + ]); + expect(page.nextCursor).toBeNull(); + }); + + test("returns an empty page when the allowed agent id set is empty", async () => { + const page = await store.listForCreator(accessFilterCreatorId, { + agentIds: [], + limit: 1, + }); + + expect(page).toEqual({ threads: [], nextCursor: null }); + }); + + test("treats noncanonical parseable cursor dates as the first page", async () => { + const first = binding("noncanonical_cursor_first", { + createdByUserId: paginationCreatorId, + }); + const second = binding("noncanonical_cursor_second", { + createdByUserId: paginationCreatorId, + }); + await store.bind(first); + await store.bind(second); + await database.insert(externalThreadMessages).values([ + { + channelsThreadId: first.channelsThreadId, + messageId: "noncanonical-cursor-first-user", + role: "user", + content: "first", + createdAt: new Date("2099-08-30T12:00:00.000Z"), + }, + { + channelsThreadId: second.channelsThreadId, + messageId: "noncanonical-cursor-second-user", + role: "user", + content: "second", + createdAt: new Date("2099-08-30T11:00:00.000Z"), + }, + ]); + + const firstPage = await store.listForCreator(paginationCreatorId, { + limit: 2, + }); + const tamperedPage = await store.listForCreator(paginationCreatorId, { + limit: 2, + cursor: encodeCursor({ recency: "0", threadId: first.channelsThreadId }), + }); + + expect(tamperedPage.threads.map((thread) => thread.threadId)).toEqual( + firstPage.threads.map((thread) => thread.threadId), + ); + }); + + test("treats signed out-of-range cursor years as the first page", async () => { + const first = binding("signed_cursor_first", { + createdByUserId: paginationCreatorId, + }); + const second = binding("signed_cursor_second", { + createdByUserId: paginationCreatorId, + }); + await store.bind(first); + await store.bind(second); + await database.insert(externalThreadMessages).values([ + { + channelsThreadId: first.channelsThreadId, + messageId: "signed-cursor-first-user", + role: "user", + content: "first", + createdAt: new Date("2099-08-31T12:00:00.000Z"), + }, + { + channelsThreadId: second.channelsThreadId, + messageId: "signed-cursor-second-user", + role: "user", + content: "second", + createdAt: new Date("2099-08-31T11:00:00.000Z"), + }, + ]); + + const firstPage = await store.listForCreator(paginationCreatorId, { + limit: 2, + }); + const tamperedPage = await store.listForCreator(paginationCreatorId, { + limit: 2, + cursor: encodeCursor({ + recency: "+275760-09-13T00:00:00.000Z", + threadId: first.channelsThreadId, + }), + }); + + expect(tamperedPage.threads.map((thread) => thread.threadId)).toEqual( + firstPage.threads.map((thread) => thread.threadId), + ); + }); + + test("treats canonical year zero cursor dates as the first page", async () => { + const first = binding("year_zero_cursor_first", { + createdByUserId: paginationCreatorId, + }); + const second = binding("year_zero_cursor_second", { + createdByUserId: paginationCreatorId, + }); + await store.bind(first); + await store.bind(second); + await database.insert(externalThreadMessages).values([ + { + channelsThreadId: first.channelsThreadId, + messageId: "year-zero-cursor-first-user", + role: "user", + content: "first", + createdAt: new Date("2099-09-01T12:00:00.000Z"), + }, + { + channelsThreadId: second.channelsThreadId, + messageId: "year-zero-cursor-second-user", + role: "user", + content: "second", + createdAt: new Date("2099-09-01T11:00:00.000Z"), + }, + ]); + + const firstPage = await store.listForCreator(paginationCreatorId, { + limit: 2, + }); + const tamperedPage = await store.listForCreator(paginationCreatorId, { + limit: 2, + cursor: encodeCursor({ + recency: "0000-01-01T00:00:00.000Z", + threadId: first.channelsThreadId, + }), + }); + + expect(tamperedPage.threads.map((thread) => thread.threadId)).toEqual( + firstPage.threads.map((thread) => thread.threadId), + ); + }); + + test("uses binding creation as the activity fallback without transcript messages", async () => { + const active = binding("activity_message", { + createdByUserId: previewCreatorId, + }); + const empty = binding("activity_empty", { + createdByUserId: previewCreatorId, + }); + await store.bind(active); + await store.bind(empty); + await database.insert(externalThreadMessages).values({ + channelsThreadId: active.channelsThreadId, + messageId: "activity-message-user", + role: "user", + content: "older transcript activity", + createdAt: new Date("2000-01-01T00:00:00.000Z"), + }); + + const page = await store.listForCreator(previewCreatorId, { limit: 10 }); + + const listed = page.threads.filter((thread) => + [empty.channelsThreadId, active.channelsThreadId].includes( + thread.threadId, + ), + ); + expect(listed.map((thread) => thread.threadId)).toEqual([ + empty.channelsThreadId, + active.channelsThreadId, + ]); + expect(listed[0]).toMatchObject({ + threadId: empty.channelsThreadId, + lastMessage: null, + lastMessageAt: null, + }); + expect(listed[0]?.createdAt).toBeInstanceOf(Date); + }); + + test("sanitizes Slack thread previews to a 200-code-point one-line cap", async () => { + const input = binding("preview_cap", { + createdByUserId: previewCreatorId, + }); + await store.bind(input); + await database.insert(externalThreadMessages).values({ + channelsThreadId: input.channelsThreadId, + messageId: "preview-cap-user", + role: "user", + content: `Start\u0001\t\n${"💬".repeat(205)} tail`, + createdAt: new Date("2099-08-30T10:00:00.000Z"), + }); + + const page = await store.listForCreator(previewCreatorId, { limit: 1 }); + + const preview = page.threads[0]?.lastMessage; + expect(preview).toBe(`Start ${"💬".repeat(193)}…`); + expect(preview).not.toContain("\u0001"); + expect(preview).not.toContain("\n"); + expect(preview).not.toContain("\t"); + expect(Array.from(preview ?? "")).toHaveLength(200); + }); + + test("reloads a binding by its provider thread identity", async () => { + const input = binding("provider_lookup"); + const bound = await store.bind(input); + + await expect( + store.getByProviderThread({ + provider: input.provider, + providerTenantId: input.providerTenantId, + providerConversationId: input.providerConversationId, + providerThreadId: input.providerThreadId, + }), + ).resolves.toEqual(bound); + }); + + test("makes the same binding idempotent", async () => { + const input = binding("idempotent"); + const bound = await store.bind(input); + + await expect(store.bind(input)).resolves.toEqual(bound); + }); + + test("never switches an established thread to another agent", async () => { + const input = binding("agent_immutable"); + await store.bind(input); + + const error = await store + .bind({ + ...input, + agentId: knowledgeAgentId, + agentName: "Knowledge Analyst", + }) + .catch((reason: unknown) => reason); + + expectAssignedToRisk(error); + }); + + test("does not let a provider thread create a second canonical binding", async () => { + const input = binding("provider_unique"); + const first = await store.bind(input); + + const error = await store + .bind({ + ...input, + channelsThreadId: `channels_provider_unique_other_${suite}`, + agentId: knowledgeAgentId, + agentName: "Knowledge Analyst", + }) + .catch((reason: unknown) => reason); + + expectAssignedToRisk(error); + await expect( + store.getByProviderThread({ + provider: input.provider, + providerTenantId: input.providerTenantId, + providerConversationId: input.providerConversationId, + providerThreadId: input.providerThreadId, + }), + ).resolves.toEqual(first); + }); + + test("does not let a canonical thread replace its provider identity", async () => { + const input = binding("channels_unique"); + const first = await store.bind(input); + + const error = await store + .bind({ + ...input, + providerConversationId: `Cchannels_unique_other_${suite}`, + providerThreadId: `Pchannels_unique_other_${suite}`, + agentId: knowledgeAgentId, + agentName: "Knowledge Analyst", + }) + .catch((reason: unknown) => reason); + + expectAssignedToRisk(error); + await expect( + store.getByChannelsThreadId(input.channelsThreadId), + ).resolves.toEqual(first); + }); + + test("keeps the winner when first deliveries race", async () => { + const input = binding("agent_race"); + const results = await concurrentBinds(input, { + ...input, + agentId: knowledgeAgentId, + agentName: "Knowledge Analyst", + }); + const [left, right] = results.map((result) => { + if (result.status === "rejected") throw result.reason; + return result.value; + }); + + expect(left.agentId).toBe(right.agentId); + expect([riskAgentId, knowledgeAgentId]).toContain(left.agentId); + }); + + test("fails closed when concurrent calls cross provider identity", async () => { + const input = binding("provider_race"); + const results = await concurrentBinds(input, { + ...input, + channelsThreadId: `channels_provider_race_other_${suite}`, + }); + + expect( + results.filter((result) => result.status === "fulfilled"), + ).toHaveLength(1); + expect( + results.filter((result) => result.status === "rejected"), + ).toHaveLength(1); + }); + + test("fails closed when concurrent calls cross canonical identity", async () => { + const input = binding("canonical_race"); + const results = await concurrentBinds(input, { + ...input, + providerConversationId: `Ccanonical_race_other_${suite}`, + providerThreadId: `Pcanonical_race_other_${suite}`, + }); + + expect( + results.filter((result) => result.status === "fulfilled"), + ).toHaveLength(1); + expect( + results.filter((result) => result.status === "rejected"), + ).toHaveLength(1); + }); + + test("fails closed when first deliveries disagree on the creator", async () => { + const input = binding("creator_race"); + const results = await concurrentBinds(input, { + ...input, + createdByUserId: otherCreatorId, + }); + + expect( + results.filter((result) => result.status === "fulfilled"), + ).toHaveLength(1); + expect( + results.filter((result) => result.status === "rejected"), + ).toHaveLength(1); + }); + + test("refuses two crossed durable identities instead of choosing one", async () => { + const canonical = binding("crossed_canonical"); + const provider = binding("crossed_provider"); + await store.bind(canonical); + await store.bind(provider); + + const error = await store + .bind({ + ...canonical, + providerConversationId: provider.providerConversationId, + providerThreadId: provider.providerThreadId, + }) + .catch((reason: unknown) => reason); + + expect(error).toBeInstanceOf(Error); + if (error instanceof Error) { + expect(error.message).toBe( + "External thread bindings have conflicting identities.", + ); + } + }); + + test("returns the current agent profile name rather than a duplicate binding name", async () => { + const input = binding("current_name"); + await store.bind(input); + await database + .update(agents) + .set({ name: "Renamed Risk Analyst" }) + .where(eq(agents.id, riskAgentId)); + + await expect( + store.getByChannelsThreadId(input.channelsThreadId), + ).resolves.toMatchObject({ agentName: "Renamed Risk Analyst" }); + + await database + .update(agents) + .set({ name: "Risk Analyst" }) + .where(eq(agents.id, riskAgentId)); + }); + + test("restricts deletion of the binding's agent and creator", async () => { + const input = binding("restrict", { + agentId: foreignKeyAgentId, + agentName: "Foreign Key Analyst", + createdByUserId: foreignKeyCreatorId, + }); + await store.bind(input); + + const agentError = await Promise.resolve( + database.delete(agents).where(eq(agents.id, foreignKeyAgentId)), + ).catch((reason: unknown) => reason); + expect(sqlState(agentError)).toBe("23503"); + expect(errorText(agentError)).toContain( + "external_thread_bindings_agent_id_agents_id_fk", + ); + + const userError = await Promise.resolve( + database.delete(users).where(eq(users.id, foreignKeyCreatorId)), + ).catch((reason: unknown) => reason); + expect(sqlState(userError)).toBe("23503"); + expect(errorText(userError)).toContain( + "external_thread_bindings_created_by_user_id_users_id_fk", + ); + }); + + test("the database refuses updates and deletes of bindings", async () => { + const input = binding("append_only"); + await store.bind(input); + + await expectSqlState( + () => + Promise.resolve( + database.execute( + sql`UPDATE "external_thread_bindings" SET "agent_id" = ${knowledgeAgentId} WHERE "channels_thread_id" = ${input.channelsThreadId}`, + ), + ), + "P0001", + ); + await expectSqlState( + () => + Promise.resolve( + database.execute( + sql`DELETE FROM "external_thread_bindings" WHERE "channels_thread_id" = ${input.channelsThreadId}`, + ), + ), + "P0001", + ); + }); + + test("the database refuses a provider other than Slack", async () => { + const input = binding("provider_check"); + + await expectSqlState( + () => + Promise.resolve( + database.execute( + sql`INSERT INTO "external_thread_bindings" ("channels_thread_id", "provider", "provider_tenant_id", "provider_conversation_id", "provider_thread_id", "agent_id", "created_by_user_id") VALUES (${input.channelsThreadId}, 'discord', ${input.providerTenantId}, ${input.providerConversationId}, ${input.providerThreadId}, ${input.agentId}, ${input.createdByUserId})`, + ), + ), + "23514", + ); + }); +}); diff --git a/server/tests/health.test.ts b/server/tests/health.test.ts index 24f112cc..f295253a 100644 --- a/server/tests/health.test.ts +++ b/server/tests/health.test.ts @@ -7,6 +7,36 @@ const app = createApp( loadConfig({ ...testEnvironment(), }), + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + () => ({ + status: "setup_required", + transport: "online", + provider: "not_attached", + token: "managed-provider-secret-must-not-leak", + workspaceId: "T-secret-workspace", + }), ); describe("health endpoint", () => { @@ -31,6 +61,13 @@ describe("runtime capabilities", () => { // A boolean, not a list: naming the registered providers would tell anybody who loads the // sign-in page which companies use this deployment. ssoConfigured: false, + channels: { + slack: { + status: "setup_required", + transport: "online", + provider: "not_attached", + }, + }, }); }); @@ -49,9 +86,12 @@ describe("runtime capabilities", () => { "durableHistory", "authProviders", "ssoConfigured", + "channels", ]); // The provider list is names, never the clients and secrets behind them. expect(body).not.toContain("google-client-secret"); + expect(body).not.toContain("managed-provider-secret-must-not-leak"); + expect(body).not.toContain("T-secret-workspace"); }); }); diff --git a/server/tests/routing-routes.test.ts b/server/tests/routing-routes.test.ts index b974d136..5ac6df66 100644 --- a/server/tests/routing-routes.test.ts +++ b/server/tests/routing-routes.test.ts @@ -6,6 +6,7 @@ import type { AuditStore } from "../src/audit"; import type { AppVariables } from "../src/auth/guards"; import type { IntentRouter, RoutingUndecided } from "../src/routing/classify"; import { createRoutingRoutes } from "../src/routing/routes"; +import { createCoworkerRoutingService } from "../src/routing/service"; /** * Why a conversation went where it went, for every conversation. @@ -32,12 +33,16 @@ const ROSTER = [ name: "Risk Analyst", roleDescription: "regulatory and compliance questions", visibility: "public", + ownerUserId: null, + deletedAt: null, }, { id: "knowledge", name: "Knowledge", roleDescription: "company knowledge", visibility: "public", + ownerUserId: null, + deletedAt: null, }, ]; @@ -87,7 +92,10 @@ function app(options: { routed?: string; undecided?: RoutingUndecided } = {}) { const server = new Hono<{ Variables: AppVariables }>(); server.route( "/api/route", - createRoutingRoutes(store, router, asActor, auditStore), + createRoutingRoutes( + createCoworkerRoutingService({ store, router, auditStore }), + asActor, + ), ); return { server, written, asked }; } @@ -113,9 +121,10 @@ describe("recording which coworker a message went to", () => { }); expect(response.status).toBe(200); - expect(await response.json()).toMatchObject({ + expect(await response.json()).toEqual({ agentId: "risk-analyst", name: "Risk Analyst", + reason: "named by the person asking", viaMention: true, fallback: false, }); diff --git a/server/tests/routing-service.test.ts b/server/tests/routing-service.test.ts new file mode 100644 index 00000000..4b039b50 --- /dev/null +++ b/server/tests/routing-service.test.ts @@ -0,0 +1,576 @@ +import { describe, expect, test } from "bun:test"; +import type { AgentProfileStore } from "../src/agents/profile-store"; +import type { AgentProfile } from "../src/agents/profile-types"; +import type { AuditStore } from "../src/audit"; +import type { + IntentRouter, + RoutingCandidate, + RoutingUndecided, +} from "../src/routing/classify"; +import { + createCoworkerRoutingService, + normalizeCoworkerName, +} from "../src/routing/service"; + +const ACTOR = { id: "u1", role: "user" } as const; + +function profile( + id: string, + name: string, + visibility: "public" | "private" = "public", + ownerUserId: string | null = null, +): AgentProfile { + return { + id, + name, + title: name, + roleDescription: `${name} work`, + avatarSeed: id, + visibility, + ownerUserId, + systemOwned: false, + hidden: false, + deletedAt: null, + endpoint: null, + hasAuth: false, + hasCallbackToken: false, + }; +} + +function makeService( + options: { + roster?: AgentProfile[]; + decision?: { + agentId: string; + reason: string; + fallback: boolean; + undecided: RoutingUndecided | null; + }; + reachableSystems?: (agentId: string) => Promise; + } = {}, +) { + const roster = options.roster ?? [ + profile("risk", "Risk Analyst"), + profile("knowledge", "Knowledge"), + ]; + const modelCalls: Array<{ + text: string; + candidates: readonly RoutingCandidate[]; + defaultId: string; + }> = []; + const audits: Array<{ + payload: Record; + targetId: string | null; + }> = []; + const store = { list: async () => roster } as unknown as AgentProfileStore; + const router = { + route: async ( + text: string, + candidates: readonly RoutingCandidate[], + defaultId: string, + ) => { + modelCalls.push({ text, candidates, defaultId }); + const selected = options.decision ?? { + agentId: "knowledge", + reason: "matches what it is for", + fallback: false, + undecided: null, + }; + const candidate = candidates.find(({ id }) => id === selected.agentId); + return { ...selected, name: candidate?.name ?? selected.agentId }; + }, + } as unknown as IntentRouter; + const auditStore = { + insert: async (event: { + payload: Record; + targetId: string | null; + }) => { + audits.push(event); + }, + } as unknown as AuditStore; + + return { + service: createCoworkerRoutingService({ + store, + router, + auditStore, + reachableSystems: options.reachableSystems, + }), + modelCalls, + audits, + }; +} + +describe("CoworkerRoutingService", () => { + test("routes a unique explicit coworker name without invoking the model", async () => { + const { service, modelCalls } = makeService(); + + const result = await service.route({ + actor: ACTOR, + text: "ask risk analyst to review this", + }); + + expect(result).toMatchObject({ + kind: "selected", + agentId: "risk", + viaMention: true, + }); + expect(modelCalls).toEqual([]); + }); + + test("normalizes explicit names with NFKC, case, and whitespace", async () => { + const { service, modelCalls } = makeService({ + roster: [profile("risk", "Risk Analyst")], + }); + + expect( + await service.route({ actor: ACTOR, text: "Ask risk\tanalyst please" }), + ).toMatchObject({ + kind: "selected", + agentId: "risk", + viaMention: true, + }); + expect(modelCalls).toEqual([]); + }); + + test("does not match a coworker name inside a larger word", async () => { + const { service, modelCalls } = makeService({ + roster: [profile("risk", "Risk")], + }); + + await service.route({ actor: ACTOR, text: "de-risking the portfolio" }); + + expect(modelCalls).toHaveLength(1); + }); + + test("uses Unicode token boundaries instead of ASCII word boundaries", async () => { + const { service, modelCalls } = makeService({ + roster: [profile("risk", "Risk")], + }); + + await service.route({ actor: ACTOR, text: "Risk\u{10400} review" }); + + expect(modelCalls).toHaveLength(1); + }); + + test("returns visible choices for an ambiguous explicit name", async () => { + const { service, modelCalls } = makeService({ + roster: [ + profile("risk", "Risk Analyst"), + profile("data", "Data Analyst"), + ], + }); + + expect( + await service.route({ actor: ACTOR, text: "ask analyst to review this" }), + ).toEqual({ + kind: "ambiguous", + names: ["Data Analyst", "Risk Analyst"], + }); + expect(modelCalls).toEqual([]); + }); + + test("treats a nested full name as ambiguous when its alias belongs to another coworker", async () => { + const { service, modelCalls } = makeService({ + roster: [profile("analyst", "Analyst"), profile("risk", "Risk Analyst")], + }); + + expect( + await service.route({ actor: ACTOR, text: "ask analyst to review this" }), + ).toEqual({ + kind: "ambiguous", + names: ["Analyst", "Risk Analyst"], + }); + expect(modelCalls).toEqual([]); + }); + + test("prefers a unique longer explicit alias over a shared suffix", async () => { + const { service, modelCalls } = makeService({ + roster: [profile("analyst", "Analyst"), profile("risk", "Risk Analyst")], + }); + + expect( + await service.route({ + actor: ACTOR, + text: "ask risk analyst to review this", + }), + ).toMatchObject({ + kind: "selected", + agentId: "risk", + viaMention: true, + }); + expect(modelCalls).toEqual([]); + }); + + test("returns choices when two independent explicit names appear in long-to-short order", async () => { + const { service, modelCalls } = makeService(); + + expect( + await service.route({ + actor: ACTOR, + text: "ask Risk Analyst and Knowledge to review this", + }), + ).toEqual({ + kind: "ambiguous", + names: ["Knowledge", "Risk Analyst"], + }); + expect(modelCalls).toEqual([]); + }); + + test("returns choices when two independent explicit names appear in short-to-long order", async () => { + const { service, modelCalls } = makeService(); + + expect( + await service.route({ + actor: ACTOR, + text: "ask Knowledge and Risk Analyst to review this", + }), + ).toEqual({ + kind: "ambiguous", + names: ["Knowledge", "Risk Analyst"], + }); + expect(modelCalls).toEqual([]); + }); + + test("selects a profile when all explicit mentions refer to that same profile", async () => { + const { service, modelCalls } = makeService(); + + expect( + await service.route({ + actor: ACTOR, + text: "ask Risk Analyst and Risk Analyst to review this", + }), + ).toMatchObject({ kind: "selected", agentId: "risk", viaMention: true }); + expect(modelCalls).toEqual([]); + }); + + test("keeps partially overlapping full names as independent explicit choices", async () => { + const { service, modelCalls } = makeService({ + roster: [profile("ann", "Ann Marie"), profile("curie", "Marie Curie")], + }); + + expect( + await service.route({ actor: ACTOR, text: "ask Ann Marie Curie" }), + ).toEqual({ + kind: "ambiguous", + names: ["Ann Marie", "Marie Curie"], + }); + expect(modelCalls).toEqual([]); + }); + + test("suppresses contained prefix aliases but retains a partially overlapping suffix name", async () => { + const { service, modelCalls } = makeService({ + roster: [ + profile("ann", "Ann"), + profile("ann-marie", "Ann Marie"), + profile("curie", "Marie Curie"), + ], + }); + + expect( + await service.route({ actor: ACTOR, text: "ask Ann Marie Curie" }), + ).toEqual({ + kind: "ambiguous", + names: ["Ann Marie", "Marie Curie"], + }); + expect(modelCalls).toEqual([]); + }); + + test("handles many repeated explicit mentions without changing their selection", async () => { + const { service, modelCalls } = makeService(); + const text = Array.from({ length: 1_500 }, () => "Risk Analyst").join( + " and ", + ); + + expect(await service.route({ actor: ACTOR, text })).toMatchObject({ + kind: "selected", + agentId: "risk", + viaMention: true, + }); + expect(modelCalls).toEqual([]); + }); + + test("labels duplicate normalized names distinctly and resolves a chosen label", async () => { + const { service, modelCalls } = makeService({ + roster: [ + profile("risk-id", "Risk Analyst"), + profile("risk-copy", "Risk Analyst"), + ], + }); + + expect( + await service.route({ + actor: ACTOR, + text: "ask risk analyst to review this", + }), + ).toEqual({ + kind: "ambiguous", + names: [ + "Risk Analyst (id 7269736b2d636f7079)", + "Risk Analyst (id 7269736b2d6964)", + ], + }); + expect( + await service.route({ + actor: ACTOR, + text: "ask Risk Analyst (id 7269736b2d636f7079) to review this", + }), + ).toMatchObject({ + kind: "selected", + agentId: "risk-copy", + viaMention: true, + }); + expect(modelCalls).toEqual([]); + }); + + test("uses stable id labels when duplicate ids differ only by case", async () => { + const roster = [ + profile("risk", "Risk Analyst"), + profile("Risk", "Risk Analyst"), + ]; + const { service, modelCalls } = makeService({ roster }); + + const result = await service.route({ + actor: ACTOR, + text: "ask risk analyst to review this", + }); + expect(result).toEqual({ + kind: "ambiguous", + names: ["Risk Analyst (id 5269736b)", "Risk Analyst (id 7269736b)"], + }); + expect(new Set(result.names.map(normalizeCoworkerName)).size).toBe( + result.names.length, + ); + expect( + await service.route({ + actor: ACTOR, + text: "ask Risk Analyst (id 5269736b) to review this", + }), + ).toMatchObject({ kind: "selected", agentId: "Risk" }); + expect(modelCalls).toEqual([]); + }); + + test("uses NFKC-distinct id labels with an order-independent mapping", async () => { + const optionA = profile("A", "Risk Analyst"); + const optionFullWidthA = profile("A", "Risk Analyst"); + const forward = makeService({ roster: [optionFullWidthA, optionA] }); + const reverse = makeService({ roster: [optionA, optionFullWidthA] }); + + const forwardResult = await forward.service.route({ + actor: ACTOR, + text: "ask risk analyst to review this", + }); + const reverseResult = await reverse.service.route({ + actor: ACTOR, + text: "ask risk analyst to review this", + }); + + expect(forwardResult).toEqual({ + kind: "ambiguous", + names: ["Risk Analyst (id 41)", "Risk Analyst (id efbca1)"], + }); + expect(reverseResult).toEqual(forwardResult); + expect( + await forward.service.route({ + actor: ACTOR, + text: "ask Risk Analyst (id 41) to review this", + }), + ).toMatchObject({ kind: "selected", agentId: "A" }); + expect( + await forward.service.route({ + actor: ACTOR, + text: "ask Risk Analyst (id efbca1) to review this", + }), + ).toMatchObject({ kind: "selected", agentId: "A" }); + }); + + test("keeps a duplicate label bound to its id when new duplicates are added or reordered", async () => { + const idA = profile("a", "Risk Analyst"); + const idB = profile("b", "Risk Analyst"); + const addedEarlier = profile("A", "Risk Analyst"); + const original = makeService({ roster: [idB, idA] }); + const expanded = makeService({ roster: [idB, addedEarlier, idA] }); + const stableLabel = "Risk Analyst (id 62)"; + + expect( + await original.service.route({ + actor: ACTOR, + text: "ask risk analyst to review this", + }), + ).toEqual({ + kind: "ambiguous", + names: ["Risk Analyst (id 61)", stableLabel], + }); + expect( + await expanded.service.route({ + actor: ACTOR, + text: "ask risk analyst to review this", + }), + ).toEqual({ + kind: "ambiguous", + names: ["Risk Analyst (id 41)", "Risk Analyst (id 61)", stableLabel], + }); + expect( + await expanded.service.route({ + actor: ACTOR, + text: `ask ${stableLabel}`, + }), + ).toMatchObject({ kind: "selected", agentId: "b" }); + }); + + test("uses normalization-safe labels for base64url case collisions", async () => { + const first = profile("\u0800", "Risk Analyst"); + const second = profile("\u081A", "Risk Analyst"); + const { service } = makeService({ roster: [second, first] }); + + const result = await service.route({ + actor: ACTOR, + text: "ask risk analyst to review this", + }); + + expect(result).toEqual({ + kind: "ambiguous", + names: ["Risk Analyst (id e0a080)", "Risk Analyst (id e0a09a)"], + }); + expect(new Set(result.names.map(normalizeCoworkerName)).size).toBe( + result.names.length, + ); + expect( + await service.route({ + actor: ACTOR, + text: "ask Risk Analyst (id e0a080) to review this", + }), + ).toMatchObject({ kind: "selected", agentId: "\u0800" }); + expect( + await service.route({ + actor: ACTOR, + text: "ask Risk Analyst (id e0a09a) to review this", + }), + ).toMatchObject({ kind: "selected", agentId: "\u081A" }); + }); + + test("returns none for an absent or empty visible roster", async () => { + const { service } = makeService({ roster: [] }); + + expect(await service.route({ actor: ACTOR, text: "anything" })).toEqual({ + kind: "none", + }); + }); + + test("returns none when an explicit composer id is inaccessible", async () => { + const { service, modelCalls } = makeService({ + roster: [ + profile("risk", "Risk Analyst"), + profile("private", "Private Analyst", "private", "u2"), + ], + }); + + expect( + await service.route({ + actor: ACTOR, + text: "anything", + agentId: "private", + }), + ).toEqual({ kind: "none" }); + expect(modelCalls).toEqual([]); + }); + + test("falls back to intent routing when no explicit name appears", async () => { + const { service, modelCalls } = makeService(); + + expect( + await service.route({ actor: ACTOR, text: "what is our PTO policy" }), + ).toMatchObject({ + kind: "selected", + agentId: "knowledge", + viaMention: false, + }); + expect(modelCalls).toHaveLength(1); + }); + + test("fails safely when coworker reachability cannot be loaded", async () => { + const { service, modelCalls } = makeService({ + reachableSystems: async () => { + throw new Error("private reachability detail"); + }, + }); + + await expect( + service.route({ actor: ACTOR, text: "what is in Drive?" }), + ).rejects.toThrow("Coworker reachability is temporarily unavailable"); + expect(modelCalls).toEqual([]); + }); + + test("passes only the actor-visible roster to intent routing", async () => { + const visible = profile("mine", "My Private", "private", ACTOR.id); + const inaccessible = profile("other", "Other Private", "private", "u2"); + const deleted = { + ...profile("deleted", "Deleted", "public"), + deletedAt: new Date(), + }; + const { service, modelCalls } = makeService({ + roster: [profile("public", "Public"), visible, inaccessible, deleted], + }); + + await service.route({ actor: ACTOR, text: "anything" }); + + expect(modelCalls[0]?.candidates.map(({ id }) => id)).toEqual([ + "public", + "mine", + ]); + }); + + test("writes selected audit fields exactly once without message text", async () => { + const { service, audits } = makeService(); + + await service.route({ actor: ACTOR, text: "private payroll details" }); + + expect(audits).toHaveLength(1); + expect(audits[0]).toMatchObject({ targetId: "knowledge" }); + expect(audits[0]?.payload).toEqual({ + chosen: "knowledge", + reason: "intent match", + fallback: false, + viaMention: false, + candidates: ["risk", "knowledge"], + undecided: null, + }); + expect(JSON.stringify(audits[0])).not.toContain("private payroll details"); + }); + + test("preserves a model fallback audit cause", async () => { + const { service, audits } = makeService({ + decision: { + agentId: "knowledge", + reason: "sent to your default while the router was unreachable", + fallback: true, + undecided: "unreachable", + }, + }); + + await service.route({ actor: ACTOR, text: "anything" }); + + expect(audits[0]?.payload).toMatchObject({ + fallback: true, + undecided: "unreachable", + }); + }); + + test("does not persist a model reason that echoes the message", async () => { + const text = "private payroll details for Sam"; + const { service, audits } = makeService({ + decision: { + agentId: "knowledge", + reason: text, + fallback: false, + undecided: null, + }, + }); + + const result = await service.route({ actor: ACTOR, text }); + + expect(result).toMatchObject({ kind: "selected", reason: text }); + expect(audits[0]?.payload.reason).toBe("intent match"); + expect(JSON.stringify(audits[0])).not.toContain(text); + }); +}); diff --git a/server/tests/schema.test.ts b/server/tests/schema.test.ts index 1a3104e9..7e18068b 100644 --- a/server/tests/schema.test.ts +++ b/server/tests/schema.test.ts @@ -15,6 +15,8 @@ import { channels, credentialKind, credentials, + externalThreadBindings, + externalThreadMessages, intelligenceChannelMappings, mcpUserCredentials, sessions, @@ -161,6 +163,62 @@ describe("OpenBot database schema", () => { ); }); + test("indexes external Slack thread listing and latest-message lookups", () => { + const bindingIndexes = getTableConfig(externalThreadBindings).indexes.map( + (index) => ({ + name: index.config.name, + columns: index.config.columns.map((column) => ({ + name: "name" in column ? column.name : undefined, + order: + "indexConfig" in column && + typeof column.indexConfig === "object" && + column.indexConfig !== null && + "order" in column.indexConfig + ? column.indexConfig.order + : "asc", + })), + unique: index.config.unique, + method: index.config.method, + }), + ); + const messageIndexes = getTableConfig(externalThreadMessages).indexes.map( + (index) => ({ + name: index.config.name, + columns: index.config.columns.map((column) => ({ + name: "name" in column ? column.name : undefined, + order: + "indexConfig" in column && + typeof column.indexConfig === "object" && + column.indexConfig !== null && + "order" in column.indexConfig + ? column.indexConfig.order + : "asc", + })), + unique: index.config.unique, + method: index.config.method, + }), + ); + + expect(bindingIndexes).toContainEqual({ + name: "external_thread_bindings_creator_thread_idx", + columns: [ + { name: "created_by_user_id", order: "asc" }, + { name: "channels_thread_id", order: "asc" }, + ], + unique: false, + method: "btree", + }); + expect(messageIndexes).toContainEqual({ + name: "external_thread_messages_thread_sequence_idx", + columns: [ + { name: "channels_thread_id", order: "asc" }, + { name: "sequence", order: "desc" }, + ], + unique: false, + method: "btree", + }); + }); + test("defines the exact agent profile and roster preference contracts", () => { expect([agentProfiles, agentPreferences].map(getTableName)).toEqual([ "agent_profiles", diff --git a/server/tests/slack-approval-authorizer.test.ts b/server/tests/slack-approval-authorizer.test.ts new file mode 100644 index 00000000..aef343a6 --- /dev/null +++ b/server/tests/slack-approval-authorizer.test.ts @@ -0,0 +1,112 @@ +import { describe, expect, test } from "bun:test"; +import { createApprovalAuthorizer } from "../src/slack/approval-authorizer"; +import type { ApprovalPresentation } from "../src/slack/approval-store"; +import type { LinkedSlackIngress } from "../src/slack/ingress-registry"; + +const presentation: ApprovalPresentation = { + presentationId: "11111111-1111-4111-8111-111111111111", + channelsThreadId: "thread-1", + conversationKey: "conversation-1", + agentId: "risk", + createdByUserId: "creator", + createdAt: new Date(), +}; + +const liveIdentity: LinkedSlackIngress = { + identityContext: { + provider: "slack", + tenant: { id: "tenant-1" }, + installation: { id: "installation-1" }, + actor: { id: "provider-user-1", kind: "human" }, + conversation: { id: "channel-1" }, + trigger: "interaction", + event: { id: "event-1", threadId: "provider-thread-1" }, + raw: null, + }, + identityResult: { + kind: "linked", + user: { id: "u1", name: "u1" }, + actor: { id: "u1", role: "user" }, + identity: { + provider: "slack", + providerTenantId: "tenant-1", + providerUserId: "provider-user-1", + providerEmail: null, + }, + }, +}; + +function authorizer( + options: { + active?: boolean; + boundAgentId?: string | null; + accessible?: boolean; + } = {}, +) { + return createApprovalAuthorizer({ + links: { + resolveActiveUser: async (id) => + options.active === false + ? null + : { id, name: id, role: "user" as const }, + } as never, + threads: { + getByChannelsThreadId: async () => + options.boundAgentId === null + ? null + : ({ + channelsThreadId: "thread-1", + provider: "slack", + providerTenantId: "tenant-1", + providerConversationId: "channel-1", + providerThreadId: "provider-thread-1", + agentId: options.boundAgentId ?? "risk", + agentName: "Risk Analyst", + createdByUserId: "creator", + createdAt: new Date(), + } as const), + } as never, + profiles: { + get: async () => (options.accessible === false ? null : ({} as never)), + }, + }); +} + +describe("Slack approval authorization", () => { + test("fails closed for unlinked, rebound, and inaccessible participants", async () => { + expect( + await authorizer({ active: false })({ + userId: "u1", + presentation, + liveIdentity, + }), + ).toBe(false); + expect( + await authorizer({ boundAgentId: "other" })({ + userId: "u1", + presentation, + liveIdentity, + }), + ).toBe(false); + expect( + await authorizer({ accessible: false })({ + userId: "u1", + presentation, + liveIdentity, + }), + ).toBe(false); + }); + + test("accepts only an active canonical user with current access to the pinned coworker", async () => { + expect( + await authorizer()({ userId: "u1", presentation, liveIdentity }), + ).toEqual({ + actor: { id: "u1", role: "user" }, + applicationUser: { id: "u1", name: "u1" }, + provider: "slack", + providerTenantId: "tenant-1", + providerConversationId: "channel-1", + providerThreadId: "provider-thread-1", + }); + }); +}); diff --git a/server/tests/slack-approval-store.integration.test.ts b/server/tests/slack-approval-store.integration.test.ts new file mode 100644 index 00000000..2d00d626 --- /dev/null +++ b/server/tests/slack-approval-store.integration.test.ts @@ -0,0 +1,397 @@ +import { afterAll, beforeAll, describe, expect, test } from "bun:test"; +import { + createChannel, + FakeAdapter, + FakeAgent, + MemoryStore, +} from "@copilotkit/channels"; +import { eq, inArray, sql } from "drizzle-orm"; +import { createDatabase } from "../src/db/client"; +import { + agents, + approvalDecisions, + externalThreadBindings, + users, +} from "../src/db/schema"; +import { createApprovalDecisionStore } from "../src/slack/approval-store"; +import { + ApprovalCard, + configureApprovalDecisionStore, +} from "../src/slack/components"; +import { runWithSlackExecution } from "../src/slack/execution-context"; +import { TEST_POOL } from "./support/database"; + +const database = createDatabase( + process.env.DATABASE_URL ?? + "postgres://openbot:openbot@localhost:5432/openbot", + TEST_POOL, +); +const presentationIds = new Set(); +const channelsThreadId = `approval-thread-${crypto.randomUUID()}`; +const agentId = `approval-agent-${crypto.randomUUID()}`; +const user1 = `approval-user-${crypto.randomUUID()}`; +const user2 = `approval-user-${crypto.randomUUID()}`; + +beforeAll(async () => { + await database + .insert(users) + .values([ + { id: user1, email: `u1-${crypto.randomUUID()}@example.test` }, + { id: user2, email: `u2-${crypto.randomUUID()}@example.test` }, + ]) + .onConflictDoNothing(); + await database.insert(agents).values({ + id: agentId, + name: "Approval Agent", + type: "remote_ag_ui", + configuration: {}, + }); + await database.insert(externalThreadBindings).values({ + channelsThreadId, + provider: "slack", + providerTenantId: `tenant-${crypto.randomUUID()}`, + providerConversationId: `conversation-${crypto.randomUUID()}`, + providerThreadId: `thread-${crypto.randomUUID()}`, + agentId, + createdByUserId: user1, + }); +}); + +type BoundAction = { + id: string; + value: { + presentationId: string; + approved: boolean; + }; +}; + +function boundActions(value: unknown): BoundAction[] { + if (!value || typeof value !== "object") return []; + const node = value as { props?: Record }; + const click = node.props?.onClick; + const action = + click && + typeof click === "object" && + typeof (click as { id?: unknown }).id === "string" && + node.props?.value && + typeof node.props.value === "object" + ? [ + { + id: (click as { id: string }).id, + value: node.props.value as BoundAction["value"], + }, + ] + : []; + const children = node.props?.children; + return [ + ...action, + ...(Array.isArray(children) + ? children.flatMap(boundActions) + : boundActions(children)), + ]; +} + +function runtime( + state: MemoryStore, + lifecycle: Array<{ isResume?: boolean }>, + script: ConstructorParameters[0], +) { + configureApprovalDecisionStore(createApprovalDecisionStore(database), { + authorize: async ({ userId }) => userId === user1 || userId === user2, + }); + const adapter = new FakeAdapter({ platform: "slack" }); + adapter.runAgentLifecycle = async (args) => { + lifecycle.push({ isResume: args.isResume }); + return args.execute(args.renderer.subscriber, undefined); + }; + const channel = createChannel({ + name: "approval-durability-probe", + identifyUser: ({ actor }) => ({ id: actor.id, name: actor.id }), + adapters: [adapter], + agent: () => new FakeAgent(script), + components: [ApprovalCard], + store: { adapter: state, actionRetentionMs: 60_000 }, + }); + channel.onMessage(async ({ thread }) => { + await thread.runAgent(); + }); + return { adapter, channel }; +} + +const presentApproval: ConstructorParameters[0][number] = ( + subscriber, +) => { + subscriber.onToolCallEndEvent?.({ + event: { toolCallId: crypto.randomUUID() }, + toolCallName: ApprovalCard.name, + toolCallArgs: { question: "Deploy this release?" }, + }); +}; + +async function present() { + const lifecycle: Array<{ isResume?: boolean }> = []; + const state = new MemoryStore(); + const running = runtime(state, lifecycle, [presentApproval, () => undefined]); + await running.channel.ɵruntime.start(); + await runWithSlackExecution( + { + actor: { id: user1, role: "user" }, + applicationUser: { id: user1, name: "Approval User" }, + provider: "slack", + providerTenantId: "approval-tenant", + providerConversationId: "approval-conversation", + providerThreadId: "approval-provider-thread", + channelsThreadId, + channelsConversationKey: "approval-thread", + messageText: "ask first", + agentId, + }, + () => + running.adapter.getSink().onTurn({ + conversationKey: "approval-thread", + replyTarget: {}, + userText: "ask first", + platform: "slack", + actor: { id: user1, kind: "human" }, + }), + ); + const actions = (running.adapter.posted[0] ?? []).flatMap(boundActions); + expect(actions).toHaveLength(2); + expect(actions.map(({ value }) => value.presentationId)).toEqual([ + actions[0]!.value.presentationId, + actions[0]!.value.presentationId, + ]); + expect(actions.map(({ value }) => value.approved)).toEqual([true, false]); + for (const action of actions) { + expect(Object.keys(action.value).sort()).toEqual([ + "approved", + "presentationId", + ]); + } + presentationIds.add(actions[0]!.value.presentationId); + return { ...running, actions, lifecycle, state }; +} + +async function click( + adapter: FakeAdapter, + action: BoundAction, + eventId: string, + actorId: string, + providerValue: unknown = action.value, +) { + await adapter.getSink().onInteraction({ + id: action.id, + value: providerValue, + conversationKey: "approval-thread", + replyTarget: {}, + eventId, + actor: { id: actorId, kind: "human" }, + }); +} + +afterAll(async () => { + await database + .delete(approvalDecisions) + .where(eq(approvalDecisions.channelsThreadId, channelsThreadId)); + await database.transaction(async (transaction) => { + await transaction.execute( + sql`alter table ${externalThreadBindings} disable trigger external_thread_bindings_append_only`, + ); + await transaction + .delete(externalThreadBindings) + .where(eq(externalThreadBindings.channelsThreadId, channelsThreadId)); + await transaction.execute( + sql`alter table ${externalThreadBindings} enable trigger external_thread_bindings_append_only`, + ); + }); + await database.delete(agents).where(eq(agents.id, agentId)); + await database.delete(users).where(inArray(users.id, [user1, user2])); + await database.$client.end({ timeout: 5 }); +}); + +describe("durable Slack approval decisions", () => { + test("a presentation id cannot be rebound to another authorization subject", async () => { + const store = createApprovalDecisionStore(database); + const presentationId = crypto.randomUUID(); + presentationIds.add(presentationId); + await store.present({ + presentationId, + channelsThreadId, + conversationKey: "approval-thread", + agentId, + createdByUserId: user1, + }); + + await expect( + store.present({ + presentationId, + channelsThreadId, + conversationKey: "different-conversation", + agentId, + createdByUserId: user2, + }), + ).rejects.toThrow("authorization subject"); + expect(await store.get(presentationId)).toMatchObject({ + channelsThreadId, + conversationKey: "approval-thread", + agentId, + createdByUserId: user1, + }); + }); + + test("an unlinked or inaccessible participant cannot claim the presentation", async () => { + const fixture = await present(); + try { + const before = fixture.lifecycle.length; + await expect( + click(fixture.adapter, fixture.actions[0]!, "unauthorized", "U3"), + ).rejects.toThrow("authorized"); + expect(fixture.lifecycle).toHaveLength(before); + await click(fixture.adapter, fixture.actions[0]!, "authorized", user1); + expect(fixture.lifecycle).toHaveLength(before + 1); + } finally { + await fixture.channel.ɵruntime.stop(); + } + }); + + for (const firstApproved of [true, false]) { + test(`${firstApproved ? "approve" : "reject"} prevents the sibling decision`, async () => { + const fixture = await present(); + try { + const first = fixture.actions.find( + ({ value }) => value.approved === firstApproved, + )!; + const sibling = fixture.actions.find( + ({ value }) => value.approved !== firstApproved, + )!; + const before = fixture.lifecycle.length; + + await click(fixture.adapter, first, "first-decision", user1); + await click(fixture.adapter, sibling, "sibling-decision", user2); + + expect(fixture.lifecycle).toHaveLength(before + 1); + expect(fixture.lifecycle.at(-1)?.isResume).toBe(true); + } finally { + await fixture.channel.ɵruntime.stop(); + } + }); + } + + test("concurrent actors can resume only one sibling action", async () => { + const fixture = await present(); + try { + const before = fixture.lifecycle.length; + await Promise.all([ + click( + fixture.adapter, + fixture.actions[0]!, + "concurrent-approve", + user1, + ), + click(fixture.adapter, fixture.actions[1]!, "concurrent-reject", user2), + ]); + expect(fixture.lifecycle).toHaveLength(before + 1); + } finally { + await fixture.channel.ɵruntime.stop(); + } + }); + + test("only the original winning actor can retry the same action", async () => { + const store = createApprovalDecisionStore(database); + const presentationId = crypto.randomUUID(); + presentationIds.add(presentationId); + await store.present({ + presentationId, + channelsThreadId, + conversationKey: "approval-thread", + agentId, + createdByUserId: user1, + }); + + expect( + await store.begin({ + presentationId, + actionId: "approve-action", + approved: true, + decidedByUserId: user1, + }), + ).toBe("first"); + expect( + await store.begin({ + presentationId, + actionId: "approve-action", + approved: true, + decidedByUserId: user2, + }), + ).toBe("rejected"); + expect( + await store.begin({ + presentationId, + actionId: "approve-action", + approved: true, + decidedByUserId: user1, + }), + ).toBe("retry"); + }); + + test("a persisted decision rejects its sibling after a runtime restart", async () => { + const first = await present(); + const before = first.lifecycle.length; + await click(first.adapter, first.actions[0]!, "before-restart", user1); + expect(first.lifecycle).toHaveLength(before + 1); + await first.channel.ɵruntime.stop(); + + const restarted = runtime(first.state, first.lifecycle, [() => undefined]); + await restarted.channel.ɵruntime.start(); + try { + await click(restarted.adapter, first.actions[1]!, "after-restart", user2); + expect(first.lifecycle).toHaveLength(before + 1); + } finally { + await restarted.channel.ɵruntime.stop(); + } + }); + + test("cold action recovery re-renders without ALS or DB side effects and resumes from stored subject", async () => { + const first = await present(); + const before = first.lifecycle.length; + await first.channel.ɵruntime.stop(); + + const restarted = runtime(first.state, first.lifecycle, [() => undefined]); + await restarted.channel.ɵruntime.start(); + try { + await click( + restarted.adapter, + first.actions[0]!, + "cold-recovery", + user1, + { + ...first.actions[0]!.value, + channelsThreadId: "provider-tampered-thread", + conversationKey: "provider-tampered-conversation", + agentId: "provider-tampered-agent", + createdByUserId: "provider-tampered-user", + approved: false, + }, + ); + expect(first.lifecycle).toHaveLength(before + 1); + expect(first.lifecycle.at(-1)?.isResume).toBe(true); + } finally { + await restarted.channel.ɵruntime.stop(); + } + }); + + test("retention cleanup preserves live action-window rows", async () => { + const store = createApprovalDecisionStore(database); + const presentationId = crypto.randomUUID(); + presentationIds.add(presentationId); + await store.present({ + presentationId, + channelsThreadId, + conversationKey: "approval-thread", + agentId, + createdByUserId: user1, + }); + expect(await store.cleanup(new Date(Date.now() - 60_000))).toBe(0); + expect(await store.get(presentationId)).not.toBeNull(); + }); +}); diff --git a/server/tests/slack-assistance.test.ts b/server/tests/slack-assistance.test.ts new file mode 100644 index 00000000..3aa617be --- /dev/null +++ b/server/tests/slack-assistance.test.ts @@ -0,0 +1,834 @@ +import { describe, expect, test } from "bun:test"; +import { createChannel, FakeAdapter, FakeAgent } from "@copilotkit/channels"; +import { renderSlackMessage } from "@copilotkit/channels/slack/render"; +import type { MiddlewareHandler } from "hono"; +import { Hono } from "hono"; +import type { AgentProfileStore } from "../src/agents/profile-store"; +import type { AgentProfile } from "../src/agents/profile-types"; +import type { TransactionalAuditStore } from "../src/audit"; +import type { AppVariables } from "../src/auth/guards"; +import type { ControlState } from "../src/computer/schema"; +import type { ExternalLinkCreationStore } from "../src/external/link-store"; +import { createExternalLinkRoutes } from "../src/external/routes"; +import { computerControlUrl, waitForAssistance } from "../src/slack/assistance"; +import { + ASSISTANCE_TTL_MS, + mintAssistanceToken, + readAssistanceToken, +} from "../src/slack/assistance-token"; +import { + ApprovalCard, + configureApprovalDecisionStore, +} from "../src/slack/components"; +import { runWithSlackExecution } from "../src/slack/execution-context"; + +const KEY = "slack-assistance-test-key"; +const NOW = 1_700_000_000_000; +const INVALID = "This assistance link has expired or is invalid."; + +function renderApproval( + question: string, + options: { + conversationKey?: string; + userId?: string; + agentId?: string; + } = {}, +) { + const conversationKey = options.conversationKey ?? "conversation-1"; + const userId = options.userId ?? "user-1"; + return runWithSlackExecution( + { + actor: { id: userId, role: "user" }, + applicationUser: { id: userId, name: "Approval User" }, + provider: "slack", + providerTenantId: "tenant-1", + providerConversationId: "channel-1", + providerThreadId: "provider-thread-1", + channelsThreadId: "thread-1", + channelsConversationKey: conversationKey, + messageText: question, + agentId: options.agentId ?? "agent-1", + }, + () => + ApprovalCard.render( + { question }, + { platform: "slack", signal: new AbortController().signal }, + ), + ); +} + +function profile(id = "coworker-1"): AgentProfile { + return { + id, + name: "Coworker", + title: "Coworker", + roleDescription: "Helps with work.", + avatarSeed: "coworker", + visibility: "private", + ownerUserId: "openbot-user-1", + systemOwned: false, + hidden: false, + deletedAt: null, + endpoint: null, + hasAuth: false, + hasCallbackToken: false, + }; +} + +describe("Slack assistance claims", () => { + test("seals a ten-minute claim without exposing its identities", async () => { + const token = await mintAssistanceToken( + { + openbotUserId: "openbot-user-private", + agentId: "coworker-private", + channelsThreadId: "thread-private", + }, + KEY, + NOW, + ); + + expect(token).not.toContain("openbot-user-private"); + expect(token).not.toContain("coworker-private"); + expect(token).not.toContain("thread-private"); + expect( + await readAssistanceToken(token, KEY, NOW + ASSISTANCE_TTL_MS), + ).toMatchObject({ + openbotUserId: "openbot-user-private", + agentId: "coworker-private", + channelsThreadId: "thread-private", + issuedAt: NOW, + expiresAt: NOW + ASSISTANCE_TTL_MS, + }); + }); + + test("maps malformed, future-issued, expired, and cross-purpose claims uniformly", async () => { + const token = await mintAssistanceToken( + { openbotUserId: "u1", agentId: "a1", channelsThreadId: "t1" }, + KEY, + NOW, + ); + + for (const [candidate, now] of [ + [undefined, NOW], + ["not-a-token", NOW], + [token, NOW - 1], + [token, NOW + ASSISTANCE_TTL_MS + 1], + ] as const) { + await expect(readAssistanceToken(candidate, KEY, now)).rejects.toThrow( + INVALID, + ); + } + }); +}); + +describe("Slack assistance waiting", () => { + const waiting: ControlState = { + holder: "human", + since: "2026-08-27T00:00:00.000Z", + requested: true, + reason: "Sign in", + }; + + test("returns answered once control is back with the bot and the request is clear", async () => { + let now = 0; + const states: ControlState[] = [ + waiting, + { + holder: "bot", + since: "2026-08-27T00:00:01.000Z", + requested: false, + }, + ]; + const outcome = await waitForAssistance({ + control: async () => + states.shift() ?? { + holder: "bot", + since: "2026-08-27T00:00:01.000Z", + requested: false, + }, + done: (state) => state.holder === "bot" && !state.requested, + now: () => now, + sleep: async (milliseconds) => { + now += milliseconds; + return "elapsed"; + }, + }); + + expect(outcome).toBe("answered"); + }); + + test("returns cancelled without polling after an aborted timer", async () => { + const controller = new AbortController(); + let polls = 0; + const outcome = await waitForAssistance({ + control: async () => { + polls += 1; + return waiting; + }, + done: () => false, + signal: controller.signal, + now: () => 0, + sleep: async () => { + controller.abort(); + return "aborted"; + }, + }); + + expect(outcome).toBe("cancelled"); + expect(polls).toBe(1); + }); + + test("expires at the bound without an extra poll", async () => { + let now = 0; + let polls = 0; + const outcome = await waitForAssistance({ + control: async () => { + polls += 1; + return waiting; + }, + done: () => false, + timeoutMs: 2_000, + pollMs: 1_000, + now: () => now, + sleep: async (milliseconds) => { + now += milliseconds; + return "elapsed"; + }, + }); + + expect(outcome).toBe("expired"); + expect(polls).toBe(2); + }); + + test("cancels while a control poll is hung and consumes its later rejection", async () => { + const controller = new AbortController(); + let rejectControl: (error: Error) => void = () => undefined; + const waiting = waitForAssistance({ + control: () => + new Promise((_resolve, reject) => { + rejectControl = reject; + }), + done: () => false, + signal: controller.signal, + timeoutMs: 1_000, + }); + controller.abort(); + + const outcome = await Promise.race([ + waiting, + new Promise<"test-timeout">((resolve) => + setTimeout(() => resolve("test-timeout"), 100), + ), + ]); + expect(outcome).toBe("cancelled"); + rejectControl(new Error("late transport failure")); + await Promise.resolve(); + }); + + test("expires while a control poll never settles", async () => { + const started = Date.now(); + const outcome = await Promise.race([ + waitForAssistance({ + control: () => new Promise(() => undefined), + done: () => false, + timeoutMs: 20, + }), + new Promise<"test-timeout">((resolve) => + setTimeout(() => resolve("test-timeout"), 100), + ), + ]); + + expect(outcome).toBe("expired"); + expect(Date.now() - started).toBeLessThan(100); + }); +}); + +test("computer control URL keeps the sealed claim in the query", () => { + const url = computerControlUrl( + "https://openbot.example/base", + "sealed-token", + ); + expect(url).toBe("https://openbot.example/assist?token=sealed-token"); +}); + +test("computer control URL allows HTTPS and loopback only", () => { + expect(computerControlUrl("http://localhost:3010", "sealed-token")).toBe( + "http://localhost:3010/assist?token=sealed-token", + ); + for (const appUrl of [ + "http://openbot.example", + "https://user:password@openbot.example", + "file:///tmp/openbot", + ]) { + expect(() => computerControlUrl(appUrl, "sealed-token")).toThrow( + "OpenBot app URL", + ); + } +}); + +test("approval buttons resume the originating thread with a boolean decision", async () => { + const presentations = new Map>(); + configureApprovalDecisionStore( + { + async present(value) { + presentations.set(value.presentationId, { + ...value, + createdAt: new Date(), + }); + }, + async get(id) { + return (presentations.get(id) ?? null) as never; + }, + async begin() { + return "first"; + }, + async complete() {}, + async cleanup() { + return 0; + }, + }, + { + authorize: async () => true, + }, + ); + const rendered = await renderApproval("Deploy this release?"); + expect(presentations.size).toBe(1); + const message = rendered as { + props: { children: Array<{ props: { children: unknown } }> }; + }; + const actions = message.props.children[1] as { + props: { + children: Array<{ + props: { onClick: (context: unknown) => Promise }; + }>; + }; + }; + const actionNodes = actions.props.children as Array<{ + key?: string | number; + props: { + onClick: (context: unknown) => Promise; + value: { presentationId: string; approved: boolean }; + }; + }>; + const decisions: unknown[] = []; + for (const [index, node] of actionNodes.entries()) { + await node.props.onClick({ + action: { id: `action-${index}`, value: node.props.value }, + thread: { + conversationKey: "conversation-1", + resume: async (decision: unknown) => void decisions.push(decision), + }, + user: { id: "user-1", name: "User" }, + actor: { kind: "human", id: "U1" }, + platform: "slack", + }); + } + + expect(message.props.children[0]?.props.children).toBe( + "Deploy this release?", + ); + expect(actionNodes.map((node) => node.key)).toEqual([ + "approval-approve", + "approval-reject", + ]); + expect(decisions).toEqual([{ approved: true }, { approved: false }]); +}); + +test("a render outside Slack execution is inert and its actions fail closed", async () => { + const calls: string[] = []; + configureApprovalDecisionStore( + { + async present() { + calls.push("present"); + }, + async get() { + calls.push("get"); + return null; + }, + async begin() { + calls.push("begin"); + return "first"; + }, + async complete() { + calls.push("complete"); + }, + async cleanup() { + calls.push("cleanup"); + return 0; + }, + }, + { authorize: async () => true }, + ); + + const rendered = (await ApprovalCard.render( + { question: "Deploy?" }, + { platform: "slack", signal: new AbortController().signal }, + )) as { props: { children: Array<{ props: { children: unknown } }> } }; + expect(calls).toEqual([]); + const action = ( + rendered.props.children[1] as { + props: { children: Array<{ props: Record }> }; + } + ).props.children[0]!; + expect(action.props.value).toBeNull(); + await expect( + (action.props.onClick as (context: unknown) => Promise)({ + action: { id: "cold-action", value: action.props.value }, + thread: { conversationKey: "conversation-1", resume: async () => {} }, + user: { id: "user-1", name: "User" }, + }), + ).rejects.toThrow(); + expect(calls).toEqual([]); +}); + +test("an existing presentation for another conversation fails closed", async () => { + let beginCalls = 0; + configureApprovalDecisionStore( + { + async present() {}, + async get(presentationId) { + return { + presentationId, + channelsThreadId: "thread-1", + conversationKey: "different-conversation", + agentId: "agent-1", + createdByUserId: "user-1", + createdAt: new Date(), + }; + }, + async begin() { + beginCalls += 1; + return "first"; + }, + async complete() {}, + async cleanup() { + return 0; + }, + }, + { + authorize: async () => true, + }, + ); + const rendered = (await renderApproval("Deploy?")) as { + props: { children: Array<{ props: { children: unknown } }> }; + }; + const action = ( + rendered.props.children[1] as { + props: { children: Array<{ props: Record }> }; + } + ).props.children[0]!; + + await expect( + (action.props.onClick as (context: unknown) => Promise)({ + action: { id: "conflicting-action", value: action.props.value }, + thread: { conversationKey: "conversation-1", resume: async () => {} }, + user: { id: "user-1", name: "User" }, + }), + ).rejects.toThrow("authorized"); + expect(beginCalls).toBe(0); +}); + +test("approval authorization fails closed and the same winner can retry a pre-resume failure", async () => { + const presentations = new Map(); + let winner: { actionId: string; approved: boolean } | null = null; + let completed = false; + let beginCalls = 0; + configureApprovalDecisionStore( + { + async present(value) { + presentations.set(value.presentationId, { + ...value, + createdAt: new Date(), + } as never); + }, + async get(id) { + return presentations.get(id) ?? null; + }, + async begin(input) { + beginCalls += 1; + if (!winner) { + winner = { actionId: input.actionId, approved: input.approved }; + return "first"; + } + return !completed && + winner.actionId === input.actionId && + winner.approved === input.approved + ? "retry" + : "rejected"; + }, + async complete() { + completed = true; + }, + async cleanup() { + return 0; + }, + }, + { + authorize: async ({ userId }) => userId === "allowed", + }, + ); + const rendered = (await renderApproval("Deploy?", { + userId: "creator", + })) as { props: { children: Array<{ props: { children: unknown } }> } }; + const nodes = ( + rendered.props.children[1] as { + props: { children: Array<{ props: Record }> }; + } + ).props.children; + const click = async ( + index: number, + userId: string | null, + resume: () => Promise, + ) => { + const node = nodes[index]!; + await (node.props.onClick as (context: unknown) => Promise)({ + action: { id: `action-${index}`, value: node.props.value }, + thread: { conversationKey: "conversation-1", resume }, + user: userId ? { id: userId, name: userId } : null, + actor: { kind: "human", id: "U1" }, + platform: "slack", + }); + }; + + await expect(click(0, null, async () => {})).rejects.toThrow("authorized"); + await expect(click(0, "denied", async () => {})).rejects.toThrow( + "authorized", + ); + expect(beginCalls).toBe(0); + await expect( + click(0, "allowed", async () => { + throw new Error("transient before continuation consumption"); + }), + ).rejects.toThrow("transient"); + await click(1, "allowed", async () => { + throw new Error("sibling must not resume"); + }); + let resumes = 0; + await click(0, "allowed", async () => { + resumes += 1; + }); + await click(0, "allowed", async () => { + resumes += 1; + }); + expect(resumes).toBe(1); +}); + +function interactiveActionIds(value: unknown): string[] { + if (!value || typeof value !== "object") return []; + const node = value as { props?: Record }; + const onClick = node.props?.onClick; + const own = + onClick && + typeof onClick === "object" && + typeof (onClick as { id?: unknown }).id === "string" + ? [(onClick as { id: string }).id] + : []; + const children = node.props?.children; + return [ + ...own, + ...(Array.isArray(children) + ? children.flatMap(interactiveActionIds) + : interactiveActionIds(children)), + ]; +} + +function slackButtonValues(value: unknown): string[] { + if (!value || typeof value !== "object") return []; + const record = value as Record; + const own = + record.type === "button" && typeof record.value === "string" + ? [record.value] + : []; + return [...own, ...Object.values(record).flatMap(slackButtonValues)]; +} + +test("registered ApprovalCard binds durable one-use actions that resume its thread", async () => { + const presentations = new Map>(); + const completed = new Set(); + configureApprovalDecisionStore( + { + async present(value) { + presentations.set(value.presentationId, { + ...value, + createdAt: new Date(), + }); + }, + async get(id) { + return (presentations.get(id) ?? null) as never; + }, + async begin({ presentationId }) { + return completed.has(presentationId) ? "rejected" : "first"; + }, + async complete(presentationId) { + completed.add(presentationId); + }, + async cleanup() { + return 0; + }, + }, + { + authorize: async () => true, + }, + ); + const adapter = new FakeAdapter({ platform: "slack" }); + const lifecycle: Array<{ isResume?: boolean }> = []; + adapter.runAgentLifecycle = async (args) => { + lifecycle.push({ isResume: args.isResume }); + return args.execute(args.renderer.subscriber, undefined); + }; + const agent = new FakeAgent([ + (subscriber) => { + subscriber.onToolCallEndEvent?.({ + event: { toolCallId: "approval-call-1" }, + toolCallName: ApprovalCard.name, + toolCallArgs: { question: "Deploy this release?" }, + }); + }, + () => undefined, + () => undefined, + ]); + const channel = createChannel({ + name: "approval-probe", + identifyUser: "platform", + adapters: [adapter], + agent: () => agent, + components: [ApprovalCard], + store: { actionRetentionMs: 60_000 }, + }); + channel.onMessage(async ({ thread }) => { + await thread.runAgent(); + }); + await channel.ɵruntime.start(); + try { + await runWithSlackExecution( + { + actor: { id: "U1", role: "user" }, + applicationUser: { id: "U1", name: "Approval User" }, + provider: "slack", + providerTenantId: "tenant-1", + providerConversationId: "channel-1", + providerThreadId: "provider-thread-1", + channelsThreadId: "thread-1", + channelsConversationKey: "approval-thread", + messageText: "ask first", + agentId: "agent-1", + }, + () => + adapter.getSink().onTurn({ + conversationKey: "approval-thread", + replyTarget: {}, + userText: "ask first", + platform: "slack", + actor: { id: "U1", kind: "human" }, + }), + ); + const actionIds = (adapter.posted[0] ?? []).flatMap(interactiveActionIds); + expect(actionIds).toHaveLength(2); + expect(new Set(actionIds).size).toBe(2); + const blockKit = renderSlackMessage(adapter.posted[0] ?? []); + const buttonValues = slackButtonValues(blockKit.blocks).map((value) => + JSON.parse(value), + ); + expect(buttonValues).toHaveLength(2); + expect(buttonValues.map((value) => Object.keys(value).sort())).toEqual([ + ["approved", "presentationId"], + ["approved", "presentationId"], + ]); + expect(buttonValues.map(({ approved }) => approved)).toEqual([true, false]); + expect(buttonValues[0]?.presentationId).toBe( + buttonValues[1]?.presentationId, + ); + expect(buttonValues[0]?.presentationId).toMatch( + /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i, + ); + const serializedButtonValues = JSON.stringify(buttonValues); + for (const privateSubject of [ + "thread-1", + "approval-thread", + "agent-1", + "U1", + ]) { + expect(serializedButtonValues).not.toContain(privateSubject); + } + const beforeResume = lifecycle.length; + + await adapter.getSink().onInteraction({ + id: actionIds[0]!, + conversationKey: "approval-thread", + replyTarget: {}, + eventId: "approve-once", + actor: { id: "U1", kind: "human" }, + }); + expect(lifecycle).toHaveLength(beforeResume + 1); + expect(lifecycle.at(-1)?.isResume).toBe(true); + + await adapter.getSink().onInteraction({ + id: actionIds[0]!, + conversationKey: "approval-thread", + replyTarget: {}, + eventId: "approve-twice", + actor: { id: "U1", kind: "human" }, + }); + expect(lifecycle).toHaveLength(beforeResume + 1); + } finally { + await channel.ɵruntime.stop(); + } +}); + +function assistanceRoutes( + actorId = "openbot-user-1", + getProfile: AgentProfileStore["get"] = async () => profile(), + requireUserOverride?: MiddlewareHandler<{ Variables: AppVariables }>, +) { + const requireUser: MiddlewareHandler<{ Variables: AppVariables }> = + requireUserOverride ?? + (async (context, next) => { + context.set("actor", { + id: actorId, + email: "member@openbot.test", + role: "user", + }); + await next(); + }); + const app = new Hono<{ Variables: AppVariables }>(); + app.route( + "/api/external-links", + createExternalLinkRoutes({ + store: {} as ExternalLinkCreationStore, + encryptionKey: KEY, + requireUser, + auditStore: { + insert: async () => undefined, + inTransaction: () => ({ insert: async () => undefined }), + } satisfies TransactionalAuditStore, + agentProfileStore: { get: getProfile }, + threadStore: { + getByChannelsThreadId: async () => null, + getByProviderThread: async () => null, + bind: async () => { + throw new Error("unused"); + }, + appendTranscriptTurn: async () => undefined, + getTranscript: async () => [], + }, + }), + ); + return app; +} + +describe("authenticated Slack assistance handoff", () => { + test("prevents caching even when authentication refuses the request", async () => { + const app = assistanceRoutes( + "openbot-user-1", + async () => profile(), + async (context) => context.json({ error: "Unauthorized" }, 401), + ); + + const response = await app.request( + "http://openbot.test/api/external-links/assistance?token=sealed-control", + ); + + expect(response.status).toBe(401); + expect(response.headers.get("cache-control")).toBe("no-store"); + }); + + test("rechecks the linked actor's coworker access and returns only the agent id", async () => { + const calls: unknown[] = []; + const token = await mintAssistanceToken( + { + openbotUserId: "openbot-user-1", + agentId: "coworker-1", + channelsThreadId: "private-thread-id", + }, + KEY, + ); + const app = assistanceRoutes("openbot-user-1", async (...args) => { + calls.push(args); + return profile(); + }); + + const response = await app.request( + `http://openbot.test/api/external-links/assistance?token=${encodeURIComponent(token)}`, + ); + + expect(response.status).toBe(200); + expect(response.headers.get("cache-control")).toBe("no-store"); + expect(await response.json()).toEqual({ agentId: "coworker-1" }); + expect(calls).toEqual([ + [{ id: "openbot-user-1", role: "user" }, "coworker-1"], + ]); + }); + + test("refuses the wrong signed-in user and inaccessible coworker without disclosing ids", async () => { + const token = await mintAssistanceToken( + { + openbotUserId: "openbot-user-1", + agentId: "coworker-1", + channelsThreadId: "private-thread-id", + }, + KEY, + ); + for (const app of [ + assistanceRoutes("openbot-user-2"), + assistanceRoutes("openbot-user-1", async () => null), + ]) { + const response = await app.request( + `http://openbot.test/api/external-links/assistance?token=${encodeURIComponent(token)}`, + ); + expect(response.status).toBe(403); + expect(response.headers.get("cache-control")).toBe("no-store"); + expect(JSON.stringify(await response.json())).not.toMatch( + /openbot-user|coworker|private-thread/, + ); + } + }); + + test("keeps profile-store outages as operational 5xx failures", async () => { + const token = await mintAssistanceToken( + { + openbotUserId: "openbot-user-1", + agentId: "coworker-1", + channelsThreadId: "private-thread-id", + }, + KEY, + ); + const app = assistanceRoutes("openbot-user-1", async () => { + throw new Error("database unavailable"); + }); + + const response = await app.request( + `http://openbot.test/api/external-links/assistance?token=${encodeURIComponent(token)}`, + ); + + expect(response.status).toBeGreaterThanOrEqual(500); + expect(response.headers.get("cache-control")).toBe("no-store"); + expect(JSON.stringify(await response.json())).not.toMatch( + /database|openbot-user|coworker|private-thread/, + ); + }); + + test("maps missing, malformed, and expired claims to one 410 response", async () => { + const expired = await mintAssistanceToken( + { + openbotUserId: "openbot-user-1", + agentId: "coworker-1", + channelsThreadId: "private-thread-id", + }, + KEY, + NOW, + ); + const app = assistanceRoutes(); + for (const suffix of [ + "", + "?token=invalid", + `?token=${encodeURIComponent(expired)}`, + ]) { + const response = await app.request( + `http://openbot.test/api/external-links/assistance${suffix}`, + ); + expect(response.status).toBe(410); + expect(response.headers.get("cache-control")).toBe("no-store"); + expect(await response.json()).toEqual({ error: INVALID }); + } + }); +}); diff --git a/server/tests/slack-channel-agent.test.ts b/server/tests/slack-channel-agent.test.ts new file mode 100644 index 00000000..0d88b9d1 --- /dev/null +++ b/server/tests/slack-channel-agent.test.ts @@ -0,0 +1,719 @@ +import { describe, expect, test } from "bun:test"; +import { + AbstractAgent, + type BaseEvent, + type RunAgentInput, +} from "@ag-ui/client"; +import { + EMPTY, + lastValueFrom, + NEVER, + type Observable, + of, + throwError, +} from "rxjs"; +import { toArray } from "rxjs/operators"; +import { z } from "zod"; +import type { ActorAgentResolver } from "../src/agents/agent-resolver"; +import { createActorAgentResolver } from "../src/agents/agent-resolver"; +import type { + ExternalThreadBinding, + ExternalThreadStore, +} from "../src/external/thread-store"; +import type { CoworkerRoutingService } from "../src/routing/service"; +import { + OpenBotChannelAgent, + type OpenBotChannelAgentDependencies, +} from "../src/slack/channel-agent"; +import { + currentSlackExecution, + runWithSlackExecution, + type SlackExecution, +} from "../src/slack/execution-context"; + +const CANONICAL_THREAD_ID = "channels-thread-1"; +const CONVERSATION_KEY = "slack:T1:C1:message-1"; +const ACTOR = { id: "alice", role: "user" } as const; + +function execution(overrides: Partial = {}): SlackExecution { + return { + actor: ACTOR, + applicationUser: { id: "alice", name: "Alice" }, + provider: "slack", + providerTenantId: "tenant-1", + providerConversationId: "conversation-1", + providerThreadId: "slack-thread-1", + messageText: "Please review this risk.", + ...overrides, + }; +} + +function input(): RunAgentInput { + return { + threadId: CANONICAL_THREAD_ID, + runId: "run-1", + state: { existing: true }, + messages: [{ id: "message-1", role: "user", content: "original input" }], + tools: [], + context: [{ description: "ordinary context", value: "kept" }], + forwardedProps: { ordinary: "kept" }, + }; +} + +function binding( + overrides: Partial = {}, +): ExternalThreadBinding { + return { + channelsThreadId: CANONICAL_THREAD_ID, + provider: "slack", + providerTenantId: "tenant-1", + providerConversationId: "conversation-1", + providerThreadId: "slack-thread-1", + agentId: "risk", + agentName: "Risk Analyst", + createdByUserId: "alice", + createdAt: new Date("2026-08-27T00:00:00.000Z"), + ...overrides, + }; +} + +class ScriptedAgent extends AbstractAgent { + readonly received: RunAgentInput[] = []; + aborts = 0; + + constructor( + readonly script: (input: RunAgentInput) => Observable = () => + EMPTY, + ) { + super({ agentId: "scripted", description: "scripted target" }); + } + + run(runInput: RunAgentInput): Observable { + this.received.push(runInput); + return this.script(runInput); + } + + abortRun(): void { + this.aborts += 1; + super.abortRun(); + } +} + +function harness( + options: { + existing?: ExternalThreadBinding | null; + bound?: ExternalThreadBinding; + route?: Awaited>; + resolve?: ActorAgentResolver["resolveAgentForActor"]; + } = {}, +) { + const getCalls: string[] = []; + const bindCalls: Parameters[0][] = []; + const transcriptCalls: Parameters< + ExternalThreadStore["appendTranscriptTurn"] + >[0][] = []; + const routeCalls: Parameters[0][] = []; + const resolveCalls: Parameters[] = + []; + const target = new ScriptedAgent(() => + of( + { type: "CUSTOM", name: "first", value: 1 } as BaseEvent, + { type: "CUSTOM", name: "second", value: 2 } as BaseEvent, + ), + ); + const store: ExternalThreadStore = { + async getByChannelsThreadId(id) { + getCalls.push(id); + return options.existing ?? null; + }, + async getByProviderThread() { + return null; + }, + async bind(value) { + bindCalls.push(value); + return ( + options.bound ?? + binding({ + ...value, + createdAt: new Date("2026-08-27T00:00:00.000Z"), + }) + ); + }, + async appendTranscriptTurn(value) { + transcriptCalls.push(value); + }, + async getTranscript() { + return []; + }, + }; + const routing: CoworkerRoutingService = { + async route(value) { + routeCalls.push(value); + return ( + options.route ?? { + kind: "selected", + agentId: "risk", + name: "Risk Analyst", + reason: "requested", + fallback: false, + viaMention: false, + } + ); + }, + }; + const resolver: ActorAgentResolver = { + async resolveAgentsForActor() { + return {}; + }, + async resolveAgentForActor(actor, agentId) { + resolveCalls.push([actor, agentId]); + return options.resolve + ? options.resolve(actor, agentId) + : Promise.resolve(target); + }, + }; + const deps: OpenBotChannelAgentDependencies = { routing, store, resolver }; + + return { + agent: new OpenBotChannelAgent(CONVERSATION_KEY, deps), + target, + getCalls, + bindCalls, + routeCalls, + resolveCalls, + transcriptCalls, + deps, + }; +} + +async function collect(agent: OpenBotChannelAgent, runInput = input()) { + return runWithSlackExecution(execution(), () => + lastValueFrom(agent.run(runInput).pipe(toArray())), + ); +} + +describe("OpenBotChannelAgent", () => { + test("uses private execution bound before Channels invokes the agent", async () => { + const { deps } = harness(); + const agent = new OpenBotChannelAgent(CONVERSATION_KEY, deps, execution()); + + const events = await lastValueFrom(agent.run(input()).pipe(toArray())); + + expect(events).toHaveLength(2); + }); + + test("carries private execution into a deferred Channels subscription", async () => { + const { agent } = harness(); + const deferredRun = runWithSlackExecution(execution(), () => + agent.run(input()), + ); + + const events = await lastValueFrom(deferredRun.pipe(toArray())); + + expect(events).toHaveLength(2); + }); + + test("routes a first Slack turn, binds its trusted identity, and forwards events", async () => { + const { agent, target, getCalls, bindCalls, routeCalls, resolveCalls } = + harness(); + const events = await collect(agent); + + expect(getCalls).toEqual([CANONICAL_THREAD_ID]); + expect(routeCalls).toEqual([ + { actor: ACTOR, text: "Please review this risk." }, + ]); + expect(bindCalls).toEqual([ + { + channelsThreadId: CANONICAL_THREAD_ID, + provider: "slack", + providerTenantId: "tenant-1", + providerConversationId: "conversation-1", + providerThreadId: "slack-thread-1", + agentId: "risk", + agentName: "Risk Analyst", + createdByUserId: "alice", + }, + ]); + expect(resolveCalls).toEqual([[ACTOR, "risk"]]); + expect(target.received).toHaveLength(1); + expect(events).toEqual([ + { type: "CUSTOM", name: "first", value: 1 }, + { type: "CUSTOM", name: "second", value: 2 }, + ]); + }); + + test("durably records the provider-visible user and assistant turn before completing", async () => { + const reply = new ScriptedAgent(() => + of( + { + type: "TEXT_MESSAGE_START", + messageId: "reply-1", + role: "assistant", + } as BaseEvent, + { + type: "TEXT_MESSAGE_CONTENT", + messageId: "reply-1", + delta: "Recorded reply", + } as BaseEvent, + { type: "TEXT_MESSAGE_END", messageId: "reply-1" } as BaseEvent, + ), + ); + const { agent, transcriptCalls } = harness({ + resolve: async () => reply, + }); + + await collect(agent); + + expect(transcriptCalls).toEqual([ + { + channelsThreadId: CANONICAL_THREAD_ID, + user: { + id: "message-1", + role: "user", + content: "Please review this risk.", + }, + assistant: { + id: "reply-1", + role: "assistant", + content: "Recorded reply", + }, + }, + ]); + }); + + test("records the compact text chunk form emitted by built-in agents", async () => { + const reply = new ScriptedAgent(() => + of({ type: "TEXT_MESSAGE_CHUNK", delta: "Compact reply" } as BaseEvent), + ); + const { agent, transcriptCalls } = harness({ + resolve: async () => reply, + }); + + await collect(agent); + + expect(transcriptCalls).toHaveLength(1); + expect(transcriptCalls[0]).toMatchObject({ + channelsThreadId: CANONICAL_THREAD_ID, + user: { id: "message-1", content: "Please review this risk." }, + assistant: { role: "assistant", content: "Compact reply" }, + }); + expect(transcriptCalls[0]?.assistant.id).toBeString(); + }); + + test("uses an established binding and resolves it freshly for the current speaker", async () => { + const { agent, bindCalls, routeCalls, resolveCalls } = harness({ + existing: binding({ agentId: "knowledge", agentName: "Knowledge" }), + }); + await runWithSlackExecution( + execution({ actor: { id: "bob", role: "user" } }), + () => lastValueFrom(agent.run(input()).pipe(toArray())), + ); + + expect(routeCalls).toEqual([]); + expect(bindCalls).toEqual([]); + expect(resolveCalls).toEqual([[{ id: "bob", role: "user" }, "knowledge"]]); + }); + + test("resolves the binding winner when another first writer won the thread", async () => { + const { agent, bindCalls, resolveCalls } = harness({ + bound: binding({ agentId: "winner", agentName: "Thread Winner" }), + }); + + await collect(agent); + + expect(bindCalls[0]?.agentId).toBe("risk"); + expect(resolveCalls).toEqual([[ACTOR, "winner"]]); + }); + + test("keeps a linked coworker pinned when the current participant loses access", async () => { + const { agent, bindCalls, routeCalls } = harness({ + existing: binding({ agentId: "private-risk", agentName: "Private Risk" }), + resolve: async () => { + throw new Error("Coworker private-risk is unavailable to this user."); + }, + }); + + await expect(collect(agent)).rejects.toThrow( + "Coworker private-risk is unavailable to this user.", + ); + expect(bindCalls).toEqual([]); + expect(routeCalls).toEqual([]); + }); + + test("rejects a first turn with the stable no-coworker error", async () => { + const { agent, bindCalls } = harness({ route: { kind: "none" } }); + + await expect(collect(agent)).rejects.toThrow( + "No coworker is available to you.", + ); + expect(bindCalls).toEqual([]); + }); + + test("lists every ambiguous coworker name without assuming a service shape", async () => { + const { agent } = harness({ + route: { kind: "ambiguous", names: ["A", "B"] }, + }); + + await expect(collect(agent)).rejects.toThrow("Name one coworker: A, B."); + }); + + test("delegates the exact original input without leaking private Slack execution", async () => { + const { agent, target } = harness(); + const original = input(); + await runWithSlackExecution(execution(), () => + lastValueFrom(agent.run(original).pipe(toArray())), + ); + + expect(target.received[0]).toBe(original); + for (const property of [ + "provider", + "providerTenantId", + "providerConversationId", + "providerThreadId", + "applicationUser", + "actor", + "messageText", + "channelsThreadId", + "agentId", + ]) { + expect(target.received[0]).not.toHaveProperty(property); + } + expect(target.received[0].context).toBe(original.context); + expect(target.received[0].forwardedProps).toBe(original.forwardedProps); + expect(target.received[0].messages).toBe(original.messages); + }); + + test("composes a resolved remote coworker before the channel delegates its direct run", async () => { + const requests: RunAgentInput[] = []; + const fetch = async (_url: string, request: RequestInit) => { + const sent = JSON.parse(String(request.body)) as RunAgentInput; + requests.push(sent); + return new Response( + [ + { type: "RUN_STARTED", threadId: sent.threadId, runId: sent.runId }, + { type: "RUN_FINISHED", threadId: sent.threadId, runId: sent.runId }, + ] + .map((event) => `data: ${JSON.stringify(event)}\n\n`) + .join(""), + { headers: { "content-type": "text/event-stream" } }, + ); + }; + const resolver = createActorAgentResolver({ + loadAgents: async () => [ + { + id: "risk", + name: "Risk Analyst", + type: "remote_ag_ui" as const, + endpoint: "https://coworker.example/ag-ui", + standingMessage: { + id: "standing-role:risk", + role: "system" as const, + content: "You are the risk coworker.", + }, + }, + ], + model: { provider: "openai", defaultModel: "gpt-5.6-terra" }, + resolveModelApiKey: async () => null, + loadToolsForActor: (actorId) => async () => [ + { + name: "mcp__risk__lookup", + description: "Look up a risk record.", + parameters: z.object({ recordId: z.string() }), + ref: "risk/lookup", + execute: async () => actorId, + }, + ], + signRunForActor: (actorId) => (botId, runId) => + `signed:${actorId}:${botId}:${runId}`, + agentFetch: fetch, + }); + const { agent } = harness({ + resolve: resolver.resolveAgentForActor, + }); + + await collect(agent); + + const sent = requests[0]; + expect( + sent?.messages.filter((message) => message.id === "standing-role:risk"), + ).toHaveLength(1); + expect(sent?.messages[0]).toMatchObject({ + id: "standing-role:risk", + role: "system", + }); + expect( + sent?.messages.find((message) => message.id === "granted-tools:risk") + ?.content, + ).toContain("risk"); + expect(sent?.tools).toContainEqual( + expect.objectContaining({ + name: "mcp__risk__lookup", + description: "Look up a risk record.", + parameters: expect.objectContaining({ + type: "object", + properties: { recordId: { type: "string" } }, + required: ["recordId"], + additionalProperties: false, + }), + }), + ); + expect(sent?.forwardedProps).toMatchObject({ + openbotBotId: "risk", + openbotDeploymentTools: ["mcp__risk__lookup"], + openbotRun: "signed:alice:risk:run-1", + }); + for (const privateProperty of [ + "provider", + "providerTenantId", + "providerConversationId", + "providerThreadId", + "applicationUser", + "actor", + "messageText", + "channelsThreadId", + "agentId", + ]) { + expect(sent).not.toHaveProperty(privateProperty); + } + }); + + test("only updates current mutable routing fields in the private execution", async () => { + const { agent } = harness(); + await runWithSlackExecution(execution(), async () => { + const current = currentSlackExecution(); + await lastValueFrom(agent.run(input()).pipe(toArray())); + expect(current.channelsThreadId).toBe(CANONICAL_THREAD_ID); + expect(current.agentId).toBe("risk"); + expect(current).toMatchObject({ + actor: ACTOR, + applicationUser: { id: "alice", name: "Alice" }, + provider: "slack", + providerTenantId: "tenant-1", + providerConversationId: "conversation-1", + providerThreadId: "slack-thread-1", + messageText: "Please review this risk.", + }); + }); + }); + + test("clones preserve the AbstractAgent base contract and keep delegate abort state independent", async () => { + const first = new ScriptedAgent(() => NEVER); + const second = new ScriptedAgent(() => NEVER); + const { agent, deps } = harness({ + existing: binding(), + resolve: async (actor) => (actor.id === "alice" ? first : second), + }); + agent.agentId = "custom-slack-router"; + agent.description = "Custom channel router"; + agent.threadId = "configured-thread"; + agent.setMessages([{ id: "saved", role: "user", content: "saved" }]); + agent.setState({ saved: true }); + agent.pendingInterrupts = [{ id: "interrupt-1" }] as never; + agent.debug = true; + const subscriber = {}; + agent.subscribe(subscriber); + const clone = agent.clone(); + expect(clone).toBeInstanceOf(OpenBotChannelAgent); + expect(clone).not.toBe(agent); + expect(clone.agentId).toBe("custom-slack-router"); + expect(clone.description).toBe("Custom channel router"); + expect(clone.threadId).toBe("configured-thread"); + expect(clone.messages).toEqual([ + { id: "saved", role: "user", content: "saved" }, + ]); + expect(clone.messages).not.toBe(agent.messages); + expect(clone.state).toEqual({ saved: true }); + expect(clone.state).not.toBe(agent.state); + expect(clone.pendingInterrupts).toEqual([{ id: "interrupt-1" }]); + expect(clone.pendingInterrupts).not.toBe(agent.pendingInterrupts); + expect(clone.debug).toEqual(agent.debug); + expect(clone.subscribers).toEqual([subscriber]); + expect(clone.subscribers).not.toBe(agent.subscribers); + + const originalSubscription = runWithSlackExecution(execution(), () => + agent.run(input()).subscribe(), + ); + const cloneSubscription = runWithSlackExecution( + execution({ actor: { id: "bob", role: "user" } }), + () => clone.run(input()).subscribe(), + ); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(first.received).toHaveLength(1); + expect(second.received).toHaveLength(1); + agent.abortRun(); + expect(first.aborts).toBe(1); + expect(second.aborts).toBe(0); + originalSubscription.unsubscribe(); + cloneSubscription.unsubscribe(); + expect(deps).toBeDefined(); + }); + + test("rejects overlap before a delegate resolves, then accepts a sequential run", async () => { + let resolveFirst!: (target: AbstractAgent) => void; + const first = new Promise( + (resolve) => (resolveFirst = resolve), + ); + const next = new ScriptedAgent(() => + of({ type: "CUSTOM", name: "next", value: true } as BaseEvent), + ); + let calls = 0; + const { agent } = harness({ + existing: binding(), + resolve: async () => (calls++ === 0 ? first : next), + }); + const running = runWithSlackExecution(execution(), () => + lastValueFrom(agent.run(input()).pipe(toArray())), + ); + await Promise.resolve(); + + await expect( + runWithSlackExecution(execution(), () => + lastValueFrom(agent.run(input()).pipe(toArray())), + ), + ).rejects.toThrow("OpenBot Slack agent is already running."); + agent.abortRun(); + await expect(running).resolves.toEqual([]); + resolveFirst(new ScriptedAgent(() => NEVER)); + await Promise.resolve(); + + await expect(collect(agent)).resolves.toEqual([ + { type: "CUSTOM", name: "next", value: true }, + ]); + }); + + test("rejects overlap after a delegate starts without orphaning the first delegate", async () => { + const target = new ScriptedAgent(() => NEVER); + const { agent } = harness({ + existing: binding(), + resolve: async () => target, + }); + const running = runWithSlackExecution(execution(), () => + lastValueFrom(agent.run(input()).pipe(toArray())), + ); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(target.received).toHaveLength(1); + + await expect( + runWithSlackExecution(execution(), () => + lastValueFrom(agent.run(input()).pipe(toArray())), + ), + ).rejects.toThrow("OpenBot Slack agent is already running."); + agent.abortRun(); + await expect(running).resolves.toEqual([]); + expect(target.aborts).toBe(1); + }); + + test("cancels a hanging resolution without aborting its eventual target or leaking its rejection", async () => { + let rejectFirst!: (error: Error) => void; + const pending = new Promise((_resolve, reject) => { + rejectFirst = reject; + }); + const reusable = new ScriptedAgent(() => + of({ type: "CUSTOM", name: "reused", value: true } as BaseEvent), + ); + let calls = 0; + const { agent } = harness({ + existing: binding(), + resolve: async () => { + calls += 1; + return calls === 1 ? pending : reusable; + }, + }); + const running = runWithSlackExecution(execution(), () => + lastValueFrom(agent.run(input()).pipe(toArray())), + ); + await Promise.resolve(); + agent.abortRun(); + await expect(running).resolves.toEqual([]); + rejectFirst(new Error("late resolver failure")); + await Promise.resolve(); + expect(reusable.aborts).toBe(0); + + await expect(collect(agent)).resolves.toEqual([ + { type: "CUSTOM", name: "reused", value: true }, + ]); + expect(reusable.aborts).toBe(0); + }); + + test("aborts a started target once and aborts started work on unsubscribe", async () => { + const first = new ScriptedAgent(() => NEVER); + const second = new ScriptedAgent(() => NEVER); + let calls = 0; + const { agent } = harness({ + existing: binding(), + resolve: async () => (calls++ === 0 ? first : second), + }); + const active = runWithSlackExecution(execution(), () => + lastValueFrom(agent.run(input()).pipe(toArray())), + ); + await new Promise((resolve) => setTimeout(resolve, 0)); + agent.abortRun(); + await expect(active).resolves.toEqual([]); + expect(first.aborts).toBe(1); + + const subscription = runWithSlackExecution(execution(), () => + agent.run(input()).subscribe(), + ); + await new Promise((resolve) => setTimeout(resolve, 0)); + subscription.unsubscribe(); + expect(second.aborts).toBe(1); + }); + + test("an abort before resolution cannot abort a later run's target", async () => { + let resolveFirst!: (agent: AbstractAgent) => void; + const firstResolution = new Promise((resolve) => { + resolveFirst = resolve; + }); + const oldTarget = new ScriptedAgent(() => NEVER); + const futureTarget = new ScriptedAgent(() => NEVER); + let calls = 0; + const { agent } = harness({ + existing: binding(), + resolve: async () => { + calls += 1; + return calls === 1 ? firstResolution : futureTarget; + }, + }); + + const oldSubscription = runWithSlackExecution(execution(), () => + agent.run(input()).subscribe(), + ); + await Promise.resolve(); + agent.abortRun(); + const futureSubscription = runWithSlackExecution(execution(), () => + agent.run(input()).subscribe(), + ); + await Promise.resolve(); + await Promise.resolve(); + resolveFirst(oldTarget); + await Promise.resolve(); + + expect(futureTarget.aborts).toBe(0); + expect(oldTarget.aborts).toBe(0); + oldSubscription.unsubscribe(); + futureSubscription.unsubscribe(); + }); + + test("propagates resolver and delegated run failures through its observable", async () => { + const resolverFailure = harness({ + existing: binding(), + resolve: async () => { + throw new Error("resolver failed"); + }, + }); + await expect(collect(resolverFailure.agent)).rejects.toThrow( + "resolver failed", + ); + + const targetFailure = new ScriptedAgent(() => + throwError(() => new Error("target failed")), + ); + const delegatedFailure = harness({ + existing: binding(), + resolve: async () => targetFailure, + }); + await expect(collect(delegatedFailure.agent)).rejects.toThrow( + "target failed", + ); + }); +}); diff --git a/server/tests/slack-channel.integration.test.tsx b/server/tests/slack-channel.integration.test.tsx new file mode 100644 index 00000000..5dc4b4ed --- /dev/null +++ b/server/tests/slack-channel.integration.test.tsx @@ -0,0 +1,1638 @@ +import { describe, expect, test } from "bun:test"; +import { AsyncResource } from "node:async_hooks"; +import { + AbstractAgent, + type BaseEvent, + type RunAgentInput, +} from "@ag-ui/client"; +import { LLMock } from "@copilotkit/aimock"; +import type { + ChannelIdentityContext, + ChannelNode, + IncomingTurn, + InteractionEvent, + PlatformAdapter, + StateStore, +} from "@copilotkit/channels"; +import { MemoryStore } from "@copilotkit/channels"; +import { BuiltInAgent } from "@copilotkit/runtime/v2"; +import { Observable } from "rxjs"; +import { z } from "zod"; +import { + type ActorAgentResolver, + createActorAgentResolver, +} from "../src/agents/agent-resolver"; +import type { ComputerGateway } from "../src/computer/gateway"; +import type { + ExternalThreadBinding, + ExternalThreadStore, +} from "../src/external/thread-store"; +import type { CoworkerRoutingService } from "../src/routing/service"; +import { createApprovalAuthorizer } from "../src/slack/approval-authorizer"; +import type { ApprovalPresentation } from "../src/slack/approval-store"; +import { + createOpenBotSlackChannel, + type OpenBotSlackChannelDependencies, +} from "../src/slack/channel"; +import { configureApprovalDecisionStore } from "../src/slack/components"; +import type { SlackIdentityResult } from "../src/slack/identity-linker"; +import { SlackIngressRegistry } from "../src/slack/ingress-registry"; +import type { + SlackTurnFailureEvent, + SlackTurnFailureLogger, +} from "../src/slack/turn-phase"; + +type SharedRunState = { + inputs: RunAgentInput[]; + active: number; + maxActive: number; + blockRuns: boolean; + pendingFinishes: Array<() => void>; + requestApproval: boolean; + approvalPresented: boolean; + toolCall?: { name: string; args: Record }; + toolPresented: boolean; +}; + +type FakeAdapterInstance = PlatformAdapter & { + posted: ChannelNode[][]; + stateStore?: StateStore; + getCanonicalThreadId?: PlatformAdapter["getCanonicalThreadId"]; + getSink(): { + onTurn(turn: IncomingTurn): void | Promise; + onInteraction(event: InteractionEvent): void | Promise; + }; +}; + +class ShareComputerGateway implements ComputerGateway { + readonly navigateCalls: Parameters[] = []; + readonly readFileCalls: Parameters[] = []; + readFileResult: Awaited> = { + path: "reports/risk.txt", + text: "Résumé 📊", + truncated: false, + bytes: 13, + }; + + async readFile(...args: Parameters) { + this.readFileCalls.push(args); + return this.readFileResult; + } + async status(): Promise { + throw new Error("unused"); + } + async screenshot(): Promise { + throw new Error("unused"); + } + async snapshot(): Promise { + throw new Error("unused"); + } + async read(): Promise { + throw new Error("unused"); + } + async navigate(...args: Parameters) { + this.navigateCalls.push(args); + return { + url: args[2], + title: "CopilotKit", + status: 200, + elapsedMs: 1, + }; + } + async click(): Promise { + throw new Error("unused"); + } + async type(): Promise { + throw new Error("unused"); + } + async key(): Promise { + throw new Error("unused"); + } + async scroll(): Promise { + throw new Error("unused"); + } + async listFiles(): Promise { + throw new Error("unused"); + } + async runCommand(): Promise { + throw new Error("unused"); + } + async writeFile(): Promise { + throw new Error("unused"); + } + async control(): Promise { + throw new Error("unused"); + } + async requestHelp(): Promise { + throw new Error("unused"); + } + async cancelAssistance(): Promise { + throw new Error("unused"); + } + async assistanceStatus(): Promise { + throw new Error("unused"); + } + async takeControl(): Promise { + throw new Error("unused"); + } + async releaseControl(): Promise { + throw new Error("unused"); + } + async requestSecret(): Promise { + throw new Error("unused"); + } + async supplySecret(): Promise { + throw new Error("unused"); + } + async humanInput(): Promise { + throw new Error("unused"); + } + async computers(): Promise { + throw new Error("unused"); + } + async stopComputer(): Promise { + throw new Error("unused"); + } + async resetComputer(): Promise { + throw new Error("unused"); + } +} + +type FakeAdapterConstructor = new (options: { + platform: string; + messageEvents: boolean; +}) => FakeAdapterInstance; + +// Channels 0.9's umbrella testing export accidentally points at the core conformance helper. +// Resolve the package's own core dependency so this still exercises the SDK's shipped FakeAdapter. +const channelsEntry = import.meta.resolve("@copilotkit/channels"); +const fakeAdapterModule = new URL( + "../../channels-core/dist/testing/fake-adapter.js", + channelsEntry, +).href; +const { FakeAdapter } = (await import(fakeAdapterModule)) as { + FakeAdapter: FakeAdapterConstructor; +}; + +class ReplyAgent extends AbstractAgent { + constructor(private readonly shared: SharedRunState) { + super({ agentId: "risk", description: "Risk Analyst" }); + } + + override clone(): ReplyAgent { + return new ReplyAgent(this.shared); + } + + run(input: RunAgentInput): Observable { + this.shared.inputs.push(input); + this.shared.active += 1; + this.shared.maxActive = Math.max(this.shared.maxActive, this.shared.active); + return new Observable((subscriber) => { + const finish = () => { + const messageId = `reply-${this.shared.inputs.length}`; + subscriber.next({ + type: "RUN_STARTED", + threadId: input.threadId, + runId: input.runId, + }); + if ( + this.shared.toolCall && + !this.shared.toolPresented && + !input.forwardedProps?.command + ) { + this.shared.toolPresented = true; + subscriber.next({ + type: "TOOL_CALL_START", + toolCallId: "channel-tool-call-1", + toolCallName: this.shared.toolCall.name, + parentMessageId: "", + }); + subscriber.next({ + type: "TOOL_CALL_ARGS", + toolCallId: "channel-tool-call-1", + delta: JSON.stringify(this.shared.toolCall.args), + }); + subscriber.next({ + type: "TOOL_CALL_END", + toolCallId: "channel-tool-call-1", + }); + } else if ( + this.shared.requestApproval && + !this.shared.approvalPresented && + !input.forwardedProps?.command + ) { + this.shared.approvalPresented = true; + subscriber.next({ + type: "TOOL_CALL_START", + toolCallId: "approval-call-1", + toolCallName: "approval_card", + parentMessageId: "", + }); + subscriber.next({ + type: "TOOL_CALL_ARGS", + toolCallId: "approval-call-1", + delta: JSON.stringify({ question: "Deploy this release?" }), + }); + subscriber.next({ + type: "TOOL_CALL_END", + toolCallId: "approval-call-1", + }); + } else { + subscriber.next({ + type: "TEXT_MESSAGE_START", + messageId, + role: "assistant", + }); + subscriber.next({ + type: "TEXT_MESSAGE_CONTENT", + messageId, + delta: "review complete", + }); + subscriber.next({ + type: "TEXT_MESSAGE_END", + messageId, + }); + } + subscriber.next({ + type: "RUN_FINISHED", + threadId: input.threadId, + runId: input.runId, + }); + this.shared.active -= 1; + subscriber.complete(); + }; + if (this.shared.blockRuns) this.shared.pendingFinishes.push(finish); + else finish(); + }); + } +} + +function identity( + eventId: string, + actorId: string, + tenantId = "T1", + conversationId = "C1", + providerThreadId = "provider-thread-1", +): ChannelIdentityContext { + return { + provider: "slack", + tenant: { id: tenantId }, + installation: { id: "I1" }, + actor: { id: actorId, kind: "human", name: actorId }, + conversation: { id: conversationId }, + trigger: "message", + event: { id: eventId, threadId: providerThreadId }, + raw: null, + }; +} + +function interactionIdentity( + eventId: string, + actorId: string, + tenantId = "T1", + conversationId = "C1", + providerThreadId = "provider-thread-1", +): ChannelIdentityContext { + return { + ...identity(eventId, actorId, tenantId, conversationId, providerThreadId), + trigger: "interaction", + }; +} + +function turn( + eventId: string, + text: string, + options: { + actorId?: string; + mentioned?: boolean; + kind?: "created" | "updated" | "deleted"; + tenantId?: string; + conversationId?: string; + conversationKey?: string; + logicalMessageId?: string; + providerThreadId?: string; + canonicalThreadId?: string; + } = {}, +) { + const actorId = options.actorId ?? "U1"; + const kind = options.kind ?? "created"; + const tenantId = options.tenantId ?? "T1"; + const conversationId = options.conversationId ?? "C1"; + return { + eventId, + conversationKey: + options.conversationKey ?? `opaque-conversation-${conversationId}`, + replyTarget: { + canonicalThreadId: options.canonicalThreadId ?? "canonical-thread-1", + }, + userText: text, + platform: "slack", + actor: { id: actorId, kind: "human" as const, name: actorId }, + identityContext: identity( + eventId, + actorId, + tenantId, + conversationId, + options.providerThreadId, + ), + operation: { + kind, + logicalMessageId: options.logicalMessageId ?? eventId, + revisionId: `${eventId}:${kind}`, + mentioned: options.mentioned ?? true, + }, + }; +} + +function postedText(adapter: FakeAdapter): string { + return JSON.stringify(adapter.posted); +} + +function actionIds(value: unknown): string[] { + if (!value || typeof value !== "object") return []; + const node = value as { props?: Record }; + const onClick = node.props?.onClick; + const own = + onClick && typeof onClick === "object" && "id" in onClick + ? [String(onClick.id)] + : []; + const children = node.props?.children; + return [ + ...own, + ...(Array.isArray(children) + ? children.flatMap(actionIds) + : actionIds(children)), + ]; +} + +function harness( + options: { + computerGateway?: ComputerGateway; + postFileResult?: Awaited< + ReturnType> + >; + stateStore?: StateStore; + shared?: SharedRunState; + bindings?: Map; + identityLinker?: OpenBotSlackChannelDependencies["identityLinker"]; + ingressRegistry?: SlackIngressRegistry; + logTurnFailure?: SlackTurnFailureLogger; + resolver?: ActorAgentResolver; + agentId?: string; + failPost?: boolean; + failSubscribe?: boolean; + failExecutionPrepare?: boolean; + configuredTenantId?: string; + detachThreadOperations?: boolean; + cacheAgents?: boolean; + } = {}, +) { + const adapter = new FakeAdapter({ platform: "slack", messageEvents: true }); + const events: SlackTurnFailureEvent[] = []; + adapter.getCanonicalThreadId = (target) => { + const id = (target as { canonicalThreadId?: unknown }).canonicalThreadId; + if (typeof id !== "string" || !id) { + throw new Error("fake delivery requires a canonical thread id"); + } + return id; + }; + const cachedAgents = new Map(); + adapter.conversationStore.getOrCreate = async ( + conversationKey, + _replyTarget, + makeAgent, + ) => { + const cached = options.cacheAgents + ? cachedAgents.get(conversationKey) + : undefined; + const agent = cached ?? makeAgent(conversationKey); + if (options.cacheAgents) cachedAgents.set(conversationKey, agent); + return { agent }; + }; + if (options.detachThreadOperations) { + const detached = new AsyncResource("detached-channel-operation"); + adapter.trackThreadOperation = (_target, operation) => + detached.runInAsyncScope(operation); + } + adapter.stateStore = options.stateStore; + if (options.failSubscribe) { + const backing = new MemoryStore(); + adapter.stateStore = { + kv: { + ...backing.kv, + async set(key, value, ttlMs) { + if (key.startsWith("sub:")) throw new Error("subscribe failed"); + await backing.kv.set(key, value, ttlMs); + }, + }, + list: backing.list, + lock: backing.lock, + dedup: backing.dedup, + queue: backing.queue, + }; + } + if (options.failPost) { + adapter.post = async () => { + throw new Error("post failed"); + }; + } + const filePosts: Parameters>[] = []; + adapter.postFile = async (...args) => { + filePosts.push(args); + return options.postFileResult ?? { ok: true, fileId: "F1" }; + }; + const createRunRenderer = adapter.createRunRenderer.bind(adapter); + adapter.createRunRenderer = (target) => { + const renderer = createRunRenderer(target); + const onText = renderer.subscriber.onTextMessageContentEvent?.bind( + renderer.subscriber, + ); + let streamed = ""; + renderer.subscriber.onTextMessageContentEvent = (event) => { + streamed += event.event.delta; + onText?.(event); + }; + const finish = renderer.finish?.bind(renderer); + renderer.finish = async () => { + if (streamed) { + adapter.posted.push([{ type: "text", props: { value: streamed } }]); + } + await finish?.(); + }; + return renderer; + }; + const bindings = options.bindings ?? new Map(); + const bindCalls: Parameters[0][] = []; + const shared: SharedRunState = options.shared ?? { + inputs: [], + active: 0, + maxActive: 0, + blockRuns: false, + pendingFinishes: [], + requestApproval: false, + approvalPresented: false, + toolPresented: false, + }; + const target = new ReplyAgent(shared); + const actors: string[] = []; + const linker = { + async resolve( + context: ChannelIdentityContext, + ): Promise { + const providerUserId = context.actor.id; + if (providerUserId === "UNLINKED") { + return { + kind: "unlinked", + linkUrl: "https://openbot.test/link/slack?token=opaque", + identity: { + provider: "slack", + providerTenantId: context.tenant.id, + providerUserId, + providerEmail: null, + }, + }; + } + return { + kind: "linked", + user: { id: providerUserId.toLowerCase(), name: providerUserId }, + actor: { id: providerUserId.toLowerCase(), role: "user" }, + identity: { + provider: "slack", + providerTenantId: context.tenant.id, + providerUserId, + providerEmail: null, + }, + }; + }, + }; + const store: ExternalThreadStore = { + async getByChannelsThreadId(id) { + return bindings.get(id) ?? null; + }, + async getByProviderThread(identity) { + return ( + [...bindings.values()].find( + (binding) => + binding.provider === identity.provider && + binding.providerTenantId === identity.providerTenantId && + binding.providerConversationId === + identity.providerConversationId && + binding.providerThreadId === identity.providerThreadId, + ) ?? null + ); + }, + async bind(input) { + bindCalls.push(input); + const value = { + ...input, + createdAt: new Date("2026-08-27T00:00:00.000Z"), + }; + bindings.set(input.channelsThreadId, value); + return value; + }, + async appendTranscriptTurn() {}, + async getTranscript() { + return []; + }, + }; + const routing: CoworkerRoutingService = { + async route() { + return { + kind: "selected", + agentId: options.agentId ?? "risk", + name: "Risk Analyst", + reason: "requested", + fallback: false, + viaMention: true, + }; + }, + }; + const resolver: ActorAgentResolver = { + async resolveAgentsForActor() { + return { risk: target }; + }, + async resolveAgentForActor(actor) { + actors.push(actor.id); + return target; + }, + }; + const deps: OpenBotSlackChannelDependencies = { + appUrl: "https://openbot.test", + identityLinker: options.identityLinker ?? linker, + configuredTenantId: options.configuredTenantId, + agentDeps: { routing, store, resolver: options.resolver ?? resolver }, + ingressRegistry: options.ingressRegistry, + computerGateway: options.computerGateway, + logTurnFailure: options.logTurnFailure ?? ((event) => events.push(event)), + prepareExecution: options.failExecutionPrepare + ? () => { + throw new Error("prepare failed"); + } + : undefined, + }; + const channel = createOpenBotSlackChannel(deps); + channel.ɵruntime.addAdapter(adapter); + return { + adapter, + channel, + bindings, + bindCalls, + shared, + actors, + filePosts, + events, + }; +} + +function toolResult(shared: SharedRunState): Record { + const message = shared.inputs + .flatMap(({ messages }) => messages) + .findLast(({ role }) => role === "tool"); + if (message?.role !== "tool" || typeof message.content !== "string") { + throw new Error("expected a channel tool result"); + } + return JSON.parse(message.content) as Record; +} + +describe("managed OpenBot Slack channel", () => { + test("does not emit in-message tool chunks before the assistant reply", () => { + const { channel } = harness(); + + expect(channel.showToolStatus).toBe(false); + }); + + test("uses the configured tenant once when managed delivery omits it", async () => { + let seenContext: ChannelIdentityContext | undefined; + const identityLinker: OpenBotSlackChannelDependencies["identityLinker"] = { + async resolve(context) { + seenContext = context; + return { + kind: "linked", + user: { id: "u1", name: "User" }, + actor: { id: "u1", role: "user" }, + identity: { + provider: "slack", + providerTenantId: context.tenant.id, + providerUserId: context.actor.id, + providerEmail: null, + }, + }; + }, + }; + const { adapter, bindCalls, channel, events, shared } = harness({ + configuredTenantId: "T05QFA4BW9X", + identityLinker, + }); + await channel.ɵruntime.start(); + + await adapter + .getSink() + .onTurn(turn("E-tenant-fallback", "hello", { tenantId: "unknown" })); + + expect(seenContext?.tenant.id).toBe("T05QFA4BW9X"); + expect(bindCalls).toEqual([ + expect.objectContaining({ + providerTenantId: "T05QFA4BW9X", + providerConversationId: "C1", + }), + ]); + expect(shared.inputs).toHaveLength(1); + expect(events).toEqual([]); + }); + + test("rejects a known managed tenant that conflicts with configuration", async () => { + const ingressRegistry = new SlackIngressRegistry(); + let linkerCalls = 0; + const identityLinker: OpenBotSlackChannelDependencies["identityLinker"] = { + async resolve(context) { + linkerCalls += 1; + return { + kind: "linked", + user: { id: "u1", name: "User" }, + actor: { id: "u1", role: "user" }, + identity: { + provider: "slack", + providerTenantId: context.tenant.id, + providerUserId: context.actor.id, + providerEmail: null, + }, + }; + }, + }; + const { adapter, bindCalls, channel, events, shared } = harness({ + configuredTenantId: "T1", + identityLinker, + ingressRegistry, + }); + await channel.ɵruntime.start(); + + await expect( + adapter + .getSink() + .onTurn(turn("E-tenant-conflict", "hello", { tenantId: "T2" })), + ).rejects.toThrow("Channel identifyUser failed"); + + expect(linkerCalls).toBe(0); + expect( + ingressRegistry.take("E-tenant-conflict", { + provider: "slack", + providerActorId: "U1", + applicationUserId: "u1", + }), + ).toBeNull(); + expect(bindCalls).toEqual([]); + expect(shared.inputs).toEqual([]); + expect(events).toEqual([ + { + type: "slack-turn-failed", + phase: "identity.resolve", + reason: "slack_identity_tenant_invalid", + }, + ]); + }); + + test("fails closed when managed delivery omits tenant and no fallback is configured", async () => { + const ingressRegistry = new SlackIngressRegistry(); + let linkerCalls = 0; + const identityLinker: OpenBotSlackChannelDependencies["identityLinker"] = { + async resolve() { + linkerCalls += 1; + throw new Error("linker must not run"); + }, + }; + const { adapter, bindCalls, channel, events, shared } = harness({ + identityLinker, + ingressRegistry, + }); + await channel.ɵruntime.start(); + + await expect( + adapter + .getSink() + .onTurn(turn("E-tenant-missing", "hello", { tenantId: "unknown" })), + ).rejects.toThrow("Channel identifyUser failed"); + + expect(linkerCalls).toBe(0); + expect( + ingressRegistry.take("E-tenant-missing", { + provider: "slack", + providerActorId: "U1", + applicationUserId: null, + }), + ).toBeNull(); + expect(bindCalls).toEqual([]); + expect(shared.inputs).toEqual([]); + expect(events).toEqual([ + { + type: "slack-turn-failed", + phase: "identity.resolve", + reason: "slack_identity_tenant_invalid", + }, + ]); + }); + + test("reports identity.resolve without leaking the error", async () => { + const identityLinker: OpenBotSlackChannelDependencies["identityLinker"] = { + async resolve() { + throw new Error("secret identity detail"); + }, + }; + const { adapter, channel, events } = harness({ identityLinker }); + await channel.ɵruntime.start(); + + await expect( + adapter.getSink().onTurn(turn("E-resolve-fail", "hello")), + ).rejects.toThrow("Channel identifyUser failed"); + + expect(events).toEqual([ + { type: "slack-turn-failed", phase: "identity.resolve" }, + ]); + expect(JSON.stringify(events)).not.toContain("secret identity detail"); + }); + + test("reports ingress.remember without leaking the error", async () => { + class FailingRememberRegistry extends SlackIngressRegistry { + override remember(): void { + throw new Error("remember failed"); + } + } + + const { adapter, channel, events } = harness({ + ingressRegistry: new FailingRememberRegistry(), + }); + await channel.ɵruntime.start(); + + await expect( + adapter.getSink().onTurn(turn("E-remember-fail", "hello")), + ).rejects.toThrow("Channel identifyUser failed"); + + expect(events).toEqual([ + { type: "slack-turn-failed", phase: "ingress.remember" }, + ]); + }); + + test("reports ingress.take without binding or running", async () => { + class FailingTakeRegistry extends SlackIngressRegistry { + override take(): never { + throw new Error("take failed"); + } + } + + const { adapter, channel, bindCalls, events, shared } = harness({ + ingressRegistry: new FailingTakeRegistry(), + }); + await channel.ɵruntime.start(); + + await expect( + adapter.getSink().onTurn(turn("E-take-fail", "hello")), + ).rejects.toThrow("take failed"); + + expect(events).toEqual([ + { type: "slack-turn-failed", phase: "ingress.take" }, + ]); + expect(bindCalls).toEqual([]); + expect(shared.inputs).toEqual([]); + }); + + test("an unlinked mention posts a link without running or binding", async () => { + const { adapter, channel, bindCalls, shared } = harness(); + await channel.ɵruntime.start(); + + await adapter + .getSink() + .onTurn(turn("E-unlinked", "hello", { actorId: "UNLINKED" })); + + expect(postedText(adapter)).toContain("Link OpenBot account"); + expect(postedText(adapter)).toContain("opaque"); + expect(bindCalls).toEqual([]); + expect(shared.inputs).toEqual([]); + }); + + test("reports link_card.post without binding or running", async () => { + const { adapter, channel, bindCalls, events, shared } = harness({ + failPost: true, + }); + await channel.ɵruntime.start(); + + await expect( + adapter + .getSink() + .onTurn( + turn("E-link-card-post-fail", "hello", { actorId: "UNLINKED" }), + ), + ).rejects.toThrow("post failed"); + + expect(events).toEqual([ + { type: "slack-turn-failed", phase: "link_card.post" }, + ]); + expect(bindCalls).toEqual([]); + expect(shared.inputs).toEqual([]); + }); + + test("reports thread.subscribe without binding or running", async () => { + const { adapter, channel, bindCalls, events, shared } = harness({ + failSubscribe: true, + }); + await channel.ɵruntime.start(); + + await expect( + adapter.getSink().onTurn(turn("E-thread-subscribe-fail", "hello")), + ).rejects.toThrow("subscribe failed"); + + expect(events).toEqual([ + { type: "slack-turn-failed", phase: "thread.subscribe" }, + ]); + expect(bindCalls).toEqual([]); + expect(shared.inputs).toEqual([]); + }); + + test("reports execution.prepare without binding or running", async () => { + const { adapter, channel, bindCalls, events, shared } = harness({ + failExecutionPrepare: true, + }); + await channel.ɵruntime.start(); + + await expect( + adapter.getSink().onTurn(turn("E-execution-prepare-fail", "hello")), + ).rejects.toThrow("prepare failed"); + + expect(events).toEqual([ + { type: "slack-turn-failed", phase: "execution.prepare" }, + ]); + expect(bindCalls).toEqual([]); + expect(shared.inputs).toEqual([]); + }); + + test("reports agent.run without serializing sensitive resolver details", async () => { + const resolver: ActorAgentResolver = { + async resolveAgentsForActor() { + return {}; + }, + async resolveAgentForActor() { + throw new Error("secret delegated run detail"); + }, + }; + const { adapter, channel, events, shared } = harness({ resolver }); + await channel.ɵruntime.start(); + + await expect( + adapter.getSink().onTurn(turn("E-agent-run-fail", "hello")), + ).rejects.toThrow("secret delegated run detail"); + + expect(events).toEqual([{ type: "slack-turn-failed", phase: "agent.run" }]); + expect(JSON.stringify(events)).not.toContain("secret delegated run detail"); + expect(shared.inputs).toEqual([]); + }); + + test("a linked mention subscribes, binds, and streams the pinned coworker", async () => { + const { adapter, channel, bindCalls, shared } = harness(); + await channel.ɵruntime.start(); + + await adapter + .getSink() + .onTurn(turn("E1", "ask Risk Analyst to review this")); + + expect(bindCalls[0]).toMatchObject({ + channelsThreadId: "opaque-conversation-C1", + provider: "slack", + providerTenantId: "T1", + providerConversationId: "C1", + providerThreadId: "provider-thread-1", + agentId: "risk", + createdByUserId: "u1", + }); + expect(shared.inputs).toHaveLength(1); + expect(postedText(adapter)).toContain("review complete"); + expect(postedText(adapter)).toContain( + "https://openbot.test/slack/thread/opaque-conversation-C1", + ); + expect(postedText(adapter)).toContain("Open in OpenBot"); + }); + + test("carries private execution across the managed delivery operation boundary", async () => { + const { adapter, channel, shared } = harness({ + detachThreadOperations: true, + }); + await channel.ɵruntime.start(); + + await adapter + .getSink() + .onTurn(turn("E-managed-boundary", "review this deployment")); + + expect(shared.inputs).toHaveLength(1); + expect(postedText(adapter)).toContain("review complete"); + }); + + test("treats managed conversation and canonical thread ids as opaque capabilities", async () => { + const { adapter, channel, bindCalls } = harness(); + await channel.ɵruntime.start(); + + await adapter.getSink().onTurn( + turn("E-opaque", "review", { + conversationKey: "opaque-conversation-capability-7f31", + canonicalThreadId: "opaque-canonical-thread-a921", + tenantId: "T1:attacker-looking-tenant", + conversationId: "C1:attacker-looking-conversation", + providerThreadId: "P1:attacker-looking-thread", + }), + ); + + expect(bindCalls[0]).toMatchObject({ + channelsThreadId: "opaque-conversation-capability-7f31", + providerTenantId: "T1:attacker-looking-tenant", + providerConversationId: "C1:attacker-looking-conversation", + providerThreadId: "P1:attacker-looking-thread", + }); + expect(JSON.stringify(bindCalls[0])).not.toContain( + "opaque-canonical-thread-a921", + ); + }); + + test("subscribed replies run without mentions and recheck each participant", async () => { + const { adapter, channel, actors } = harness(); + await channel.ɵruntime.start(); + await adapter.getSink().onTurn(turn("E1", "start")); + await adapter + .getSink() + .onTurn(turn("E2", "follow up", { actorId: "U2", mentioned: false })); + + expect(actors).toEqual(["u1", "u2"]); + }); + + test("ignores edits and deletions and deduplicates repeated creates", async () => { + const { adapter, channel, shared } = harness(); + await channel.ɵruntime.start(); + const sink = adapter.getSink(); + await sink.onTurn(turn("E1", "start")); + await sink.onTurn(turn("E1", "start")); + await sink.onTurn(turn("E2", "edited", { kind: "updated" })); + await sink.onTurn(turn("E3", "deleted", { kind: "deleted" })); + + expect(shared.inputs).toHaveLength(1); + }); + + for (const order of [ + ["U1", "U2"], + ["U2", "U1"], + ] as const) { + test(`same provider event id cannot cross-pair principals (${order.join(" then ")})`, async () => { + const ingressRegistry = new SlackIngressRegistry(); + const { adapter, channel, bindCalls, actors } = harness({ + ingressRegistry, + }); + await channel.ɵruntime.start(); + const turns = { + U1: turn("E-collision", "first", { + actorId: "U1", + conversationId: "C1", + conversationKey: "opaque-conversation-c1", + canonicalThreadId: "opaque-canonical-c1", + providerThreadId: "provider-thread-c1", + logicalMessageId: "M1", + }), + U2: turn("E-collision", "second", { + actorId: "U2", + conversationId: "C2", + conversationKey: "opaque-conversation-c2", + canonicalThreadId: "opaque-canonical-c2", + providerThreadId: "provider-thread-c2", + logicalMessageId: "M2", + }), + }; + + await Promise.all( + order.map((actorId) => adapter.getSink().onTurn(turns[actorId])), + ); + + expect(new Set(actors)).toEqual(new Set(["u1", "u2"])); + expect(bindCalls).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + channelsThreadId: "opaque-conversation-c1", + providerConversationId: "C1", + createdByUserId: "u1", + }), + expect.objectContaining({ + channelsThreadId: "opaque-conversation-c2", + providerConversationId: "C2", + createdByUserId: "u2", + }), + ]), + ); + }); + } + + test("a provider/canonical principal mismatch fails before subscription, binding, or run", async () => { + const identityLinker: OpenBotSlackChannelDependencies["identityLinker"] = { + async resolve(context) { + return { + kind: "linked", + user: { id: "victim", name: "Victim" }, + actor: { id: "victim", role: "user" }, + identity: { + provider: "slack", + providerTenantId: context.tenant.id, + providerUserId: "DIFFERENT-SLACK-ACTOR", + providerEmail: null, + }, + }; + }, + }; + const { adapter, channel, bindCalls, events, shared } = harness({ + identityLinker, + }); + await channel.ɵruntime.start(); + + await expect( + adapter.getSink().onTurn(turn("E-mismatch", "start")), + ).rejects.toThrow("identity is no longer available"); + await adapter + .getSink() + .onTurn(turn("E-after", "follow up", { mentioned: false })); + + expect(bindCalls).toEqual([]); + expect(shared.inputs).toEqual([]); + expect(adapter.posted).toEqual([]); + expect(events).toEqual([ + { type: "slack-turn-failed", phase: "identity.validate" }, + ]); + }); + + test("passes multimodal content parts and serializes overlapping thread turns", async () => { + const { adapter, channel, shared } = harness(); + await channel.ɵruntime.start(); + shared.blockRuns = true; + const first = adapter.getSink().onTurn({ + ...turn("E1", "start"), + contentParts: [{ type: "text" as const, text: "from a content part" }], + }); + await new Promise((resolve) => setTimeout(resolve, 0)); + const second = adapter + .getSink() + .onTurn(turn("E2", "follow up", { mentioned: false })); + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(shared.inputs).toHaveLength(1); + expect(shared.inputs[0]?.messages.at(-1)?.content).toEqual([ + { type: "text", text: "from a content part" }, + ]); + expect(shared.maxActive).toBe(1); + shared.blockRuns = false; + shared.pendingFinishes.shift()?.(); + await Promise.all([first, second]); + expect(shared.inputs).toHaveLength(2); + expect(shared.maxActive).toBe(1); + }); + + test("exposes governed computer tools, file sharing, and approvals to the delegated run", async () => { + // No handler is invoked in this registration test; Task 9 exercises each operation against a + // full fake gateway, including successful/truncated/refused file uploads. + const computerGateway = {} as ComputerGateway; + const { adapter, channel, shared } = harness({ computerGateway }); + await channel.ɵruntime.start(); + await adapter.getSink().onTurn(turn("E-tools", "start")); + + const names = shared.inputs[0]?.tools.map(({ name }) => name); + expect(names).toContain("computer_navigate"); + expect(names).toContain("computer_share_file"); + expect(names).toContain("approval_card"); + }); + + test("executes UTF-8 file sharing through the managed channel", async () => { + const gateway = new ShareComputerGateway(); + const { adapter, channel, shared, filePosts } = harness({ + computerGateway: gateway, + }); + shared.toolCall = { + name: "computer_share_file", + args: { path: "reports/risk.txt", filename: "résumé.txt" }, + }; + await channel.ɵruntime.start(); + await adapter.getSink().onTurn(turn("E-share", "share the report")); + + expect(gateway.readFileCalls).toEqual([ + ["risk", { id: "u1", userId: "u1" }, { path: "reports/risk.txt" }], + ]); + expect(filePosts).toHaveLength(1); + expect(filePosts[0]?.[1]).toEqual({ + bytes: new TextEncoder().encode("Résumé 📊"), + filename: "résumé.txt", + }); + expect(toolResult(shared)).toMatchObject({ + ok: true, + shared: true, + filename: "résumé.txt", + fileId: "F1", + }); + }); + + test("a cached agent uses the current Slack execution for a later computer tool", async () => { + const gateway = new ShareComputerGateway(); + const { adapter, channel, shared } = harness({ + computerGateway: gateway, + cacheAgents: true, + detachThreadOperations: true, + }); + await channel.ɵruntime.start(); + + await adapter.getSink().onTurn(turn("E-first", "hello")); + shared.toolCall = { + name: "computer_navigate", + args: { url: "https://copilotkit.ai" }, + }; + await adapter.getSink().onTurn( + turn("E-second", "open it", { + actorId: "U2", + mentioned: false, + }), + ); + + expect(gateway.navigateCalls).toEqual([ + ["risk", { id: "u2", userId: "u2" }, "https://copilotkit.ai"], + ]); + expect(toolResult(shared)).toMatchObject({ + ok: true, + url: "https://copilotkit.ai", + }); + }); + + test("refuses truncated and adapter-rejected file shares explicitly", async () => { + const truncated = new ShareComputerGateway(); + truncated.readFileResult = { + path: "large.txt", + text: "partial", + truncated: true, + bytes: 999, + }; + const first = harness({ computerGateway: truncated }); + first.shared.toolCall = { + name: "computer_share_file", + args: { path: "large.txt" }, + }; + await first.channel.ɵruntime.start(); + await first.adapter.getSink().onTurn(turn("E-large", "share it")); + expect(first.filePosts).toEqual([]); + expect(toolResult(first.shared)).toMatchObject({ + ok: false, + reason: expect.stringContaining("too large"), + }); + + const refused = new ShareComputerGateway(); + const second = harness({ + computerGateway: refused, + postFileResult: { ok: false, error: "Slack rejected this file type." }, + }); + second.shared.toolCall = { + name: "computer_share_file", + args: { path: "reports/risk.txt" }, + }; + await second.channel.ɵruntime.start(); + await second.adapter.getSink().onTurn(turn("E-refused", "share it")); + expect(second.filePosts).toHaveLength(1); + expect(toolResult(second.shared)).toEqual({ + ok: false, + reason: "Slack rejected this file type.", + }); + }); + + test("shared actor resolver carries managed inputs and current grants to built-in and remote coworkers", async () => { + const role = "You are the same standing risk coworker in every channel."; + const grantActors: string[] = []; + const signed: string[] = []; + const grantedFor = (actorId: string) => async () => { + grantActors.push(actorId); + return [ + { + name: "mcp__risk__lookup", + description: "Look up a risk record.", + parameters: z.object({ recordId: z.string() }), + ref: "risk/lookup", + execute: async () => actorId, + }, + ]; + }; + const llm = new LLMock(); + const priorBase = process.env.OPENAI_BASE_URL; + const priorKey = process.env.OPENAI_API_KEY; + const baseUrl = await llm.start(); + process.env.OPENAI_BASE_URL = baseUrl; + process.env.OPENAI_API_KEY = "managed-channel-test-key"; + llm.onMessage(/.*/, { type: "text", content: "built-in complete" }); + + try { + const builtResolver = createActorAgentResolver({ + loadAgents: async () => [ + { + id: "analyst", + name: "Analyst", + type: "built_in" as const, + systemPrompt: role, + }, + ], + model: { provider: "openai", defaultModel: "gpt-5.5" }, + resolveModelApiKey: async () => "managed-channel-test-key", + loadToolsForActor: grantedFor, + signRunForActor: (actorId) => (botId, runId) => { + const assertion = `signed:${actorId}:${botId}:${runId}`; + signed.push(assertion); + return assertion; + }, + }); + const originalBuilt = await builtResolver.resolveAgentForActor( + { id: "u1", role: "user" }, + "analyst", + ); + const clonedBuilt = originalBuilt.clone() as AbstractAgent; + expect(clonedBuilt).toBeInstanceOf(BuiltInAgent); + expect(Object.getPrototypeOf(clonedBuilt)).toBe( + Object.getPrototypeOf(originalBuilt), + ); + const clonedResolver: ActorAgentResolver = { + async resolveAgentsForActor() { + return { analyst: clonedBuilt }; + }, + async resolveAgentForActor(actor, agentId) { + expect(actor.id).toBe("u1"); + expect(agentId).toBe("analyst"); + return clonedBuilt; + }, + }; + const built = harness({ resolver: clonedResolver, agentId: "analyst" }); + await built.channel.ɵruntime.start(); + await built.adapter.getSink().onTurn({ + ...turn("E-built", "ignored fallback"), + contentParts: [{ type: "text", text: "inspect built-in content" }], + }); + await built.channel.ɵruntime.stop(); + + const builtRequest = llm.getRequests().at(-1)?.body as { + messages?: Array<{ role?: string; content?: unknown }>; + tools?: Array<{ function?: { name?: string } }>; + }; + expect( + builtRequest.messages?.some( + ({ role: messageRole, content }) => + messageRole === "system" && String(content).includes(role), + ), + ).toBe(true); + expect(JSON.stringify(builtRequest.messages)).toContain( + "inspect built-in content", + ); + const builtTools = (builtRequest.tools ?? []).map( + (tool) => tool.function?.name, + ); + expect(builtTools).toContain("mcp__risk__lookup"); + expect(builtTools).toContain("approval_card"); + expect(JSON.stringify(builtRequest)).not.toContain("signed:u1:analyst:"); + expect(JSON.stringify(builtRequest)).not.toContain("providerTenantId"); + + const remoteInputs: RunAgentInput[] = []; + const remoteResolver = createActorAgentResolver({ + loadAgents: async () => [ + { + id: "risk", + name: "Risk", + type: "remote_ag_ui" as const, + endpoint: "https://risk.example/ag-ui", + standingMessage: { + id: "standing-role:risk", + role: "system" as const, + content: role, + }, + }, + ], + model: { provider: "openai", defaultModel: "gpt-5.5" }, + resolveModelApiKey: async () => null, + loadToolsForActor: grantedFor, + signRunForActor: (actorId) => (botId, runId) => { + const assertion = `signed:${actorId}:${botId}:${runId}`; + signed.push(assertion); + return assertion; + }, + agentFetch: async (_input, init) => { + const sent = JSON.parse(String(init?.body)) as RunAgentInput; + remoteInputs.push(sent); + return new Response( + [ + { + type: "RUN_STARTED", + threadId: sent.threadId, + runId: sent.runId, + }, + { + type: "RUN_FINISHED", + threadId: sent.threadId, + runId: sent.runId, + }, + ] + .map((event) => `data: ${JSON.stringify(event)}\n\n`) + .join(""), + { headers: { "content-type": "text/event-stream" } }, + ); + }, + }); + const remote = harness({ resolver: remoteResolver, agentId: "risk" }); + await remote.channel.ɵruntime.start(); + await remote.adapter.getSink().onTurn({ + ...turn("E-remote", "ignored fallback"), + contentParts: [{ type: "text", text: "inspect remote content" }], + }); + await remote.channel.ɵruntime.stop(); + + const sent = remoteInputs[0]; + expect(sent?.messages[0]).toMatchObject({ + role: "system", + content: role, + }); + expect(JSON.stringify(sent?.messages)).toContain( + "inspect remote content", + ); + expect(sent?.tools.map(({ name }) => name)).toEqual( + expect.arrayContaining(["approval_card", "mcp__risk__lookup"]), + ); + expect(sent?.forwardedProps).toMatchObject({ + openbotBotId: "risk", + openbotDeploymentTools: ["mcp__risk__lookup"], + openbotRun: expect.stringContaining("signed:u1:risk:"), + }); + expect(grantActors).toEqual(["u1", "u1"]); + expect(signed).toEqual([ + expect.stringContaining("signed:u1:analyst:"), + expect.stringContaining("signed:u1:risk:"), + ]); + for (const input of [sent]) { + expect(JSON.stringify(input)).not.toContain("providerTenantId"); + expect(JSON.stringify(input)).not.toContain("channelsConversationKey"); + } + } finally { + await llm.stop(); + if (priorBase === undefined) delete process.env.OPENAI_BASE_URL; + else process.env.OPENAI_BASE_URL = priorBase; + if (priorKey === undefined) delete process.env.OPENAI_API_KEY; + else process.env.OPENAI_API_KEY = priorKey; + } + }); + + test("an authorized approval interaction resumes the pinned agent as the current participant", async () => { + const presentations = new Map(); + configureApprovalDecisionStore( + { + async present(value) { + presentations.set(value.presentationId, { + ...value, + createdAt: new Date(), + }); + }, + async get(id) { + return presentations.get(id) ?? null; + }, + async begin() { + return "first"; + }, + async complete() {}, + async cleanup() { + return 0; + }, + }, + { + authorize: async ({ userId }) => ({ + actor: { id: userId, role: "user" }, + applicationUser: { id: userId, name: userId }, + provider: "slack", + providerTenantId: "T1", + providerConversationId: "C1", + providerThreadId: "provider-thread-1", + }), + }, + ); + const { adapter, channel, shared, actors } = harness(); + shared.requestApproval = true; + await channel.ɵruntime.start(); + await adapter.getSink().onTurn(turn("E-approval", "deploy")); + expect(shared.inputs).toHaveLength(2); + const approve = adapter.posted.flat().flatMap(actionIds)[0]; + expect(approve).toBeDefined(); + + await adapter.getSink().onInteraction({ + id: approve!, + conversationKey: "opaque-conversation-C1", + replyTarget: { canonicalThreadId: "canonical-thread-1" }, + eventId: "E-click", + actor: { id: "U2", kind: "human", name: "U2" }, + identityContext: interactionIdentity("E-click", "U2"), + }); + + expect(actors.at(-1)).toBe("u2"); + expect(shared.inputs.at(-1)?.forwardedProps?.command).toEqual({ + resume: { approved: true }, + }); + }); + + test("cold approval recovery reauthorizes the current participant and reconstructs private execution", async () => { + const state = new MemoryStore(); + const presentations = new Map(); + let active = true; + let accessible = true; + const bindings = new Map(); + const authorizationBinding: ExternalThreadBinding = { + channelsThreadId: "opaque-conversation-C1", + provider: "slack", + providerTenantId: "T1", + providerConversationId: "C1", + providerThreadId: "provider-thread-1", + agentId: "risk", + agentName: "Risk Analyst", + createdByUserId: "u1", + createdAt: new Date("2026-08-27T00:00:00.000Z"), + }; + bindings.set(authorizationBinding.channelsThreadId, authorizationBinding); + const authorizer = createApprovalAuthorizer({ + links: { + async find() { + return null; + }, + async findVerifiedUserByEmail() { + return null; + }, + async link() { + throw new Error("unused"); + }, + async resolveActiveUser(id) { + return active ? { id, name: `Current ${id}`, role: "user" } : null; + }, + }, + threads: { + async getByChannelsThreadId(id) { + return bindings.get(id) ?? null; + }, + async getByProviderThread() { + return null; + }, + async bind() { + throw new Error("unused"); + }, + }, + profiles: { + async get() { + return accessible ? ({} as never) : null; + }, + }, + }); + configureApprovalDecisionStore( + { + async present(value) { + presentations.set(value.presentationId, { + ...value, + createdAt: new Date(), + }); + }, + async get(id) { + return presentations.get(id) ?? null; + }, + async begin() { + return "first"; + }, + async complete() {}, + async cleanup() { + return 0; + }, + }, + { authorize: authorizer }, + ); + const identityLinker: OpenBotSlackChannelDependencies["identityLinker"] = { + async resolve(context) { + if (context.actor.id === "UNLINKED") { + return { + kind: "unlinked", + linkUrl: "https://openbot.test/link/slack?token=opaque", + identity: { + provider: "slack", + providerTenantId: context.tenant.id, + providerUserId: context.actor.id, + providerEmail: null, + }, + }; + } + const applicationUserId = context.actor.id.startsWith("U2") + ? "u2" + : context.actor.id.toLowerCase(); + return { + kind: "linked", + user: { + id: applicationUserId, + name: context.actor.id, + }, + actor: { id: applicationUserId, role: "user" }, + identity: { + provider: "slack", + providerTenantId: context.tenant.id, + providerUserId: + context.actor.id === "U2-FOREIGN" ? "U2" : context.actor.id, + providerEmail: null, + }, + }; + }, + }; + + const first = harness({ stateStore: state, bindings, identityLinker }); + first.shared.requestApproval = true; + await first.channel.ɵruntime.start(); + await first.adapter.getSink().onTurn(turn("E-cold", "deploy")); + const approve = first.adapter.posted.flat().flatMap(actionIds)[0]; + expect(approve).toBeDefined(); + const providerAction = first.adapter.posted + .flat() + .find((node) => actionIds(node).includes(approve!)); + expect(JSON.stringify(providerAction)).not.toContain("providerTenantId"); + expect(JSON.stringify(providerAction)).not.toContain("channelsThreadId"); + await first.channel.ɵruntime.stop(); + + const restarted = harness({ + stateStore: state, + shared: first.shared, + bindings, + identityLinker, + }); + await restarted.channel.ɵruntime.start(); + const click = ( + eventId: string, + actorId: string, + overrides: Partial = {}, + ) => + restarted.adapter.getSink().onInteraction({ + id: approve!, + value: { + approved: false, + providerTenantId: "provider-tampered", + channelsThreadId: "provider-tampered", + }, + conversationKey: "opaque-conversation-C1", + replyTarget: { canonicalThreadId: "canonical-thread-1" }, + eventId, + actor: { id: actorId, kind: "human", name: actorId }, + identityContext: interactionIdentity(eventId, actorId), + ...overrides, + }); + + await expect( + click("E-wrong-conversation", "U2", { + conversationKey: "opaque-foreign-conversation", + }), + ).rejects.toThrow("authorized"); + await expect( + click("E-wrong-tenant", "U2", { + identityContext: { + ...interactionIdentity("E-wrong-tenant", "U2"), + tenant: { id: "T2" }, + }, + }), + ).rejects.toThrow("authorized"); + await expect( + click("E-wrong-provider-conversation", "U2", { + identityContext: interactionIdentity( + "E-wrong-provider-conversation", + "U2", + "T1", + "C9", + ), + }), + ).rejects.toThrow("authorized"); + await expect( + click("E-wrong-provider-thread", "U2", { + identityContext: interactionIdentity( + "E-wrong-provider-thread", + "U2", + "T1", + "C1", + "provider-thread-foreign", + ), + }), + ).rejects.toThrow("authorized"); + await expect( + click("E-wrong-provider-actor", "U2-FOREIGN", { + identityContext: interactionIdentity( + "E-wrong-provider-actor", + "U2-FOREIGN", + "T1", + "C1", + ), + }), + ).rejects.toThrow("authorized"); + await expect(click("E-unlinked-click", "UNLINKED")).rejects.toThrow( + "authorized", + ); + active = false; + await expect(click("E-revoked", "U2")).rejects.toThrow("authorized"); + active = true; + accessible = false; + await expect(click("E-no-access", "U2")).rejects.toThrow("authorized"); + accessible = true; + + await click("E-authorized", "U2"); + expect(restarted.actors.at(-1)).toBe("u2"); + expect(restarted.shared.inputs.at(-1)?.forwardedProps?.command).toEqual({ + resume: { approved: true }, + }); + await restarted.channel.ɵruntime.stop(); + }); +}); diff --git a/server/tests/slack-computer-tools.test.ts b/server/tests/slack-computer-tools.test.ts new file mode 100644 index 00000000..2ba2e26f --- /dev/null +++ b/server/tests/slack-computer-tools.test.ts @@ -0,0 +1,1789 @@ +import { describe, expect, spyOn, test } from "bun:test"; +import { + ActionRegistry, + type ChannelTool, + type ChannelToolContext, + defineChannelTool, + FakeAdapter, + InMemoryActionStore, + MemoryStore, + type PlatformAdapter, + parseToolArgs, + Thread, + type ThreadDeps, +} from "@copilotkit/channels"; +import { + ActionRefusedError, + type ComputerGateway, + ComputerUnavailableError, + ElementNotFoundError, + HumanHasControlError, + NavigationRefusedError, + StaleSnapshotError, + WorkspaceRefusedError, + WorkspaceRequestError, +} from "../src/computer/gateway"; +import { + createSlackComputerTools, + type SlackComputerTool, +} from "../src/slack/computer-tools"; +import { + runWithSlackExecution, + type SlackExecution, +} from "../src/slack/execution-context"; + +const STOPPED = { ok: false, stopped: true, reason: "Stopped." }; +const UNAVAILABLE = { + ok: false, + reason: "The assistant's computer could not be reached.", +}; +const CONTEXT_UNAVAILABLE = { + ok: false, + reason: + "The computer action could not start because its Slack context was unavailable.", +}; +const ACTION_FAILED = { + ok: false, + reason: "The computer action failed.", +}; + +type LoggedComputerFailure = { + type?: unknown; + error?: unknown; + context?: { + integration?: unknown; + operation?: unknown; + errorCategory?: unknown; + }; + timestamp?: unknown; +}; + +function isLoggedComputerFailure( + value: unknown, +): value is LoggedComputerFailure { + return ( + typeof value === "object" && + value !== null && + "type" in value && + value.type === "slack-computer-tool-failed" + ); +} + +function capturedComputerFailures(calls: unknown[][]): LoggedComputerFailure[] { + return calls.flatMap(([value]) => { + try { + const parsed: unknown = JSON.parse(String(value)); + return isLoggedComputerFailure(parsed) ? [parsed] : []; + } catch { + return []; + } + }); +} + +function deferred() { + let resolve!: (value: T | PromiseLike) => void; + let reject!: (error: unknown) => void; + const promise = new Promise((resolvePromise, rejectPromise) => { + resolve = resolvePromise; + reject = rejectPromise; + }); + return { promise, resolve, reject }; +} + +type UploadArgs = Parameters>[1]; +type UploadResult = Awaited< + ReturnType> +>; + +class FileAdapter extends FakeAdapter { + readonly uploads: UploadArgs[] = []; + readonly effects: Array<"message" | "file"> = []; + result: UploadResult = { ok: true, fileId: "slack-file-1" }; + afterUpload?: () => void; + beforePost?: () => void; + postError?: unknown; + postGate?: Promise; + + override async post(...args: Parameters) { + this.effects.push("message"); + this.beforePost?.(); + if (this.postError !== undefined) throw this.postError; + await this.postGate; + return super.post(...args); + } + + async postFile( + _target: Parameters>[0], + args: UploadArgs, + ): Promise { + this.effects.push("file"); + this.uploads.push(args); + this.afterUpload?.(); + return this.result; + } +} + +function channelContext( + adapter: FileAdapter, + signal?: AbortSignal, +): ChannelToolContext { + const actor = { id: "provider-U999", kind: "human" } as const; + const user = { id: "u1", name: "OpenBot User" }; + const deps: ThreadDeps = { + adapter, + platform: "slack", + replyTarget: { channelId: "C1", threadTs: "1.2" }, + conversationKey: "slack:T1:C1:1.2", + channelName: "openbot", + threadId: "channels-thread-1", + registry: new ActionRegistry({ store: new InMemoryActionStore() }), + agentFactory(id) { + throw new Error(`Agent ${id} is not used by this test.`); + }, + tools: new Map(), + toolDescriptors: [], + context: [], + registerWaiter() {}, + interruptHandlers: new Map(), + state: new MemoryStore(), + user, + actor, + }; + return { + thread: new Thread(deps), + user, + actor, + signal, + platform: "slack", + }; +} + +function execution(overrides: Partial = {}): SlackExecution { + return { + actor: { id: "u1", role: "user" }, + applicationUser: { id: "u1", name: "OpenBot User" }, + provider: "slack", + providerTenantId: "T1", + providerConversationId: "C1", + providerThreadId: "1.2", + messageText: "Use the computer.", + agentId: "risk", + ...overrides, + }; +} + +class FakeComputerGateway implements ComputerGateway { + declare readonly provider: ComputerGateway["provider"]; + + readonly navigateCalls: Parameters[] = []; + readonly screenshotCalls: Parameters[] = []; + readonly snapshotCalls: Parameters[] = []; + readonly readCalls: Parameters[] = []; + readonly clickCalls: Parameters[] = []; + readonly typeCalls: Parameters[] = []; + readonly keyCalls: Parameters[] = []; + readonly scrollCalls: Parameters[] = []; + readonly listFilesCalls: Parameters[] = []; + readonly readFileCalls: Parameters[] = []; + readonly runCommandCalls: Parameters[] = []; + readonly writeFileCalls: Parameters[] = []; + readonly controlCalls: Parameters[] = []; + readonly requestHelpCalls: Parameters[] = []; + readonly requestSecretCalls: Parameters[] = + []; + readonly releaseControlCalls: Parameters< + ComputerGateway["releaseControl"] + >[] = []; + readonly cancelAssistanceCalls: Parameters< + ComputerGateway["cancelAssistance"] + >[] = []; + readonly assistanceStatusCalls: Parameters< + ComputerGateway["assistanceStatus"] + >[] = []; + nextError?: unknown; + afterCall?: () => void; + afterRequest?: () => void; + requestError?: unknown; + requestIdentityMismatch = false; + releaseError?: unknown; + cancelError?: unknown; + cancelResult?: Awaited>; + assistanceStatusResult: Awaited< + ReturnType + > = "completed"; + assistanceStatusResults?: Array< + Awaited> + >; + readFileResult: Awaited> = { + path: "reports/risk.txt", + text: "Résumé 📊", + truncated: false, + bytes: 13, + }; + + private answer(result: T): T { + this.afterCall?.(); + if (this.nextError !== undefined) { + const error = this.nextError; + this.nextError = undefined; + throw error; + } + return result; + } + + async locate(): Promise { + throw new Error("unused"); + } + async status(): Promise { + throw new Error("unused"); + } + async screenshot(...args: Parameters) { + this.screenshotCalls.push(args); + return this.answer({ + base64: "iVBORw0KGgo=", + width: 1440, + height: 900, + capturedAt: "2026-08-28T12:00:00.000Z", + url: "https://copilotkit.ai/", + }); + } + async snapshot(...args: Parameters) { + this.snapshotCalls.push(args); + return this.answer({ + snapshotId: 9, + url: "https://example.com", + title: "Example", + elements: [], + truncated: false, + }); + } + async read(...args: Parameters) { + this.readCalls.push(args); + return this.answer({ + url: "https://example.com", + title: "Example", + text: "Page text", + truncated: false, + }); + } + async navigate(...args: Parameters) { + this.navigateCalls.push(args); + return this.answer({ + url: args[2], + title: "Example", + text: "Page text", + truncated: false, + elapsedMs: 4, + }); + } + async click(...args: Parameters) { + this.clickCalls.push(args); + return this.answer({ + action: "click" as const, + ref: args[2].ref, + url: "https://example.com", + elapsedMs: 2, + }); + } + async type(...args: Parameters) { + this.typeCalls.push(args); + return this.answer({ + action: "type" as const, + ref: args[2].ref, + characters: args[2].text.length, + submitted: args[2].submit, + url: "https://example.com", + elapsedMs: 2, + }); + } + async key(...args: Parameters) { + this.keyCalls.push(args); + return this.answer({ + action: "key" as const, + ref: args[2].ref, + key: args[2].key, + url: "https://example.com", + elapsedMs: 2, + }); + } + async scroll(...args: Parameters) { + this.scrollCalls.push(args); + return this.answer({ + action: "scroll" as const, + deltaY: args[2].deltaY, + url: "https://example.com", + elapsedMs: 2, + }); + } + async readFile(...args: Parameters) { + this.readFileCalls.push(args); + return this.answer(this.readFileResult); + } + async listFiles(...args: Parameters) { + this.listFilesCalls.push(args); + return this.answer({ + path: args[2].path ?? ".", + entries: [{ path: "reports/risk.txt", kind: "file" as const, bytes: 13 }], + truncated: false, + }); + } + async runCommand(...args: Parameters) { + this.runCommandCalls.push(args); + return this.answer({ + command: args[2].command, + exitCode: 0, + stdout: "done\n", + stderr: "", + truncated: false, + timedOut: false, + elapsedMs: 5, + }); + } + async writeFile(...args: Parameters) { + this.writeFileCalls.push(args); + return this.answer({ + path: args[2].path, + bytes: new TextEncoder().encode(args[2].contents).byteLength, + appended: args[2].append === true, + }); + } + async control(...args: Parameters) { + this.controlCalls.push(args); + return { + holder: "bot" as const, + since: "2026-08-27T00:00:00.000Z", + requested: false, + }; + } + async requestHelp(...args: Parameters) { + this.requestHelpCalls.push(args); + this.afterRequest?.(); + if (this.requestError !== undefined) throw this.requestError; + return { + holder: "bot" as const, + since: "2026-08-27T00:00:00.000Z", + requested: true, + helpRequestId: this.requestIdentityMismatch + ? crypto.randomUUID() + : args[3], + }; + } + async takeControl(): Promise { + throw new Error("unused"); + } + async releaseControl(...args: Parameters) { + this.releaseControlCalls.push(args); + if (this.releaseError !== undefined) throw this.releaseError; + return { + holder: "bot" as const, + since: "2026-08-27T00:00:00.000Z", + requested: false, + }; + } + async cancelAssistance( + ...args: Parameters + ) { + this.cancelAssistanceCalls.push(args); + if (this.cancelError !== undefined) throw this.cancelError; + return ( + this.cancelResult ?? { + cancelled: true, + status: "cancelled" as const, + state: { + holder: "bot" as const, + since: "2026-08-27T00:00:00.000Z", + requested: false, + }, + } + ); + } + async assistanceStatus( + ...args: Parameters + ) { + this.assistanceStatusCalls.push(args); + return this.assistanceStatusResults?.shift() ?? this.assistanceStatusResult; + } + async requestSecret(...args: Parameters) { + this.requestSecretCalls.push(args); + this.afterRequest?.(); + if (this.requestError !== undefined) throw this.requestError; + return { + holder: "bot" as const, + since: "2026-08-27T00:00:00.000Z", + requested: false, + secretWanted: args[2].label, + secretRequestId: this.requestIdentityMismatch + ? crypto.randomUUID() + : args[3], + }; + } + async supplySecret(): Promise { + throw new Error("unused"); + } + async humanInput(): Promise { + throw new Error("unused"); + } + async computers(): Promise { + throw new Error("unused"); + } + async stopComputer(): Promise { + throw new Error("unused"); + } + async resetComputer(): Promise { + throw new Error("unused"); + } +} + +function toolsByName(gateway: ComputerGateway) { + return new Map( + createSlackComputerTools(gateway).map((tool) => [tool.name, tool]), + ); +} + +function toolsWithAssistance(gateway: ComputerGateway) { + return new Map( + createSlackComputerTools(gateway, { + appUrl: "https://openbot.example", + encryptionKey: "slack-assistance-key", + }).map((tool) => [tool.name, tool]), + ); +} + +async function invoke( + tool: SlackComputerTool | ChannelTool, + args: unknown, + context: ChannelToolContext, +) { + const parsed = await parseToolArgs(tool.parameters, args); + if (!parsed.ok) throw new Error(parsed.error); + return tool.handler(parsed.value, context); +} + +function inSlack(run: () => T, overrides: Partial = {}): T { + return runWithSlackExecution(execution(overrides), run); +} + +describe("Slack computer ChannelTools", () => { + test("exposes every non-assistance web operation and Slack file sharing", () => { + const names = [...toolsByName(new FakeComputerGateway()).keys()]; + expect(names).toEqual([ + "computer_navigate", + "computer_open_and_share_screenshot", + "computer_screenshot", + "computer_read", + "computer_snapshot", + "computer_type", + "computer_click", + "computer_key", + "computer_list_files", + "computer_read_file", + "computer_run_command", + "computer_write_file", + "computer_scroll", + "computer_share_file", + ]); + expect(names).not.toContain("computer_request_help"); + expect(names).not.toContain("computer_request_secret"); + }); + + test("adds web-parity assistance tools only when the secure handoff is configured", () => { + expect([...toolsWithAssistance(new FakeComputerGateway()).keys()]).toEqual([ + ...toolsByName(new FakeComputerGateway()).keys(), + "computer_request_help", + "computer_request_secret", + ]); + }); + + test("posts secure help and secret handoffs without refs, cookies, or plain private ids", async () => { + const gateway = new FakeComputerGateway(); + const tools = toolsWithAssistance(gateway); + const adapter = new FileAdapter(); + const context = channelContext(adapter); + + const [help, secret] = await inSlack( + async () => [ + await invoke( + tools.get("computer_request_help")!, + { reason: "Please finish signing in." }, + context, + ), + await invoke( + tools.get("computer_request_secret")!, + { label: "one-time code", ref: "field-ref-private", snapshotId: 9 }, + context, + ), + ], + { channelsThreadId: "channels-thread-private" }, + ); + + const rendered = JSON.stringify(adapter.posted); + expect(rendered).toContain("Please finish signing in."); + expect(rendered).toContain("one-time code"); + expect(rendered).toContain("/assist?token="); + expect(rendered).not.toMatch( + /field-ref-private|session-cookie-private|channels-thread-private|provider-U999|"u1"|"risk"/, + ); + expect(gateway.requestHelpCalls).toHaveLength(1); + expect(gateway.requestHelpCalls[0]?.slice(0, 3)).toEqual([ + "risk", + { id: "u1", userId: "u1" }, + "Please finish signing in.", + ]); + expect(gateway.requestSecretCalls).toHaveLength(1); + expect(gateway.requestSecretCalls[0]?.slice(0, 3)).toEqual([ + "risk", + { id: "u1", userId: "u1" }, + { label: "one-time code", ref: "field-ref-private", snapshotId: 9 }, + ]); + for (const requestId of [ + gateway.requestHelpCalls[0]?.[3], + gateway.requestSecretCalls[0]?.[3], + ]) { + expect(requestId).toMatch( + /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i, + ); + expect(rendered).not.toContain(requestId as string); + } + expect(help).toMatchObject({ + ok: true, + result: expect.stringContaining("handed control back"), + }); + expect(secret).toMatchObject({ + ok: true, + result: expect.stringContaining("you were not told what it is"), + }); + }); + + test("validates the secure handoff before creating a help request", async () => { + const gateway = new FakeComputerGateway(); + const tool = createSlackComputerTools(gateway, { + appUrl: "http://not-loopback.example", + encryptionKey: "slack-assistance-key", + }).find((candidate) => candidate.name === "computer_request_help")!; + + const result = await inSlack( + () => + invoke( + tool, + { reason: "Please sign in." }, + channelContext(new FileAdapter()), + ), + { channelsThreadId: "channels-thread-private" }, + ); + + expect(gateway.requestHelpCalls).toEqual([]); + expect(result).toEqual(ACTION_FAILED); + }); + + for (const toolName of [ + "computer_request_help", + "computer_request_secret", + ] as const) { + const args = + toolName === "computer_request_help" + ? { reason: "Please sign in." } + : { label: "one-time code", ref: "e9", snapshotId: 9 }; + + test(`${toolName} stops before committing when already aborted`, async () => { + const gateway = new FakeComputerGateway(); + const controller = new AbortController(); + controller.abort(); + const result = await inSlack( + () => + invoke( + toolsWithAssistance(gateway).get(toolName)!, + args, + channelContext(new FileAdapter(), controller.signal), + ), + { channelsThreadId: "channels-thread-private" }, + ); + + expect(result).toEqual(STOPPED); + expect(gateway.requestHelpCalls).toEqual([]); + expect(gateway.requestSecretCalls).toEqual([]); + expect(gateway.cancelAssistanceCalls).toEqual([]); + expect(gateway.releaseControlCalls).toEqual([]); + }); + + test(`${toolName} clears its committed request when Slack delivery fails`, async () => { + const gateway = new FakeComputerGateway(); + gateway.assistanceStatusResult = "pending"; + const adapter = new FileAdapter(); + adapter.postError = new Error("Slack unavailable"); + const result = await inSlack( + () => + invoke( + toolsWithAssistance(gateway).get(toolName)!, + args, + channelContext(adapter), + ), + { channelsThreadId: "channels-thread-private" }, + ); + + const requestCall = + toolName === "computer_request_help" + ? gateway.requestHelpCalls[0] + : gateway.requestSecretCalls[0]; + const requestId = requestCall?.at(-1); + expect(requestId).toMatch( + /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i, + ); + expect(gateway.cancelAssistanceCalls).toEqual([ + ["risk", { id: "u1", userId: "u1" }, requestId, expect.anything()], + ]); + expect(gateway.releaseControlCalls).toEqual([]); + expect(result).toEqual({ + ok: false, + reason: + "The Slack handoff could not be delivered. Its OpenBot assistance request was cleared; ask again if help is still needed.", + }); + }); + + test(`${toolName} conditionally clears a possibly-committed request failure`, async () => { + const gateway = new FakeComputerGateway(); + gateway.assistanceStatusResult = "unknown"; + gateway.requestError = new Error("audit failed after transport commit"); + + const result = await inSlack( + () => + invoke( + toolsWithAssistance(gateway).get(toolName)!, + args, + channelContext(new FileAdapter()), + ), + { channelsThreadId: "channels-thread-private" }, + ); + + const requestId = ( + toolName === "computer_request_help" + ? gateway.requestHelpCalls[0] + : gateway.requestSecretCalls[0] + )?.at(-1); + expect(gateway.cancelAssistanceCalls[0]?.[2]).toBe(requestId); + expect(gateway.releaseControlCalls).toEqual([]); + expect(result).toEqual({ + ok: false, + reason: + "The OpenBot assistance request could not be created safely. Its exact request generation is no longer pending; ask again if help is still needed.", + }); + }); + + test(`${toolName} refuses delivery when the computer does not confirm its request identity`, async () => { + const gateway = new FakeComputerGateway(); + gateway.requestIdentityMismatch = true; + const adapter = new FileAdapter(); + + const result = await inSlack( + () => + invoke( + toolsWithAssistance(gateway).get(toolName)!, + args, + channelContext(adapter), + ), + { channelsThreadId: "channels-thread-private" }, + ); + + expect(adapter.posted).toEqual([]); + expect(gateway.cancelAssistanceCalls).toEqual([]); + expect(result).toEqual({ + ok: false, + assistanceMayBePending: true, + reason: + "The Slack assistance flow could not be completed, and its OpenBot assistance request may still be pending. Open the coworker directly to clear it before asking again.", + }); + }); + + test(`${toolName} compensates when Stop races immediately after request commit`, async () => { + const gateway = new FakeComputerGateway(); + gateway.assistanceStatusResult = "pending"; + const controller = new AbortController(); + gateway.afterRequest = () => controller.abort(); + const adapter = new FileAdapter(); + const result = await inSlack( + () => + invoke( + toolsWithAssistance(gateway).get(toolName)!, + args, + channelContext(adapter, controller.signal), + ), + { channelsThreadId: "channels-thread-private" }, + ); + + expect(adapter.posted).toEqual([]); + expect(gateway.cancelAssistanceCalls).toHaveLength(1); + expect(gateway.cancelAssistanceCalls[0]?.[2]).toBe( + (toolName === "computer_request_help" + ? gateway.requestHelpCalls[0] + : gateway.requestSecretCalls[0] + )?.at(-1), + ); + expect(gateway.releaseControlCalls).toEqual([]); + expect(result).toEqual(STOPPED); + }); + } + + test("reports a possibly-live request when Slack delivery and compensation both fail", async () => { + const gateway = new FakeComputerGateway(); + gateway.assistanceStatusResult = "pending"; + gateway.cancelError = new Error("computer unavailable"); + const adapter = new FileAdapter(); + adapter.postError = new Error("Slack unavailable"); + + const result = await inSlack( + () => + invoke( + toolsWithAssistance(gateway).get("computer_request_help")!, + { reason: "Please sign in." }, + channelContext(adapter), + ), + { channelsThreadId: "channels-thread-private" }, + ); + + expect(result).toEqual({ + ok: false, + assistanceMayBePending: true, + reason: + "The Slack assistance flow could not be completed, and its OpenBot assistance request may still be pending. Open the coworker directly to clear it before asking again.", + }); + }); + + test("does not report cleanup when cancellation still returns the exact request", async () => { + const gateway = new FakeComputerGateway(); + gateway.assistanceStatusResult = "pending"; + const adapter = new FileAdapter(); + adapter.postError = new Error("Slack unavailable"); + gateway.cancelResult = { + cancelled: false, + status: "pending", + state: { + holder: "bot", + since: "2026-08-27T00:00:00.000Z", + requested: true, + helpRequestId: "filled-after-request", + }, + }; + gateway.cancelAssistance = async (...args) => { + gateway.cancelAssistanceCalls.push(args); + return { + ...gateway.cancelResult!, + state: { ...gateway.cancelResult!.state, helpRequestId: args[2] }, + }; + }; + + const result = await inSlack( + () => + invoke( + toolsWithAssistance(gateway).get("computer_request_help")!, + { reason: "Please sign in." }, + channelContext(adapter), + ), + { channelsThreadId: "channels-thread-private" }, + ); + + expect(result).toEqual({ + ok: false, + assistanceMayBePending: true, + reason: + "The Slack assistance flow could not be completed, and its OpenBot assistance request may still be pending. Open the coworker directly to clear it before asking again.", + }); + }); + + test("does not cancel an exact request while a person is driving", async () => { + const gateway = new FakeComputerGateway(); + gateway.assistanceStatusResult = "human"; + const controller = new AbortController(); + gateway.afterRequest = () => controller.abort(); + + const result = await inSlack( + () => + invoke( + toolsWithAssistance(gateway).get("computer_request_help")!, + { reason: "Please sign in." }, + channelContext(new FileAdapter(), controller.signal), + ), + { channelsThreadId: "channels-thread-private" }, + ); + + expect(gateway.cancelAssistanceCalls).toEqual([]); + expect(result).toEqual({ + ok: false, + assistanceMayBePending: true, + reason: + "The Slack assistance flow could not be completed, and its OpenBot assistance request may still be pending. Open the coworker directly to clear it before asking again.", + }); + }); + + test("uses one deadline and never posts an already-expired assistance link", async () => { + const gateway = new FakeComputerGateway(); + gateway.assistanceStatusResult = "pending"; + let now = 0; + gateway.afterRequest = () => { + now = 10 * 60_000; + }; + const adapter = new FileAdapter(); + const tools = new Map( + createSlackComputerTools(gateway, { + appUrl: "https://openbot.example", + encryptionKey: "slack-assistance-key", + now: () => now, + }).map((tool) => [tool.name, tool]), + ); + + await inSlack( + () => + invoke( + tools.get("computer_request_help")!, + { reason: "Please sign in." }, + channelContext(adapter), + ), + { channelsThreadId: "channels-thread-private" }, + ); + + expect(adapter.posted).toEqual([]); + expect(gateway.cancelAssistanceCalls).toHaveLength(1); + }); + + for (const toolName of [ + "computer_request_help", + "computer_request_secret", + ] as const) { + test(`${toolName} reports a late exact completion that wins the deadline race`, async () => { + const gateway = new FakeComputerGateway(); + let now = 0; + gateway.afterRequest = () => { + now = 10 * 60_000; + }; + gateway.assistanceStatusResult = "completed"; + const tools = new Map( + createSlackComputerTools(gateway, { + appUrl: "https://openbot.example", + encryptionKey: "slack-assistance-key", + now: () => now, + }).map((tool) => [tool.name, tool]), + ); + + const result = await inSlack( + () => + invoke( + tools.get(toolName)!, + toolName === "computer_request_help" + ? { reason: "Please sign in." } + : { label: "one-time code", ref: "e9", snapshotId: 9 }, + channelContext(new FileAdapter()), + ), + { channelsThreadId: "channels-thread-private" }, + ); + + expect(result).toMatchObject({ + ok: true, + result: expect.stringContaining( + toolName === "computer_request_help" + ? "handed control back" + : "you were not told what it is", + ), + }); + expect(gateway.cancelAssistanceCalls).toEqual([]); + }); + } + + test("a completed request wins a Slack post failure and cancellation race", async () => { + const gateway = new FakeComputerGateway(); + gateway.assistanceStatusResult = "completed"; + const adapter = new FileAdapter(); + adapter.postError = new Error("Slack unavailable after human completion"); + + const result = await inSlack( + () => + invoke( + toolsWithAssistance(gateway).get("computer_request_help")!, + { reason: "Please sign in." }, + channelContext(adapter), + ), + { channelsThreadId: "channels-thread-private" }, + ); + + expect(result).toMatchObject({ + ok: true, + result: expect.stringContaining("handed control back"), + }); + expect(gateway.cancelAssistanceCalls).toEqual([]); + }); + + test("completion between the status read and conditional cancel wins the race", async () => { + const gateway = new FakeComputerGateway(); + gateway.assistanceStatusResult = "pending"; + gateway.cancelResult = { + cancelled: false, + status: "completed", + state: { + holder: "bot", + since: "2026-08-27T00:00:00.000Z", + requested: false, + }, + }; + const adapter = new FileAdapter(); + adapter.postError = new Error("Slack unavailable during handoff"); + + const result = await inSlack( + () => + invoke( + toolsWithAssistance(gateway).get("computer_request_help")!, + { reason: "Please sign in." }, + channelContext(adapter), + ), + { channelsThreadId: "channels-thread-private" }, + ); + + expect(result).toMatchObject({ + ok: true, + result: expect.stringContaining("handed control back"), + }); + expect(gateway.cancelAssistanceCalls).toHaveLength(1); + }); + + test("a hung Slack post gets only the remainder of the original deadline", async () => { + const gateway = new FakeComputerGateway(); + let now = 0; + gateway.afterRequest = () => { + now = 10 * 60_000 - 2; + }; + const adapter = new FileAdapter(); + adapter.postGate = new Promise(() => undefined); + const tools = new Map( + createSlackComputerTools(gateway, { + appUrl: "https://openbot.example", + encryptionKey: "slack-assistance-key", + now: () => now, + }).map((tool) => [tool.name, tool]), + ); + + const result = await inSlack( + () => + invoke( + tools.get("computer_request_help")!, + { reason: "Please sign in." }, + channelContext(adapter), + ), + { channelsThreadId: "channels-thread-private" }, + ); + + expect(result).toMatchObject({ + ok: false, + deliveryMayBePending: true, + assistanceMayBePending: true, + }); + expect(gateway.cancelAssistanceCalls).toEqual([]); + }); + + for (const [status, expected] of [ + [ + "superseded", + { + ok: false, + reason: + "This exact OpenBot assistance request was replaced by a newer request and did not complete.", + }, + ], + [ + "cancelled", + { + ok: false, + reason: + "This exact OpenBot assistance request was already cancelled. Ask again only if help is still needed.", + }, + ], + [ + "expired", + { + ok: false, + reason: + "This exact OpenBot assistance request expired without completion. Ask again only if help is still needed.", + }, + ], + [ + "unknown", + { + ok: false, + assistanceMayBePending: true, + reason: + "OpenBot no longer knows the exact assistance request outcome. Check the coworker before asking again.", + }, + ], + ] as const) { + test(`reports an exact ${status} generation without claiming human completion`, async () => { + const gateway = new FakeComputerGateway(); + gateway.assistanceStatusResult = status; + const result = await inSlack( + () => + invoke( + toolsWithAssistance(gateway).get("computer_request_help")!, + { reason: "Please sign in." }, + channelContext(new FileAdapter()), + ), + { channelsThreadId: "channels-thread-private" }, + ); + expect(result).toEqual(expected); + expect(gateway.cancelAssistanceCalls).toEqual([]); + }); + } + + for (const lateOutcome of ["resolve", "reject"] as const) { + test(`leaves an unknown Slack delivery untouched when posting later ${lateOutcome}s`, async () => { + const gateway = new FakeComputerGateway(); + const controller = new AbortController(); + const gate = deferred(); + const adapter = new FileAdapter(); + adapter.postGate = gate.promise; + adapter.beforePost = () => controller.abort(); + + const result = await inSlack( + () => + invoke( + toolsWithAssistance(gateway).get("computer_request_help")!, + { reason: "Please sign in." }, + channelContext(adapter, controller.signal), + ), + { channelsThreadId: "channels-thread-private" }, + ); + + expect(result).toEqual({ + ok: false, + deliveryMayBePending: true, + assistanceMayBePending: true, + reason: + "Slack may still deliver the secure assistance link, and its OpenBot request is still pending. Do not send another request until this one is checked.", + }); + expect(gateway.cancelAssistanceCalls).toEqual([]); + expect(gateway.releaseControlCalls).toEqual([]); + + if (lateOutcome === "resolve") gate.resolve(); + else gate.reject(new Error("late Slack rejection")); + await Promise.resolve(); + await Promise.resolve(); + expect(adapter.posted.length).toBe(lateOutcome === "resolve" ? 1 : 0); + expect(gateway.cancelAssistanceCalls).toEqual([]); + }); + } + + test("passes the pinned coworker, linked actor, parsed click input, and signal", async () => { + const gateway = new FakeComputerGateway(); + const tools = toolsByName(gateway); + const signal = new AbortController().signal; + + const result = await inSlack(() => + invoke( + tools.get("computer_click")!, + { ref: "e4", snapshotId: 9, ignored: "removed by Zod" }, + channelContext(new FileAdapter(), signal), + ), + ); + + expect(gateway.clickCalls).toEqual([ + [ + "risk", + { id: "u1", userId: "u1" }, + { ref: "e4", snapshotId: 9 }, + signal, + ], + ]); + expect(result).toMatchObject({ ok: true, action: "click", ref: "e4" }); + }); + + test("calls each matching gateway method once with its exact current signature", async () => { + const gateway = new FakeComputerGateway(); + const tools = toolsByName(gateway); + const adapter = new FileAdapter(); + const signal = new AbortController().signal; + const context = channelContext(adapter, signal); + const actor = { id: "u1", userId: "u1" }; + + await inSlack(async () => { + await invoke( + tools.get("computer_navigate")!, + { url: "https://example.com" }, + context, + ); + await invoke(tools.get("computer_read")!, {}, context); + await invoke(tools.get("computer_snapshot")!, {}, context); + await invoke( + tools.get("computer_type")!, + { ref: "e2", snapshotId: 9, text: "hello", submit: true }, + context, + ); + await invoke( + tools.get("computer_key")!, + { key: "Enter", ref: "e2", snapshotId: 9 }, + context, + ); + await invoke(tools.get("computer_scroll")!, { deltaY: -300 }, context); + await invoke( + tools.get("computer_list_files")!, + { path: "reports" }, + context, + ); + await invoke( + tools.get("computer_read_file")!, + { path: "reports/risk.txt" }, + context, + ); + await invoke( + tools.get("computer_run_command")!, + { command: "wc -l risk.txt" }, + context, + ); + await invoke( + tools.get("computer_write_file")!, + { path: "notes.txt", contents: "hello", append: true }, + context, + ); + }); + + expect(gateway.navigateCalls).toEqual([ + ["risk", actor, "https://example.com"], + ]); + expect(gateway.readCalls).toEqual([["risk"]]); + expect(gateway.snapshotCalls).toEqual([["risk"]]); + expect(gateway.typeCalls).toEqual([ + [ + "risk", + actor, + { ref: "e2", snapshotId: 9, text: "hello", submit: true }, + signal, + ], + ]); + expect(gateway.keyCalls).toEqual([ + ["risk", actor, { key: "Enter", ref: "e2", snapshotId: 9 }, signal], + ]); + expect(gateway.scrollCalls).toEqual([["risk", actor, { deltaY: -300 }]]); + expect(gateway.listFilesCalls).toEqual([ + ["risk", actor, { path: "reports" }], + ]); + expect(gateway.readFileCalls).toEqual([ + ["risk", actor, { path: "reports/risk.txt" }], + ]); + expect(gateway.runCommandCalls).toEqual([ + ["risk", actor, { command: "wc -l risk.txt" }, signal], + ]); + expect(gateway.writeFileCalls).toEqual([ + ["risk", actor, { path: "notes.txt", contents: "hello", append: true }], + ]); + }); + + test("fails safely outside Slack execution without touching the gateway", async () => { + const gateway = new FakeComputerGateway(); + const tool = toolsByName(gateway).get("computer_click")!; + const logged = spyOn(console, "error").mockImplementation(() => {}); + try { + const result = await invoke( + tool, + { ref: "e4", snapshotId: 9 }, + channelContext(new FileAdapter()), + ); + expect(result).toEqual(CONTEXT_UNAVAILABLE); + const event = capturedComputerFailures(logged.mock.calls).find( + ({ context }) => context?.errorCategory === "execution-context", + ); + expect(event).toMatchObject({ + type: "slack-computer-tool-failed", + error: "SlackComputerContextError", + context: { + integration: "slack", + operation: "computer-tool", + errorCategory: "execution-context", + }, + }); + expect( + Number.isNaN( + Date.parse( + typeof event?.timestamp === "string" ? event.timestamp : "", + ), + ), + ).toBe(false); + expect(gateway.clickCalls).toHaveLength(0); + } finally { + logged.mockRestore(); + } + }); + + test("fails safely when no coworker is pinned without touching the gateway", async () => { + const gateway = new FakeComputerGateway(); + const tool = toolsByName(gateway).get("computer_click")!; + const result = await inSlack( + () => + invoke( + tool, + { ref: "e4", snapshotId: 9 }, + channelContext(new FileAdapter()), + ), + { agentId: undefined }, + ); + expect(result).toEqual(CONTEXT_UNAVAILABLE); + expect(gateway.clickCalls).toHaveLength(0); + }); + + test("normalizes every public gateway domain error by class", async () => { + const gateway = new FakeComputerGateway(); + const tool = toolsByName(gateway).get("computer_click")!; + const context = channelContext(new FileAdapter()); + const cases = [ + { + error: new ActionRefusedError("Blocked by policy.", "deny-payments"), + expected: { + ok: false, + refused: true, + reason: "Blocked by policy.", + rule: "deny-payments", + }, + }, + { + error: new StaleSnapshotError("Take a new snapshot."), + expected: { + ok: false, + staleRefs: true, + reason: "Take a new snapshot.", + }, + }, + { + error: new ElementNotFoundError("That element is gone."), + expected: { + ok: false, + staleRefs: true, + reason: "That element is gone.", + }, + }, + { + error: new HumanHasControlError("A person is driving."), + expected: { + ok: false, + humanHasControl: true, + reason: "A person is driving.", + }, + }, + { + error: new NavigationRefusedError("Private hosts are not allowed."), + expected: { + ok: false, + refused: true, + reason: "Private hosts are not allowed.", + }, + }, + { + error: new WorkspaceRefusedError("That path leaves the workspace."), + expected: { + ok: false, + refused: true, + reason: "That path leaves the workspace.", + }, + }, + { + error: new WorkspaceRequestError("There is no file at notes.md."), + expected: { + ok: false, + reason: "There is no file at notes.md.", + }, + }, + { error: new DOMException("cancelled", "AbortError"), expected: STOPPED }, + ]; + + for (const item of cases) { + gateway.nextError = item.error; + const result = await inSlack(() => + invoke(tool, { ref: "e4", snapshotId: 9 }, context), + ); + expect(result).toEqual(item.expected); + } + expect(gateway.clickCalls).toHaveLength(cases.length); + }); + + test("maps an aborted transport ComputerUnavailableError to stopped", async () => { + const gateway = new FakeComputerGateway(); + const controller = new AbortController(); + gateway.afterCall = () => controller.abort(); + gateway.nextError = new ComputerUnavailableError( + "The assistant's computer is not running.", + ); + + const result = await inSlack(() => + invoke( + toolsByName(gateway).get("computer_click")!, + { ref: "e4", snapshotId: 9 }, + channelContext(new FileAdapter(), controller.signal), + ), + ); + + expect(result).toEqual(STOPPED); + expect(gateway.clickCalls).toHaveLength(1); + }); + + test("does not trust an ordinary Error renamed AbortError", async () => { + const gateway = new FakeComputerGateway(); + const renamed = new Error("internal detail"); + renamed.name = "AbortError"; + gateway.nextError = renamed; + + const result = await inSlack(() => + invoke( + toolsByName(gateway).get("computer_click")!, + { ref: "e4", snapshotId: 9 }, + channelContext(new FileAdapter()), + ), + ); + + expect(result).toEqual(ACTION_FAILED); + }); + + test("hides unavailable and unknown error details and never retries", async () => { + const gateway = new FakeComputerGateway(); + const tool = toolsByName(gateway).get("computer_click")!; + const logged = spyOn(console, "error").mockImplementation(() => {}); + const unknown = new Error("socket token=secret internal stack detail"); + unknown.name = "token=secret"; + try { + for (const error of [ + new ComputerUnavailableError("host token=secret did not answer"), + unknown, + ]) { + gateway.nextError = error; + const result = await inSlack(() => + invoke( + tool, + { ref: "e4", snapshotId: 9 }, + channelContext(new FileAdapter()), + ), + ); + expect(result).toEqual( + error instanceof ComputerUnavailableError + ? UNAVAILABLE + : ACTION_FAILED, + ); + expect(JSON.stringify(result)).not.toContain("secret"); + } + const failures = capturedComputerFailures(logged.mock.calls); + expect(JSON.stringify(failures)).not.toContain("secret"); + const event = failures.find( + ({ context, error }) => + context?.errorCategory === "unknown" && error === "UnknownError", + ); + expect(event).toMatchObject({ + type: "slack-computer-tool-failed", + error: "UnknownError", + context: { + integration: "slack", + operation: "computer-tool", + errorCategory: "unknown", + }, + }); + expect( + Number.isNaN( + Date.parse( + typeof event?.timestamp === "string" ? event.timestamp : "", + ), + ), + ).toBe(false); + } finally { + logged.mockRestore(); + } + expect(gateway.clickCalls).toHaveLength(2); + }); + + test("checks abort before and after non-cancellable read-only gateway methods", async () => { + const gateway = new FakeComputerGateway(); + const tool = toolsByName(gateway).get("computer_read")!; + const alreadyStopped = new AbortController(); + alreadyStopped.abort(); + const before = await inSlack(() => + invoke( + tool, + {}, + channelContext(new FileAdapter(), alreadyStopped.signal), + ), + ); + expect(before).toEqual(STOPPED); + expect(gateway.readCalls).toHaveLength(0); + + const stoppedAfter = new AbortController(); + gateway.afterCall = () => stoppedAfter.abort(); + const after = await inSlack(() => + invoke(tool, {}, channelContext(new FileAdapter(), stoppedAfter.signal)), + ); + expect(after).toEqual(STOPPED); + expect(gateway.readCalls).toHaveLength(1); + }); + + test("reports successful non-cancellable mutators truthfully after their commit point", async () => { + const cases = [ + { + name: "computer_navigate", + input: { url: "https://example.com" }, + assert(result: unknown) { + expect(result).toMatchObject({ + ok: true, + url: "https://example.com", + }); + }, + }, + { + name: "computer_scroll", + input: { deltaY: 300 }, + assert(result: unknown) { + expect(result).toMatchObject({ ok: true, action: "scroll" }); + }, + }, + { + name: "computer_write_file", + input: { path: "log.txt", contents: "next\n", append: true }, + assert(result: unknown) { + expect(result).toMatchObject({ ok: true, appended: true }); + }, + }, + ]; + + for (const item of cases) { + const gateway = new FakeComputerGateway(); + const controller = new AbortController(); + gateway.afterCall = () => controller.abort(); + const result = await inSlack(() => + invoke( + toolsByName(gateway).get(item.name)!, + item.input, + channelContext(new FileAdapter(), controller.signal), + ), + ); + item.assert(result); + } + }); + + test("reports signal-aware mutator success truthfully when abort races its return", async () => { + const gateway = new FakeComputerGateway(); + const controller = new AbortController(); + gateway.afterCall = () => controller.abort(); + + const result = await inSlack(() => + invoke( + toolsByName(gateway).get("computer_click")!, + { ref: "e4", snapshotId: 9 }, + channelContext(new FileAdapter(), controller.signal), + ), + ); + + expect(result).toMatchObject({ ok: true, action: "click", ref: "e4" }); + expect(gateway.clickCalls).toHaveLength(1); + }); + + test("serializes an unexpected non-plain gateway value without silently collapsing it", async () => { + const gateway = Object.assign(new FakeComputerGateway(), { + async read() { + return new Map([["status", "ready"]]); + }, + }); + + const result = await inSlack(() => + invoke( + toolsByName(gateway).get("computer_read")!, + {}, + channelContext(new FileAdapter()), + ), + ); + + expect(result).toEqual({ ok: true, result: [["status", "ready"]] }); + expect(JSON.stringify(result)).toBe( + '{"ok":true,"result":[["status","ready"]]}', + ); + }); + + test("fails safely for unsupported non-plain gateway values, including nested ones", async () => { + for (const value of [/private-state/u, [/private-state/u]]) { + const gateway = Object.assign(new FakeComputerGateway(), { + async read() { + return value; + }, + }); + + const result = await inSlack(() => + invoke( + toolsByName(gateway).get("computer_read")!, + {}, + channelContext(new FileAdapter()), + ), + ); + + expect(result).toEqual(ACTION_FAILED); + expect(JSON.stringify(result)).not.toContain("private-state"); + } + }); + + test("shares complete UTF-8 text with an explicit filename and reports adapter success", async () => { + const gateway = new FakeComputerGateway(); + const adapter = new FileAdapter(); + const signal = new AbortController().signal; + const tool = toolsByName(gateway).get("computer_share_file")!; + + const result = await inSlack(() => + invoke( + tool, + { path: "reports/risk.txt", filename: "review.txt" }, + channelContext(adapter, signal), + ), + ); + + expect(gateway.readFileCalls).toEqual([ + ["risk", { id: "u1", userId: "u1" }, { path: "reports/risk.txt" }], + ]); + expect(adapter.uploads).toHaveLength(1); + expect(adapter.uploads[0]?.filename).toBe("review.txt"); + expect(new TextDecoder().decode(adapter.uploads[0]?.bytes)).toBe( + "Résumé 📊", + ); + expect(result).toEqual({ + ok: true, + shared: true, + filename: "review.txt", + fileId: "slack-file-1", + }); + }); + + test("captures the current page and shares its PNG in the Slack thread", async () => { + const gateway = new FakeComputerGateway(); + const adapter = new FileAdapter(); + + const result = await inSlack(() => + invoke( + toolsByName(gateway).get("computer_screenshot")!, + { filename: "copilotkit-homepage.png" }, + channelContext(adapter), + ), + ); + + expect(gateway.screenshotCalls).toEqual([["risk"]]); + expect(adapter.uploads).toHaveLength(1); + expect(adapter.uploads[0]?.filename).toBe("copilotkit-homepage.png"); + expect([...adapter.uploads[0]!.bytes]).toEqual([ + 137, 80, 78, 71, 13, 10, 26, 10, + ]); + expect(result).toEqual({ + ok: true, + shared: true, + filename: "copilotkit-homepage.png", + fileId: "slack-file-1", + width: 1440, + height: 900, + url: "https://copilotkit.ai/", + }); + }); + + test("opens, reads, and shares a website screenshot in one tool round", async () => { + const gateway = new FakeComputerGateway(); + const adapter = new FileAdapter(); + + const result = await inSlack(() => + invoke( + toolsByName(gateway).get("computer_open_and_share_screenshot")!, + { + url: "https://copilotkit.ai", + filename: "copilotkit-homepage.png", + }, + channelContext(adapter), + ), + ); + + expect(gateway.navigateCalls).toEqual([ + ["risk", { id: "u1", userId: "u1" }, "https://copilotkit.ai"], + ]); + expect(gateway.screenshotCalls).toEqual([["risk"]]); + expect(adapter.effects).toEqual(["message", "file"]); + expect(JSON.stringify(adapter.posted)).toContain("Summary: Page text"); + expect(JSON.stringify(adapter.posted)).toContain( + "Source: https://copilotkit.ai", + ); + expect(adapter.uploads).toHaveLength(1); + expect(result).toEqual({ + ok: true, + url: "https://copilotkit.ai", + title: "Example", + text: "Page text", + truncated: false, + elapsedMs: 4, + summaryShared: true, + screenshotShared: true, + screenshotFilename: "copilotkit-homepage.png", + screenshotWidth: 1440, + screenshotHeight: 900, + fileId: "slack-file-1", + }); + }); + + test("uses only a safe basename when the shared filename is omitted", async () => { + const gateway = new FakeComputerGateway(); + const adapter = new FileAdapter(); + const result = await inSlack(() => + invoke( + toolsByName(gateway).get("computer_share_file")!, + { path: "private/reports/risk.txt" }, + channelContext(adapter), + ), + ); + expect(adapter.uploads[0]?.filename).toBe("risk.txt"); + expect(JSON.stringify(result)).not.toContain("private/reports"); + }); + + test("refuses truncated reads and never posts incomplete file content", async () => { + const gateway = new FakeComputerGateway(); + gateway.readFileResult = { + path: "reports/huge.txt", + text: "incomplete private content", + truncated: true, + bytes: 99_000_000, + }; + const adapter = new FileAdapter(); + const result = await inSlack(() => + invoke( + toolsByName(gateway).get("computer_share_file")!, + { path: "reports/huge.txt" }, + channelContext(adapter), + ), + ); + expect(result).toEqual({ + ok: false, + reason: + "That file is too large to read completely, so it was not shared.", + }); + expect(JSON.stringify(result)).not.toContain("private content"); + expect(adapter.uploads).toHaveLength(0); + }); + + test("returns an adapter size or type rejection exactly and never claims success", async () => { + const gateway = new FakeComputerGateway(); + const adapter = new FileAdapter(); + adapter.result = { + ok: false, + error: "Slack rejected this file type or size.", + }; + const result = await inSlack(() => + invoke( + toolsByName(gateway).get("computer_share_file")!, + { path: "reports/risk.txt" }, + channelContext(adapter), + ), + ); + expect(result).toEqual({ + ok: false, + reason: "Slack rejected this file type or size.", + }); + expect(result).not.toHaveProperty("shared"); + expect(JSON.stringify(result)).not.toContain("Résumé"); + }); + + test("honors abort checkpoints before upload without unsupported arguments", async () => { + const gateway = new FakeComputerGateway(); + const adapter = new FileAdapter(); + const stoppedAfterRead = new AbortController(); + gateway.afterCall = () => stoppedAfterRead.abort(); + const afterRead = await inSlack(() => + invoke( + toolsByName(gateway).get("computer_share_file")!, + { path: "reports/risk.txt" }, + channelContext(adapter, stoppedAfterRead.signal), + ), + ); + expect(afterRead).toEqual(STOPPED); + expect(gateway.readFileCalls[0]).toHaveLength(3); + expect(adapter.uploads).toHaveLength(0); + + gateway.afterCall = undefined; + const stoppedAfterUpload = new AbortController(); + adapter.afterUpload = () => stoppedAfterUpload.abort(); + const afterUpload = await inSlack(() => + invoke( + toolsByName(gateway).get("computer_share_file")!, + { path: "reports/risk.txt" }, + channelContext(adapter, stoppedAfterUpload.signal), + ), + ); + expect(afterUpload).toEqual({ + ok: true, + shared: true, + filename: "risk.txt", + fileId: "slack-file-1", + }); + expect(adapter.uploads).toHaveLength(1); + }); + + test("revalidates a filename after removing Unicode control and format characters", async () => { + const gateway = new FakeComputerGateway(); + const adapter = new FileAdapter(); + const result = await inSlack(() => + invoke( + toolsByName(gateway).get("computer_share_file")!, + { path: "reports/risk.txt", filename: `.\u0000\u202e.` }, + channelContext(adapter), + ), + ); + expect(adapter.uploads[0]?.filename).toBe("file.txt"); + expect(result).toMatchObject({ + ok: true, + shared: true, + filename: "file.txt", + }); + }); + + test("removes Unicode line separators from shared filenames", async () => { + const gateway = new FakeComputerGateway(); + const adapter = new FileAdapter(); + const result = await inSlack(() => + invoke( + toolsByName(gateway).get("computer_share_file")!, + { path: "reports/risk.txt", filename: "report\u2028private\u2029.txt" }, + channelContext(adapter), + ), + ); + expect(adapter.uploads[0]?.filename).toBe("reportprivate.txt"); + expect(result).toMatchObject({ filename: "reportprivate.txt" }); + }); + + test("limits shared filenames to 255 UTF-8 bytes without splitting code points", async () => { + const cases = [ + { + filename: `${"a".repeat(300)}.txt`, + expectedSuffix: ".txt", + }, + { + filename: `${"📊".repeat(100)}.json`, + expectedSuffix: ".json", + }, + ]; + + for (const item of cases) { + const gateway = new FakeComputerGateway(); + const adapter = new FileAdapter(); + const result = await inSlack(() => + invoke( + toolsByName(gateway).get("computer_share_file")!, + { path: "reports/risk.txt", filename: item.filename }, + channelContext(adapter), + ), + ); + const filename = adapter.uploads[0]?.filename ?? ""; + expect(new TextEncoder().encode(filename).byteLength).toBeLessThanOrEqual( + 255, + ); + expect(filename.endsWith(item.expectedSuffix)).toBe(true); + expect(filename).not.toContain("�"); + expect(result).toMatchObject({ ok: true, shared: true, filename }); + } + }); + + test("ChannelTools use schemas that Channels can parse without compatibility casts", async () => { + const echo = defineChannelTool({ + ...toolsByName(new FakeComputerGateway()).get("computer_click")!, + handler: ({ ref, snapshotId }) => ({ ref, snapshotId }), + }); + const parsed = await parseToolArgs(echo.parameters, { + ref: "e8", + snapshotId: 3, + }); + expect(parsed).toEqual({ + ok: true, + value: { ref: "e8", snapshotId: 3 }, + }); + }); +}); diff --git a/server/tests/slack-execution-context.test.ts b/server/tests/slack-execution-context.test.ts new file mode 100644 index 00000000..5a1e6efc --- /dev/null +++ b/server/tests/slack-execution-context.test.ts @@ -0,0 +1,104 @@ +import { describe, expect, test } from "bun:test"; +import { + currentSlackExecution, + runWithSlackExecution, + type SlackExecution, +} from "../src/slack/execution-context"; + +function execution(id: string): SlackExecution { + return { + actor: { id, role: "user" }, + applicationUser: { id, name: id }, + provider: "slack", + providerTenantId: "T1", + providerConversationId: "C1", + providerThreadId: "thread-1", + messageText: "hello", + }; +} + +describe("private Slack execution context", () => { + test("survives await and microtask boundaries", async () => { + const value = execution("alice"); + let current: SlackExecution | undefined; + await runWithSlackExecution(value, async () => { + await Promise.resolve(); + current = currentSlackExecution(); + expect(current).toMatchObject(value); + await new Promise((resolve) => queueMicrotask(resolve)); + expect(currentSlackExecution()).toBe(current); + }); + }); + + test("isolates overlapping turns", async () => { + let releaseAlice!: () => void; + const aliceGate = new Promise((resolve) => (releaseAlice = resolve)); + const alice = runWithSlackExecution(execution("alice"), async () => { + await aliceGate; + return currentSlackExecution().actor.id; + }); + const bob = runWithSlackExecution(execution("bob"), async () => { + await Promise.resolve(); + return currentSlackExecution().actor.id; + }); + + await expect(bob).resolves.toBe("bob"); + releaseAlice(); + await expect(alice).resolves.toBe("alice"); + }); + + test("restores an outer run after a nested run", async () => { + await runWithSlackExecution(execution("alice"), async () => { + expect(currentSlackExecution().actor.id).toBe("alice"); + await runWithSlackExecution(execution("bob"), async () => { + expect(currentSlackExecution().actor.id).toBe("bob"); + }); + expect(currentSlackExecution().actor.id).toBe("alice"); + }); + }); + + test("protects identity fields while leaving later routing fields writable", () => { + const value = execution("alice"); + runWithSlackExecution(value, () => { + const current = currentSlackExecution(); + expect(Reflect.set(current, "provider", "discord")).toBe(false); + expect(Reflect.set(current, "providerTenantId", "other-tenant")).toBe( + false, + ); + expect( + Reflect.set(current, "providerConversationId", "other-conversation"), + ).toBe(false); + expect(Reflect.set(current, "providerThreadId", "other-thread")).toBe( + false, + ); + expect(Reflect.set(current, "messageText", "different message")).toBe( + false, + ); + expect(Reflect.set(current.actor, "id", "mallory")).toBe(false); + expect(Reflect.set(current.applicationUser, "name", "Mallory")).toBe( + false, + ); + current.channelsThreadId = "channels-thread"; + current.agentId = "agent-1"; + + expect(currentSlackExecution()).toMatchObject({ + provider: "slack", + actor: { id: "alice" }, + applicationUser: { name: "alice" }, + providerTenantId: "T1", + providerConversationId: "C1", + providerThreadId: "thread-1", + messageText: "hello", + channelsThreadId: "channels-thread", + agentId: "agent-1", + }); + }); + expect(value.channelsThreadId).toBeUndefined(); + }); + + test("requires a private context outside Slack execution", () => { + expect(() => currentSlackExecution()).toThrow( + "A Slack agent run requires a private execution context.", + ); + }); +}); diff --git a/server/tests/slack-identity-linker.test.ts b/server/tests/slack-identity-linker.test.ts new file mode 100644 index 00000000..b97338a8 --- /dev/null +++ b/server/tests/slack-identity-linker.test.ts @@ -0,0 +1,428 @@ +import { describe, expect, test } from "bun:test"; +import type { ChannelIdentityContext } from "@copilotkit/channels"; +import type { AgentActor } from "../src/agents/profile-types"; +import type { ExternalLinkAuthorizationStore } from "../src/external/link-store"; +import type { + ExternalProviderIdentity, + ExternalUserLink, +} from "../src/external/schema-types"; +import { SlackIdentityLinker } from "../src/slack/identity-linker"; + +const KEY = "slack-identity-linker-test-key"; +const IDENTITY: ExternalProviderIdentity = { + provider: "slack", + providerTenantId: "T1", + providerUserId: "U1", + providerEmail: "adapter@example.test", +}; + +type ActiveUser = { id: string; name: string; role: AgentActor["role"] }; + +function linked( + openbotUserId: string, + providerEmail = IDENTITY.providerEmail, +): ExternalUserLink { + return { + ...IDENTITY, + providerEmail, + openbotUserId, + linkedAt: new Date(0), + updatedAt: new Date(0), + }; +} + +function context( + overrides: Partial = {}, +): ChannelIdentityContext { + return { + provider: "slack", + tenant: { id: "T1" }, + installation: { id: "I1" }, + actor: { id: "U1", kind: "human", email: "untrusted@example.test" }, + conversation: { id: "C1" }, + trigger: "message", + event: { id: "Ev1" }, + raw: null, + ...overrides, + }; +} + +function storeFor( + input: { + link?: ExternalUserLink | null; + found?: { id: string; name: string } | null; + active?: Map; + onLink?: () => { link: ExternalUserLink; error?: Error }; + } = {}, +): ExternalLinkAuthorizationStore & { + linkedWith: ExternalProviderIdentity[]; + findKeys: [string, string, string][]; + verifiedEmails: string[]; + activeIds: string[]; +} { + let currentLink = input.link ?? null; + const linkedWith: ExternalProviderIdentity[] = []; + const findKeys: [string, string, string][] = []; + const verifiedEmails: string[] = []; + const activeIds: string[] = []; + const active = input.active ?? new Map(); + return { + linkedWith, + findKeys, + verifiedEmails, + activeIds, + async find(provider, tenantId, providerUserId) { + findKeys.push([provider, tenantId, providerUserId]); + return currentLink; + }, + async findVerifiedUserByEmail(email) { + verifiedEmails.push(email); + return input.found ?? null; + }, + async resolveActiveUser(id) { + activeIds.push(id); + return active.get(id) ?? null; + }, + async link(value) { + linkedWith.push(value); + const outcome = input.onLink?.(); + if (outcome) { + currentLink = outcome.link; + if (outcome.error) throw outcome.error; + } else { + currentLink = linked(value.openbotUserId, value.providerEmail); + } + return currentLink; + }, + }; +} + +function linker( + store: ExternalLinkAuthorizationStore, + appUrl = "https://openbot.test", +) { + return new SlackIdentityLinker({ store, encryptionKey: KEY, appUrl }); +} + +describe("SlackIdentityLinker", () => { + test("reloads the current OpenBot user and role for every existing link", async () => { + const store = storeFor({ + link: linked("alice"), + active: new Map([ + ["alice", { id: "alice", name: "Alice Now", role: "admin" }], + ]), + }); + + await expect(linker(store).resolve(context())).resolves.toMatchObject({ + kind: "linked", + user: { id: "alice", name: "Alice Now" }, + actor: { id: "alice", role: "admin" }, + }); + }); + + test("refuses a linked user that has been revoked or lost every supported role", async () => { + const store = storeFor({ link: linked("alice") }); + + const result = await linker(store).resolve(context()); + + expect(result.kind).toBe("unlinked"); + expect(store.linkedWith).toEqual([]); + }); + + test("accepts only human provider actors", async () => { + const store = storeFor({ + found: { id: "alice", name: "Alice" }, + active: new Map([ + ["alice", { id: "alice", name: "Alice", role: "user" }], + ]), + }); + + await expect( + linker(store).resolve(context({ actor: { id: "B1", kind: "bot" } })), + ).rejects.toThrow("Slack identity requires a known tenant and actor id."); + expect(store.linkedWith).toEqual([]); + }); + + test("uses only the adapter profile email for automatic linking", async () => { + const store = storeFor({ + found: { id: "alice", name: "Alice" }, + active: new Map([ + ["alice", { id: "alice", name: "Alice", role: "user" }], + ]), + }); + const result = await linker(store).resolve( + context({ + actor: { id: "U1", kind: "human", email: "attacker@example.test" }, + lookupProfile: async () => ({ + id: "U1", + kind: "human", + email: " ADAPTER@EXAMPLE.TEST ", + }), + }), + ); + + expect(result).toMatchObject({ + kind: "linked", + actor: { id: "alice", role: "user" }, + }); + expect(store.linkedWith).toEqual([{ ...IDENTITY, openbotUserId: "alice" }]); + }); + + test("returns a secure linking flow for absent, ambiguous, unverified, or revoked adapter email", async () => { + for (const profile of [ + undefined, + { id: "U1", kind: "human" as const }, + { id: "U1", kind: "human" as const, email: "" }, + ]) { + const store = storeFor(); + const result = await linker(store).resolve( + context({ lookupProfile: async () => profile }), + ); + expect(result.kind).toBe("unlinked"); + expect(store.linkedWith).toEqual([]); + if (result.kind === "unlinked") + expect(result.linkUrl).toStartWith( + "https://openbot.test/link/slack?token=", + ); + } + }); + + test("does not auto-link a matching email without an active OpenBot role", async () => { + const store = storeFor({ found: { id: "alice", name: "Alice" } }); + const result = await linker(store).resolve( + context({ + lookupProfile: async () => ({ + id: "U1", + kind: "human", + email: IDENTITY.providerEmail ?? undefined, + }), + }), + ); + expect(result.kind).toBe("unlinked"); + expect(store.linkedWith).toEqual([]); + }); + + test("does not reassign a conflicting identity and only accepts the safe existing winner", async () => { + const store = storeFor({ + found: { id: "alice", name: "Alice" }, + active: new Map([ + ["alice", { id: "alice", name: "Alice", role: "user" }], + ["bob", { id: "bob", name: "Bob", role: "admin" }], + ]), + onLink: () => ({ + link: linked("bob"), + error: new Error("That Slack identity is already linked."), + }), + }); + const result = await linker(store).resolve( + context({ + lookupProfile: async () => ({ + id: "U1", + kind: "human", + email: IDENTITY.providerEmail ?? undefined, + }), + }), + ); + + expect(result).toMatchObject({ + kind: "linked", + actor: { id: "bob", role: "admin" }, + }); + expect(store.linkedWith).toHaveLength(1); + }); + + test("requires a configured absolute app URL for an unlinked result", async () => { + await expect(linker(storeFor(), "").resolve(context())).rejects.toThrow( + "Slack link setup requires an absolute OPENBOT_APP_URL.", + ); + await expect( + linker(storeFor(), "/relative").resolve(context()), + ).rejects.toThrow("Slack link setup requires an absolute OPENBOT_APP_URL."); + }); + + test("derives tenant and provider user only from ChannelIdentityContext", async () => { + const store = storeFor(); + const result = await linker(store).resolve( + context({ + tenant: { id: "T-trusted" }, + actor: { + id: "U-trusted", + kind: "human", + email: "ignored@example.test", + }, + raw: { tenant: "attacker", user: "attacker" }, + }), + ); + expect(result).toMatchObject({ + identity: { + provider: "slack", + providerTenantId: "T-trusted", + providerUserId: "U-trusted", + }, + }); + }); + + test("rejects noncanonical Slack tenant and actor identities before every store operation", async () => { + for (const input of [ + { tenant: { id: "" } }, + { tenant: { id: " " } }, + { tenant: { id: "UnKnOwN" } }, + { actor: { id: "" } }, + { actor: { id: " " } }, + { actor: { id: " unknown " } }, + { actor: { id: undefined as never } }, + { tenant: { id: undefined as never } }, + { provider: "discord" }, + ]) { + const store = storeFor(); + await expect( + linker(store).resolve( + context({ + ...input, + actor: { + id: "U1", + kind: "human", + ...(input.actor ?? {}), + }, + }), + ), + ).rejects.toMatchObject({ + message: "Slack identity requires a known tenant and actor id.", + code: expect.stringMatching( + /^slack_identity_(provider|actor_kind|tenant|actor)_invalid$/, + ), + }); + expect(store.findKeys).toEqual([]); + expect(store.verifiedEmails).toEqual([]); + expect(store.linkedWith).toEqual([]); + expect(store.activeIds).toEqual([]); + } + }); + + test("classifies each invalid canonical identity field separately", async () => { + const cases = [ + [context({ provider: "discord" }), "slack_identity_provider_invalid"], + [ + context({ actor: { id: "U1", kind: "unknown" } }), + "slack_identity_actor_kind_invalid", + ], + [context({ tenant: { id: "unknown" } }), "slack_identity_tenant_invalid"], + [ + context({ actor: { id: "unknown", kind: "human" } }), + "slack_identity_actor_invalid", + ], + ] as const; + + for (const [identityContext, code] of cases) { + await expect( + linker(storeFor()).resolve(identityContext), + ).rejects.toMatchObject({ code }); + } + }); + + test("classifies link lookup failures without exposing their details", async () => { + const store = storeFor(); + store.find = async () => { + throw new Error("postgres host and credential detail"); + }; + + await expect(linker(store).resolve(context())).rejects.toMatchObject({ + message: "Slack identity link lookup failed.", + code: "slack_identity_link_lookup_failed", + }); + }); + + test("uses canonical trimmed tenant and actor ids for every store key and link", async () => { + const store = storeFor({ + found: { id: "alice", name: "Alice" }, + active: new Map([ + ["alice", { id: "alice", name: "Alice", role: "user" }], + ]), + }); + await linker(store).resolve( + context({ + tenant: { id: " T1 " }, + actor: { id: " U1 ", kind: "human" }, + lookupProfile: async () => ({ + id: " U1 ", + kind: "human", + email: "adapter@example.test", + }), + }), + ); + + expect(store.findKeys).toEqual([ + ["slack", "T1", "U1"], + ["slack", "T1", "U1"], + ]); + expect(store.linkedWith).toEqual([{ ...IDENTITY, openbotUserId: "alice" }]); + }); + + test("requires a matching human adapter profile before automatic linking", async () => { + for (const profile of [ + { id: "another-user", kind: "human" as const, email: "other@test" }, + { id: "U1", kind: "bot" as const, email: "other@test" }, + ]) { + const store = storeFor({ + found: { id: "alice", name: "Alice" }, + active: new Map([ + ["alice", { id: "alice", name: "Alice", role: "user" }], + ]), + }); + const result = await linker(store).resolve( + context({ lookupProfile: async () => profile }), + ); + + expect(result.kind).toBe("unlinked"); + if (result.kind === "unlinked") { + expect(result.identity.providerEmail).toBeNull(); + } + expect(store.verifiedEmails).toEqual([]); + expect(store.linkedWith).toEqual([]); + } + }); + + test("uses an explicit link flow when adapter profile lookup rejects", async () => { + const store = storeFor(); + const result = await linker(store).resolve( + context({ + lookupProfile: async () => { + throw new Error("adapter unavailable"); + }, + }), + ); + + expect(result.kind).toBe("unlinked"); + if (result.kind === "unlinked") { + expect(result.identity.providerEmail).toBeNull(); + } + expect(store.verifiedEmails).toEqual([]); + expect(store.linkedWith).toEqual([]); + }); + + test("rejects unsafe app URLs and permits only loopback HTTP development URLs", async () => { + for (const appUrl of [ + "https://user:secret@example.com", + "http://example.com", + "ftp://example.com", + ]) { + await expect( + linker(storeFor(), appUrl).resolve(context()), + ).rejects.toThrow( + "Slack link setup requires an absolute OPENBOT_APP_URL.", + ); + } + for (const appUrl of [ + "http://localhost:3000", + "http://127.0.0.1:3000", + "http://[::1]:3000", + ]) { + const result = await linker(storeFor(), appUrl).resolve(context()); + expect(result.kind).toBe("unlinked"); + if (result.kind === "unlinked") { + expect(result.linkUrl).toStartWith(`${appUrl}/link/slack?token=`); + expect(result.linkUrl).not.toContain("@example.com"); + } + } + }); +}); diff --git a/server/tests/slack-ingress-registry.test.ts b/server/tests/slack-ingress-registry.test.ts new file mode 100644 index 00000000..90523d64 --- /dev/null +++ b/server/tests/slack-ingress-registry.test.ts @@ -0,0 +1,275 @@ +import { describe, expect, test } from "bun:test"; +import type { ChannelIdentityContext } from "@copilotkit/channels"; +import { + SlackIngressRegistry, + type Timer, +} from "../src/slack/ingress-registry"; + +type Scheduled = { delay: number; callback: () => void; cancelled: boolean }; + +function clock() { + const scheduled: Scheduled[] = []; + return { + scheduled, + after(delay: number, callback: () => void): Timer { + const entry = { delay, callback, cancelled: false }; + scheduled.push(entry); + return { + cancel: () => { + entry.cancelled = true; + }, + }; + }, + }; +} + +function context(eventId = "Ev1"): ChannelIdentityContext { + return { + provider: "slack", + tenant: { id: "T1" }, + installation: { id: "I1" }, + actor: { id: "U1", kind: "human" }, + conversation: { id: "C1" }, + trigger: "message", + event: { id: eventId }, + raw: null, + }; +} + +function selector(overrides: Record = {}) { + return { + provider: "slack", + providerActorId: "U1", + applicationUserId: null, + ...overrides, + }; +} + +const identityResult = { + kind: "unlinked" as const, + linkUrl: "https://openbot.test/link/slack?token=token", + identity: { + provider: "slack" as const, + providerTenantId: "T1", + providerUserId: "U1", + providerEmail: null, + }, +}; + +describe("managed Slack ingress registry", () => { + test("takes a remembered ingress only once", () => { + const registry = new SlackIngressRegistry(clock()); + const ingress = { identityContext: context(), identityResult }; + + registry.remember("Ev1", ingress); + + expect(registry.take("Ev1", selector())).toBe(ingress); + expect(registry.take("Ev1", selector())).toBeNull(); + }); + + test("rejects a missing or blank managed event id", () => { + const registry = new SlackIngressRegistry(clock()); + const ingress = { identityContext: context(), identityResult }; + + for (const eventId of ["", " ", undefined]) { + expect(() => registry.remember(eventId, ingress)).toThrow( + "Managed Slack ingress requires an event id.", + ); + expect(() => registry.take(eventId, selector())).toThrow( + "Managed Slack ingress requires an event id.", + ); + } + }); + + test("replaces an ingress by cancelling its prior expiry", () => { + const timer = clock(); + const registry = new SlackIngressRegistry(timer); + const first = { identityContext: context(), identityResult }; + const latest = { identityContext: context("Ev1"), identityResult }; + + registry.remember("Ev1", first); + registry.remember("Ev1", latest); + + expect(timer.scheduled[0]).toMatchObject({ + delay: 30_000, + cancelled: true, + }); + expect(registry.take("Ev1", selector())).toBe(latest); + }); + + test("cancels expiry when an ingress is taken", () => { + const timer = clock(); + const registry = new SlackIngressRegistry(timer); + registry.remember("Ev1", { identityContext: context(), identityResult }); + + registry.take("Ev1", selector()); + + expect(timer.scheduled[0]?.cancelled).toBe(true); + }); + + test("expires an untouched ingress after exactly thirty seconds", () => { + const timer = clock(); + const registry = new SlackIngressRegistry(timer); + registry.remember("Ev1", { identityContext: context(), identityResult }); + + expect(timer.scheduled[0]?.delay).toBe(30_000); + timer.scheduled[0]?.callback(); + + expect(registry.take("Ev1", selector())).toBeNull(); + }); + + test("does not let a stale expiry delete a replacement", () => { + const timer = clock(); + const registry = new SlackIngressRegistry(timer); + const first = { identityContext: context(), identityResult }; + const latest = { identityContext: context(), identityResult }; + registry.remember("Ev1", first); + registry.remember("Ev1", latest); + + timer.scheduled[0]?.callback(); + + expect(registry.take("Ev1", selector())).toBe(latest); + }); + + test("uses one trimmed event key for replacement, expiry, and take", () => { + const timer = clock(); + const registry = new SlackIngressRegistry(timer); + const first = { identityContext: context(), identityResult }; + const latest = { identityContext: context(), identityResult }; + registry.remember(" Ev1 ", first); + registry.remember("Ev1", latest); + + timer.scheduled[0]?.callback(); + + expect(registry.take(" Ev1 ", selector())).toBe(latest); + }); + + test("same event id cannot cross provider actor or conversation principals", () => { + const registry = new SlackIngressRegistry(clock()); + const first = { identityContext: context(), identityResult }; + const secondContext = { + ...context(), + actor: { id: "U2", kind: "human" as const }, + conversation: { id: "C2" }, + }; + const second = { + identityContext: secondContext, + identityResult: { + ...identityResult, + identity: { + ...identityResult.identity, + providerUserId: "U2", + }, + }, + }; + registry.remember("Ev1", first); + registry.remember("Ev1", second); + + expect(registry.take("Ev1", selector())).toBe(first); + expect( + registry.take( + "Ev1", + selector({ + providerActorId: "U2", + }), + ), + ).toBe(second); + }); + + test("same event and linked principal across conversations is ambiguous in either delivery order", () => { + const linked = (conversationId: string) => ({ + identityContext: { + ...context(), + conversation: { id: conversationId }, + }, + identityResult: { + kind: "linked" as const, + user: { id: "u1", name: "User One" }, + actor: { id: "u1", role: "user" as const }, + identity: { + provider: "slack" as const, + providerTenantId: "T1", + providerUserId: "U1", + providerEmail: null, + }, + }, + }); + const linkedSelector = selector({ applicationUserId: "u1" }); + + for (const order of [ + [linked("C1"), linked("C2")], + [linked("C2"), linked("C1")], + ]) { + const registry = new SlackIngressRegistry(clock()); + registry.remember("Ev-shared", order[0]!); + registry.remember("Ev-shared", order[1]!); + + expect(registry.take("Ev-shared", linkedSelector)).toBeNull(); + } + }); + + test("same event and linked principal cannot overwrite a different provider thread", () => { + const registry = new SlackIngressRegistry(clock()); + const linked = (providerThreadId: string) => ({ + identityContext: { + ...context(), + event: { id: "Ev-shared", threadId: providerThreadId }, + }, + identityResult: { + kind: "linked" as const, + user: { id: "u1", name: "User One" }, + actor: { id: "u1", role: "user" as const }, + identity: { + provider: "slack" as const, + providerTenantId: "T1", + providerUserId: "U1", + providerEmail: null, + }, + }, + }); + registry.remember("Ev-shared", linked("provider-thread-1")); + registry.remember("Ev-shared", linked("provider-thread-2")); + + expect( + registry.take("Ev-shared", selector({ applicationUserId: "u1" })), + ).toBeNull(); + }); + + test("consumes a live linked interaction principal once and burns ambiguity", () => { + const registry = new SlackIngressRegistry(clock()); + const linked = (actorId: string, conversationId: string) => ({ + identityContext: { + ...context(), + actor: { id: actorId, kind: "human" as const }, + conversation: { id: conversationId }, + trigger: "interaction", + }, + identityResult: { + kind: "linked" as const, + user: { id: "u1", name: "User One" }, + actor: { id: "u1", role: "user" as const }, + identity: { + provider: "slack" as const, + providerTenantId: "T1", + providerUserId: actorId, + providerEmail: null, + }, + }, + }); + const first = linked("U1", "C1"); + registry.remember("interaction-1", first); + const interactionSelector = { + provider: "slack" as const, + providerActorId: "U1", + applicationUserId: "u1", + }; + + expect(registry.takeInteraction(interactionSelector)).toBe(first); + expect(registry.takeInteraction(interactionSelector)).toBeNull(); + + registry.remember("interaction-2", linked("U1", "C1")); + registry.remember("interaction-3", linked("U1", "C2")); + expect(registry.takeInteraction(interactionSelector)).toBeNull(); + expect(registry.takeInteraction(interactionSelector)).toBeNull(); + }); +}); diff --git a/server/tests/slack-lifecycle.test.ts b/server/tests/slack-lifecycle.test.ts new file mode 100644 index 00000000..bf518ac0 --- /dev/null +++ b/server/tests/slack-lifecycle.test.ts @@ -0,0 +1,467 @@ +import { describe, expect, spyOn, test } from "bun:test"; +import { createApp } from "../src/app"; +import { loadConfig } from "../src/config"; +import { + activateManagedChannels, + createGracefulShutdown, + projectSlackStatus, + registerShutdownSignals, + startManagedChannelHost, +} from "../src/slack/status"; +import { testEnvironment } from "./support/environment"; + +describe("Slack lifecycle status", () => { + test("projects a joined transport with no Slack provider as setup required", () => { + expect( + projectSlackStatus({ + overall: "online", + channels: { openbot: "setup_required" }, + detail: { + openbot: { + status: "setup_required", + transport: "online", + provider: "not_attached", + }, + }, + }), + ).toEqual({ + transport: "online", + provider: "not_attached", + status: "setup_required", + }); + }); + + test("projects a reconnecting Slack provider without certifying it online", () => { + expect( + projectSlackStatus({ + overall: "reconnecting", + channels: { openbot: "reconnecting" }, + detail: { + openbot: { + status: "reconnecting", + transport: "reconnecting", + provider: "attached", + }, + }, + }), + ).toEqual({ + transport: "reconnecting", + provider: "attached", + status: "reconnecting", + }); + }); + + test("reports stopped with unknown provider before Channels exists", () => { + expect(projectSlackStatus()).toEqual({ + status: "stopped", + transport: "stopped", + provider: "unknown", + }); + }); +}); + +describe("managed Channels lifecycle", () => { + test("reports activation failure with the caught error as a separate argument", async () => { + const failure = new Error("gateway offline"); + const logged = spyOn(console, "error").mockImplementation(() => {}); + try { + await activateManagedChannels({ + ready: async () => { + throw failure; + }, + }); + + expect(logged).toHaveBeenCalledTimes(1); + expect(logged).toHaveBeenCalledWith( + "OpenBot Slack Channel activation failed", + failure, + ); + } finally { + logged.mockRestore(); + } + }); + + test("SIGTERM and SIGINT share one shutdown and unregister their callbacks", async () => { + const listeners = new Map void>>(); + const signals = { + on(signal: "SIGINT" | "SIGTERM", listener: () => void) { + const registered = listeners.get(signal) ?? new Set(); + registered.add(listener); + listeners.set(signal, registered); + }, + off(signal: "SIGINT" | "SIGTERM", listener: () => void) { + listeners.get(signal)?.delete(listener); + }, + emit(signal: "SIGINT" | "SIGTERM") { + for (const listener of listeners.get(signal) ?? []) listener(); + }, + count(signal: "SIGINT" | "SIGTERM") { + return listeners.get(signal)?.size ?? 0; + }, + }; + let channelStops = 0; + let httpStops = 0; + const httpStopForces: Array = []; + let listenerStops = 0; + const exitCodes: number[] = []; + let markExited: (() => void) | undefined; + const exited = new Promise((resolve) => { + markExited = resolve; + }); + await startManagedChannelHost({ + startWeb: () => ({ + stop(force?: boolean): Promise { + httpStops += 1; + httpStopForces.push(force); + if (force) return Promise.resolve(); + return new Promise(() => {}); + }, + }), + stopWeb: (web) => web.stop(true), + channels: { + ready: async () => {}, + stop: async () => { + channelStops += 1; + }, + }, + signals, + stopOthers: [ + async () => { + listenerStops += 1; + }, + ], + exit: (code) => { + exitCodes.push(code); + markExited?.(); + }, + }); + + expect(signals.count("SIGINT")).toBe(1); + expect(signals.count("SIGTERM")).toBe(1); + signals.emit("SIGTERM"); + signals.emit("SIGINT"); + await exited; + await Promise.resolve(); + + expect(channelStops).toBe(1); + expect(httpStops).toBe(1); + expect(httpStopForces).toEqual([true]); + expect(listenerStops).toBe(1); + expect(exitCodes).toEqual([0]); + expect(signals.count("SIGINT")).toBe(0); + expect(signals.count("SIGTERM")).toBe(0); + }); + + test("reports failed stops canonically and exits unsuccessfully", async () => { + const logged = spyOn(console, "error").mockImplementation(() => {}); + const exitCodes: Array = []; + try { + const shutdown = createGracefulShutdown({ + channels: { + stop: async () => { + throw new Error("private stop detail"); + }, + }, + stopOthers: [async () => {}], + exit: (code) => exitCodes.push(code), + }); + + await shutdown(); + + expect(exitCodes).toEqual([1]); + expect(logged).toHaveBeenCalledWith("OpenBot shutdown failed", { + code: "shutdown_stop_failed", + component: "channels", + }); + expect(JSON.stringify(logged.mock.calls)).not.toContain( + "private stop detail", + ); + } finally { + logged.mockRestore(); + } + }); + + test("times out a hanging stop once and observes its late rejection", async () => { + let triggerTimeout: (() => void) | undefined; + let rejectStop: ((error: Error) => void) | undefined; + let stopCalls = 0; + const failures: Array<{ code: string; component: string }> = []; + const exitCodes: number[] = []; + const shutdown = createGracefulShutdown({ + channels: { + stop: () => { + stopCalls += 1; + return new Promise((_resolve, reject) => { + rejectStop = reject; + }); + }, + }, + stopOthers: [], + exit: (code) => exitCodes.push(code), + reportFailure: (failure) => failures.push(failure), + timeoutMs: 25, + startTimeout: (callback) => { + triggerTimeout = callback; + return () => {}; + }, + }); + + const first = shutdown(); + const second = shutdown(); + expect(triggerTimeout).toBeFunction(); + triggerTimeout?.(); + await Promise.all([first, second]); + + expect(stopCalls).toBe(1); + expect(failures).toEqual([ + { code: "shutdown_stop_timeout", component: "channels" }, + ]); + expect(exitCodes).toEqual([1]); + + rejectStop?.(new Error("private late stop detail")); + await Promise.resolve(); + await Promise.resolve(); + expect(failures).toHaveLength(1); + expect(exitCodes).toEqual([1]); + }); + + test("reports a prompt rejection and a concurrent timeout exactly once", async () => { + let triggerTimeout: (() => void) | undefined; + let rejectLateStop: ((error: Error) => void) | undefined; + const failures: Array<{ code: string; component: string }> = []; + const exitCodes: number[] = []; + const shutdown = createGracefulShutdown({ + channels: { + stop: async () => { + throw new Error("private prompt rejection detail"); + }, + }, + stopOthers: [ + () => + new Promise((_resolve, reject) => { + rejectLateStop = reject; + }), + ], + exit: (code) => exitCodes.push(code), + reportFailure: (failure) => failures.push(failure), + timeoutMs: 25, + startTimeout: (callback) => { + triggerTimeout = callback; + return () => {}; + }, + }); + + const result = shutdown(); + await Promise.resolve(); + await Promise.resolve(); + triggerTimeout?.(); + await result; + + expect(failures).toEqual([ + { code: "shutdown_stop_failed", component: "channels" }, + { code: "shutdown_stop_timeout", component: "background_0" }, + ]); + expect(JSON.stringify(failures)).not.toContain("private prompt rejection"); + expect(exitCodes).toEqual([1]); + + rejectLateStop?.(new Error("private late rejection detail")); + await Promise.resolve(); + await Promise.resolve(); + expect(failures).toHaveLength(2); + expect(JSON.stringify(failures)).not.toContain("private late rejection"); + expect(exitCodes).toEqual([1]); + }); + + test("a broken failure reporter cannot prevent shutdown exit", async () => { + for (const reportFailure of [ + () => { + throw new Error("reporter threw"); + }, + async () => { + throw new Error("reporter rejected"); + }, + ]) { + const exitCodes: number[] = []; + const shutdown = createGracefulShutdown({ + channels: { + stop: async () => { + throw new Error("private stop detail"); + }, + }, + stopOthers: [], + exit: (code) => exitCodes.push(code), + reportFailure, + }); + + await shutdown(); + await Promise.resolve(); + expect(exitCodes).toEqual([1]); + } + }); + + test("handles a rejecting signal shutdown and still unregisters listeners", async () => { + const listeners = new Map void>>(); + const signals = { + on(signal: "SIGINT" | "SIGTERM", listener: () => void) { + const registered = listeners.get(signal) ?? new Set(); + registered.add(listener); + listeners.set(signal, registered); + }, + off(signal: "SIGINT" | "SIGTERM", listener: () => void) { + listeners.get(signal)?.delete(listener); + }, + }; + const logged = spyOn(console, "error").mockImplementation(() => {}); + const exitCodes: number[] = []; + try { + registerShutdownSignals( + signals, + async () => { + throw new Error("private shutdown detail"); + }, + undefined, + (code) => exitCodes.push(code), + ); + for (const listener of listeners.get("SIGTERM") ?? []) listener(); + for (const listener of listeners.get("SIGINT") ?? []) listener(); + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(listeners.get("SIGINT")?.size ?? 0).toBe(0); + expect(listeners.get("SIGTERM")?.size ?? 0).toBe(0); + expect(logged).toHaveBeenCalledWith("OpenBot shutdown failed", { + code: "shutdown_promise_rejected", + component: "signal_handler", + }); + expect(JSON.stringify(logged.mock.calls)).not.toContain( + "private shutdown detail", + ); + expect(exitCodes).toEqual([1]); + } finally { + logged.mockRestore(); + } + }); + + test("terminates when a signal shutdown throws before returning a promise", async () => { + const listeners = new Map void>>(); + const signals = { + on(signal: "SIGINT" | "SIGTERM", listener: () => void) { + const registered = listeners.get(signal) ?? new Set(); + registered.add(listener); + listeners.set(signal, registered); + }, + off(signal: "SIGINT" | "SIGTERM", listener: () => void) { + listeners.get(signal)?.delete(listener); + }, + }; + const exitCodes: number[] = []; + registerShutdownSignals( + signals, + () => { + throw new Error("private synchronous shutdown detail"); + }, + () => {}, + (code) => exitCodes.push(code), + ); + + for (const listener of listeners.get("SIGTERM") ?? []) listener(); + await Promise.resolve(); + await Promise.resolve(); + + expect(listeners.get("SIGINT")?.size ?? 0).toBe(0); + expect(listeners.get("SIGTERM")?.size ?? 0).toBe(0); + expect(exitCodes).toEqual([1]); + }); + + test("the production Bun host force-stops active WebSockets", async () => { + const source = await Bun.file( + new URL("../src/index.ts", import.meta.url), + ).text(); + + expect(source).toContain("stopWeb: (server) => server.stop(true)"); + }); + + test("starts a live web host before managed activation and keeps it live on rejection", async () => { + const events: string[] = []; + let rejectActivation: ((error: Error) => void) | undefined; + const activation = new Promise((_resolve, reject) => { + rejectActivation = reject; + }); + const snapshot = { + overall: "error" as const, + channels: { openbot: "error" as const }, + detail: { + openbot: { + status: "error" as const, + transport: "error" as const, + provider: "unknown" as const, + }, + }, + }; + const app = createApp( + loadConfig(testEnvironment()), + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + () => projectSlackStatus(snapshot), + ); + let live: { app: typeof app; stop(): Promise } | undefined; + const start = startManagedChannelHost({ + startWeb: () => { + events.push("serve"); + live = { app, stop: async () => {} }; + return live; + }, + stopWeb: (web) => web.stop(), + channels: { + ready: async () => { + events.push("activate"); + return activation; + }, + stop: async () => {}, + }, + signals: { on: () => {}, off: () => {} }, + stopOthers: [], + exit: () => {}, + reportActivationFailure: () => {}, + }); + + await Promise.resolve(); + expect(events).toEqual(["serve", "activate"]); + expect(live).toBeDefined(); + expect( + (await live?.app.request("http://openbot.local/health"))?.status, + ).toBe(200); + + rejectActivation?.(new Error("gateway unavailable")); + await expect(start).resolves.toBe(live); + const capabilities = await live?.app.request( + "http://openbot.local/api/capabilities", + ); + expect(capabilities?.status).toBe(200); + expect((await capabilities?.json())?.channels.slack).toEqual({ + status: "error", + transport: "error", + provider: "unknown", + }); + }); +}); diff --git a/server/tests/slack-tenant-context.test.ts b/server/tests/slack-tenant-context.test.ts new file mode 100644 index 00000000..10b33959 --- /dev/null +++ b/server/tests/slack-tenant-context.test.ts @@ -0,0 +1,95 @@ +import { describe, expect, test } from "bun:test"; +import type { ChannelIdentityContext } from "@copilotkit/channels"; +import { + MANAGED_SLACK_TENANT_ERROR, + normalizeSlackTenantContext, +} from "../src/slack/tenant-context"; + +function identity(tenantId: string): ChannelIdentityContext { + return { + provider: "slack", + tenant: { id: tenantId, name: "Workspace" }, + installation: { id: "I1" }, + actor: { id: "U1", kind: "human", name: "User" }, + conversation: { id: "C1", kind: "channel" }, + trigger: "message", + event: { id: "E1", threadId: "TH1" }, + raw: { untrusted: true }, + lookupProfile: async () => ({ + id: "U1", + kind: "human", + name: "User", + email: "user@example.test", + }), + }; +} + +describe("managed Slack tenant context", () => { + test("keeps a known managed tenant authoritative without configuration", () => { + const context = identity("T1"); + + expect(normalizeSlackTenantContext(context)).toBe(context); + }); + + test("keeps a known managed tenant when configuration matches exactly", () => { + const context = identity("T1"); + + expect(normalizeSlackTenantContext(context, "T1")).toBe(context); + }); + + test("shares one canonical known tenant with linking and ingress", () => { + const context = identity(" T1 "); + + const normalized = normalizeSlackTenantContext(context, "T1"); + + expect(normalized).not.toBe(context); + expect(normalized.tenant).toEqual({ id: "T1", name: "Workspace" }); + expect(context.tenant.id).toBe(" T1 "); + expect(Object.isFrozen(normalized)).toBe(true); + expect(Object.isFrozen(normalized.tenant)).toBe(true); + }); + + test.each(["unknown", " UNKNOWN ", "", " "])( + "replaces a missing canonical tenant %j with operator configuration", + (tenantId) => { + const context = identity(tenantId); + + const normalized = normalizeSlackTenantContext(context, "T1"); + + expect(normalized).not.toBe(context); + expect(normalized.tenant).toEqual({ id: "T1", name: "Workspace" }); + expect(normalized.provider).toBe(context.provider); + expect(normalized.installation).toBe(context.installation); + expect(normalized.actor).toBe(context.actor); + expect(normalized.conversation).toBe(context.conversation); + expect(normalized.event).toBe(context.event); + expect(normalized.trigger).toBe(context.trigger); + expect(normalized.raw).toBe(context.raw); + expect(normalized.lookupProfile).toBe(context.lookupProfile); + expect(Object.isFrozen(normalized)).toBe(true); + expect(Object.isFrozen(normalized.tenant)).toBe(true); + expect(context.tenant.id).toBe(tenantId); + }, + ); + + test("rejects a conflict between managed and configured tenants", () => { + expect(() => normalizeSlackTenantContext(identity("T2"), "T1")).toThrow( + expect.objectContaining({ + message: MANAGED_SLACK_TENANT_ERROR, + code: "slack_identity_tenant_invalid", + }), + ); + }); + + test.each(["unknown", " UNKNOWN ", "", " "])( + "fails closed for missing canonical tenant %j without configuration", + (tenantId) => { + expect(() => normalizeSlackTenantContext(identity(tenantId))).toThrow( + expect.objectContaining({ + message: MANAGED_SLACK_TENANT_ERROR, + code: "slack_identity_tenant_invalid", + }), + ); + }, + ); +}); diff --git a/server/tests/slack-turn-phase.test.ts b/server/tests/slack-turn-phase.test.ts new file mode 100644 index 00000000..d136912a --- /dev/null +++ b/server/tests/slack-turn-phase.test.ts @@ -0,0 +1,96 @@ +import { describe, expect, test } from "bun:test"; +import { + runSlackPhase, + SLACK_TURN_PHASES, + type SlackTurnFailureEvent, +} from "../src/slack/turn-phase"; + +describe("Slack turn phase diagnostics", () => { + test("logs only the fixed event type and phase for every allowed phase", async () => { + for (const phase of SLACK_TURN_PHASES) { + const events: SlackTurnFailureEvent[] = []; + const original = new Error("sensitive failure"); + + await expect( + runSlackPhase( + phase, + async () => { + throw original; + }, + (event) => events.push(event), + ), + ).rejects.toBe(original); + + expect(events).toEqual([{ type: "slack-turn-failed", phase }]); + expect(Object.keys(events[0] ?? {}).sort()).toEqual(["phase", "type"]); + expect(JSON.stringify(events)).not.toContain("sensitive"); + } + }); + + test("successful operations are silent", async () => { + const events: SlackTurnFailureEvent[] = []; + await expect( + runSlackPhase( + "identity.resolve", + () => "linked", + (event) => events.push(event), + ), + ).resolves.toBe("linked"); + expect(events).toEqual([]); + }); + + test("logs only allowlisted identity failure codes", async () => { + const events: SlackTurnFailureEvent[] = []; + const coded = Object.assign(new Error("sensitive database detail"), { + code: "slack_identity_link_lookup_failed", + }); + + await expect( + runSlackPhase( + "identity.resolve", + async () => { + throw coded; + }, + (event) => events.push(event), + ), + ).rejects.toBe(coded); + + expect(events).toEqual([ + { + type: "slack-turn-failed", + phase: "identity.resolve", + reason: "slack_identity_link_lookup_failed", + }, + ]); + expect(JSON.stringify(events)).not.toContain("sensitive"); + + const untrustedEvents: SlackTurnFailureEvent[] = []; + await expect( + runSlackPhase( + "identity.resolve", + async () => { + throw Object.assign(new Error("secret"), { code: "secret" }); + }, + (event) => untrustedEvents.push(event), + ), + ).rejects.toThrow("secret"); + expect(untrustedEvents).toEqual([ + { type: "slack-turn-failed", phase: "identity.resolve" }, + ]); + }); + + test("logger failure cannot replace the application error", async () => { + const original = new Error("application failure"); + await expect( + runSlackPhase( + "agent.run", + async () => { + throw original; + }, + () => { + throw new Error("logger failure"); + }, + ), + ).rejects.toBe(original); + }); +}); diff --git a/server/tsconfig.json b/server/tsconfig.json index 8858dd28..80c6e392 100644 --- a/server/tsconfig.json +++ b/server/tsconfig.json @@ -7,5 +7,9 @@ // type-checked and turning it on surfaces a few hundred years of accumulated `any`. It is what let // a test pass an options object where a connection string belongs and hear nothing back. Its own // change, because it is a sweep and not a fix. + "compilerOptions": { + "jsx": "react-jsx", + "jsxImportSource": "@copilotkit/channels" + }, "include": ["src", "scripts"] } diff --git a/shared/computer-tool-contracts.ts b/shared/computer-tool-contracts.ts new file mode 100644 index 00000000..e2b1dd31 --- /dev/null +++ b/shared/computer-tool-contracts.ts @@ -0,0 +1,269 @@ +import { z } from "zod"; + +/** + * Platform-neutral computer tool contracts shared by the web and Channels surfaces. + * + * Keep handlers and renderers at their platform boundary. A contract contains only the public tool + * name, description, and Zod parameters so every surface gives the model the same call shape. + */ + +export const computerNavigateContract = { + name: "computer_navigate", + description: + "Open a web page on your own computer so the person can watch. Use this when asked to look " + + "at, visit, open or check a website. Returns the page title and its readable text, so answer " + + "from what comes back rather than telling the person to go and look.", + parameters: z.object({ + url: z.string().describe("Full web address to open, including https://"), + }), +} as const; + +/** Channels-only bounded path for a website summary plus visual proof. */ +export const computerOpenAndShareScreenshotContract = { + name: "computer_open_and_share_screenshot", + description: + "Open a website, return its readable text for summarization, and share a current PNG " + + "screenshot in this Slack conversation in one operation. Use this instead of separate " + + "navigate and screenshot calls whenever the request asks both to inspect or summarize a site " + + "and to send a picture.", + parameters: z.object({ + url: z.string().describe("Full web address to open, including https://"), + filename: z + .string() + .optional() + .describe("Optional PNG filename to show in Slack"), + }), +} as const; + +export const computerReadContract = { + name: "computer_read", + description: + "Read the page currently open on your computer, without opening anything. Use this after you " + + "click something that changes the page, such as submitting a form, to find out what it now says.", + parameters: z.object({}), +} as const; + +export const computerSnapshotContract = { + name: "computer_snapshot", + description: + "List the things on the current page you can act on: fields, buttons, links and checkboxes, " + + "each with a ref, its label and its current value. Call this BEFORE clicking or typing, and " + + "use the refs it returns. Always send back the snapshotId it gives you. If an action reports " + + "that your refs are stale, the page changed: call this again and use the new refs.", + parameters: z.object({}), +} as const; + +export const computerTypeContract = { + name: "computer_type", + description: + "Enter text into a field on the page. Give the ref of the field from your most recent " + + "snapshot and the snapshotId it came from. This replaces whatever the field already contains. " + + "Set submit to true to press Enter afterwards.", + parameters: z.object({ + ref: z + .string() + .describe("Ref of the field, from your most recent snapshot"), + snapshotId: z.number().describe("The snapshotId that ref came from"), + text: z.string().describe("The text to enter"), + submit: z + .boolean() + .optional() + .describe("Press Enter after typing, to submit a single-field form"), + }), +} as const; + +export const computerClickContract = { + name: "computer_click", + description: + "Click something on the page: a button, a link, a checkbox or a radio option. Give the ref " + + "from your most recent snapshot and the snapshotId it came from.", + parameters: z.object({ + ref: z + .string() + .describe("Ref of the element to click, from your most recent snapshot"), + snapshotId: z.number().describe("The snapshotId that ref came from"), + }), +} as const; + +export const computerKeyContract = { + name: "computer_key", + description: + "Press a key, such as Enter, Tab or Escape. Give a ref to press it while a particular field " + + "is focused, or omit the ref to press it on the page.", + parameters: z.object({ + key: z.string().describe("Key name, such as Enter, Tab or Escape"), + ref: z.string().optional().describe("Optional ref to press the key on"), + snapshotId: z + .number() + .optional() + .describe("The snapshotId the ref came from, required if ref is given"), + }), +} as const; + +export const computerRequestSecretContract = { + name: "computer_request_secret", + description: + "Ask the person for ONE value you must not be told: a password, a one-time code, a card number. " + + "Focus the field first with computer_click, then call this with the ref of that field and a " + + "short label for what you need. They type it into a masked box that goes straight to the page. " + + "You will never see the value, and you must not ask for it any other way. Prefer this over a " + + "full takeover when you only need one field filled in. The value is only TYPED into the field: " + + "if the form needs submitting, do that yourself afterwards with computer_click.", + parameters: z.object({ + label: z + .string() + .describe( + "What you need, in a few words, e.g. 'the code sent to your phone'", + ), + ref: z + .string() + .describe("Ref of the field it goes in, from your most recent snapshot"), + snapshotId: z.number().describe("The snapshotId that ref came from"), + }), +} as const; + +export const reportRefusalContract = { + name: "report_refusal", + description: + "Record that you DECLINED something you were asked to do, because it looked unsafe, was outside " + + "what you are for, or you judged you should not. Call this whenever you say no to a request, in " + + "addition to telling the person. It changes nothing about your answer; it exists so an " + + "administrator can see what this Bot is being asked to do. Do not call it when you simply could " + + "not do something, only when you chose not to.", + parameters: z.object({ + reason: z + .string() + .describe("Why you declined, in one sentence and in your own words"), + request: z + .string() + .optional() + .describe("What you were asked to do, in a few words"), + }), +} as const; + +export const computerRequestHelpContract = { + name: "computer_request_help", + description: + "Ask the person to take control of your computer and do something you cannot: sign in, enter a " + + "password or a one-time code, or clear a CAPTCHA. Say specifically what you need done. They " + + "will drive the browser themselves and hand it back, and you carry on in the same session. " + + "Use this INSTEAD of giving up, and instead of ever asking them to type a password to you. " + + "This call is the only thing that reaches them: until you make it they are not looking at the " + + "page and have no way to help, so saying you need them to sign in, or asking whether they would " + + "like to proceed, hands over nothing and leaves the page where it is.", + parameters: z.object({ + reason: z + .string() + .describe( + "What you need the person to do, in one sentence, e.g. 'This page is asking for a code sent to your phone.'", + ), + }), +} as const; + +export const computerListFilesContract = { + name: "computer_list_files", + description: + "List what is in your workspace: every file and folder you have saved, with sizes. Call this " + + "FIRST when you are asked what files you have, or before reading a file whose exact name you " + + "are not sure of. Never guess a filename.", + parameters: z.object({ + path: z + .string() + .optional() + .describe("Optional folder to list. Omit for the whole workspace."), + }), +} as const; + +export const computerReadFileContract = { + name: "computer_read_file", + description: + "Read a file you saved earlier in your own workspace. Paths are relative to your workspace, " + + "such as notes.md or reports/august.csv. Your workspace survives between conversations, so use " + + "this to pick up notes you made before.", + parameters: z.object({ + path: z + .string() + .describe("Path relative to your workspace, such as notes.md"), + }), +} as const; + +export const computerRunCommandContract = { + name: "computer_run_command", + description: + "Run a shell command on your own computer. Use this for anything the browser cannot do: " + + "installing a tool you need, processing a file you saved, running a script. The working " + + "directory is your workspace, so paths are relative to it and files you write here are the " + + "same ones the file tools see. Commands run in bash, so pipes and && work. Long output is " + + "truncated from the start, and a command that runs too long is stopped. " + + "You are not the root user, so anything that writes outside your workspace needs sudo, " + + "which asks for no password: installing a package is " + + "`sudo apt-get update && sudo apt-get install -y `. If sudo is refused, this " + + "computer does not grant it, so say so rather than retrying.", + parameters: z.object({ + command: z + .string() + .describe("The command to run, such as: sudo apt-get install -y jq"), + }), +} as const; + +export const computerWriteFileContract = { + name: "computer_write_file", + description: + "Save a file in your own workspace so you still have it later. Paths are relative to your " + + "workspace and folders are created as needed. Set append to true to add to the end of an " + + "existing file rather than replacing it. Text only.", + parameters: z.object({ + path: z + .string() + .describe("Path relative to your workspace, such as reports/august.csv"), + contents: z.string().describe("The text to save"), + append: z + .boolean() + .optional() + .describe("Add to the end of the file instead of replacing it"), + }), +} as const; + +export const computerScrollContract = { + name: "computer_scroll", + description: + "Scroll the page down, or up with a negative amount, to bring more of a long page into view.", + parameters: z.object({ + deltaY: z + .number() + .optional() + .describe("Pixels to scroll; positive is down. Defaults to 600."), + }), +} as const; + +/** Channels-only capture and delivery of the current browser viewport. */ +export const computerScreenshotContract = { + name: "computer_screenshot", + description: + "Capture the web page currently open on your computer and share the PNG in this Slack " + + "conversation. Use this after navigating when someone asks for a picture or screenshot of a " + + "website. Optionally provide the filename people should see.", + parameters: z.object({ + filename: z + .string() + .optional() + .describe("Optional PNG filename to show in Slack"), + }), +} as const; + +/** Channels-only delivery of an existing complete workspace text file. */ +export const computerShareFileContract = { + name: "computer_share_file", + description: + "Share a complete text file from your workspace in this Slack conversation. Use the saved " + + "workspace path; optionally provide the filename people should see.", + parameters: z.object({ + path: z + .string() + .describe("Path relative to your workspace, such as reports/august.csv"), + filename: z + .string() + .optional() + .describe("Optional filename to show in Slack"), + }), +} as const;