Support authenticated WebSocket routes and agent views in plugins (#17111)

## Thinking Path

> - Paperclip manages agents and their work.
> - Plugins provide optional views and connect to external runtimes.
> - A live runtime view needs a binary browser connection.
> - The current plugin API supports JSON routes but cannot proxy that
connection.
> - Agent detail tabs are declared in the plugin contract but are not
mounted on agent pages.
> - This PR completes those two host capabilities and keeps upstream
credentials on the server.

## Linked Issues or Issue Description

**Subsystem affected**

Plugin scoped API routes and agent detail views.

**Problem or motivation**

Plugins cannot expose a binary or text stream through a browser session
without exposing an upstream credential or adding a separate browser
gateway. Agent-scoped detail contributions also have no mount on the
agent page.

**Proposed solution**

Allow declared board GET routes to select a server-only WSS upstream.
Run upgrades through the existing HTTP authentication pipeline and
trusted Origin check. Add agent company resolution with the existing
`agent:read` decision. Mount contributed detail views in agent
navigation.

**Alternatives considered**

A separately hosted viewer needs its own browser credentials and
cross-origin policy. JSON or SSE transport adds encoding and buffering
to a binary protocol.

**Roadmap alignment**

This extends the existing plugin API and detail-tab contract. It adds no
desktop provider to Core. I checked ROADMAP.md and searched related open
PRs; I found no equivalent transport and agent view change.

## What Changed

- Add declared WebSocket routes requiring board authentication, GET, and
`http.outbound`. Reject body-based company resolution because browser
upgrades cannot send a body.
- Keep worker-selected upstream URLs and headers out of browser JSON
responses. Strip browser cookies and Origin. Preserve binary frames and
socket backpressure.
- Close both sockets on disconnect or worker shutdown. Reauthorize on
reconnect after a five-minute connection lifetime. Preserve HTTP
idle-work accounting for the stream lifetime.
- Resolve agent scope through `agent:read` and mount generic agent
plugin detail views, with current agent/view breadcrumbs and browser
titles.

## Verification

- Final head `9d8d269040acf9dc7bb65524876406af522dacb5`: CI passes
(including Runner Rust, build, and browser E2E); Greptile 5/5; no
unresolved review threads; no merge conflicts. Verified 2026-10-21.
- Core server TypeScript check: `node node_modules/typescript/bin/tsc -p
server/tsconfig.json --noEmit`.
- Focused server suites: 17 passing tests
(`server/src/__tests__/plugin-scoped-api-routes.test.ts` and
`server/src/http/server.test.ts`), including arbitrary build-output and
metrics channels with real local text and binary WebSocket round trips,
disconnect cleanup, denied actors and origins, credential exclusion,
agent-read denial, and cross-company 404 equivalence. The test replaces
TLS with local HTTP while retaining the real upgrade and binary stream.
- UI suites: 9 passing tests for independent Insights and Notes
contributions, removed contributions, built-in route preservation,
sidebar links, direct plugin entry and heading updates after tab
navigation. These contributions do not require the WebSocket transport.
- Shared validator suite: 42 passing tests, including rejecting
body-based WebSocket resolution while retaining ordinary HTTP body
resolution and accepting query/entity scopes.
- UI token gates pass.
- UI TypeScript check: `node ../scripts/storybook-catalog.mjs` then `tsc
-b`.
- Shared and plugin SDK builds pass.
- Full Runner build reached its unrelated Rust step and stopped because
cargo is unavailable. Runner TypeScript build was run separately. No
live remote VM was used.

## Risks

- This adds a browser ingress path for trusted installed plugins. The
plugin must select its upstream from a trusted binding. HTTP requests
cannot retrieve the upstream descriptor.
- Membership or binding changes are rechecked on reconnect. Connections
last at most five minutes, and worker shutdown is checked every five
seconds.
- Initial CI caught a missing cross-company 404 guard and an old test
that assumed exactly one upgrade listener. Both were corrected.
Greptile's body-resolution and stale-heading findings are fixed with
regression coverage; latest-head CI passes and Greptile scored 5/5 with
no unresolved findings.
- No schema migration. Rollback restores the previous Core image;
plugins using this transport must be disabled or rolled back first.

## Model Used

OpenAI Codex, based on GPT-6, with reasoning and code/tool execution.
The exact runtime model ID and context-window size are not exposed in
this session.

## Checklist

- [x] Thinking path and issue description included
- [x] Model described
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] No internal issues, private links, or runtime credentials included
- [x] Descriptive branch name
- [x] Focused tests and relevant typechecks run
- [x] Tests and documentation updated
- [x] Risks documented
- [x] All CI gates green
- [x] Greptile 5/5 with no unresolved findings
- [x] I will address review findings before requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Nicky LeachandPaperclip authored and GitHub committed 2026-10-21 10:10:00 -07:00
1 parent 507a0d7098
commit 42715d459f
15 files changed
+457 -7

No files matched your search

+34
View File
@@ -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.
@@ -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<string, string> };
status?: number;
headers?: Record<string, string>;
body?: unknown;
+4 -1
View File
@@ -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. */
+20 -1
View File
@@ -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<string, unknown>) {
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);
});
});
+11 -1
View File
@@ -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<typeof pluginApiRouteDeclarationSchema>;
@@ -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({
@@ -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<PaperclipPluginManifestV1["apiRoutes"]>
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<string, unknown> | 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<string, unknown>, 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<void>(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<string, unknown> = {};
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<void>(resolve => server.close(() => resolve())), new Promise<void>(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<string, unknown> = { 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();
});
-1
View File
@@ -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();
});
+2
View File
@@ -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;
}
+102
View File
@@ -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<string, string> },
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<string, string | string[]> = { ...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<typeof httpsRequest>;
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();
}
+38
View File
@@ -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<string, string> };
status?: number;
headers?: Record<string, string>;
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;
+1 -1
View File
@@ -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 })));
@@ -33,6 +33,8 @@ vi.mock("./SidebarNavItem", () => ({
SidebarNavItem: ({ to, label }: { to: string; label: string }) => <a href={to}>{label}</a>,
}));
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(<QueryClientProvider client={client}><MemoryRouter>
<AgentContextualSidebar agentRef="agent" agentId="agent-1" agentName="Agent" />
</MemoryRouter></QueryClientProvider>);
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();
@@ -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({
</div>
))}
{pluginTabs.length > 0 && <div data-slot="contextual-sidebar-section" className={contextualSidebarStyles.section}>
<p className={contextualSidebarStyles.sectionLabel}>Apps</p>
<div className={contextualSidebarStyles.group}>
{pluginTabs.map(slot => <SidebarNavItem key={`${slot.pluginKey}:${slot.id}`} to={`/agents/${encodeURIComponent(agentRef)}/${encodeURIComponent(`plugin:${slot.pluginKey}:${slot.id}`)}`} label={slot.displayName} icon={Wrench} />)}
</div>
</div>}
<div data-slot="contextual-sidebar-section" className={contextualSidebarStyles.section}>
<p
data-slot="contextual-sidebar-section-label"
+74
View File
@@ -0,0 +1,74 @@
// @vitest-environment jsdom
import { act } from "react";
import { createRoot } from "react-dom/client";
import { BreadcrumbProvider } from "@/context/BreadcrumbContext";
import { renderToStaticMarkup } from "react-dom/server";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { describe, expect, it, vi } from "vitest";
import { queryKeys } from "@/lib/queryKeys";
import { AgentDetailPage } from "./AgentDetailPage";
const params = vi.hoisted(() => ({ 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 }) => <a>{children}</a> }));
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 <p>Built-in agent view</p>;
} };
});
vi.mock("@/plugins/slots", () => ({ usePluginSlots: () => state, PluginSlotMount: (props: unknown) => { mount(props); return <p>Plugin contribution</p>; } }));
function view() {
const client = new QueryClient();
client.setQueryData([...queryKeys.agents.detail("agent"), "company"], { id: "agent-id", companyId: "company", name: "Agent" });
return <QueryClientProvider client={client}><BreadcrumbProvider companyName="Test company"><AgentDetailPage /></BreadcrumbProvider></QueryClientProvider>;
}
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";
}
});
+42
View File
@@ -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:") ? <AgentPluginDetail /> : <AgentDetail />;
}
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 <p role="alert">Agent unavailable</p>;
if (!agent || isLoading || slotsLoading) return <p role="status">Loading agent view…</p>;
return <div className="mx-auto max-w-5xl space-y-4">
<Link to={`/agents/${encodeURIComponent(agentId)}/overview`} className="text-sm text-muted-foreground">{agent.name}</Link>
{slot ? <PluginSlotMount slot={slot} context={{ companyId: agent.companyId, companyPrefix: companyPrefix ?? null, entityId: agent.id, entityType: "agent" }} missingBehavior="placeholder" />
: <p role="status">{errorMessage ? "Agent view unavailable" : "This plugin view is unavailable"}</p>}
</div>;
}