From efe35a62a22184a42593f5ae37c5f211aa4f46da Mon Sep 17 00:00:00 2001 From: Jerel John Velarde Date: Sun, 30 Aug 2026 07:54:40 -0700 Subject: [PATCH 1/3] Build a coworker in one place, and route to one that was named MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four callers now build a Bot for a person — a chat request, a routine's headless turn, a hop delivered to another Bot, and the boundary's own lookup — and each passed the same eleven collaborators positionally. One of them getting an argument wrong is a Bot that runs and quietly holds different tools or a different role from the one the person is talking to. ActorAgentResolver binds them once. Choosing a coworker moves out of the HTTP route for the same reason: it was the routing model call, the visibility rule, and the channel.routed row all written inside a Hono handler, so nothing that is not an HTTP request could route. CoworkerRoutingService owns the decision, and the route turns its outcome into status codes. That move makes an explicit name cheap enough to honour: a message that names exactly one coworker on the asking person's roster no longer pays a model call to be told what the person already said. Two matches are refused with both names rather than guessed at. --- CHANGELOG.md | 16 + server/src/agents/agent-resolver.ts | 101 +++++ server/src/app.ts | 47 ++- server/src/copilot.ts | 119 +----- server/src/index.ts | 221 +++++----- server/src/routing/routes.ts | 211 ++-------- server/src/routing/service.ts | 382 ++++++++++++++++++ server/tests/agent-resolver.test.ts | 112 ++++++ server/tests/copilot.test.ts | 27 +- server/tests/routing-routes.test.ts | 13 +- server/tests/routing-service.test.ts | 576 +++++++++++++++++++++++++++ 11 files changed, 1408 insertions(+), 417 deletions(-) create mode 100644 server/src/agents/agent-resolver.ts create mode 100644 server/src/routing/service.ts create mode 100644 server/tests/agent-resolver.test.ts create mode 100644 server/tests/routing-service.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index c851fcd3..032f829a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,22 @@ Newest first. `Unreleased` is what is on `main` and not yet tagged. ## Unreleased +### A coworker named in the message is routed to without asking a model + +Naming a coworker in the text — "ask Risk Analyst to review this" — went to the intent router like +any other message, so the deployment paid a model call to be told what the person had already said, +and sometimes was told something else. A name that matches exactly one coworker on that person's +roster now routes straight to them, recorded as `named by the person asking` on the same +`channel.routed` row. A name that matches more than one is refused with both names rather than +guessed at, and a name nobody on the roster answers to falls through to the router as before. + +### Routing refuses rather than routes on a connector read it could not make + +Which systems a coworker can reach is weighed by the router alongside what the coworker is for. A +failed read of that used to be treated as "reaches nothing", which is a statement about the +deployment rather than an absence of one: a database that blinked quietly re-routed messages away +from the coworker that could actually do the work. It now fails the request instead. + ### A Bot's shell can no longer reach the embedded database without a password In the all-in-one image the cluster was `trust`-auth on loopback, and the Bot's shell runs in the 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/app.ts b/server/src/app.ts index 20561446..e7a5155d 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -44,6 +44,7 @@ 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 { PackageStatusReader } from "./tenant-package"; /** @@ -788,29 +789,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, ), ); } diff --git a/server/src/copilot.ts b/server/src/copilot.ts index 891c53ee..d8d44f2f 100644 --- a/server/src/copilot.ts +++ b/server/src/copilot.ts @@ -10,10 +10,8 @@ import { createCopilotHonoHandler } from "@copilotkit/runtime/v2/hono"; import type { Observable } from "rxjs"; import { defer, 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"; @@ -856,55 +854,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 +947,10 @@ 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, ) { const { intelligence } = config.runtime; @@ -1039,25 +976,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); }; /* @@ -1088,26 +1012,7 @@ 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, }); return { diff --git a/server/src/index.ts b/server/src/index.ts index a3b18ef6..f41b3feb 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"; @@ -47,10 +48,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 { @@ -595,6 +596,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 +745,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 @@ -717,93 +814,9 @@ 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]; - }, ); /** 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/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/copilot.test.ts b/server/tests/copilot.test.ts index 6d439464..e362c4a8 100644 --- a/server/tests/copilot.test.ts +++ b/server/tests/copilot.test.ts @@ -2,6 +2,7 @@ import { describe, expect, spyOn, test } from "bun:test"; import { HttpAgent } 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, @@ -518,12 +519,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"); @@ -538,11 +541,13 @@ describe("standing agent roles", () => { 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"); 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); + }); +}); From e6df88532c28f5a9aa34f954fa6b4a9677319f09 Mon Sep 17 00:00:00 2001 From: Jerel John Velarde Date: Sun, 30 Aug 2026 07:56:45 -0700 Subject: [PATCH 2/3] Talk to an OpenBot coworker from Slack A person mentions @OpenBot in a Slack thread and names or describes the coworker they want. The thread is pinned to that coworker and replies continue with it, without another mention. Channels SDK owns Slack ingress, delivery, streaming and files. This deployment stays the authority for everything that decides what may happen: every turn re-resolves the Slack speaker to an OpenBot user and reloads THAT person's roster, grants, policy and audit identity. A second person in the same thread who cannot see the pinned coworker is refused rather than run as the person who started it. The coworker is built by the same resolver a browser turn uses, so a Slack turn holds the same tools, the same standing role, the same signed run assertion and the same stall guard. Its computer runs through the same gateway, which means the same boundary decides, and the same audit row is written. Secrets, sign-in control and 2FA are never asked for in Slack. The thread gets an expiring link to this deployment's own screen, and the bounded assistance wait resumes when control is released there. An unlinked Slack user is told so and handed a signed, expiring link; the agent does not run. An exact match between a verified Slack email and one active OpenBot account may create the first link. Nothing already linked is ever silently reassigned. State lives in Postgres, not in the process: the thread binding, the transcript, the identity link and the approval decisions are all tables, so a reply delivered to a second replica finds the same conversation. The bindings table is append-only by trigger. --- .env.example | 17 + .gitignore | 4 + CHANGELOG.md | 16 - agent-computer/src/control.ts | 171 +- agent-computer/src/index.ts | 31 +- agent-computer/src/workspace.ts | 61 +- agent-computer/tests/control.test.ts | 122 +- agent-computer/tests/workspace.test.ts | 104 + bun.lock | 88 +- docs/architecture.md | 33 + docs/configuration.md | 67 +- docs/slack.md | 289 ++ package.json | 4 +- server/drizzle/0024_slack_channels.sql | 72 + server/drizzle/meta/0024_snapshot.json | 3519 +++++++++++++++++ server/drizzle/meta/_journal.json | 9 +- server/package.json | 1 + server/src/agents/profile-store.ts | 9 + server/src/app.ts | 34 +- server/src/audit.ts | 18 +- server/src/computer/gateway.ts | 57 +- server/src/computer/schema.ts | 21 + server/src/config.ts | 17 + server/src/copilot.ts | 247 +- server/src/db/schema/core.ts | 124 + server/src/external/link-store.ts | 235 ++ server/src/external/link-token.ts | 119 + server/src/external/routes.ts | 257 ++ server/src/external/schema-types.ts | 14 + server/src/external/thread-store.ts | 504 +++ server/src/index.ts | 162 +- server/src/slack/approval-authorizer.ts | 76 + server/src/slack/approval-store.ts | 134 + server/src/slack/assistance-token.ts | 112 + server/src/slack/assistance.ts | 556 +++ server/src/slack/channel-agent.ts | 287 ++ server/src/slack/channel.tsx | 348 ++ server/src/slack/components.tsx | 269 ++ server/src/slack/computer-tools.ts | 647 +++ server/src/slack/execution-context.ts | 65 + server/src/slack/identity-linker.ts | 248 ++ server/src/slack/ingress-registry.ts | 174 + server/src/slack/status.ts | 307 ++ server/src/slack/tenant-context.ts | 57 + server/src/slack/turn-phase.ts | 78 + .../agent-profile-store.integration.test.ts | 36 + server/tests/audit.test.ts | 8 + server/tests/computer-gateway.test.ts | 44 + server/tests/config.test.ts | 33 + server/tests/copilot.test.ts | 112 +- server/tests/external-link-routes.test.ts | 633 +++ .../external-link-store.integration.test.ts | 476 +++ server/tests/external-link-token.test.ts | 193 + .../external-thread-store.integration.test.ts | 844 ++++ server/tests/health.test.ts | 40 + server/tests/schema.test.ts | 58 + .../tests/slack-approval-authorizer.test.ts | 112 + .../slack-approval-store.integration.test.ts | 397 ++ server/tests/slack-assistance.test.ts | 834 ++++ server/tests/slack-channel-agent.test.ts | 719 ++++ .../tests/slack-channel.integration.test.tsx | 1638 ++++++++ server/tests/slack-computer-tools.test.ts | 1789 +++++++++ server/tests/slack-execution-context.test.ts | 104 + server/tests/slack-identity-linker.test.ts | 428 ++ server/tests/slack-ingress-registry.test.ts | 275 ++ server/tests/slack-lifecycle.test.ts | 467 +++ server/tests/slack-tenant-context.test.ts | 95 + server/tests/slack-turn-phase.test.ts | 96 + server/tsconfig.json | 4 + shared/computer-tool-contracts.ts | 269 ++ tests/dockerfile.test.ts | 13 + 71 files changed, 19340 insertions(+), 161 deletions(-) create mode 100644 docs/slack.md create mode 100644 server/drizzle/0024_slack_channels.sql create mode 100644 server/drizzle/meta/0024_snapshot.json create mode 100644 server/src/external/link-store.ts create mode 100644 server/src/external/link-token.ts create mode 100644 server/src/external/routes.ts create mode 100644 server/src/external/schema-types.ts create mode 100644 server/src/external/thread-store.ts create mode 100644 server/src/slack/approval-authorizer.ts create mode 100644 server/src/slack/approval-store.ts create mode 100644 server/src/slack/assistance-token.ts create mode 100644 server/src/slack/assistance.ts create mode 100644 server/src/slack/channel-agent.ts create mode 100644 server/src/slack/channel.tsx create mode 100644 server/src/slack/components.tsx create mode 100644 server/src/slack/computer-tools.ts create mode 100644 server/src/slack/execution-context.ts create mode 100644 server/src/slack/identity-linker.ts create mode 100644 server/src/slack/ingress-registry.ts create mode 100644 server/src/slack/status.ts create mode 100644 server/src/slack/tenant-context.ts create mode 100644 server/src/slack/turn-phase.ts create mode 100644 server/tests/external-link-routes.test.ts create mode 100644 server/tests/external-link-store.integration.test.ts create mode 100644 server/tests/external-link-token.test.ts create mode 100644 server/tests/external-thread-store.integration.test.ts create mode 100644 server/tests/slack-approval-authorizer.test.ts create mode 100644 server/tests/slack-approval-store.integration.test.ts create mode 100644 server/tests/slack-assistance.test.ts create mode 100644 server/tests/slack-channel-agent.test.ts create mode 100644 server/tests/slack-channel.integration.test.tsx create mode 100644 server/tests/slack-computer-tools.test.ts create mode 100644 server/tests/slack-execution-context.test.ts create mode 100644 server/tests/slack-identity-linker.test.ts create mode 100644 server/tests/slack-ingress-registry.test.ts create mode 100644 server/tests/slack-lifecycle.test.ts create mode 100644 server/tests/slack-tenant-context.test.ts create mode 100644 server/tests/slack-turn-phase.test.ts create mode 100644 shared/computer-tool-contracts.ts create mode 100644 tests/dockerfile.test.ts 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/CHANGELOG.md b/CHANGELOG.md index 032f829a..c851fcd3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,22 +8,6 @@ Newest first. `Unreleased` is what is on `main` and not yet tagged. ## Unreleased -### A coworker named in the message is routed to without asking a model - -Naming a coworker in the text — "ask Risk Analyst to review this" — went to the intent router like -any other message, so the deployment paid a model call to be told what the person had already said, -and sometimes was told something else. A name that matches exactly one coworker on that person's -roster now routes straight to them, recorded as `named by the person asking` on the same -`channel.routed` row. A name that matches more than one is refused with both names rather than -guessed at, and a name nobody on the roster answers to falls through to the router as before. - -### Routing refuses rather than routes on a connector read it could not make - -Which systems a coworker can reach is weighed by the router alongside what the coworker is for. A -failed read of that used to be treated as "reaches nothing", which is a statement about the -deployment rather than an absence of one: a database that blinked quietly re-routed messages away -from the coworker that could actually do the work. It now fails the request instead. - ### A Bot's shell can no longer reach the embedded database without a password In the all-in-one image the cluster was `trust`-auth on loopback, and the Bot's shell runs in the 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/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/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 e7a5155d..fb3e6c0d 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -45,6 +45,7 @@ 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"; /** @@ -191,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 }>(); @@ -198,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, /* @@ -221,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. * @@ -830,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 d8d44f2f..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,7 +9,7 @@ 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 { PROVENANCE_GUIDANCE } from "../../shared/bot-prompt"; import type { ActorAgentResolver } from "./agents/agent-resolver"; @@ -396,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. @@ -417,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, @@ -426,6 +430,9 @@ async function buildAgent( computerGuidance, connectedVendors, ), + agent, + tools, + signRun, ); const whole = withTools(granted); @@ -518,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 @@ -578,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, @@ -613,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), ); +} - return remote; +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; + } + + abortRun(): void { + this.active?.abortRun(); + super.abortRun(); + } } /** @@ -951,6 +1056,7 @@ export function mountCopilotRuntime( identifyUser: IdentifyUser, identifyActor: IdentifyActor, basePath = "/api/copilotkit", + channels: Channel[] = [], ) { const { intelligence } = config.runtime; @@ -1001,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, @@ -1015,8 +1122,18 @@ export function mountCopilotRuntime( 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 f41b3feb..7e69a3fe 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -20,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 { @@ -60,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"; @@ -70,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, @@ -153,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); @@ -803,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. * @@ -817,8 +903,33 @@ const copilotRuntime = mountCopilotRuntime( actorAgentResolver, identifyUser, identifyActor, + "/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. * @@ -1039,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()), ); /** @@ -1087,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); @@ -1185,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) { @@ -1196,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/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/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 e362c4a8..38636851 100644 --- a/server/tests/copilot.test.ts +++ b/server/tests/copilot.test.ts @@ -1,5 +1,5 @@ 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"; @@ -7,12 +7,19 @@ 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", @@ -165,7 +172,7 @@ describe("registered Copilot agents", () => { ); expect(agents["general-assistant"]).toBeInstanceOf(BuiltInAgent); - expect(agents.risk).toBeInstanceOf(HttpAgent); + expect(agents.risk?.agentId).toBe("risk"); }); /* @@ -207,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"); }); /* @@ -219,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", @@ -244,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. @@ -271,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); }); @@ -285,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 () => [ { @@ -307,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); }); /* @@ -321,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", @@ -341,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 () => { @@ -406,7 +441,7 @@ describe("registered Copilot agents", () => { }, ); - expect(agents.risk).toBeInstanceOf(HttpAgent); + expect(agents.risk?.agentId).toBe("risk"); expect(resolverInvoked).toBe(false); }); }); @@ -534,7 +569,7 @@ 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 () => { @@ -585,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/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; diff --git a/tests/dockerfile.test.ts b/tests/dockerfile.test.ts new file mode 100644 index 00000000..dc17f16d --- /dev/null +++ b/tests/dockerfile.test.ts @@ -0,0 +1,13 @@ +import { expect, test } from "bun:test"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +test("keeps s6-overlay commands on PATH for platform lifecycle wrappers", () => { + const dockerfile = readFileSync( + join(import.meta.dir, "..", "Dockerfile"), + "utf8", + ); + + // biome-ignore lint/suspicious/noTemplateCurlyInString: `${PATH}` must stay literal in Dockerfile. + expect(dockerfile).toContain('ENV PATH="/command:/usr/local/bin:${PATH}"'); +}); From 242b5d34bd4c6b84b24e644a23d2e064f0b37945 Mon Sep 17 00:00:00 2001 From: Jerel John Velarde Date: Sun, 30 Aug 2026 07:56:57 -0700 Subject: [PATCH 3/3] See a Slack conversation, and finish one, in OpenBot The Slack side of a conversation was only in Slack: a person could not read what their coworker had done, and the account link and the secure prompt a Slack turn sends somebody to had nowhere to land. Three surfaces, all behind the existing session guard. Confirming a Slack account is theirs happens on a page that reads the signed link token and binds only to the OpenBot user completing the flow, with a sign-in return that comes back to the same confirmation rather than the roster. Taking the wheel or answering a secure prompt happens on the coworker's own screen, reached from the expiring link in the thread. And a Slack thread appears in the conversation sidebar, labelled, next to the channels it already lists, opening a read-only transcript of the turns as they were stored. The computer tools a Slack turn calls are declared once, in shared, so the browser and the channel offer the same contract rather than two drifting copies of it. --- .../components/app-sidebar/app-sidebar.tsx | 167 ++++++--- app/src/components/app-sidebar/roster.ts | 127 +++++++ .../components/app-sidebar/slack-channel.tsx | 89 +++++ .../channels/external-thread-chat.tsx | 54 +++ app/src/lib/auth/pending-return.ts | 97 ++++++ app/src/lib/copilot/computer-tools.tsx | 207 ++--------- app/src/lib/external/queries.ts | 147 ++++++++ app/src/routeTree.gen.ts | 64 ++++ app/src/routes/_authed.tsx | 15 +- .../_authed/_app/slack/thread/$threadId.tsx | 33 ++ app/src/routes/_authed/assist.tsx | 176 ++++++++++ app/src/routes/_authed/link/slack.tsx | 326 ++++++++++++++++++ app/src/routes/sign.tsx | 11 +- app/tests/auth-return.test.ts | 88 +++++ app/tests/external-thread-route.test.ts | 178 ++++++++++ app/tests/sidebar-roster.test.ts | 181 ++++++++++ app/tests/slack-assist-route.test.ts | 74 ++++ app/tests/slack-link-component.test.tsx | 147 ++++++++ app/tests/slack-link-route.test.ts | 78 +++++ app/tests/slack-sidebar-row.test.tsx | 80 +++++ tests/dockerfile.test.ts | 13 - 21 files changed, 2112 insertions(+), 240 deletions(-) create mode 100644 app/src/components/app-sidebar/roster.ts create mode 100644 app/src/components/app-sidebar/slack-channel.tsx create mode 100644 app/src/components/channels/external-thread-chat.tsx create mode 100644 app/src/lib/auth/pending-return.ts create mode 100644 app/src/lib/external/queries.ts create mode 100644 app/src/routes/_authed/_app/slack/thread/$threadId.tsx create mode 100644 app/src/routes/_authed/assist.tsx create mode 100644 app/src/routes/_authed/link/slack.tsx create mode 100644 app/tests/auth-return.test.ts create mode 100644 app/tests/external-thread-route.test.ts create mode 100644 app/tests/sidebar-roster.test.ts create mode 100644 app/tests/slack-assist-route.test.ts create mode 100644 app/tests/slack-link-component.test.tsx create mode 100644 app/tests/slack-link-route.test.ts create mode 100644 app/tests/slack-sidebar-row.test.tsx delete mode 100644 tests/dockerfile.test.ts 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/tests/dockerfile.test.ts b/tests/dockerfile.test.ts deleted file mode 100644 index dc17f16d..00000000 --- a/tests/dockerfile.test.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { expect, test } from "bun:test"; -import { readFileSync } from "node:fs"; -import { join } from "node:path"; - -test("keeps s6-overlay commands on PATH for platform lifecycle wrappers", () => { - const dockerfile = readFileSync( - join(import.meta.dir, "..", "Dockerfile"), - "utf8", - ); - - // biome-ignore lint/suspicious/noTemplateCurlyInString: `${PATH}` must stay literal in Dockerfile. - expect(dockerfile).toContain('ENV PATH="/command:/usr/local/bin:${PATH}"'); -});