Let an AI agent see the open editor through MCP (#429)
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:
1 parent
edef2b6323
commit
48ec722660
32 files changed
+3324
-12
No files matched your search
+132
-5
@@ -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) {
|
||||
|
||||
@@ -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 {}
|
||||
Reference in new issue
Block a user