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

+132
View File
@@ -0,0 +1,132 @@
import type { AgentToken, MadeAgentToken } from '../../core/store/AgentStore';
import { Button, HStack, Label, Popup, Spacer, TextField, Theme, ui } from '../../ui';
const WIDTH = 520;
const LIFETIMES = [
{ days: 30, label: '30 days' },
{ days: 90, label: '90 days' },
{ days: 365, label: '1 year' },
] as const;
/** `2026-10-17`: the day is enough for a token that lasts months. */
function day(seconds: number): string {
return new Date(seconds * 1000).toISOString().slice(0, 10);
}
/** What one token is: when it was made, when it ends and when an agent last used it. */
export function tokenLine(token: AgentToken): string {
const used = token.usedAt === null ? 'never used' : `last used ${day(token.usedAt)}`;
return `made ${day(token.createdAt)} · expires ${day(token.expiresAt)} · ${used}`;
}
/** The line that sets the editor up in Claude Code, with the token in it. */
export function claudeCommand(endpoint: string, token: string): string {
return `claude mcp add --transport http feuerwerk ${endpoint} --header "Authorization: Bearer ${token}"`;
}
/**
* "Agent tokens…" in the account menu (#429): the account's tokens with Revoke, and a new one made
* from a name and a lifetime. A token made is shown here once, with the line that sets it up in
* Claude Code; the server keeps only its hash. Every action closes the dialog and hands the choice
* on, and the caller opens it again on the new state, like the Release dialog.
*/
export class AgentTokensDialog extends Popup {
private readonly nameField: TextField;
private days: number = LIFETIMES[1].days;
private readonly made: MadeAgentToken | null;
private readonly command: string | null;
constructor(
private readonly tokens: readonly AgentToken[],
made: MadeAgentToken | null,
endpoint: string,
actions: {
onCreate(name: string, days: number): void;
onRevoke(token: AgentToken): void;
onCopy(text: string, what: string): void;
},
) {
super({ modal: true, padding: Theme.padding * 2, gap: Theme.gap });
this.minWidth = WIDTH;
this.made = made;
this.command = made && claudeCommand(endpoint, made.token);
this.content.add(new Label('Agent tokens', { style: 'bodyBold' }));
this.content.add(
new Label(
'A token lets an agent such as Claude Code work in this editor while "Agent" is switched on in ' +
'the account menu. It opens nothing else.',
{ style: 'muted', wrap: true },
),
);
if (made && this.command) {
this.content.add(
new Label(`"${made.name}" – copy it now, it is not shown again:`, { style: 'warning' }),
);
this.content.add(this.copyRow(made.token, 'Copy token', () => actions.onCopy(made.token, 'Token')));
this.content.add(new Label('Set it up in Claude Code:', { style: 'muted' }));
const command = this.command;
this.content.add(this.copyRow(command, 'Copy command', () => actions.onCopy(command, 'Command')));
}
if (tokens.length === 0) this.content.add(new Label('No tokens yet.', { style: 'muted' }));
for (const token of tokens) {
const row = new HStack({ gap: Theme.gap, align: 'center' });
row.minHeight = Theme.rowHeight;
row.add(new Label(token.name));
row.add(new Label(tokenLine(token), { style: 'small' }), 1);
row.add(
new Button('Revoke', { variant: 'danger', onClick: this.closeThen(() => actions.onRevoke(token)) }),
);
this.content.add(row);
}
const create = new HStack({ gap: Theme.gap, align: 'center' });
this.nameField = new TextField('', { placeholder: 'Name, e.g. Claude Code', width: 200 });
create.add(this.nameField, 1);
const lifetimes = LIFETIMES.map(({ days, label }) => {
const button = new Button(label, { toggle: true });
button.setToggled(days === this.days);
return { days, button };
});
for (const { days, button } of lifetimes) {
button.onClick = () => {
this.days = days;
for (const other of lifetimes) other.button.setToggled(other.days === days);
};
create.add(button);
}
const make = () => {
const name = this.nameField.value.trim();
if (name !== '') this.closeThen(() => actions.onCreate(name, this.days))();
};
this.nameField.onSubmit = make;
create.add(new Button('New token', { variant: 'primary', onClick: make }));
this.content.add(create);
const end = new HStack({ gap: Theme.gap, align: 'center' });
end.add(new Spacer(), 1);
end.add(new Button('Close', { onClick: this.closeThen() }));
this.content.add(end);
}
focusName(): void {
ui().focus.focus(this.nameField);
}
/** What the dialog shows, for the check scripts. */
info(): { tokens: string[]; made: string | null; command: string | null } {
return {
tokens: this.tokens.map((token) => token.name),
made: this.made?.token ?? null,
command: this.command,
};
}
private copyRow(text: string, caption: string, onCopy: () => void): HStack {
const row = new HStack({ gap: Theme.gap, align: 'center' });
row.add(new TextField(text, { readOnly: true, width: WIDTH - 130 }), 1);
row.add(new Button(caption, { onClick: onCopy }));
return row;
}
}