mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-30 13:20:54 +01:00
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:
1 parent
507a0d7098
commit
42715d459f
15 files changed
+457
-7
No files matched your search
@@ -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;
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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
@@ -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"
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
});
|
||||
@@ -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>;
|
||||
}
|
||||
Reference in new issue
Block a user