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>
133 lines
5.2 KiB
TypeScript
133 lines
5.2 KiB
TypeScript
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;
|
||
}
|
||
}
|