Files
PaperClipAI/server/src/realtime/plugin-api-ws.ts
T
Nicky LeachandPaperclip 42715d459f 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>
2026-10-21 10:10:00 -07:00

103 lines
5.0 KiB
TypeScript

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();
}