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