diff --git a/doc/plugins/PLUGIN_SPEC.md b/doc/plugins/PLUGIN_SPEC.md index 8c0292b4dce..539b87d51d3 100644 --- a/doc/plugins/PLUGIN_SPEC.md +++ b/doc/plugins/PLUGIN_SPEC.md @@ -1844,3 +1844,37 @@ Paperclip should implement: - strict preservation of core governance and audit rules That is the complete target design for the Paperclip plugin system. + +### Browser WebSocket API routes + +A scoped API route may declare `transport: "websocket"` with `method: "GET"` +and `auth: "board"`. Its upgrade runs through the ordinary HTTP authentication +and company checks and requires a trusted browser Origin. Use +`companyResolution: { from: "agent", param: "agentId" }` to apply the existing +`agent:read` decision to the agent selected by the path. + +The worker's `onApiRequest` returns `{ websocket: { url, headers } }` for an +available upstream. The URL must use WSS. These connection details remain in +trusted server memory and are never returned to the browser as JSON. Browser +cookies and Origin are not forwarded. Worker response headers cannot override +WebSocket negotiation or forwarding headers. A failure can return an HTTP error +status without an upstream. Ordinary HTTP requests cannot open this transport. + +The host pipes binary frames with socket backpressure, closes both ends on +disconnect or worker shutdown, and limits each connection to five minutes. +Clients may reconnect; every reconnect repeats authorization and worker +resolution. The route's worker must resolve targets from its trusted resource +bindings rather than accepting upstream addresses from browser parameters. + +The transport carries opaque text and binary WebSocket frames. For example, +a plugin can declare `/channels/:channelId` for build output, metrics, or a +remote tool protocol. The worker receives the route key and parameters and +selects the upstream; Core does not interpret channel contents or require an +agent resource. WebSocket routes support query or entity-based company +resolution; body-based resolution is rejected because browser upgrade requests +cannot send a request body. Ordinary HTTP routes retain body-based resolution. + +Agent detail-tab contributions are also independent of WebSockets. Any plugin +can mount a detail component, including a report or notes view backed by its +normal JSON API or UI bridge. The sidebar and detail page select declared slots +by plugin key and slot ID, with no knowledge of the component's subject. diff --git a/packages/plugins/sdk/src/define-plugin.ts b/packages/plugins/sdk/src/define-plugin.ts index 60ce90ac3de..18edbc1db9d 100644 --- a/packages/plugins/sdk/src/define-plugin.ts +++ b/packages/plugins/sdk/src/define-plugin.ts @@ -182,6 +182,8 @@ export interface PluginApiRequestInput { } export interface PluginApiResponse { + /** Server-only upstream for a declared WebSocket route. Never returned as JSON. */ + websocket?: { url: string; headers?: Record }; status?: number; headers?: Record; body?: unknown; diff --git a/packages/shared/src/types/plugin.ts b/packages/shared/src/types/plugin.ts index 9fe0b39a7df..ef4d7be5550 100644 --- a/packages/shared/src/types/plugin.ts +++ b/packages/shared/src/types/plugin.ts @@ -610,13 +610,16 @@ export interface PluginDatabaseDeclaration { export type PluginApiRouteCompanyResolution = | { from: "body"; key: string } | { from: "query"; key: string } - | { from: "issue"; param: string }; + | { from: "issue"; param: string } + | { from: "agent"; param: string }; export interface PluginApiRouteDeclaration { /** Stable plugin-defined route key passed to the worker. */ routeKey: string; /** HTTP method accepted by this route. */ method: PluginApiRouteMethod; + /** Browser WebSocket upgrade, authorized through the ordinary HTTP middleware. */ + transport?: "websocket"; /** Plugin-local path under `/api/plugins/:pluginId/api`, e.g. `/issues/:issueId/smoke`. */ path: string; /** Actor class allowed to call the route. */ diff --git a/packages/shared/src/validators/plugin.test.ts b/packages/shared/src/validators/plugin.test.ts index e4a56eb919c..43e789f0cf9 100644 --- a/packages/shared/src/validators/plugin.test.ts +++ b/packages/shared/src/validators/plugin.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from "vitest"; import { PLUGIN_CAPABILITIES } from "../constants.js"; import { resolveDeclaredSandboxCapabilities } from "../environment-support.js"; -import { pluginManagedRoutineDeclarationSchema, pluginManifestV1Schema, pluginUiSlotDeclarationSchema } from "./plugin.js"; +import { pluginApiRouteDeclarationSchema, pluginManagedRoutineDeclarationSchema, pluginManifestV1Schema, pluginUiSlotDeclarationSchema } from "./plugin.js"; function buildSandboxProviderManifest(driver: Record) { return { @@ -460,3 +460,22 @@ describe("login pty transport capability and legacy alias", () => { expect(rejected.success).toBe(false); }); }); + +describe("plugin WebSocket company resolution", () => { + const route = { routeKey: "channel.connect", method: "GET", path: "/channels/:channelId", auth: "board", capability: "api.routes.register" }; + + it("rejects body resolution only for WebSocket routes", () => { + const companyResolution = { from: "body", key: "companyId" }; + expect(pluginApiRouteDeclarationSchema.safeParse({ ...route, companyResolution }).success).toBe(true); + expect(pluginApiRouteDeclarationSchema.safeParse({ ...route, companyResolution, transport: "websocket" }).success).toBe(false); + }); + + it.each([ + undefined, + { from: "query", key: "companyId" }, + { from: "issue", param: "issueId" }, + { from: "agent", param: "agentId" }, + ])("accepts body-free resolution %j", (companyResolution) => { + expect(pluginApiRouteDeclarationSchema.safeParse({ ...route, companyResolution, transport: "websocket" }).success).toBe(true); + }); +}); diff --git a/packages/shared/src/validators/plugin.ts b/packages/shared/src/validators/plugin.ts index 3f9b33dd608..6f011359783 100644 --- a/packages/shared/src/validators/plugin.ts +++ b/packages/shared/src/validators/plugin.ts @@ -647,6 +647,7 @@ export const pluginApiRouteDeclarationSchema = z.object({ message: "routeKey must be lowercase letters, digits, dots, colons, underscores, or hyphens", }), method: z.enum(PLUGIN_API_ROUTE_METHODS), + transport: z.literal("websocket").optional(), path: z.string().min(1).regex(/^\/[a-zA-Z0-9:_./-]*$/, { message: "path must start with / and contain only path-safe literal or :param segments", }).refine( @@ -666,8 +667,13 @@ export const pluginApiRouteDeclarationSchema = z.object({ z.object({ from: z.literal("body"), key: z.string().min(1) }), z.object({ from: z.literal("query"), key: z.string().min(1) }), z.object({ from: z.literal("issue"), param: z.string().min(1) }), + z.object({ from: z.literal("agent"), param: z.string().min(1) }), ]).optional(), -}); +}).refine(route => !route.transport || (route.method === "GET" && route.auth === "board"), { message: "WebSocket routes require GET and board authentication" }) + .refine(route => !route.transport || route.companyResolution?.from !== "body", { + message: "WebSocket routes cannot resolve company from a request body", + path: ["companyResolution"], + }); export type PluginApiRouteDeclarationInput = z.infer; @@ -952,6 +958,10 @@ export const pluginManifestV1Schema = z.object({ } } + if (manifest.apiRoutes?.some(route => route.transport === "websocket") && !manifest.capabilities.includes("http.outbound")) { + ctx.addIssue({ code: z.ZodIssueCode.custom, message: "WebSocket routes require http.outbound", path: ["capabilities"] }); + } + if (manifest.apiRoutes && manifest.apiRoutes.length > 0) { if (!manifest.capabilities.includes("api.routes.register")) { ctx.addIssue({ diff --git a/server/src/__tests__/plugin-scoped-api-routes.test.ts b/server/src/__tests__/plugin-scoped-api-routes.test.ts index 36e8444197f..09818f0a24c 100644 --- a/server/src/__tests__/plugin-scoped-api-routes.test.ts +++ b/server/src/__tests__/plugin-scoped-api-routes.test.ts @@ -1,8 +1,21 @@ import express from "express"; +import { createServer, request as httpRequest, type Server } from "node:http"; +import { once } from "node:events"; +import { WebSocket, WebSocketServer } from "ws"; +import { setupPluginApiWebSocketServer } from "../realtime/plugin-api-ws.js"; + +// Keep a real upstream WebSocket and byte stream; only TLS is replaced locally. +vi.mock("node:https", () => ({ request: (url: URL, options: object) => { + const local = new URL(url); local.protocol = "http:"; + return httpRequest(local, options); +} })); import request from "supertest"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { pluginManifestV1Schema, type PaperclipPluginManifestV1 } from "@paperclipai/shared"; +const mockAccess = vi.hoisted(() => ({ decide: vi.fn().mockResolvedValue({ allowed: true }) })); +vi.mock("../services/authorization.js", () => ({ authorizationService: () => mockAccess })); + const mockRegistry = vi.hoisted(() => ({ getById: vi.fn(), getByKey: vi.fn(), @@ -47,7 +60,7 @@ function manifest(apiRoutes: NonNullable description: "Test plugin for scoped API routes", author: "Paperclip", categories: ["automation"], - capabilities: ["api.routes.register"], + capabilities: ["api.routes.register", "http.outbound"], entrypoints: { worker: "dist/worker.js" }, apiRoutes, }; @@ -58,6 +71,7 @@ async function createApp(input: { plugin?: Record | null; workerRunning?: boolean; workerResult?: unknown; + db?: unknown; }) { const [{ pluginRoutes }, { errorHandler }] = await Promise.all([ import("../routes/plugins.js"), @@ -81,7 +95,7 @@ async function createApp(input: { app.use( "/api", pluginRoutes( - {} as never, + (input.db ?? {}) as never, { installPlugin: vi.fn() } as never, undefined, undefined, @@ -475,3 +489,102 @@ describe("plugin scoped API routes", () => { ); }); }); + + +describe("plugin browser WebSockets", () => { + const companyId = "22222222-2222-4222-8222-222222222222"; + const pluginId = "11111111-1111-4111-8111-111111111111"; + const apiRoutes = manifest([{ routeKey: "channel.connect", method: "GET", path: "/channels/:channelId", transport: "websocket", auth: "board", capability: "api.routes.register", companyResolution: { from: "query", key: "companyId" } }]); + const plugin = { id: pluginId, pluginKey: apiRoutes.id, status: "ready", manifestJson: apiRoutes }; + const board = { type: "board", userId: "user-1", source: "local_implicit", isInstanceAdmin: true }; + const endpoint = `/api/plugins/${pluginId}/api/channels/build-output?companyId=${companyId}`; + async function listen(server: Server) { + server.listen(0, "127.0.0.1"); await once(server, "listening"); + return `http://127.0.0.1:${(server.address() as { port: number }).port}`; + } + async function rejection(actor: Record, origin?: string) { + const { app, workerManager } = await createApp({ actor, plugin }); + const server = createServer(app); setupPluginApiWebSocketServer(server, app); + const base = await listen(server); + try { + return await new Promise<{ status: number; calls: number }>((resolve, reject) => { + const ws = new WebSocket(`${base}${endpoint}`, origin ? { origin } : {}); + ws.on("unexpected-response", (_request, response) => { response.resume(); resolve({ status: response.statusCode!, calls: workerManager.call.mock.calls.length }); }); + ws.on("error", reject); + }); + } finally { server.closeAllConnections(); await new Promise(resolve => server.close(() => resolve())); } + } + it("rejects missing/bad Origin, anonymous callers and agents before worker dispatch", async () => { + expect(await rejection(board)).toEqual({ status: 403, calls: 0 }); + expect(await rejection(board, "https://attacker.test")).toEqual({ status: 403, calls: 0 }); + expect(await rejection({ type: "none" }, "http://localhost:3100")).toEqual({ status: 403, calls: 0 }); + expect(await rejection({ type: "agent", agentId: "agent", companyId }, "http://localhost:3100")).toEqual({ status: 403, calls: 0 }); + }); + it("never exposes a worker's upstream credentials over ordinary HTTP", async () => { + const { app, workerManager } = await createApp({ actor: board, plugin, workerResult: { websocket: { url: "wss://private.test", headers: { Authorization: "fixture-secret" } } } }); + const response = await request(app).get(endpoint); + expect(response.status).toBe(426); + expect(response.text).not.toContain("fixture-secret"); + expect(workerManager.call).not.toHaveBeenCalled(); + }); + it.each(["build-output", "metrics"])("proxies text and binary %s channels without resource or protocol assumptions", async (channelId) => { + const upstream = createServer(); + const upstreamWs = new WebSocketServer({ noServer: true }); + let upstreamHeaders: Record = {}; + upstream.on("upgrade", (req, socket, head) => { upstreamHeaders = req.headers; upstreamWs.handleUpgrade(req, socket, head, ws => upstreamWs.emit("connection", ws)); }); + upstreamWs.on("connection", ws => { ws.on("message", (bytes, isBinary) => ws.send(bytes, { binary: isBinary })); }); + const upstreamBase = await listen(upstream); + const { app, workerManager } = await createApp({ actor: board, plugin, workerResult: { websocket: { url: upstreamBase.replace("http:", "wss:") + `/channels/${channelId}`, headers: { Authorization: "Bearer fixture-only" } } } }); + const server = createServer(app); setupPluginApiWebSocketServer(server, app); + const base = await listen(server); + const ws = new WebSocket(`${base}${endpoint.replace("build-output", channelId)}`, { origin: "http://localhost:3100", headers: { Cookie: "browser=fixture", Authorization: "browser-only" } }); + try { + await once(ws, "open"); + const payload = Buffer.from([0, 1, 127, 128, 255]); + const reply = once(ws, "message"); ws.send(payload); + const binaryReply = await reply; + expect(binaryReply[0]).toEqual(payload); + expect(binaryReply[1]).toBe(true); + const event = JSON.stringify({ event: "progress", value: 42 }); + const textReply = once(ws, "message"); ws.send(event); + const [text, isBinary] = await textReply; + expect(text.toString()).toBe(event); + expect(isBinary).toBe(false); + expect(workerManager.call).toHaveBeenCalledWith(pluginId, "handleApiRequest", expect.objectContaining({ routeKey: "channel.connect", params: { channelId }, companyId })); + expect(upstreamHeaders.authorization).toBe("Bearer fixture-only"); + expect(upstreamHeaders.cookie).toBeUndefined(); + expect(upstreamHeaders.origin).toBeUndefined(); + const remote = [...upstreamWs.clients][0]; + const closed = once(remote, "close"); ws.close(); await closed; + } finally { + ws.terminate(); for (const client of upstreamWs.clients) client.terminate(); + upstreamWs.close(); server.closeAllConnections(); upstream.closeAllConnections(); + await Promise.all([new Promise(resolve => server.close(() => resolve())), new Promise(resolve => upstream.close(() => resolve()))]); + } + }); + it("only permits declared board GET WebSockets", () => { + expect(pluginManifestV1Schema.safeParse(apiRoutes).success).toBe(true); + const bad = structuredClone(apiRoutes); bad.apiRoutes![0].auth = "agent"; + expect(pluginManifestV1Schema.safeParse(bad).success).toBe(false); + }); +}); + +it("agent-scoped plugin routes reuse agent:read and refuse denied access before worker dispatch", async () => { + const agentId = "33333333-3333-4333-8333-333333333333", companyId = "22222222-2222-4222-8222-222222222222", pluginId = "11111111-1111-4111-8111-111111111111"; + const chain = { from: () => chain, where: () => chain, limit: vi.fn(async () => [{ id: agentId, companyId }]) }; + const apiRoutes = manifest([{ routeKey: "status", method: "GET", path: "/agents/:agentId/status", auth: "board", capability: "api.routes.register", companyResolution: { from: "agent", param: "agentId" } }]); + const actor: Record = { type: "board", source: "local_implicit", userId: "user", isInstanceAdmin: true }; + const { app, workerManager } = await createApp({ db: { select: () => chain }, actor, plugin: { id: pluginId, pluginKey: apiRoutes.id, status: "ready", manifestJson: apiRoutes } }); + mockAccess.decide.mockResolvedValue({ allowed: true }); + expect((await request(app).get(`/api/plugins/${pluginId}/api/agents/${agentId}/status`)).status).toBe(200); + expect(mockAccess.decide).toHaveBeenCalledWith(expect.objectContaining({ action: "agent:read", resource: { type: "agent", companyId, agentId } })); + workerManager.call.mockClear(); mockAccess.decide.mockResolvedValue({ allowed: false }); + expect((await request(app).get(`/api/plugins/${pluginId}/api/agents/${agentId}/status`)).status).toBe(404); + actor.source = "session"; actor.companyIds = ["another-company"]; + const crossCompany = await request(app).get(`/api/plugins/${pluginId}/api/agents/${agentId}/status`); + chain.limit.mockResolvedValue([]); + const missing = await request(app).get(`/api/plugins/${pluginId}/api/agents/${agentId}/status`); + expect(crossCompany.status).toBe(404); + expect(crossCompany.body).toEqual(missing.body); + expect(workerManager.call).not.toHaveBeenCalled(); +}); diff --git a/server/src/http/server.test.ts b/server/src/http/server.test.ts index 6798ab9a472..ec7d7a50cb6 100644 --- a/server/src/http/server.test.ts +++ b/server/src/http/server.test.ts @@ -19,7 +19,6 @@ describe("Paperclip HTTP server runner transport", () => { authority: { handleUpgrade } as unknown as DurablePrpControlPlane, }); expect(authority.connectUrl).toBe(`ws://127.0.0.1:3210/api/runner/v1/connect/${runId}`); - expect(server.listenerCount("upgrade")).toBe(1); await authority.release(); server.close(); }); diff --git a/server/src/http/server.ts b/server/src/http/server.ts index f3097ee4558..232f2ce8324 100644 --- a/server/src/http/server.ts +++ b/server/src/http/server.ts @@ -1,4 +1,5 @@ import { createServer, type RequestListener, type ServerOptions } from "node:http"; +import { setupPluginApiWebSocketServer } from "../realtime/plugin-api-ws.js"; import { setupRunnerPrpWebSocketServer } from "../realtime/runner-prp-ws.js"; /** Create the HTTP listener used by both production and custom launchers. */ @@ -10,5 +11,6 @@ export function createPaperclipHttpServer( // Native execution uses the listener even when a launcher has no browser // realtime connection. Register it before any run can be admitted. setupRunnerPrpWebSocketServer(server, { apiUrl: options.apiUrl }); + setupPluginApiWebSocketServer(server, listener); return server; } diff --git a/server/src/realtime/plugin-api-ws.ts b/server/src/realtime/plugin-api-ws.ts new file mode 100644 index 00000000000..d9c046cf5d3 --- /dev/null +++ b/server/src/realtime/plugin-api-ws.ts @@ -0,0 +1,102 @@ +import { ServerResponse, type IncomingMessage, type RequestListener, type Server } from "node:http"; +import { request as httpsRequest } from "node:https"; +import type { Socket } from "node:net"; +import type { Duplex } from "node:stream"; +import { validateHeaderName, validateHeaderValue } from "node:http"; +import type { Request, Response } from "express"; + +export const pluginApiUpgrade = Symbol("pluginApiUpgrade"); +type UpgradeRequest = IncomingMessage & { + paperclipWebSocketHandled?: boolean; + [pluginApiUpgrade]?: { socket: Socket; head: Buffer }; +}; + +/** Reuse the full HTTP authentication/company pipeline for plugin upgrades. */ +export function setupPluginApiWebSocketServer(server: Server, handler: RequestListener) { + server.on("upgrade", (request: UpgradeRequest, socket: Socket, head: Buffer) => { + if (request.paperclipWebSocketHandled || !/^\/api\/plugins\/[^/]+\/api\//.test(request.url ?? "")) return; + request.paperclipWebSocketHandled = true; + socket.on("error", () => socket.destroy()); + if (request.method !== "GET" || request.headers.upgrade?.toLowerCase() !== "websocket" + || request.headers["content-length"] || request.headers["transfer-encoding"]) { + socket.end("HTTP/1.1 400 Bad Request\r\nConnection: close\r\nContent-Length: 0\r\n\r\n"); + return; + } + request[pluginApiUpgrade] = { socket, head }; + const response = new ServerResponse(request); + response.assignSocket(socket); + response.setHeader("Connection", "close"); + response.once("finish", () => socket.end()); + handler(request, response); + }); +} + +/** Only worker-selected WSS targets and credentials reach the upstream. */ +export function proxyPluginApiWebSocket( + req: Request, + res: Response, + target: { url: string; headers?: Record }, + available: () => boolean, +) { + const upgrade = (req as UpgradeRequest)[pluginApiUpgrade]; + if (!upgrade || upgrade.socket.destroyed) return; + const { socket, head } = upgrade; + let url: URL; + try { + url = new URL(target.url); + if (url.protocol !== "wss:" || url.username || url.password || url.hash) throw new Error(); + for (const [name, value] of Object.entries(target.headers ?? {})) { validateHeaderName(name); validateHeaderValue(name, value); } + // A plugin may supply upstream authorization, never browser cookies/origin. + if (Object.keys(target.headers ?? {}).some(key => /^(cookie|origin|host|connection|upgrade|sec-websocket-|x-forwarded-)/i.test(key))) throw new Error(); + } catch { + res.status(502).json({ error: "Plugin connection unavailable" }); + return; + } + url.protocol = "https:"; + const headers: Record = { ...target.headers, Connection: "Upgrade", Upgrade: "websocket" }; + for (const name of ["sec-websocket-key", "sec-websocket-version", "sec-websocket-protocol", "sec-websocket-extensions"]) { + if (req.headers[name]) headers[name] = req.headers[name]!; + } + let connected = false; + let upstream: Duplex | undefined; + const fail = () => { + if (!connected && !socket.destroyed && !res.headersSent) res.status(502).json({ error: "Plugin connection unavailable" }); + else socket.destroy(); + }; + let connection: ReturnType; + try { connection = httpsRequest(url, { method: "GET", headers, timeout: 15_000 }); } + catch { fail(); return; } + const cleanup = () => { clearInterval(watch); clearTimeout(lifetime); connection.destroy(); upstream?.destroy(); }; + // Periodic reconnect reauthorizes the browser through HTTP; it also bounds + // how long a removed membership or changed plugin binding can retain access. + const lifetime = setTimeout(() => socket.destroy(), 5 * 60_000); + const watch = setInterval(() => { if (!available()) socket.destroy(); }, 5_000); + lifetime.unref(); + watch.unref(); + socket.once("close", cleanup); + connection.on("timeout", () => { connection.destroy(); fail(); }); + connection.on("error", fail); + connection.on("response", response => { response.destroy(); fail(); }); + connection.on("upgrade", (response, remote, remoteHead) => { + upstream = remote; + if (socket.destroyed || !available() || response.statusCode !== 101) { remote.destroy(); fail(); return; } + connected = true; + connection.setTimeout(0); + socket.setTimeout(0); + res.detachSocket(socket); + // Express request accounting must observe the lifetime of the upgraded stream. + socket.once("close", () => res.emit("close")); + let handshake = "HTTP/1.1 101 Switching Protocols\r\nConnection: Upgrade\r\nUpgrade: websocket\r\n"; + for (const name of ["sec-websocket-accept", "sec-websocket-protocol", "sec-websocket-extensions"]) { + const value = response.headers[name]; + if (typeof value === "string") handshake += `${name}: ${value}\r\n`; + } + socket.write(handshake + "\r\n"); + if (remoteHead.length) socket.write(remoteHead); + if (head.length) remote.write(head); + remote.on("error", () => socket.destroy()); + remote.once("close", () => socket.destroy()); + socket.pipe(remote).pipe(socket); + }); + connection.end(); +} diff --git a/server/src/routes/plugins.ts b/server/src/routes/plugins.ts index 971283dd0e2..3071e30f3c9 100644 --- a/server/src/routes/plugins.ts +++ b/server/src/routes/plugins.ts @@ -22,6 +22,9 @@ import { access, readdir, readFile } from "node:fs/promises"; import path from "node:path"; import { randomUUID } from "node:crypto"; +import { pluginApiUpgrade, proxyPluginApiWebSocket } from "../realtime/plugin-api-ws.js"; +import { trustedBoardMutationOrigin } from "../middleware/board-mutation-guard.js"; +import { authorizationService } from "../services/authorization.js"; import { fileURLToPath } from "node:url"; import { Router } from "express"; import type { Request, Response } from "express"; @@ -72,6 +75,7 @@ import { assertCompanyAccess, assertInstanceAdmin, getActorInfo, + hasCompanyAccess, } from "./authz.js"; import { validateInstanceConfig } from "../services/plugin-config-validator.js"; import { @@ -466,6 +470,7 @@ interface PluginScopedApiRequest { } interface PluginScopedApiResponse { + websocket?: { url: string; headers?: Record }; status?: number; headers?: Record; body?: unknown; @@ -627,6 +632,17 @@ export function pluginRoutes( return typeof value === "string" ? value : null; } + if (resolution.from === "agent") { + const id = params[resolution.param]; + if (!id) return null; + const [agent] = await db.select({ id: agents.id, companyId: agents.companyId }).from(agents).where(eq(agents.id, id)).limit(1); + if (!agent || !hasCompanyAccess(req, agent.companyId)) throw notFound("Agent not found"); + assertCompanyAccess(req, agent.companyId); + const decision = await authorizationService(db).decide({ actor: req.actor, action: "agent:read", resource: { type: "agent", companyId: agent.companyId, agentId: agent.id } }); + if (!decision.allowed) throw notFound("Agent not found"); + return agent.companyId; + } + const issueId = params[resolution.param ?? ""]; if (!issueId) return null; const issue = await issuesSvc.getById(issueId); @@ -1869,6 +1885,20 @@ export function pluginRoutes( } try { + const upgrade = (req as Request & { [pluginApiUpgrade]?: unknown })[pluginApiUpgrade]; + if (match.route.transport === "websocket") { + if (!upgrade || req.method !== "GET" || match.route.auth !== "board" || !plugin.manifestJson.capabilities.includes("http.outbound")) { + res.status(426).json({ error: "Browser WebSocket required" }); + return; + } + if (!req.header("origin") || trustedBoardMutationOrigin(req) !== req.header("origin")?.toLowerCase()) { + res.status(403).json({ error: "Trusted browser origin required" }); + return; + } + } else if (upgrade) { + res.status(400).json({ error: "Route does not accept WebSockets" }); + return; + } assertScopedApiAuth(req, match.route); const companyId = await resolveScopedApiCompanyId(match.route, match.params, req); if (!companyId) { @@ -1912,6 +1942,14 @@ export function pluginRoutes( "handleApiRequest", input, ) as PluginScopedApiResponse; + if (match.route.transport === "websocket") { + if (result.websocket) { + proxyPluginApiWebSocket(req, res, result.websocket, () => bridgeDeps.workerManager.isRunning(plugin.id)); + } else { + res.status(result.status && result.status >= 400 && result.status <= 599 ? result.status : 502).json({ error: "Plugin connection unavailable" }); + } + return; + } const status = Number.isInteger(result.status) && Number(result.status) >= 200 && Number(result.status) <= 599 ? Number(result.status) : 200; diff --git a/ui/src/App.tsx b/ui/src/App.tsx index daee663274b..24f19888e05 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -69,7 +69,7 @@ const Dashboard = lazy(() => import("./pages/Dashboard").then((module) => ({ def const DashboardLive = lazy(() => import("./pages/DashboardLive").then((module) => ({ default: module.DashboardLive }))); const Timeline = lazy(() => import("./pages/Timeline").then((module) => ({ default: module.Timeline }))); const Companies = lazy(() => import("./pages/Companies").then((module) => ({ default: module.Companies }))); -const AgentDetail = lazy(() => import("./pages/AgentDetail").then((module) => ({ default: module.AgentDetail }))); +const AgentDetail = lazy(() => import("./pages/AgentDetailPage").then((module) => ({ default: module.AgentDetailPage }))); const Projects = lazy(() => import("./pages/Projects").then((module) => ({ default: module.Projects }))); const ProjectDetail = lazy(() => import("./pages/ProjectDetail").then((module) => ({ default: module.ProjectDetail }))); const ProjectWorkspaceDetail = lazy(() => import("./pages/ProjectWorkspaceDetail").then((module) => ({ default: module.ProjectWorkspaceDetail }))); diff --git a/ui/src/components/AgentContextualSidebar.test.tsx b/ui/src/components/AgentContextualSidebar.test.tsx index 7b4eaa5491d..50cfae97ab0 100644 --- a/ui/src/components/AgentContextualSidebar.test.tsx +++ b/ui/src/components/AgentContextualSidebar.test.tsx @@ -33,6 +33,8 @@ vi.mock("./SidebarNavItem", () => ({ SidebarNavItem: ({ to, label }: { to: string; label: string }) => {label}, })); +vi.mock("@/plugins/slots", () => ({ usePluginSlots: () => ({ slots: [{ pluginKey: "test.plugin", id: "insights", displayName: "Insights" }] }) })); + describe("AgentContextualSidebar", () => { it.each([false, true])("keeps Channels available for default email regardless of the chat setting (%s)", (enabled) => { const client = new QueryClient(); @@ -40,6 +42,8 @@ describe("AgentContextualSidebar", () => { const markup = renderToStaticMarkup( ); + expect(markup).toContain("Insights"); + expect(markup).toContain("plugin%3Atest.plugin%3Ainsights"); expect(markup.includes('href="/agents/agent/channels"')).toBe(true); expect(markup).toContain('href="/agents/agent/tools"'); client.clear(); diff --git a/ui/src/components/AgentContextualSidebar.tsx b/ui/src/components/AgentContextualSidebar.tsx index 14ab6a27afc..78e1838c488 100644 --- a/ui/src/components/AgentContextualSidebar.tsx +++ b/ui/src/components/AgentContextualSidebar.tsx @@ -14,6 +14,7 @@ import { Sparkles, Wrench, } from "lucide-react"; +import { usePluginSlots } from "@/plugins/slots"; import { agentsApi } from "@/api/agents"; import { useCompany } from "@/context/CompanyContext"; import { queryKeys } from "@/lib/queryKeys"; @@ -66,6 +67,7 @@ export function AgentContextualSidebar({ enabled: shouldResolveAgent && Boolean(agentRef && selectedCompanyId), }); const resolvedId = agentId ?? resolvedAgent?.id; + const { slots: pluginTabs } = usePluginSlots({ slotTypes: ["detailTab"], entityType: "agent", companyId: resolvedAgent?.companyId ?? selectedCompanyId, enabled: Boolean(resolvedId) }); const resolvedName = agentName ?? resolvedAgent?.name ?? "Agent"; return ( @@ -110,6 +112,12 @@ export function AgentContextualSidebar({ ))} + {pluginTabs.length > 0 &&
+

Apps

+
+ {pluginTabs.map(slot => )} +
+
}

({ agentId: "agent", companyPrefix: "CO", tab: "plugin:test.plugin:insights" })); +const state = vi.hoisted(() => ({ slots: [{ pluginKey: "test.plugin", id: "insights", displayName: "Insights" }, { pluginKey: "test.plugin", id: "notes", displayName: "Notes" }] as unknown[], isLoading: false, errorMessage: null })); +const mount = vi.hoisted(() => vi.fn()); +vi.mock("@/lib/router", () => ({ useParams: () => params, Link: ({ children }: { children: React.ReactNode }) => {children} })); +vi.mock("@/context/CompanyContext", () => ({ useCompany: () => ({ selectedCompanyId: "other", companies: [{ id: "company", issuePrefix: "CO" }] }) })); +vi.mock("./AgentDetail", async () => { + const { useEffect } = await import("react"); + const { useBreadcrumbs } = await import("@/context/BreadcrumbContext"); + return { AgentDetail: () => { + const { setBreadcrumbs } = useBreadcrumbs(); + useEffect(() => { setBreadcrumbs([{ label: "Overview" }]); }, [setBreadcrumbs]); + return

Built-in agent view

; + } }; +}); +vi.mock("@/plugins/slots", () => ({ usePluginSlots: () => state, PluginSlotMount: (props: unknown) => { mount(props); return

Plugin contribution

; } })); +function view() { + const client = new QueryClient(); + client.setQueryData([...queryKeys.agents.detail("agent"), "company"], { id: "agent-id", companyId: "company", name: "Agent" }); + return ; +} +function render() { + return renderToStaticMarkup(view()); +} +describe("agent plugin detail views", () => { + it.each(["insights", "notes"])("mounts arbitrary %s contributions with the resolved agent/company", (id) => { + params.tab = `plugin:test.plugin:${id}`; + expect(render()).toContain("Plugin contribution"); + expect(mount).toHaveBeenCalledWith(expect.objectContaining({ slot: expect.objectContaining({ id }), context: expect.objectContaining({ companyId: "company", entityId: "agent-id", entityType: "agent" }) })); + }); + it("shows unavailable for a removed contribution", () => { + params.tab = "plugin:removed:insights"; + expect(render()).toContain("This plugin view is unavailable"); + params.tab = "plugin:test.plugin:insights"; + }); + it("preserves built-in agent routes", () => { + params.tab = "overview"; + expect(render()).toContain("Built-in agent view"); + params.tab = "plugin:test.plugin:insights"; + }); +}); + +it.each(["plugin:test.plugin:insights", "overview"])("updates headings on entry from %s and between plugin views", (initialTab) => { + Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }); + const container = document.createElement("div"); + const root = createRoot(container); + try { + params.tab = initialTab; + act(() => { root.render(view()); }); + if (initialTab === "overview") { + expect(document.title).toBe("Overview • Test company • Paperclip"); + params.tab = "plugin:test.plugin:insights"; + act(() => { root.render(view()); }); + } + expect(document.title).toBe("Insights • Agent • Agents • Test company • Paperclip"); + params.tab = "plugin:test.plugin:notes"; + act(() => { root.render(view()); }); + expect(document.title).toBe("Notes • Agent • Agents • Test company • Paperclip"); + params.tab = "plugin:removed:notes"; + act(() => { root.render(view()); }); + expect(document.title).toBe("Plugin view • Agent • Agents • Test company • Paperclip"); + } finally { + act(() => { root.unmount(); }); + params.tab = "plugin:test.plugin:insights"; + } +}); diff --git a/ui/src/pages/AgentDetailPage.tsx b/ui/src/pages/AgentDetailPage.tsx new file mode 100644 index 00000000000..a6d1515f9df --- /dev/null +++ b/ui/src/pages/AgentDetailPage.tsx @@ -0,0 +1,42 @@ +import { useEffect } from "react"; +import { useBreadcrumbs } from "@/context/BreadcrumbContext"; +import { useQuery } from "@tanstack/react-query"; +import { useParams, Link } from "@/lib/router"; +import { agentsApi } from "@/api/agents"; +import { useCompany } from "@/context/CompanyContext"; +import { queryKeys } from "@/lib/queryKeys"; +import { PluginSlotMount, usePluginSlots } from "@/plugins/slots"; +import { AgentDetail } from "./AgentDetail"; + +export function AgentDetailPage() { + const { tab } = useParams<{ tab?: string }>(); + return tab?.startsWith("plugin:") ? : ; +} + +function AgentPluginDetail() { + const { agentId = "", companyPrefix, tab } = useParams<{ agentId: string; companyPrefix?: string; tab: string }>(); + const { companies, selectedCompanyId } = useCompany(); + const companyId = companies.find(company => company.issuePrefix.toUpperCase() === companyPrefix?.toUpperCase())?.id ?? selectedCompanyId; + const { data: agent, isLoading, error } = useQuery({ + queryKey: [...queryKeys.agents.detail(agentId), companyId ?? null], + queryFn: () => agentsApi.get(agentId, companyId ?? undefined), + enabled: Boolean(agentId && companyId), + }); + const { slots, isLoading: slotsLoading, errorMessage } = usePluginSlots({ slotTypes: ["detailTab"], entityType: "agent", companyId: agent?.companyId, enabled: Boolean(agent) }); + const slot = slots.find(item => `plugin:${item.pluginKey}:${item.id}` === tab); + const { setBreadcrumbs } = useBreadcrumbs(); + useEffect(() => { + setBreadcrumbs([ + { label: "Agents", href: "/agents" }, + { label: agent?.name ?? agentId, href: `/agents/${encodeURIComponent(agentId)}/overview` }, + { label: slot?.displayName ?? "Plugin view" }, + ]); + }, [setBreadcrumbs, agent?.name, agentId, slot?.displayName]); + if (error) return

Agent unavailable

; + if (!agent || isLoading || slotsLoading) return

Loading agent view…

; + return
+ {agent.name} + {slot ? + :

{errorMessage ? "Agent view unavailable" : "This plugin view is unavailable"}

} +
; +}