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);