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

+143
View File
@@ -0,0 +1,143 @@
<?php
declare(strict_types=1);
// A command-line script: under a web server it does nothing (#427).
if (PHP_SAPI !== 'cli') {
exit(1);
}
/**
* Agent tokens and tickets of the MCP relay (#429), on a scratch directory with a clock of its own.
* Plain PHP like limits.test.php; CI runs it with `php tests/php/agents.test.php`.
*/
require __DIR__ . '/../../api/lib/AtomicFile.php';
require __DIR__ . '/../../api/lib/AgentTokens.php';
$failed = 0;
$passed = 0;
function check(string $name, bool $ok, mixed $got = null): void
{
global $failed, $passed;
if ($ok) {
$passed++;
echo "PASS {$name}\n";
return;
}
$failed++;
echo "FAIL {$name}", $got === null ? '' : ' — got ' . json_encode($got), "\n";
}
$dir = sys_get_temp_dir() . '/feuerwerk-php-agents-' . bin2hex(random_bytes(4));
mkdir($dir);
register_shutdown_function(static fn() => exec('rm -rf ' . escapeshellarg($dir)));
$now = 1_000_000;
$tokens = new AgentTokens("{$dir}/agent-tokens", "{$dir}/agent-tickets", static function () use (&$now): int {
return $now;
});
// --- Tokens ----------------------------------------------------------------------------------------
$made = $tokens->create('alice', ' Claude Code ', 30);
check(
'a token is made with its name trimmed, an expiry in days, and the token itself in the answer',
preg_match('/^fwa_[0-9a-f]{64}$/', $made['token']) === 1
&& $made['name'] === 'Claude Code'
&& $made['expiresAt'] === $now + 30 * 86400
&& $made['usedAt'] === null,
$made,
);
$files = glob("{$dir}/agent-tokens/*.json");
$stored = implode('', array_map('file_get_contents', $files));
check(
'on disk it is one file under the hash of the token, 0640, and the token is nowhere in it',
count($files) === 1
&& basename($files[0]) === hash('sha256', $made['token']) . '.json'
&& (fileperms($files[0]) & 0777) === 0640
&& !str_contains($stored, substr($made['token'], 4)),
[$files, $stored],
);
$listed = $tokens->listOf('Alice');
check(
'its owner lists it, under any spelling of the name, without the token',
count($listed) === 1 && $listed[0]['id'] === $made['id'] && !isset($listed[0]['token']) && !isset($listed[0]['username']),
$listed,
);
check('another account lists nothing', $tokens->listOf('bob') === []);
check('the token names its account', $tokens->verify($made['token']) === 'alice');
check('and its use is written down', $tokens->listOf('alice')[0]['usedAt'] === $now);
$now += 30;
$tokens->verify($made['token']);
check('but not again within the minute', $tokens->listOf('alice')[0]['usedAt'] === $now - 30);
$now += 60;
$tokens->verify($made['token']);
check('after it, again', $tokens->listOf('alice')[0]['usedAt'] === $now);
$wrong = 'fwa_' . str_repeat('0', 64);
check('an unknown token names nobody', $tokens->verify($wrong) === null);
check('nor does one of the wrong shape', $tokens->verify(substr($made['token'], 4)) === null && $tokens->verify($made['token'] . '0') === null);
check('nor a path', $tokens->verify('fwa_../../users') === null);
check('another account cannot revoke it', $tokens->revoke('bob', $made['id']) === false && $tokens->verify($made['token']) === 'alice');
check('its owner can', $tokens->revoke('alice', $made['id']) === true);
check('and then it names nobody', $tokens->verify($made['token']) === null && $tokens->listOf('alice') === []);
check('revoking it twice finds nothing', $tokens->revoke('alice', $made['id']) === false);
$short = $tokens->create('alice', 'one day', 1);
$now += 86400 - 1;
check('a token works until the second before it expires', $tokens->verify($short['token']) === 'alice');
$now += 1;
check('then it names nobody', $tokens->verify($short['token']) === null);
check('and is gone from the list and the disk', $tokens->listOf('alice') === [] && glob("{$dir}/agent-tokens/*.json") === []);
$long = $tokens->create('alice', 'long', 10_000);
check('a token lasts at most a year', $long['expiresAt'] === $now + AgentTokens::MAX_DAYS * 86400, $long);
$tokens->create('bob', 'bob', 5);
check('ending an account’s tokens ends its own alone', $tokens->endAllOf('ALICE') === 1 && count($tokens->listOf('bob')) === 1);
for ($i = 0; $i < AgentTokens::MAX_PER_ACCOUNT; $i++) {
$tokens->create('carol', "t{$i}", 5);
}
$refused = null;
try {
$tokens->create('carol', 'one too many', 5);
} catch (TooManyAgentTokensException $e) {
$refused = $e->getMessage();
}
check('an account has at most ' . AgentTokens::MAX_PER_ACCOUNT . ' tokens', $refused !== null && count($tokens->listOf('carol')) === AgentTokens::MAX_PER_ACCOUNT, $refused);
check('a name is 1–60 characters', AgentTokens::isValidName('x') && !AgentTokens::isValidName(' ') && !AgentTokens::isValidName(str_repeat('x', 61)));
// --- Tickets ---------------------------------------------------------------------------------------
$ticket = $tokens->issueTicket('alice');
$ticketFiles = glob("{$dir}/agent-tickets/*.json");
check(
'a ticket is kept under its hash, 0640',
preg_match('/^[0-9a-f]{64}$/', $ticket) === 1
&& count($ticketFiles) === 1
&& basename($ticketFiles[0]) === hash('sha256', $ticket) . '.json'
&& (fileperms($ticketFiles[0]) & 0777) === 0640,
$ticketFiles,
);
check('redeemed, it names its account', $tokens->redeemTicket($ticket) === 'alice');
check('a second time, nobody', $tokens->redeemTicket($ticket) === null);
check('and nothing of it is left', glob("{$dir}/agent-tickets/*") === []);
$late = $tokens->issueTicket('alice');
$now += AgentTokens::TICKET_SECONDS + 1;
check('a ticket older than a minute names nobody', $tokens->redeemTicket($late) === null);
check('an agent token is no ticket', $tokens->redeemTicket($long['token']) === null);
$stale = $tokens->issueTicket('bob');
$now += AgentTokens::TICKET_SECONDS + 1;
touch("{$dir}/agent-tickets/" . hash('sha256', $stale) . '.json', $now - AgentTokens::TICKET_SECONDS - 1);
$tokens->issueTicket('bob');
check('issuing a ticket sweeps the ones nobody redeemed', count(glob("{$dir}/agent-tickets/*.json")) === 1);
echo $failed === 0 ? "ALL PASSED – {$passed} checks\n" : "{$failed} FAILED, {$passed} passed\n";
exit($failed === 0 ? 0 : 1);
+65 -1
View File
@@ -157,6 +157,8 @@ check(
$saved['status'] === 200 && $texture !== '' && $sound !== '' && isset($snapshot['id'], $release['token'], $demo['token']) && $offered === 204,
[$saved['status'], $texture, $sound, $snapshot, $release, $demo, $offered],
);
// An agent token of alice's (#429), which only the relay's routes take.
$agent = call('POST', 'agent-tokens', $alice, ['name' => 'Claude Code', 'days' => 7])['body'];
$folder = explode('/', $texture)[0];
$p = $project['id'];
$s = $snapshot['id'];
@@ -219,6 +221,14 @@ $matrix = [
['POST', 'pixels', 'not a cel', 'POST pixels', [401, 422, 422, 422]],
['GET', 'plugins', null, 'GET plugins', [200, 200, 200, 200]],
['GET', 'plugins/nothing.js', null, 'GET plugins/{}', [404, 404, 404, 404]],
['GET', 'agent-tokens', null, 'GET agent-tokens', [401, 200, 200, 200]],
['POST', 'agent-tokens', ['name' => ''], 'POST agent-tokens', [401, 422, 422, 422]],
['DELETE', "agent-tokens/{$agent['id']}", null, 'DELETE agent-tokens/{}', [401, null, 404, 404]],
['POST', 'agent/ticket', null, 'POST agent/ticket', [401, 200, 200, 200]],
// Without the relay's key they are not there, for anybody.
['POST', 'agent/verify', ['token' => $agent['token'], 'address' => '192.0.2.1'], 'POST agent/verify', [404, 404, 404, 404]],
['POST', 'agent/redeem', ['ticket' => str_repeat('0', 64)], 'POST agent/redeem', [404, 404, 404, 404]],
];
$actors = ['signed out' => null, 'alice' => $alice, 'bob' => $bob, 'an administrator' => $admin];
@@ -285,6 +295,60 @@ check('a demo copy goes out under its token, not the project id', ($opened['proj
check('an unknown demo is not found', call('GET', 'demos/' . str_repeat('0', 32))['status'] === 404);
check('a demo cannot be changed through its open route', call('POST', "demos/{$demo['token']}", $admin)['status'] === 404);
// --- The MCP relay's routes (#429) ----------------------------------------------------------------
$relayKey = bin2hex(random_bytes(32));
file_put_contents("{$data}/mcp-relay.key", $relayKey . "\n");
$relay = ['X-Relay-Key' => $relayKey];
$verify = static fn(string $token, string $address, array $headers): array =>
call('POST', 'agent/verify', null, ['token' => $token, 'address' => $address], $headers);
$known = $verify($agent['token'], '192.0.2.1', $relay);
check('with the key, the relay learns whose token it is', $known['status'] === 200 && $known['body'] === ['user' => 'alice', 'role' => 'user'], $known);
check('with a wrong key the route is not there', $verify($agent['token'], '192.0.2.1', ['X-Relay-Key' => str_repeat('a', 64)])['status'] === 404);
check('a wrong token is 401', $verify('fwa_' . str_repeat('0', 64), '192.0.2.1', $relay)['status'] === 401);
check(
'an agent token opens no route of the API',
call('GET', 'projects', null, null, ['Authorization' => "Bearer {$agent['token']}"])['status'] === 401,
);
$ticket = call('POST', 'agent/ticket', $alice)['body'];
check('a tab gets a ticket for a minute', preg_match('/^[0-9a-f]{64}$/', (string) ($ticket['ticket'] ?? '')) === 1 && $ticket['expiresIn'] === 60, $ticket);
check('asked without the key, it is not taken', call('POST', 'agent/redeem', null, ['ticket' => $ticket['ticket']])['status'] === 404);
$redeemed = call('POST', 'agent/redeem', null, ['ticket' => $ticket['ticket']], $relay);
check('the relay redeems it for its account', $redeemed['status'] === 200 && $redeemed['body']['user'] === 'alice', $redeemed);
check('once', call('POST', 'agent/redeem', null, ['ticket' => $ticket['ticket']], $relay)['status'] === 401);
$guesses = [];
for ($i = 0; $i < 5; $i++) {
$guesses[] = $verify('fwa_' . str_repeat((string) $i, 64), '198.51.100.9', $relay)['status'];
}
$braked = $verify($agent['token'], '198.51.100.9', $relay);
check(
'five wrong tokens from one address, and even the right one waits there, with Retry-After',
$guesses === [401, 401, 401, 401, 401] && $braked['status'] === 429 && preg_grep('/^Retry-After: \d+$/i', $braked['headers']) !== [],
[$guesses, $braked['status']],
);
check('another address is not braked', $verify($agent['token'], '192.0.2.1', $relay)['status'] === 200);
$bobsToken = call('POST', 'agent-tokens', $bob, ['name' => 'bob'])['body'];
check('another account cannot revoke alice’s token', call('DELETE', "agent-tokens/{$agent['id']}", $bob)['status'] === 404);
check(
'an account lists its own tokens, never a token itself',
array_column(call('GET', 'agent-tokens', $bob)['body'], 'id') === [$bobsToken['id']]
&& !array_key_exists('token', call('GET', 'agent-tokens', $bob)['body'][0]),
);
check('alice revokes hers', call('DELETE', "agent-tokens/{$agent['id']}", $alice)['status'] === 204);
check('and the relay no longer takes it', $verify($agent['token'], '192.0.2.1', $relay)['status'] === 401);
check('a token lasts at most a year', call('POST', 'agent-tokens', $alice, ['name' => 'x', 'days' => 366])['status'] === 422);
$second = call('POST', 'agent-tokens', $alice, ['name' => 'again'])['body'];
call('POST', 'auth/password', $alice, ['currentPassword' => 'password-alice', 'newPassword' => 'password-alice-2']);
check('a new password ends the account’s tokens', $verify($second['token'], '192.0.2.1', $relay)['status'] === 401);
call('PATCH', 'users/bob', $admin, ['password' => 'password-bob-2']);
check('so does one an administrator sets', $verify($bobsToken['token'], '192.0.2.1', $relay)['status'] === 401);
$bob = signIn('bob', 'password-bob-2');
// --- Paths, depth and unknown routes --------------------------------------------------------------
check('an unknown route is 401 signed out', call('GET', 'nothing')['status'] === 401);
@@ -339,7 +403,7 @@ for ($i = 0; $i < 5; $i++) {
$wrong[] = call('POST', 'auth/login', null, ['username' => 'bob', 'password' => 'guess'])['status'];
}
check('five wrong passwords are five 401', $wrong === [401, 401, 401, 401, 401], $wrong);
$locked = call('POST', 'auth/login', null, ['username' => 'bob', 'password' => 'password-bob']);
$locked = call('POST', 'auth/login', null, ['username' => 'bob', 'password' => 'password-bob-2']);
$retry = preg_grep('/^Retry-After: \d+$/i', $locked['headers']);
check('then even the right one waits, with 429 and Retry-After', $locked['status'] === 429 && $retry !== [], $locked);
check('the session of somebody already signed in goes on', call('GET', 'projects', $alice)['status'] === 200);