Let an AI agent see the open editor through MCP (#429)
feuerwerk / gate (push) Successful in 5m41s
feuerwerk / suite (push) Successful in 18m44s

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>
This commit is contained in:
adminandClaude Opus 5.5 committed 2026-10-18 10:39:34 +02:00
1 parent edef2b6323
commit 48ec722660
32 files changed
+3324 -12

No files matched your search

+18 -1
View File
@@ -1,5 +1,12 @@
import { describe, expect, it } from 'vitest';
import { API_ENDPOINT, dataPath, moveOldStoredKeys, sitePath } from '../../src/core/site';
import {
API_ENDPOINT,
dataPath,
MCP_PATH,
moveOldStoredKeys,
sitePath,
socketUrl,
} from '../../src/core/site';
function memoryStore(initial: Record<string, string>) {
const items = new Map(Object.entries(initial));
@@ -21,6 +28,16 @@ describe('site', () => {
expect(dataPath('textures')).toBe('/feuerwerk/data/textures');
});
it('opens the MCP relay on the page’s own host, encrypted on an encrypted page (#429)', () => {
expect(MCP_PATH).toBe('/feuerwerk/mcp');
expect(socketUrl('/mcp', { protocol: 'https:', host: 'feuerwerk.nockal.com' })).toBe(
'wss://feuerwerk.nockal.com/mcp',
);
expect(socketUrl('/feuerwerk/mcp', { protocol: 'http:', host: '127.0.0.1:8081' })).toBe(
'ws://127.0.0.1:8081/feuerwerk/mcp',
);
});
it('takes the path from the folder of the open page', () => {
expect(sitePath('/feuerwerk/')).toBe('/feuerwerk/');
expect(sitePath('/feuerwerk/index.html')).toBe('/feuerwerk/');
+170
View File
@@ -0,0 +1,170 @@
import { describe, expect, it } from 'vitest';
import { CreateAssetCommand } from '../../src/core/commands/assetCommands';
import { agentView, type ViewFacts, viewSummary } from '../../src/editor/agent/agentView';
import { runTool } from '../../src/editor/agent/agentTools';
import { claudeCommand, tokenLine } from '../../src/editor/agent/AgentTokensDialog';
import TOOLS from '../../src/editor/agent/tools.json';
import { graphEditor, makeBehavior, makeSpriteScene, place } from '../helpers';
/** What the agent learns of the editor (#429): `get_view`, its one line, and the tools around it. */
function facts(fixture: ReturnType<typeof makeSpriteScene>, extra: Partial<ViewFacts> = {}): ViewFacts {
return {
project: fixture.project,
mode: 'edit',
paused: false,
saveStatus: 'saved',
tab: { id: 'scene', label: 'Scene' },
document: null,
windows: [],
selection: null,
popups: [],
...extra,
};
}
describe('get_view', () => {
it('names the selected objects, with the asset’s name where an object has none', () => {
const fixture = makeSpriteScene('Crate');
const a = place(fixture, fixture.spriteId, { x: 0, y: 0 });
const b = place(fixture, fixture.spriteId, { x: 10, y: 0 });
fixture.object(b).name = 'Lid';
const view = agentView(
facts(fixture, {
selection: { kind: 'object', holderId: fixture.sceneId, objectId: b, objectIds: [a, b] },
}),
);
expect(view.selection).toEqual({
kind: 'object',
in: { id: fixture.sceneId, name: 'Level', type: 'scene' },
primary: b,
objects: [
{ id: a, name: 'Crate', asset: fixture.spriteId },
{ id: b, name: 'Lid', asset: fixture.spriteId },
],
});
expect(viewSummary(view)).toBe(
'project "P"; tab Scene; selection: 2 objects ("Crate", "Lid") in "Level"; editing',
);
});
it('names the selected nodes by type, and the document a tab shows', () => {
const fixture = makeSpriteScene();
const behavior = makeBehavior(fixture, 'Jump');
const node = graphEditor(fixture, behavior).addNode('OnStart');
const view = agentView(
facts(fixture, {
tab: { id: 'graph', label: 'Behavior' },
document: `asset:${behavior.id}`,
selection: {
kind: 'node',
assetId: behavior.id,
nodeId: node.id,
nodeIds: [node.id],
annotationIds: [],
edgeIds: [],
},
mode: 'play',
paused: true,
}),
);
expect(view.document).toEqual({
id: `asset:${behavior.id}`,
asset: { id: behavior.id, name: 'Jump', type: 'behavior' },
});
expect(view.selection).toMatchObject({
kind: 'node',
nodes: [{ id: node.id, type: 'OnStart' }],
edges: [],
});
expect(view.play).toBe('paused');
expect(viewSummary(view)).toBe(
'project "P"; tab Behavior showing behavior "Jump"; selection: 1 node (OnStart) in "Jump"; paused',
);
});
it('keeps the ids of the other kinds, says what an asset is, and the dialog on top', () => {
const fixture = makeSpriteScene();
const texture = new CreateAssetCommand('texture', 'Tiles');
fixture.stack.execute(texture);
const frame = agentView(
facts(fixture, { selection: { kind: 'frame', assetId: texture.asset.id, frameId: 'f1' } }),
);
expect(frame.selection).toEqual({
kind: 'frame',
in: { id: texture.asset.id, name: 'Tiles', type: 'texture' },
frameId: 'f1',
});
const asset = agentView(
facts(fixture, {
selection: { kind: 'asset', assetId: fixture.spriteId },
popups: [
{ kind: 'menu', title: 'New project' },
{ kind: 'dialog', title: 'Agent tokens' },
],
}),
);
expect(viewSummary(asset)).toBe(
'project "P"; tab Scene; selection: sprite "Box"; editing; dialog "Agent tokens" is open',
);
});
it('has a line that does not move with the camera', () => {
const fixture = makeSpriteScene();
const view = agentView(facts(fixture));
expect(viewSummary({ ...view, camera: { x: 1, y: 2, zoom: 3 } })).toBe(viewSummary(view));
});
});
describe('the tools', () => {
const host = {
view: () => agentView(facts(makeSpriteScene())),
screenshot: (maxWidth: number) =>
Promise.resolve({ data: 'iVBOR', width: Math.min(maxWidth, 1600), height: 900 }),
};
it('are the ones the relay lists, each with a schema', () => {
expect(TOOLS.map((tool) => tool.name)).toEqual(['get_view', 'screenshot']);
for (const tool of TOOLS) expect(tool.inputSchema.type).toBe('object');
});
it('get_view answers the view as text and as structured content', async () => {
const result = await runTool('get_view', {}, host);
expect(result.structuredContent).toMatchObject({ tab: { id: 'scene' }, play: 'editing' });
expect(result.content[0]).toEqual({ type: 'text', text: JSON.stringify(result.structuredContent) });
});
it('screenshot answers a PNG at 1280 pixels unless told otherwise, and refuses a width out of range', async () => {
const shot = await runTool('screenshot', {}, host);
expect(shot.content[0]).toEqual({ type: 'image', data: 'iVBOR', mimeType: 'image/png' });
expect(shot.content[1]).toEqual({
type: 'text',
text: 'The editor as the user sees it, 1280x900 pixels.',
});
expect((await runTool('screenshot', { maxWidth: 100 }, host)).isError).toBe(true);
expect((await runTool('screenshot', { maxWidth: 'big' }, host)).isError).toBe(true);
});
it('an unknown tool says to reload', async () => {
const answer = (await runTool('run_command', {}, host)).content[0];
expect(answer?.type === 'text' && answer.text).toMatch(/reload/);
});
});
describe('agent tokens', () => {
it('are listed by day made, day of expiry and last use', () => {
const day = Date.UTC(2026, 9, 17) / 1000;
expect(tokenLine({ id: 'a', name: 'x', createdAt: day, expiresAt: day + 90 * 86400, usedAt: null })).toBe(
'made 2026-10-17 · expires 2027-01-15 · never used',
);
expect(
tokenLine({ id: 'a', name: 'x', createdAt: day, expiresAt: day + 86400, usedAt: day + 3600 }),
).toBe('made 2026-10-17 · expires 2026-10-18 · last used 2026-10-17');
});
it('are set up in Claude Code with one line', () => {
expect(claudeCommand('https://feuerwerk.nockal.com/mcp', 'fwa_00')).toBe(
'claude mcp add --transport http feuerwerk https://feuerwerk.nockal.com/mcp --header "Authorization: Bearer fwa_00"',
);
});
});
+477
View File
@@ -0,0 +1,477 @@
import { createServer, type Server } from 'node:http';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { type ApiAnswer, CLOSE, type Relay, startRelay, VIEW_URI } from '../../tools/mcp/relay.mjs';
/**
* The MCP relay (#429) between a fake API, agents speaking MCP over HTTP, and fake editor tabs on
* WebSockets: no browser, no PHP.
*/
const ORIGIN = 'https://editor.test';
const TOOLS = [
{ name: 'get_view', description: 'view', inputSchema: { type: 'object' } },
{ name: 'screenshot', description: 'picture', inputSchema: { type: 'object' } },
];
const TOKENS: Record<string, string> = { fwa_alice: 'alice', fwa_bob: 'bob', fwa_alice2: 'Alice' };
let relay!: Relay;
let running = false;
let tickets: Map<string, string>;
let addresses: string[];
let throttled: boolean;
const open: WebSocket[] = [];
async function start(extra: Partial<Parameters<typeof startRelay>[0]> = {}): Promise<void> {
if (running) await relay.close();
relay = await startRelay({
api: {
verifyToken: (token: string, address: string): Promise<ApiAnswer> => {
addresses.push(address);
if (throttled) return Promise.resolve({ status: 429, retryAfter: '600', error: 'Too many' });
const user = TOKENS[token];
return Promise.resolve(user ? { status: 200, user } : { status: 401 });
},
redeemTicket: (ticket: string): Promise<ApiAnswer> => {
const user = tickets.get(ticket);
tickets.delete(ticket);
return Promise.resolve(user ? { status: 200, user } : { status: 401 });
},
},
tools: () => Promise.resolve(TOOLS),
origins: [ORIGIN],
...extra,
});
running = true;
}
beforeEach(async () => {
tickets = new Map();
addresses = [];
throttled = false;
await start();
});
afterEach(async () => {
for (const socket of open.splice(0)) socket.close();
await relay.close();
running = false;
});
const url = () => `http://127.0.0.1:${relay.port}/mcp`;
interface Content {
type: string;
text?: string;
data?: string;
mimeType?: string;
}
interface ToolResult {
content: Content[];
isError?: boolean;
structuredContent?: object;
}
interface Reply {
result?: Record<string, unknown>;
error?: { code: number; message: string };
}
/** What a fake tab hears from the relay. */
interface TabMessage {
type: string;
user?: string;
count?: number;
id?: number;
tool?: string;
}
const toolResult = (body: Reply | null) => body?.result as unknown as ToolResult;
const textOf = (body: Reply | null, at = 0) => toolResult(body).content[at]?.text;
const viewText = (body: Reply | null) =>
(body?.result as unknown as { contents: { text: string }[] }).contents[0]?.text ?? '';
async function rpc(
token: string | null,
method: string,
params: object = {},
session?: string,
headers: Record<string, string> = {},
) {
const res = await fetch(url(), {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json, text/event-stream',
...(token ? { Authorization: `Bearer ${token}` } : {}),
...(session ? { 'Mcp-Session-Id': session } : {}),
...headers,
},
body: JSON.stringify({ jsonrpc: '2.0', id: 1, method, params }),
});
const text = await res.text();
return {
status: res.status,
headers: res.headers,
body: (text ? JSON.parse(text) : null) as Reply | null,
};
}
async function initialize(token: string, protocolVersion = '2025-06-18'): Promise<string> {
const answer = await rpc(token, 'initialize', {
protocolVersion,
capabilities: {},
clientInfo: { name: 'test' },
});
expect(answer.status).toBe(200);
return answer.headers.get('mcp-session-id') ?? '';
}
interface FakeTab {
socket: WebSocket;
received: TabMessage[];
closed: Promise<number>;
/** Answers every call with what `answer` makes of it; null leaves it unanswered. */
answer: (call: TabMessage) => object | null;
next(type: string): Promise<TabMessage>;
}
function connectTab(
user: string,
origin: string | null = ORIGIN,
ticket = `ticket-${Math.random()}`,
): FakeTab {
tickets.set(ticket, user);
const socket = new WebSocket(url().replace('http', 'ws'), {
headers: origin ? { Origin: origin } : {},
} as unknown as string[]);
open.push(socket);
const tab: FakeTab = {
socket,
received: [],
// Node's client reports a refused handshake as an error alone, without the close a browser adds.
closed: new Promise((resolve) => {
socket.addEventListener('close', (event) => resolve(event.code));
socket.addEventListener('error', () => resolve(1006));
}),
answer: (call) => ({ content: [{ type: 'text', text: `${user} did ${call.tool ?? ''}` }] }),
next: (type) =>
new Promise((resolve) => {
const seen = tab.received.find((message) => message.type === type);
if (seen) {
tab.received.splice(tab.received.indexOf(seen), 1);
resolve(seen);
return;
}
const listener = (event: MessageEvent) => {
const message = JSON.parse(String(event.data)) as TabMessage;
if (message.type !== type) return;
tab.received.splice(tab.received.indexOf(message), 1);
socket.removeEventListener('message', listener);
resolve(message);
};
socket.addEventListener('message', listener);
}),
};
socket.addEventListener('open', () => socket.send(JSON.stringify({ type: 'hello', ticket })));
socket.addEventListener('message', (event) => {
const message = JSON.parse(String(event.data)) as TabMessage;
if (message.type === 'call') {
const result = tab.answer(message);
if (result) socket.send(JSON.stringify({ type: 'result', id: message.id, ...result }));
return;
}
tab.received.push(message);
});
return tab;
}
async function onTab(user: string): Promise<FakeTab> {
const tab = connectTab(user);
expect((await tab.next('welcome')).user).toBe(user);
return tab;
}
const call = (token: string, session: string, name: string, args: object = {}) =>
rpc(token, 'tools/call', { name, arguments: args }, session);
describe('agents', () => {
it('need a token the API knows, and hear 429 while the API brakes them', async () => {
const none = await rpc(null, 'initialize');
const wrong = await rpc('fwa_nobody', 'initialize');
throttled = true;
const braked = await rpc('fwa_alice', 'initialize');
expect([none.status, wrong.status, braked.status]).toEqual([401, 401, 429]);
expect(none.headers.get('www-authenticate')).toMatch(/^Bearer/);
expect(braked.headers.get('retry-after')).toBe('600');
});
it('pass their own address to the brake, or nginx’s word for it only when told to trust it', async () => {
await rpc('fwa_alice', 'initialize', {}, undefined, { 'X-Real-IP': '203.0.113.9' });
await start({ trustProxy: true });
await rpc('fwa_alice', 'initialize', {}, undefined, { 'X-Real-IP': '203.0.113.9' });
expect(addresses).toHaveLength(2);
expect(addresses[0]).not.toBe('203.0.113.9');
expect(addresses[1]).toBe('203.0.113.9');
});
it('start a session in the protocol version they ask for, or the newest', async () => {
const asked = await rpc('fwa_alice', 'initialize', { protocolVersion: '2025-03-26' });
const unknown = await rpc('fwa_alice', 'initialize', { protocolVersion: '1999-01-01' });
expect(asked.body?.result).toMatchObject({
protocolVersion: '2025-03-26',
capabilities: { tools: {}, resources: { subscribe: true } },
serverInfo: { name: 'feuerwerk' },
});
expect(asked.headers.get('mcp-session-id')).toMatch(/^[0-9a-f-]{36}$/);
expect(unknown.body?.result?.protocolVersion).toBe('2025-11-25');
});
it('get 202 for a notification and the tool list on asking', async () => {
const session = await initialize('fwa_alice');
const res = await fetch(url(), {
method: 'POST',
headers: {
Authorization: 'Bearer fwa_alice',
'Mcp-Session-Id': session,
'Content-Type': 'application/json',
},
body: JSON.stringify({ jsonrpc: '2.0', method: 'notifications/initialized' }),
});
expect(res.status).toBe(202);
expect((await rpc('fwa_alice', 'tools/list', {}, session)).body?.result).toEqual({ tools: TOOLS });
});
it('cannot use a session of another account, nor one that is not there', async () => {
const session = await initialize('fwa_alice');
expect((await rpc('fwa_bob', 'tools/list', {}, session)).status).toBe(404);
expect((await rpc('fwa_alice', 'tools/list', {}, 'no-such-session')).status).toBe(404);
expect((await rpc('fwa_alice2', 'tools/list', {}, session)).status).toBe(200);
});
it('are refused from a page of another site, not from the editor’s own', async () => {
const foreign = await rpc('fwa_alice', 'initialize', {}, undefined, { Origin: 'https://evil.test' });
const own = await rpc('fwa_alice', 'initialize', {}, undefined, { Origin: ORIGIN });
expect([foreign.status, own.status]).toEqual([403, 200]);
});
it('hear about unknown tools and methods in JSON-RPC terms', async () => {
const session = await initialize('fwa_alice');
expect((await call('fwa_alice', session, 'rm_rf')).body?.error?.code).toBe(-32602);
expect((await rpc('fwa_alice', 'prompts/list', {}, session)).body?.error?.code).toBe(-32601);
});
it('end a session with DELETE', async () => {
const session = await initialize('fwa_alice');
const res = await fetch(url(), {
method: 'DELETE',
headers: { Authorization: 'Bearer fwa_alice', 'Mcp-Session-Id': session },
});
expect(res.status).toBe(204);
expect((await rpc('fwa_alice', 'tools/list', {}, session)).status).toBe(404);
});
it('send no body larger than the limit', async () => {
await start({ maxBodyBytes: 1000 });
const res = await fetch(url(), {
method: 'POST',
headers: { Authorization: 'Bearer fwa_alice', 'Content-Type': 'application/json' },
body: JSON.stringify({
jsonrpc: '2.0',
id: 1,
method: 'initialize',
params: { pad: 'x'.repeat(2000) },
}),
});
expect(res.status).toBe(413);
});
});
describe('tabs', () => {
it('get a call through to the tab of the same account and its answer back', async () => {
await onTab('alice');
const session = await initialize('fwa_alice');
const answer = await call('fwa_alice', session, 'get_view');
expect(answer.body?.result).toEqual({
content: [{ type: 'text', text: 'alice did get_view' }],
isError: false,
});
});
it('without a tab, a call says so and how to fix it', async () => {
const session = await initialize('fwa_alice');
const answer = await call('fwa_alice', session, 'get_view');
expect(toolResult(answer.body).isError).toBe(true);
expect(textOf(answer.body)).toMatch(/No Feuerwerk editor is connected.*"Agent"/);
});
it('never reach the tab of another account', async () => {
const bobs = await onTab('bob');
const calls: string[] = [];
bobs.answer = (message) => {
calls.push(message.tool ?? '');
return { content: [{ type: 'text', text: 'bob' }] };
};
const session = await initialize('fwa_alice');
const answer = await call('fwa_alice', session, 'get_view');
expect(toolResult(answer.body).isError).toBe(true);
expect(calls).toEqual([]);
});
it('are refused from another origin or none, before a ticket is asked for', async () => {
const foreign = connectTab('alice', 'https://evil.test');
const none = connectTab('alice', null);
expect(await foreign.closed).toBe(1006);
expect(await none.closed).toBe(1006);
expect(tickets.size).toBe(2);
});
it('need a ticket the API takes, once', async () => {
const tab = connectTab('alice', ORIGIN, 'shared');
await tab.next('welcome');
tickets.delete('shared');
const again = connectTab('alice', ORIGIN, 'other');
tickets.delete('other');
expect(await again.closed).toBe(CLOSE.refused);
expect(relay.stats().tabs).toEqual(['alice']);
});
it('that say no hello in time are let go', async () => {
await start({ helloTimeoutMs: 100 });
const socket = new WebSocket(url().replace('http', 'ws'), {
headers: { Origin: ORIGIN },
} as unknown as string[]);
open.push(socket);
const code = await new Promise((resolve) =>
socket.addEventListener('close', (event) => resolve(event.code)),
);
expect(code).toBe(CLOSE.helloTimeout);
});
it('one per account: the next takes over, and a call open on the first ends', async () => {
const first = await onTab('alice');
first.answer = () => null;
const session = await initialize('fwa_alice');
const pending = call('fwa_alice', session, 'get_view');
await new Promise((resolve) => setTimeout(resolve, 50));
const second = await onTab('Alice');
expect((await first.next('replaced')).type).toBe('replaced');
expect(await first.closed).toBe(CLOSE.replaced);
expect(textOf((await pending).body)).toBe('Another editor tab took over');
expect(textOf((await call('fwa_alice', session, 'get_view')).body)).toBe('Alice did get_view');
second.socket.close();
});
it('that do not answer in time end the call with an error', async () => {
await start({ callTimeoutMs: 100 });
const tab = await onTab('alice');
tab.answer = () => null;
const session = await initialize('fwa_alice');
const answer = await call('fwa_alice', session, 'screenshot');
expect(answer.body?.result).toEqual({
content: [{ type: 'text', text: 'The editor did not answer within 0 s' }],
isError: true,
});
});
it('pass a picture of several hundred kilobytes through, and only content the protocol knows', async () => {
const tab = await onTab('alice');
const data = Buffer.alloc(300_000, 7).toString('base64');
tab.answer = () => ({
content: [
{ type: 'image', data, mimeType: 'image/png' },
{ type: 'script', text: 'x' },
],
structuredContent: { width: 10 },
});
const session = await initialize('fwa_alice');
const answer = await call('fwa_alice', session, 'screenshot');
expect(toolResult(answer.body).content).toEqual([{ type: 'image', data, mimeType: 'image/png' }]);
expect(toolResult(answer.body).structuredContent).toEqual({ width: 10 });
});
it('learn how many agents their account has on, and an agent gone quiet counts out', async () => {
await start({ agentRecentMs: 150, pingMs: 50 });
await initialize('fwa_alice');
await new Promise((resolve) => setTimeout(resolve, 200));
const tab = await onTab('alice');
expect((await tab.next('agents')).count).toBe(0);
await initialize('fwa_alice');
expect((await tab.next('agents')).count).toBe(1);
expect((await tab.next('agents')).count).toBe(0);
});
});
describe('the view', () => {
it('is read as a resource, and a subscribed agent hears when it changes', async () => {
const tab = await onTab('alice');
const session = await initialize('fwa_alice');
expect((await rpc('fwa_alice', 'resources/list', {}, session)).body?.result).toMatchObject({
resources: [{ uri: VIEW_URI }],
});
expect((await rpc('fwa_alice', 'resources/subscribe', { uri: VIEW_URI }, session)).body?.result).toEqual(
{},
);
const stream = await fetch(url(), {
headers: { Authorization: 'Bearer fwa_alice', 'Mcp-Session-Id': session, Accept: 'text/event-stream' },
});
expect(stream.headers.get('content-type')).toBe('text/event-stream');
const reader = stream.body!.getReader();
const heard = (async () => {
let text = '';
while (!text.includes('notifications/resources/updated')) {
const { value, done } = await reader.read();
if (done) break;
text += new TextDecoder().decode(value);
}
return text;
})();
tab.socket.send(JSON.stringify({ type: 'view', view: { tab: 'graph' }, summary: 'tab graph' }));
expect(await heard).toContain(`"params":{"uri":"${VIEW_URI}"}`);
await reader.cancel();
const read = await rpc('fwa_alice', 'resources/read', { uri: VIEW_URI }, session);
expect(JSON.parse(viewText(read.body))).toEqual({ tab: 'graph' });
});
it('read without a tab says it is not connected', async () => {
const session = await initialize('fwa_alice');
const read = await rpc('fwa_alice', 'resources/read', { uri: VIEW_URI }, session);
expect(JSON.parse(viewText(read.body))).toMatchObject({ connected: false });
});
it('changed since the agent last looked is told with the next answer, once', async () => {
const tab = await onTab('alice');
const session = await initialize('fwa_alice');
tab.socket.send(JSON.stringify({ type: 'view', view: { tab: 'scene' }, summary: 'tab scene' }));
await new Promise((resolve) => setTimeout(resolve, 50));
await call('fwa_alice', session, 'get_view');
const same = await call('fwa_alice', session, 'screenshot');
tab.socket.send(JSON.stringify({ type: 'view', view: { tab: 'graph' }, summary: 'tab graph' }));
await new Promise((resolve) => setTimeout(resolve, 50));
const changed = await call('fwa_alice', session, 'screenshot');
const after = await call('fwa_alice', session, 'screenshot');
expect(toolResult(same.body).content).toHaveLength(1);
expect(textOf(changed.body, 1)).toBe("The user's view has changed since you last looked: tab graph");
expect(toolResult(after.body).content).toHaveLength(1);
});
});
describe('in front of a plain server', () => {
let backend: Server;
afterEach(() => new Promise<void>((resolve) => backend.close(() => resolve())));
it('passes everything off its own path on', async () => {
backend = createServer((req, res) =>
res.end(`backend ${req.method} ${req.url} ${req.headers.range ?? ''}`),
);
await new Promise<void>((resolve) => backend.listen(0, '127.0.0.1', () => resolve()));
const port = (backend.address() as { port: number }).port;
await start({ backend: `http://127.0.0.1:${port}` });
const page = await fetch(`http://127.0.0.1:${relay.port}/feuerwerk/data/x.mp4?y=1`, {
headers: { Range: 'bytes=0-9' },
});
expect(await page.text()).toBe('backend GET /feuerwerk/data/x.mp4?y=1 bytes=0-9');
expect((await fetch(`http://127.0.0.1:${relay.port}/feuerwerk/mcp`, { method: 'POST' })).status).toBe(
401,
);
});
});
+143
View File
@@ -0,0 +1,143 @@
<?php
declare(strict_types=1);
// A command-line script: under a web server it does nothing (#427).
if (PHP_SAPI !== 'cli') {
exit(1);
}
/**
* Agent tokens and tickets of the MCP relay (#429), on a scratch directory with a clock of its own.
* Plain PHP like limits.test.php; CI runs it with `php tests/php/agents.test.php`.
*/
require __DIR__ . '/../../api/lib/AtomicFile.php';
require __DIR__ . '/../../api/lib/AgentTokens.php';
$failed = 0;
$passed = 0;
function check(string $name, bool $ok, mixed $got = null): void
{
global $failed, $passed;
if ($ok) {
$passed++;
echo "PASS {$name}\n";
return;
}
$failed++;
echo "FAIL {$name}", $got === null ? '' : ' — got ' . json_encode($got), "\n";
}
$dir = sys_get_temp_dir() . '/feuerwerk-php-agents-' . bin2hex(random_bytes(4));
mkdir($dir);
register_shutdown_function(static fn() => exec('rm -rf ' . escapeshellarg($dir)));
$now = 1_000_000;
$tokens = new AgentTokens("{$dir}/agent-tokens", "{$dir}/agent-tickets", static function () use (&$now): int {
return $now;
});
// --- Tokens ----------------------------------------------------------------------------------------
$made = $tokens->create('alice', ' Claude Code ', 30);
check(
'a token is made with its name trimmed, an expiry in days, and the token itself in the answer',
preg_match('/^fwa_[0-9a-f]{64}$/', $made['token']) === 1
&& $made['name'] === 'Claude Code'
&& $made['expiresAt'] === $now + 30 * 86400
&& $made['usedAt'] === null,
$made,
);
$files = glob("{$dir}/agent-tokens/*.json");
$stored = implode('', array_map('file_get_contents', $files));
check(
'on disk it is one file under the hash of the token, 0640, and the token is nowhere in it',
count($files) === 1
&& basename($files[0]) === hash('sha256', $made['token']) . '.json'
&& (fileperms($files[0]) & 0777) === 0640
&& !str_contains($stored, substr($made['token'], 4)),
[$files, $stored],
);
$listed = $tokens->listOf('Alice');
check(
'its owner lists it, under any spelling of the name, without the token',
count($listed) === 1 && $listed[0]['id'] === $made['id'] && !isset($listed[0]['token']) && !isset($listed[0]['username']),
$listed,
);
check('another account lists nothing', $tokens->listOf('bob') === []);
check('the token names its account', $tokens->verify($made['token']) === 'alice');
check('and its use is written down', $tokens->listOf('alice')[0]['usedAt'] === $now);
$now += 30;
$tokens->verify($made['token']);
check('but not again within the minute', $tokens->listOf('alice')[0]['usedAt'] === $now - 30);
$now += 60;
$tokens->verify($made['token']);
check('after it, again', $tokens->listOf('alice')[0]['usedAt'] === $now);
$wrong = 'fwa_' . str_repeat('0', 64);
check('an unknown token names nobody', $tokens->verify($wrong) === null);
check('nor does one of the wrong shape', $tokens->verify(substr($made['token'], 4)) === null && $tokens->verify($made['token'] . '0') === null);
check('nor a path', $tokens->verify('fwa_../../users') === null);
check('another account cannot revoke it', $tokens->revoke('bob', $made['id']) === false && $tokens->verify($made['token']) === 'alice');
check('its owner can', $tokens->revoke('alice', $made['id']) === true);
check('and then it names nobody', $tokens->verify($made['token']) === null && $tokens->listOf('alice') === []);
check('revoking it twice finds nothing', $tokens->revoke('alice', $made['id']) === false);
$short = $tokens->create('alice', 'one day', 1);
$now += 86400 - 1;
check('a token works until the second before it expires', $tokens->verify($short['token']) === 'alice');
$now += 1;
check('then it names nobody', $tokens->verify($short['token']) === null);
check('and is gone from the list and the disk', $tokens->listOf('alice') === [] && glob("{$dir}/agent-tokens/*.json") === []);
$long = $tokens->create('alice', 'long', 10_000);
check('a token lasts at most a year', $long['expiresAt'] === $now + AgentTokens::MAX_DAYS * 86400, $long);
$tokens->create('bob', 'bob', 5);
check('ending an account’s tokens ends its own alone', $tokens->endAllOf('ALICE') === 1 && count($tokens->listOf('bob')) === 1);
for ($i = 0; $i < AgentTokens::MAX_PER_ACCOUNT; $i++) {
$tokens->create('carol', "t{$i}", 5);
}
$refused = null;
try {
$tokens->create('carol', 'one too many', 5);
} catch (TooManyAgentTokensException $e) {
$refused = $e->getMessage();
}
check('an account has at most ' . AgentTokens::MAX_PER_ACCOUNT . ' tokens', $refused !== null && count($tokens->listOf('carol')) === AgentTokens::MAX_PER_ACCOUNT, $refused);
check('a name is 1–60 characters', AgentTokens::isValidName('x') && !AgentTokens::isValidName(' ') && !AgentTokens::isValidName(str_repeat('x', 61)));
// --- Tickets ---------------------------------------------------------------------------------------
$ticket = $tokens->issueTicket('alice');
$ticketFiles = glob("{$dir}/agent-tickets/*.json");
check(
'a ticket is kept under its hash, 0640',
preg_match('/^[0-9a-f]{64}$/', $ticket) === 1
&& count($ticketFiles) === 1
&& basename($ticketFiles[0]) === hash('sha256', $ticket) . '.json'
&& (fileperms($ticketFiles[0]) & 0777) === 0640,
$ticketFiles,
);
check('redeemed, it names its account', $tokens->redeemTicket($ticket) === 'alice');
check('a second time, nobody', $tokens->redeemTicket($ticket) === null);
check('and nothing of it is left', glob("{$dir}/agent-tickets/*") === []);
$late = $tokens->issueTicket('alice');
$now += AgentTokens::TICKET_SECONDS + 1;
check('a ticket older than a minute names nobody', $tokens->redeemTicket($late) === null);
check('an agent token is no ticket', $tokens->redeemTicket($long['token']) === null);
$stale = $tokens->issueTicket('bob');
$now += AgentTokens::TICKET_SECONDS + 1;
touch("{$dir}/agent-tickets/" . hash('sha256', $stale) . '.json', $now - AgentTokens::TICKET_SECONDS - 1);
$tokens->issueTicket('bob');
check('issuing a ticket sweeps the ones nobody redeemed', count(glob("{$dir}/agent-tickets/*.json")) === 1);
echo $failed === 0 ? "ALL PASSED – {$passed} checks\n" : "{$failed} FAILED, {$passed} passed\n";
exit($failed === 0 ? 0 : 1);
+65 -1
View File
@@ -157,6 +157,8 @@ check(
$saved['status'] === 200 && $texture !== '' && $sound !== '' && isset($snapshot['id'], $release['token'], $demo['token']) && $offered === 204,
[$saved['status'], $texture, $sound, $snapshot, $release, $demo, $offered],
);
// An agent token of alice's (#429), which only the relay's routes take.
$agent = call('POST', 'agent-tokens', $alice, ['name' => 'Claude Code', 'days' => 7])['body'];
$folder = explode('/', $texture)[0];
$p = $project['id'];
$s = $snapshot['id'];
@@ -219,6 +221,14 @@ $matrix = [
['POST', 'pixels', 'not a cel', 'POST pixels', [401, 422, 422, 422]],
['GET', 'plugins', null, 'GET plugins', [200, 200, 200, 200]],
['GET', 'plugins/nothing.js', null, 'GET plugins/{}', [404, 404, 404, 404]],
['GET', 'agent-tokens', null, 'GET agent-tokens', [401, 200, 200, 200]],
['POST', 'agent-tokens', ['name' => ''], 'POST agent-tokens', [401, 422, 422, 422]],
['DELETE', "agent-tokens/{$agent['id']}", null, 'DELETE agent-tokens/{}', [401, null, 404, 404]],
['POST', 'agent/ticket', null, 'POST agent/ticket', [401, 200, 200, 200]],
// Without the relay's key they are not there, for anybody.
['POST', 'agent/verify', ['token' => $agent['token'], 'address' => '192.0.2.1'], 'POST agent/verify', [404, 404, 404, 404]],
['POST', 'agent/redeem', ['ticket' => str_repeat('0', 64)], 'POST agent/redeem', [404, 404, 404, 404]],
];
$actors = ['signed out' => null, 'alice' => $alice, 'bob' => $bob, 'an administrator' => $admin];
@@ -285,6 +295,60 @@ check('a demo copy goes out under its token, not the project id', ($opened['proj
check('an unknown demo is not found', call('GET', 'demos/' . str_repeat('0', 32))['status'] === 404);
check('a demo cannot be changed through its open route', call('POST', "demos/{$demo['token']}", $admin)['status'] === 404);
// --- The MCP relay's routes (#429) ----------------------------------------------------------------
$relayKey = bin2hex(random_bytes(32));
file_put_contents("{$data}/mcp-relay.key", $relayKey . "\n");
$relay = ['X-Relay-Key' => $relayKey];
$verify = static fn(string $token, string $address, array $headers): array =>
call('POST', 'agent/verify', null, ['token' => $token, 'address' => $address], $headers);
$known = $verify($agent['token'], '192.0.2.1', $relay);
check('with the key, the relay learns whose token it is', $known['status'] === 200 && $known['body'] === ['user' => 'alice', 'role' => 'user'], $known);
check('with a wrong key the route is not there', $verify($agent['token'], '192.0.2.1', ['X-Relay-Key' => str_repeat('a', 64)])['status'] === 404);
check('a wrong token is 401', $verify('fwa_' . str_repeat('0', 64), '192.0.2.1', $relay)['status'] === 401);
check(
'an agent token opens no route of the API',
call('GET', 'projects', null, null, ['Authorization' => "Bearer {$agent['token']}"])['status'] === 401,
);
$ticket = call('POST', 'agent/ticket', $alice)['body'];
check('a tab gets a ticket for a minute', preg_match('/^[0-9a-f]{64}$/', (string) ($ticket['ticket'] ?? '')) === 1 && $ticket['expiresIn'] === 60, $ticket);
check('asked without the key, it is not taken', call('POST', 'agent/redeem', null, ['ticket' => $ticket['ticket']])['status'] === 404);
$redeemed = call('POST', 'agent/redeem', null, ['ticket' => $ticket['ticket']], $relay);
check('the relay redeems it for its account', $redeemed['status'] === 200 && $redeemed['body']['user'] === 'alice', $redeemed);
check('once', call('POST', 'agent/redeem', null, ['ticket' => $ticket['ticket']], $relay)['status'] === 401);
$guesses = [];
for ($i = 0; $i < 5; $i++) {
$guesses[] = $verify('fwa_' . str_repeat((string) $i, 64), '198.51.100.9', $relay)['status'];
}
$braked = $verify($agent['token'], '198.51.100.9', $relay);
check(
'five wrong tokens from one address, and even the right one waits there, with Retry-After',
$guesses === [401, 401, 401, 401, 401] && $braked['status'] === 429 && preg_grep('/^Retry-After: \d+$/i', $braked['headers']) !== [],
[$guesses, $braked['status']],
);
check('another address is not braked', $verify($agent['token'], '192.0.2.1', $relay)['status'] === 200);
$bobsToken = call('POST', 'agent-tokens', $bob, ['name' => 'bob'])['body'];
check('another account cannot revoke alice’s token', call('DELETE', "agent-tokens/{$agent['id']}", $bob)['status'] === 404);
check(
'an account lists its own tokens, never a token itself',
array_column(call('GET', 'agent-tokens', $bob)['body'], 'id') === [$bobsToken['id']]
&& !array_key_exists('token', call('GET', 'agent-tokens', $bob)['body'][0]),
);
check('alice revokes hers', call('DELETE', "agent-tokens/{$agent['id']}", $alice)['status'] === 204);
check('and the relay no longer takes it', $verify($agent['token'], '192.0.2.1', $relay)['status'] === 401);
check('a token lasts at most a year', call('POST', 'agent-tokens', $alice, ['name' => 'x', 'days' => 366])['status'] === 422);
$second = call('POST', 'agent-tokens', $alice, ['name' => 'again'])['body'];
call('POST', 'auth/password', $alice, ['currentPassword' => 'password-alice', 'newPassword' => 'password-alice-2']);
check('a new password ends the account’s tokens', $verify($second['token'], '192.0.2.1', $relay)['status'] === 401);
call('PATCH', 'users/bob', $admin, ['password' => 'password-bob-2']);
check('so does one an administrator sets', $verify($bobsToken['token'], '192.0.2.1', $relay)['status'] === 401);
$bob = signIn('bob', 'password-bob-2');
// --- Paths, depth and unknown routes --------------------------------------------------------------
check('an unknown route is 401 signed out', call('GET', 'nothing')['status'] === 401);
@@ -339,7 +403,7 @@ for ($i = 0; $i < 5; $i++) {
$wrong[] = call('POST', 'auth/login', null, ['username' => 'bob', 'password' => 'guess'])['status'];
}
check('five wrong passwords are five 401', $wrong === [401, 401, 401, 401, 401], $wrong);
$locked = call('POST', 'auth/login', null, ['username' => 'bob', 'password' => 'password-bob']);
$locked = call('POST', 'auth/login', null, ['username' => 'bob', 'password' => 'password-bob-2']);
$retry = preg_grep('/^Retry-After: \d+$/i', $locked['headers']);
check('then even the right one waits, with 429 and Retry-After', $locked['status'] === 429 && $retry !== [], $locked);
check('the session of somebody already signed in goes on', call('GET', 'projects', $alice)['status'] === 200);