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 -5
View File
@@ -15,7 +15,14 @@ declare(strict_types=1);
* other logins, keeps the one asking)
*
* Five wrong passwords from one address within 15 minutes, and it gets 429 with `Retry-After` for
* the next 15 (see LoginThrottle, #427).
* the next 15 (see LoginThrottle, #427). Wrong agent tokens count alike.
*
* The MCP relay (#429, tools/mcp/) asks two routes of its own, with the key both sides read from
* `data/mcp-relay.key` in `X-Relay-Key`; without it they answer 404 like a route that is not there:
*
* POST ?r=agent/verify {token,address} → {user, role} | 401 | 429
* (`address` is the agent's, which the relay saw; the brake counts it)
* POST ?r=agent/redeem {ticket} → {user, role} | 401
*
* A request that changes something (anything but GET) is refused with 403 when the browser says it
* comes from another site, `Sec-Fetch-Site` being `cross-site` or `same-site`: the session cookie is
@@ -39,6 +46,15 @@ declare(strict_types=1);
* (renaming or deleting signs the account out everywhere; a new
* password ends its other sessions)
*
* Agent tokens (#429) open the relay for an agent of the account and nothing else; a ticket gets a
* tab onto it (see AgentTokens). Neither is for a guest:
*
* GET ?r=agent-tokens → [{id,name,createdAt,expiresAt,usedAt}], oldest first
* POST ?r=agent-tokens {name,days?} → 201 {id,name,createdAt,expiresAt,usedAt,token} | 409 | 422
* (the one answer the token is in; `days` 1–365, 90 without)
* DELETE ?r=agent-tokens/{id} → 204 | 404
* POST ?r=agent/ticket → {ticket, expiresIn}
*
* GET ?r=projects → [{id,name,revision,updatedAt}]
* POST ?r=projects {name} → {id,name,revision,updatedAt,project}
* GET ?r=projects/{id} → {id,name,revision,updatedAt,project}
@@ -124,6 +140,7 @@ declare(strict_types=1);
*/
require __DIR__ . '/lib/AccountRules.php';
require __DIR__ . '/lib/AgentTokens.php';
require __DIR__ . '/lib/AtomicFile.php';
require __DIR__ . '/lib/Auth.php';
require __DIR__ . '/lib/DemoFiles.php';
@@ -239,6 +256,7 @@ function routeAuth(array $parts, string $method, Auth $auth): never
countFailure($throttle);
Response::error('Wrong password', 401);
}
agentTokens()->endAllOf($user);
Response::noContent();
}
@@ -259,18 +277,18 @@ function clientAddress(): string
return (string) ($_SERVER['REMOTE_ADDR'] ?? '');
}
function countFailure(LoginThrottle $throttle): void
function countFailure(LoginThrottle $throttle, ?string $address = null): void
{
$throttle->fail(clientAddress());
$throttle->fail($address ?? clientAddress());
// Now and then, so the folder holds the addresses of the last quarter of an hour and no more.
if (random_int(1, 20) === 1) {
$throttle->prune();
}
}
function refuseWhileThrottled(LoginThrottle $throttle): void
function refuseWhileThrottled(LoginThrottle $throttle, ?string $address = null): void
{
$wait = $throttle->waitFor(clientAddress());
$wait = $throttle->waitFor($address ?? clientAddress());
if ($wait === 0) {
return;
}
@@ -521,6 +539,107 @@ function snapshotName(array $body): string
return $name;
}
function agentTokens(): AgentTokens
{
return new AgentTokens(DATA_DIR . '/agent-tokens', DATA_DIR . '/agent-tickets');
}
/**
* Whether the request is the MCP relay's (#429): it carries the key that lies in `data/` for both
* sides. Nobody else can read that folder, nginx serves none of it.
*/
function fromRelay(): bool
{
$path = DATA_DIR . '/mcp-relay.key';
$key = is_file($path) ? trim((string) file_get_contents($path)) : '';
$given = (string) ($_SERVER['HTTP_X_RELAY_KEY'] ?? '');
return strlen($key) >= 32 && hash_equals($key, $given);
}
/**
* The relay asks who is behind an agent's token or a tab's ticket. A wrong token is a wrong password
* to the brake, counted against the agent's address, not the relay's.
*
* @param list<string> $parts
*/
function routeRelay(array $parts, string $method, Auth $auth): never
{
if (!fromRelay() || count($parts) !== 2) {
Response::error('Not found', 404);
}
if ($method !== 'POST') {
Response::error('Method not allowed', 405);
}
$body = Response::body();
$tokens = agentTokens();
if ($parts[1] === 'verify') {
$throttle = new LoginThrottle(DATA_DIR . '/sign-ins');
$address = (string) ($body['address'] ?? '');
refuseWhileThrottled($throttle, $address);
$user = $tokens->verify((string) ($body['token'] ?? ''));
if ($user === null) {
countFailure($throttle, $address);
Response::error('Unknown or expired agent token', 401);
}
Response::json(['user' => $user, 'role' => $auth->role($user)]);
}
if ($parts[1] === 'redeem') {
$user = $tokens->redeemTicket((string) ($body['ticket'] ?? ''));
if ($user === null) {
Response::error('Unknown or used ticket', 401);
}
Response::json(['user' => $user, 'role' => $auth->role($user)]);
}
Response::error('Not found', 404);
}
/** @param list<string> $parts */
function routeAgentTokens(array $parts, string $method, string $user): never
{
$tokens = agentTokens();
if (count($parts) === 1) {
if ($method === 'GET') {
Response::json($tokens->listOf($user));
}
if ($method === 'POST') {
$body = Response::body();
$name = (string) ($body['name'] ?? '');
$days = $body['days'] ?? AgentTokens::DEFAULT_DAYS;
if (!AgentTokens::isValidName($name)) {
Response::error('A token name is 1–60 characters', 422);
}
if (!is_int($days) || $days < 1 || $days > AgentTokens::MAX_DAYS) {
Response::error('A token lasts 1–' . AgentTokens::MAX_DAYS . ' days', 422);
}
try {
Response::json($tokens->create($user, $name, $days), 201);
} catch (TooManyAgentTokensException $e) {
Response::error($e->getMessage(), 409);
}
}
Response::error('Method not allowed', 405);
}
if ($method !== 'DELETE') {
Response::error('Method not allowed', 405);
}
if (!$tokens->revoke($user, $parts[1])) {
Response::error('Agent token not found', 404);
}
Response::noContent();
}
/** @param list<string> $parts */
function routeAgent(array $parts, string $method, string $user): never
{
if (($parts[1] ?? '') !== 'ticket' || count($parts) !== 2) {
Response::error('Not found', 404);
}
if ($method !== 'POST') {
Response::error('Method not allowed', 405);
}
Response::json(['ticket' => agentTokens()->issueTicket($user), 'expiresIn' => AgentTokens::TICKET_SECONDS]);
}
/** @param list<string> $parts */
function routeUsers(array $parts, string $method, Auth $auth, UserStore $users): never
{
@@ -604,6 +723,7 @@ function routeUsers(array $parts, string $method, Auth $auth, UserStore $users):
if ($password !== null) {
$auth->resetPassword($target, $password);
agentTokens()->endAllOf($target);
}
if ($newRole !== null && $newRole !== $role) {
$users->setRole($target, $newRole);
@@ -613,6 +733,7 @@ function routeUsers(array $parts, string $method, Auth $auth, UserStore $users):
$users->rename($name, $newName);
$projects->transfer($name, $newName);
$auth->endSessionsOf($name);
agentTokens()->endAllOf($name);
$name = $newName;
}
Response::json($shown($name));
@@ -629,6 +750,7 @@ function routeUsers(array $parts, string $method, Auth $auth, UserStore $users):
}
$users->remove($target);
$auth->endSessionsOf($target);
agentTokens()->endAllOf($target);
Response::noContent();
}
@@ -872,6 +994,9 @@ try {
if ($parts[0] === 'demos') {
routeCopies($parts, $method, new ReleaseStore(DATA_DIR . '/demos'), 'Demo not found', true);
}
if ($parts[0] === 'agent' && in_array($parts[1] ?? '', ['verify', 'redeem'], true)) {
routeRelay($parts, $method, $auth);
}
// A guest's editor runs the plugins a demo switches on (#428); they are the server's own code,
// put there by its administrator, and only to be read.
if ($parts[0] === 'plugins' && $method === 'GET') {
@@ -947,6 +1072,8 @@ try {
),
'pixels' => routePixels($parts, $method),
'plugins' => routePlugins($parts, $method),
'agent-tokens' => routeAgentTokens($parts, $method, $user),
'agent' => routeAgent($parts, $method, $user),
default => Response::error('Not found', 404),
};
} catch (Throwable $e) {