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
@@ -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);
|
||||
Reference in new issue
Block a user