Files
feuerwerk/tools/mcp/websocket.mjs
T
adminandClaude Opus 5.5 48ec722660
feuerwerk / gate (push) Successful in 5m41s
feuerwerk / suite (push) Successful in 18m44s
Let an AI agent see the open editor through MCP (#429)
Claude Code should be able to work inside the user's open editor tab.
This first stage connects the two and lets the agent look: what the
user sees and a picture of it. Changing the project comes in stage 2.

- An agent token per account opens only /mcp, no API route. The
  dialog "Agent tokens..." in the account menu creates it once, shows
  the ready "claude mcp add" line and revokes it. The server keeps
  only its sha256 (api/lib/AgentTokens.php).
- Wrong tokens count into the sign-in brake from #427. Changing the
  password, renaming or deleting an account ends its tokens.
- The relay (tools/mcp/) is a small Node service without packages,
  WebSocket server included. It runs in the container feuerwerk-mcp
  because PHP-FPM cannot hold connections. Whether a token or a tab
  ticket is valid is still decided by the PHP API, through two routes
  that answer only with the relay key.
- The tab joins over WebSocket on the same origin with the normal
  session, an Origin check and a one-time ticket. It connects only
  after "Agent" is switched on in the account menu. One tab per
  account is active, a new one takes over. Guests get nothing.
- Tools: get_view (project, tab, document, selection with names,
  camera, play state, open dialog) and screenshot. The resource
  feuerwerk://view can be subscribed. Claude Code does not act on
  that reliably, so a changed view is also noted on the next answer.
- A badge next to the account menu shows the connected agents. It
  counts only agents with an open stream or a request in the last
  two minutes.
- CI starts the relay in front of php -S (tools/ci/serve.sh), and
  e2e 71 runs the whole path in the browser.

Found on the live test: the badge showed four agents because old
sessions of revoked tokens were still counted.

Tests:
- Gate on atx: 316 files, 4888 tests
- relay protocol test with fake API and fake tabs: 23/23
- get_view, tools, socketUrl: 16/16
- PHP agents 29, rights 103 checks
- e2e 71 on atx 10/10, against the live site 10/10, no CSP violation
- real Claude Code against the live site: get_view answered
- ZAP against /mcp: nothing at High or Medium
- every new check fails when the guard it covers is taken out (6)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 10:39:34 +02:00

201 lines
6.0 KiB
JavaScript

import { createHash } from 'node:crypto';
/**
* The server half of RFC 6455, as much as the relay needs (#429): text messages, fragmented or not,
* ping and pong, close. Node brings a WebSocket client of its own but no server, and the relay keeps
* without dependencies, like the rest of tools/.
*
* Binary messages are refused (1003): the tab only ever speaks JSON.
*/
const GUID = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11';
const utf8 = new TextDecoder('utf-8', { fatal: true });
export function acceptKey(key) {
return createHash('sha1')
.update(key + GUID)
.digest('base64');
}
/**
* Answers an upgrade request with 101 and hands back the connection, or refuses it with 400 and
* answers null.
*/
export function acceptWebSocket(req, socket, head, { maxMessageBytes }) {
const key = req.headers['sec-websocket-key'];
if (
String(req.headers.upgrade).toLowerCase() !== 'websocket' ||
typeof key !== 'string' ||
!/^[A-Za-z0-9+/]{22}==$/.test(key) ||
req.headers['sec-websocket-version'] !== '13'
) {
socket.end('HTTP/1.1 400 Bad Request\r\nConnection: close\r\n\r\n');
return null;
}
socket.write(
[
'HTTP/1.1 101 Switching Protocols',
'Upgrade: websocket',
'Connection: Upgrade',
`Sec-WebSocket-Accept: ${acceptKey(key)}`,
'',
'',
].join('\r\n'),
);
return new WebSocketConnection(socket, head, maxMessageBytes);
}
export class WebSocketConnection {
/** @type {(text: string) => void} */
onmessage = () => {};
/** @type {(code: number, reason: string) => void} */
onclose = () => {};
#socket;
#max;
#buffer = Buffer.alloc(0);
#fragments = [];
#fragmentBytes = 0;
#closed = false;
constructor(socket, head, maxMessageBytes) {
this.#socket = socket;
this.#max = maxMessageBytes;
socket.setNoDelay(true);
socket.on('data', (data) => this.#receive(data));
socket.on('close', () => this.#gone(1006, ''));
socket.on('error', () => socket.destroy());
if (head.length > 0) this.#receive(head);
}
get closed() {
return this.#closed;
}
send(text) {
if (!this.#closed) this.#frame(0x1, Buffer.from(text, 'utf8'));
}
ping() {
if (!this.#closed) this.#frame(0x9, Buffer.alloc(0));
}
close(code = 1000, reason = '') {
if (this.#closed) return;
const words = Buffer.from(reason.slice(0, 100), 'utf8');
const payload = Buffer.alloc(2 + words.length);
payload.writeUInt16BE(code, 0);
words.copy(payload, 2);
this.#frame(0x8, payload);
this.#socket.end();
this.#gone(code, reason);
}
#gone(code, reason) {
if (this.#closed) return;
this.#closed = true;
this.onclose(code, reason);
}
#frame(opcode, payload) {
const length = payload.length;
let header;
if (length < 126) {
header = Buffer.from([0x80 | opcode, length]);
} else if (length < 65536) {
header = Buffer.alloc(4);
header[0] = 0x80 | opcode;
header[1] = 126;
header.writeUInt16BE(length, 2);
} else {
header = Buffer.alloc(10);
header[0] = 0x80 | opcode;
header[1] = 127;
header.writeBigUInt64BE(BigInt(length), 2);
}
this.#socket.write(Buffer.concat([header, payload]));
}
#fail(code, reason) {
this.close(code, reason);
this.#socket.destroy();
}
#receive(data) {
if (this.#closed) return;
this.#buffer = this.#buffer.length === 0 ? data : Buffer.concat([this.#buffer, data]);
for (;;) {
const buffer = this.#buffer;
if (buffer.length < 2) return;
const fin = (buffer[0] & 0x80) !== 0;
const opcode = buffer[0] & 0x0f;
if ((buffer[0] & 0x70) !== 0) return this.#fail(1002, 'reserved bits');
// A client masks every frame it sends (RFC 6455, 5.1).
if ((buffer[1] & 0x80) === 0) return this.#fail(1002, 'unmasked frame');
let length = buffer[1] & 0x7f;
let offset = 2;
if (length === 126) {
if (buffer.length < 4) return;
length = buffer.readUInt16BE(2);
offset = 4;
} else if (length === 127) {
if (buffer.length < 10) return;
const long = buffer.readBigUInt64BE(2);
if (long > BigInt(this.#max)) return this.#fail(1009, 'message too big');
length = Number(long);
offset = 10;
}
const control = opcode >= 0x8;
if (control && (!fin || length > 125)) return this.#fail(1002, 'bad control frame');
if (!control && this.#fragmentBytes + length > this.#max) return this.#fail(1009, 'message too big');
if (buffer.length < offset + 4 + length) return;
const mask = buffer.subarray(offset, offset + 4);
const payload = Buffer.from(buffer.subarray(offset + 4, offset + 4 + length));
for (let i = 0; i < payload.length; i++) payload[i] ^= mask[i & 3];
this.#buffer = buffer.subarray(offset + 4 + length);
this.#handle(opcode, fin, payload);
if (this.#closed) return;
}
}
#handle(opcode, fin, payload) {
switch (opcode) {
case 0x0:
case 0x1:
if ((opcode === 0x1) === this.#fragments.length > 0) return this.#fail(1002, 'bad continuation');
this.#fragments.push(payload);
this.#fragmentBytes += payload.length;
if (!fin) return;
{
const whole = Buffer.concat(this.#fragments);
this.#fragments = [];
this.#fragmentBytes = 0;
let text;
try {
text = utf8.decode(whole);
} catch {
return this.#fail(1007, 'not UTF-8');
}
this.onmessage(text);
}
return;
case 0x2:
return this.#fail(1003, 'text only');
case 0x8: {
// Echoed, unless it is one no endpoint may send.
const code = payload.length >= 2 ? payload.readUInt16BE(0) : 1000;
const sendable = code >= 1000 && code <= 4999 && ![1004, 1005, 1006, 1015].includes(code);
this.close(sendable ? code : 1000);
return;
}
case 0x9:
this.#frame(0xa, payload);
return;
case 0xa:
return;
default:
return this.#fail(1002, 'unknown opcode');
}
}
}