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>
227 lines
9.6 KiB
JavaScript
227 lines
9.6 KiB
JavaScript
import { authHeaders, SESSION_COOKIE, sessionToken } from '../lib/auth.mjs';
|
||
|
||
/** The tokens this step makes; a run that died halfway leaves them for the next to revoke. */
|
||
const TOKEN_NAME = 'e2e 71';
|
||
|
||
/**
|
||
* An agent in the editor over the MCP relay (#429): a token from the account menu's dialog, the
|
||
* agent switched on there, and an MCP client that reads what the user sees – the tab, the
|
||
* selection, a screenshot – and hears when it changes. Switched off at the badge, the agent gets
|
||
* no further.
|
||
*
|
||
* In CI the relay stands in front of the PHP server on a port of its own (`FEUERWERK_MCP_BASE_URL`,
|
||
* see tools/ci/serve.sh); on stein2 nginx serves it beside the editor.
|
||
*/
|
||
export async function mcp(t) {
|
||
const { check, json, sleep, waitUntil, settle, press, locate, click, cdp, baseUrl, projectId } = t;
|
||
const { clickText, menuTicked, exceptions, navigate, shot } = t;
|
||
const site = process.env.FEUERWERK_MCP_BASE_URL ?? baseUrl;
|
||
const endpoint = `${site}mcp`;
|
||
|
||
const api = async (method, route, body) =>
|
||
fetch(`${baseUrl}api/index.php?r=${route}`, {
|
||
method,
|
||
headers: { ...(await authHeaders(baseUrl)), 'Content-Type': 'application/json' },
|
||
body: body === undefined ? undefined : JSON.stringify(body),
|
||
signal: AbortSignal.timeout(20_000),
|
||
});
|
||
const sweep = async () => {
|
||
for (const token of await (await api('GET', 'agent-tokens')).json()) {
|
||
if (token.name.startsWith(TOKEN_NAME)) await api('DELETE', `agent-tokens/${token.id}`);
|
||
}
|
||
};
|
||
|
||
let token = '';
|
||
let session = '';
|
||
const rpc = async (method, params = {}, as = token) => {
|
||
const res = await fetch(endpoint, {
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
Accept: 'application/json, text/event-stream',
|
||
Authorization: `Bearer ${as}`,
|
||
...(session ? { 'Mcp-Session-Id': session } : {}),
|
||
},
|
||
body: JSON.stringify({ jsonrpc: '2.0', id: 1, method, params }),
|
||
signal: AbortSignal.timeout(40_000),
|
||
});
|
||
const text = await res.text();
|
||
return {
|
||
status: res.status,
|
||
session: res.headers.get('mcp-session-id'),
|
||
body: text ? JSON.parse(text) : null,
|
||
};
|
||
};
|
||
const callTool = async (name, args = {}) =>
|
||
(await rpc('tools/call', { name, arguments: args })).body?.result;
|
||
const viewNow = async () => (await callTool('get_view'))?.structuredContent;
|
||
const openAccountMenu = async () => {
|
||
await clickText('Account');
|
||
await sleep(200);
|
||
};
|
||
|
||
await sweep();
|
||
let stream = null;
|
||
try {
|
||
if (site !== baseUrl) {
|
||
await cdp.send('Network.setCookie', {
|
||
name: SESSION_COOKIE,
|
||
value: await sessionToken(baseUrl),
|
||
url: site,
|
||
});
|
||
await cdp.send('Page.navigate', { url: `${site}?project=${projectId}` });
|
||
await waitUntil('Boolean(window.__gm?.state?.project?.id)', {
|
||
timeout: 20000,
|
||
label: 'the editor behind the relay',
|
||
});
|
||
await sleep(1000);
|
||
}
|
||
|
||
// 71a. The account menu has the switch and the tokens; the dialog makes a token and shows it once,
|
||
// with the line for Claude Code.
|
||
await openAccountMenu();
|
||
const menu = await menuTicked();
|
||
await clickText('Agent tokens…');
|
||
await waitUntil('__gm.agentTokens() !== null', { label: 'the token dialog is open' });
|
||
await t.typeText(TOKEN_NAME);
|
||
await press('Enter');
|
||
await waitUntil('__gm.agentTokens()?.made != null', { label: 'the dialog shows the new token' });
|
||
const dialog = await json('__gm.agentTokens()');
|
||
token = dialog.made;
|
||
await shot('71-agent-tokens');
|
||
await press('Escape');
|
||
await settle();
|
||
const listed = await (await api('GET', 'agent-tokens')).json();
|
||
check(
|
||
'the account menu switches the agent and opens the tokens; a token made there is shown once with the Claude Code line',
|
||
menu.some((row) => row?.label === 'Agent' && row.ticked === false) &&
|
||
menu.some((row) => row?.label === 'Agent tokens…') &&
|
||
/^fwa_[0-9a-f]{64}$/.test(token) &&
|
||
dialog.tokens.includes(TOKEN_NAME) &&
|
||
dialog.command ===
|
||
`claude mcp add --transport http feuerwerk ${endpoint} --header "Authorization: Bearer ${token}"` &&
|
||
listed.some((row) => row.name === TOKEN_NAME && row.token === undefined),
|
||
JSON.stringify({
|
||
menu,
|
||
dialog: { ...dialog, made: Boolean(dialog.made), command: dialog.command?.slice(0, 60) },
|
||
}),
|
||
);
|
||
|
||
// 71b. Before the switch, the agent hears there is no editor.
|
||
const init = await rpc('initialize', {
|
||
protocolVersion: '2025-06-18',
|
||
capabilities: {},
|
||
clientInfo: { name: 'e2e' },
|
||
});
|
||
session = init.session ?? '';
|
||
const tools = (await rpc('tools/list')).body?.result?.tools.map((tool) => tool.name);
|
||
const lonely = await callTool('get_view');
|
||
check(
|
||
'an agent with the token starts a session, lists get_view and screenshot, and hears no editor is on',
|
||
init.status === 200 &&
|
||
init.body?.result?.serverInfo?.name === 'feuerwerk' &&
|
||
JSON.stringify(tools) === '["get_view","screenshot"]' &&
|
||
lonely?.isError === true &&
|
||
/No Feuerwerk editor is connected/.test(lonely.content[0].text),
|
||
JSON.stringify({ status: init.status, tools, lonely }),
|
||
);
|
||
|
||
// 71c. Switched on in the menu, the tab gets onto the relay; the badge counts the agent.
|
||
await openAccountMenu();
|
||
await clickText('Agent');
|
||
await waitUntil('__gm.agent().status === "on"', { timeout: 15000, label: 'the agent link is on' });
|
||
await waitUntil('__gm.agent().badge === "Agent · 1"', {
|
||
timeout: 5000,
|
||
label: 'the badge counts the agent',
|
||
});
|
||
await openAccountMenu();
|
||
const ticked = (await menuTicked()).find((row) => row?.label === 'Agent')?.ticked;
|
||
await press('Escape');
|
||
const agent = await json('__gm.agent()');
|
||
check(
|
||
'switched on in the account menu, the tab is on the relay and the badge says one agent is connected',
|
||
agent.status === 'on' && agent.badge === 'Agent · 1' && ticked === true,
|
||
JSON.stringify({ agent: { ...agent, view: undefined }, ticked }),
|
||
);
|
||
|
||
// 71d. get_view is what the tab shows.
|
||
await t.evaluate('__gm.showTab("scene")');
|
||
await settle();
|
||
const scene = await viewNow();
|
||
check(
|
||
'get_view names the project and the tab the user sees',
|
||
scene?.project?.id === projectId && scene?.tab?.id === 'scene' && scene.play === 'editing',
|
||
JSON.stringify(scene),
|
||
);
|
||
|
||
// 71e. Subscribed, the agent hears when the user switches the tab, and get_view follows it.
|
||
await rpc('resources/subscribe', { uri: 'feuerwerk://view' });
|
||
const events = await fetch(endpoint, {
|
||
headers: { Authorization: `Bearer ${token}`, 'Mcp-Session-Id': session, Accept: 'text/event-stream' },
|
||
});
|
||
stream = events.body.getReader();
|
||
const heard = (async () => {
|
||
let text = '';
|
||
const until = Date.now() + 10_000;
|
||
while (!text.includes('notifications/resources/updated') && Date.now() < until) {
|
||
const { value, done } = await stream.read();
|
||
if (done) break;
|
||
text += new TextDecoder().decode(value);
|
||
}
|
||
return text;
|
||
})();
|
||
await clickText('Game');
|
||
await settle();
|
||
const note = await heard;
|
||
const game = await viewNow();
|
||
check(
|
||
'a tab switched by the user reaches the subscribed agent, and get_view says the Game tab',
|
||
note.includes('"uri":"feuerwerk://view"') &&
|
||
game?.tab?.id === 'game' &&
|
||
(await json('__gm.tab()')) === 'game',
|
||
JSON.stringify({ note: note.slice(-160), tab: game?.tab }),
|
||
);
|
||
|
||
// 71f. The screenshot is the editor as drawn, scaled to the width asked for; an agent that has
|
||
// not looked since the user moved on is told so with it.
|
||
await t.evaluate('__gm.showTab("scene")');
|
||
await settle();
|
||
await sleep(800);
|
||
const shownTab = await json('__gm.tab()');
|
||
const picture = await callTool('screenshot', { maxWidth: 640 });
|
||
const png = Buffer.from(picture?.content?.[0]?.data ?? '', 'base64');
|
||
const width = png.length > 24 ? png.readUInt32BE(16) : 0;
|
||
const height = png.length > 24 ? png.readUInt32BE(20) : 0;
|
||
const canvas = await json('({ w: __gm.app.canvas.width, h: __gm.app.canvas.height })');
|
||
const told =
|
||
picture?.content?.find((item) => item.type === 'text' && item.text.includes('has changed'))?.text ?? '';
|
||
check(
|
||
'screenshot answers a PNG of the editor at the width asked for, and tells the agent the view changed',
|
||
png.subarray(1, 4).toString() === 'PNG' &&
|
||
width === 640 &&
|
||
Math.abs(height - Math.round((canvas.h * 640) / canvas.w)) <= 1 &&
|
||
told.includes('tab Scene'),
|
||
JSON.stringify({ shownTab, width, height, canvas, bytes: png.length, told }),
|
||
);
|
||
|
||
// 71g. The badge is the off switch: the agent gets no further, and a revoked token not even in.
|
||
const badge = await locate('Agent · 1');
|
||
await click(badge.x, badge.y);
|
||
await waitUntil('__gm.agent().status === "off"', { label: 'the agent is off' });
|
||
const after = await callTool('get_view');
|
||
await api('DELETE', `agent-tokens/${listed.find((row) => row.name === TOKEN_NAME).id}`);
|
||
const revoked = await rpc('tools/list');
|
||
check(
|
||
'a click on the badge switches the agent off; the agent hears no editor, and after revoking it is refused',
|
||
(await json('__gm.agent().badge')) === null && after?.isError === true && revoked.status === 401,
|
||
JSON.stringify({ after, revoked: revoked.status }),
|
||
);
|
||
check('the agent session threw nothing', exceptions().length === 0, exceptions().join(' | '));
|
||
} finally {
|
||
await stream?.cancel().catch(() => {});
|
||
await t.evaluate('__gm.switchAgent?.(false)').catch(() => {});
|
||
await sweep();
|
||
if (site !== baseUrl) await navigate();
|
||
}
|
||
}
|