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

+238
View File
@@ -0,0 +1,238 @@
<?php
declare(strict_types=1);
/**
* The keys of the MCP relay (#429): agent tokens, which an account makes for an agent such as
* Claude Code, and tickets, with which an editor tab gets onto the relay.
*
* A token is shown once, when it is made, and kept as `{dir}/{sha256(token)}.json` like a session:
* the file on disk cannot be replayed as the token. It opens the relay and nothing else, the API
* takes no token. It ends at its expiry, at its owner's word, and with every login of the account
* when the password changes or the account is renamed or deleted.
*
* A ticket is what a tab gets from the API with its session cookie, and hands to the relay on its
* WebSocket: the cookie belongs to the API's folder and never reaches the relay. It lasts a minute
* and is taken once.
*/
final class AgentTokens
{
public const PREFIX = 'fwa_';
public const DEFAULT_DAYS = 90;
public const MAX_DAYS = 365;
public const MAX_PER_ACCOUNT = 20;
public const TICKET_SECONDS = 60;
/** How often a use is written down: every request of an agent would cost a write otherwise. */
private const USE_RESOLUTION_SECONDS = 60;
/** @param (Closure():int)|null $clock the tests' way to move time on */
public function __construct(
private readonly string $dir,
private readonly string $ticketDir,
private readonly ?Closure $clock = null,
) {}
public static function isValidName(string $name): bool
{
return trim($name) !== '' && mb_strlen($name) <= 60;
}
/**
* Makes a token for `$user`. The answer is the only place the token itself ever appears.
*
* @return array{id:string,name:string,createdAt:int,expiresAt:int,usedAt:null,token:string}
*/
public function create(string $user, string $name, int $days): array
{
if (count($this->listOf($user)) >= self::MAX_PER_ACCOUNT) {
throw new TooManyAgentTokensException('An account has at most ' . self::MAX_PER_ACCOUNT . ' agent tokens');
}
$token = self::PREFIX . bin2hex(random_bytes(32));
$now = $this->now();
$record = [
'id' => bin2hex(random_bytes(8)),
'username' => $user,
'name' => trim($name),
'createdAt' => $now,
'expiresAt' => $now + max(1, min(self::MAX_DAYS, $days)) * 86400,
'usedAt' => null,
];
$this->write($this->dir . '/' . hash('sha256', $token) . '.json', $record);
return self::shown($record) + ['token' => $token];
}
/**
* The tokens of `$user` that have not expired, oldest first, without the tokens themselves.
*
* @return list<array{id:string,name:string,createdAt:int,expiresAt:int,usedAt:?int}>
*/
public function listOf(string $user): array
{
$found = [];
foreach ($this->records() as $record) {
if (strcasecmp($record['username'], $user) === 0) {
$found[] = self::shown($record);
}
}
usort($found, static fn(array $a, array $b): int => $a['createdAt'] <=> $b['createdAt']);
return $found;
}
/** Ends one token of `$user`; false when they have none by that id. */
public function revoke(string $user, string $id): bool
{
foreach ($this->records() as $path => $record) {
if ($record['id'] === $id && strcasecmp($record['username'], $user) === 0) {
return @unlink($path);
}
}
return false;
}
/** Ends every token of `$user`, and answers how many there were. */
public function endAllOf(string $user): int
{
$ended = 0;
foreach ($this->records() as $path => $record) {
if (strcasecmp($record['username'], $user) === 0 && @unlink($path)) {
$ended++;
}
}
return $ended;
}
/** The account a token belongs to, or null when it is unknown, malformed or expired. */
public function verify(string $token): ?string
{
if (!preg_match('/^' . self::PREFIX . '[0-9a-f]{64}$/', $token)) {
return null;
}
$path = $this->dir . '/' . hash('sha256', $token) . '.json';
$record = self::read($path);
if ($record === null) {
return null;
}
$now = $this->now();
if ($record['expiresAt'] <= $now) {
@unlink($path);
return null;
}
if ($record['usedAt'] === null || $now - $record['usedAt'] >= self::USE_RESOLUTION_SECONDS) {
$record['usedAt'] = $now;
$this->write($path, $record);
}
return $record['username'];
}
/** A ticket for `$user`'s tab to get onto the relay with. */
public function issueTicket(string $user): string
{
$this->sweepTickets();
$ticket = bin2hex(random_bytes(32));
$this->write($this->ticketDir . '/' . hash('sha256', $ticket) . '.json', [
'username' => $user,
'createdAt' => $this->now(),
]);
return $ticket;
}
/** The account a ticket was issued to, once: a second redemption finds nothing. */
public function redeemTicket(string $ticket): ?string
{
if (!preg_match('/^[0-9a-f]{64}$/', $ticket)) {
return null;
}
$path = $this->ticketDir . '/' . hash('sha256', $ticket) . '.json';
// Moved aside before it is read: of two redemptions at once, one gets the file.
$taken = $path . '.taken-' . bin2hex(random_bytes(4));
if (!@rename($path, $taken)) {
return null;
}
$raw = file_get_contents($taken);
@unlink($taken);
$data = $raw === false ? null : json_decode($raw, true);
if (!is_array($data) || !is_string($data['username'] ?? null) || !is_int($data['createdAt'] ?? null)) {
return null;
}
return $this->now() - $data['createdAt'] <= self::TICKET_SECONDS ? $data['username'] : null;
}
/** Throws out the tickets nobody redeemed. */
private function sweepTickets(): void
{
foreach (glob($this->ticketDir . '/*.json*') ?: [] as $path) {
if ($this->now() - (int) @filemtime($path) > self::TICKET_SECONDS) {
@unlink($path);
}
}
}
/**
* Every stored token by its path; expired ones are dropped on the way.
*
* @return array<string,array{id:string,username:string,name:string,createdAt:int,expiresAt:int,usedAt:?int}>
*/
private function records(): array
{
$records = [];
foreach (glob($this->dir . '/*.json') ?: [] as $path) {
$record = self::read($path);
if ($record === null) {
continue;
}
if ($record['expiresAt'] <= $this->now()) {
@unlink($path);
continue;
}
$records[$path] = $record;
}
return $records;
}
/** @return array{id:string,username:string,name:string,createdAt:int,expiresAt:int,usedAt:?int}|null */
private static function read(string $path): ?array
{
$raw = is_file($path) ? file_get_contents($path) : false;
$data = $raw === false ? null : json_decode($raw, true);
if (
!is_array($data)
|| !is_string($data['id'] ?? null)
|| !is_string($data['username'] ?? null)
|| !is_string($data['name'] ?? null)
|| !is_int($data['createdAt'] ?? null)
|| !is_int($data['expiresAt'] ?? null)
|| !(is_int($data['usedAt'] ?? null) || ($data['usedAt'] ?? null) === null)
) {
return null;
}
return $data;
}
/** @return array{id:string,name:string,createdAt:int,expiresAt:int,usedAt:?int} */
private static function shown(array $record): array
{
return [
'id' => $record['id'],
'name' => $record['name'],
'createdAt' => $record['createdAt'],
'expiresAt' => $record['expiresAt'],
'usedAt' => $record['usedAt'],
];
}
private function write(string $path, array $data): void
{
$dir = dirname($path);
if (!is_dir($dir) && !mkdir($dir, 0775, true) && !is_dir($dir)) {
throw new RuntimeException("Cannot create {$dir}");
}
AtomicFile::write($path, (string) json_encode($data, JSON_UNESCAPED_SLASHES), AtomicFile::HIDDEN, 'agent token');
}
private function now(): int
{
return $this->clock === null ? time() : ($this->clock)();
}
}
final class TooManyAgentTokensException extends RuntimeException {}