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
+18
-1
@@ -1,5 +1,12 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { API_ENDPOINT, dataPath, moveOldStoredKeys, sitePath } from '../../src/core/site';
|
||||
import {
|
||||
API_ENDPOINT,
|
||||
dataPath,
|
||||
MCP_PATH,
|
||||
moveOldStoredKeys,
|
||||
sitePath,
|
||||
socketUrl,
|
||||
} from '../../src/core/site';
|
||||
|
||||
function memoryStore(initial: Record<string, string>) {
|
||||
const items = new Map(Object.entries(initial));
|
||||
@@ -21,6 +28,16 @@ describe('site', () => {
|
||||
expect(dataPath('textures')).toBe('/feuerwerk/data/textures');
|
||||
});
|
||||
|
||||
it('opens the MCP relay on the page’s own host, encrypted on an encrypted page (#429)', () => {
|
||||
expect(MCP_PATH).toBe('/feuerwerk/mcp');
|
||||
expect(socketUrl('/mcp', { protocol: 'https:', host: 'feuerwerk.nockal.com' })).toBe(
|
||||
'wss://feuerwerk.nockal.com/mcp',
|
||||
);
|
||||
expect(socketUrl('/feuerwerk/mcp', { protocol: 'http:', host: '127.0.0.1:8081' })).toBe(
|
||||
'ws://127.0.0.1:8081/feuerwerk/mcp',
|
||||
);
|
||||
});
|
||||
|
||||
it('takes the path from the folder of the open page', () => {
|
||||
expect(sitePath('/feuerwerk/')).toBe('/feuerwerk/');
|
||||
expect(sitePath('/feuerwerk/index.html')).toBe('/feuerwerk/');
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { CreateAssetCommand } from '../../src/core/commands/assetCommands';
|
||||
import { agentView, type ViewFacts, viewSummary } from '../../src/editor/agent/agentView';
|
||||
import { runTool } from '../../src/editor/agent/agentTools';
|
||||
import { claudeCommand, tokenLine } from '../../src/editor/agent/AgentTokensDialog';
|
||||
import TOOLS from '../../src/editor/agent/tools.json';
|
||||
import { graphEditor, makeBehavior, makeSpriteScene, place } from '../helpers';
|
||||
|
||||
/** What the agent learns of the editor (#429): `get_view`, its one line, and the tools around it. */
|
||||
|
||||
function facts(fixture: ReturnType<typeof makeSpriteScene>, extra: Partial<ViewFacts> = {}): ViewFacts {
|
||||
return {
|
||||
project: fixture.project,
|
||||
mode: 'edit',
|
||||
paused: false,
|
||||
saveStatus: 'saved',
|
||||
tab: { id: 'scene', label: 'Scene' },
|
||||
document: null,
|
||||
windows: [],
|
||||
selection: null,
|
||||
popups: [],
|
||||
...extra,
|
||||
};
|
||||
}
|
||||
|
||||
describe('get_view', () => {
|
||||
it('names the selected objects, with the asset’s name where an object has none', () => {
|
||||
const fixture = makeSpriteScene('Crate');
|
||||
const a = place(fixture, fixture.spriteId, { x: 0, y: 0 });
|
||||
const b = place(fixture, fixture.spriteId, { x: 10, y: 0 });
|
||||
fixture.object(b).name = 'Lid';
|
||||
const view = agentView(
|
||||
facts(fixture, {
|
||||
selection: { kind: 'object', holderId: fixture.sceneId, objectId: b, objectIds: [a, b] },
|
||||
}),
|
||||
);
|
||||
expect(view.selection).toEqual({
|
||||
kind: 'object',
|
||||
in: { id: fixture.sceneId, name: 'Level', type: 'scene' },
|
||||
primary: b,
|
||||
objects: [
|
||||
{ id: a, name: 'Crate', asset: fixture.spriteId },
|
||||
{ id: b, name: 'Lid', asset: fixture.spriteId },
|
||||
],
|
||||
});
|
||||
expect(viewSummary(view)).toBe(
|
||||
'project "P"; tab Scene; selection: 2 objects ("Crate", "Lid") in "Level"; editing',
|
||||
);
|
||||
});
|
||||
|
||||
it('names the selected nodes by type, and the document a tab shows', () => {
|
||||
const fixture = makeSpriteScene();
|
||||
const behavior = makeBehavior(fixture, 'Jump');
|
||||
const node = graphEditor(fixture, behavior).addNode('OnStart');
|
||||
const view = agentView(
|
||||
facts(fixture, {
|
||||
tab: { id: 'graph', label: 'Behavior' },
|
||||
document: `asset:${behavior.id}`,
|
||||
selection: {
|
||||
kind: 'node',
|
||||
assetId: behavior.id,
|
||||
nodeId: node.id,
|
||||
nodeIds: [node.id],
|
||||
annotationIds: [],
|
||||
edgeIds: [],
|
||||
},
|
||||
mode: 'play',
|
||||
paused: true,
|
||||
}),
|
||||
);
|
||||
expect(view.document).toEqual({
|
||||
id: `asset:${behavior.id}`,
|
||||
asset: { id: behavior.id, name: 'Jump', type: 'behavior' },
|
||||
});
|
||||
expect(view.selection).toMatchObject({
|
||||
kind: 'node',
|
||||
nodes: [{ id: node.id, type: 'OnStart' }],
|
||||
edges: [],
|
||||
});
|
||||
expect(view.play).toBe('paused');
|
||||
expect(viewSummary(view)).toBe(
|
||||
'project "P"; tab Behavior showing behavior "Jump"; selection: 1 node (OnStart) in "Jump"; paused',
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps the ids of the other kinds, says what an asset is, and the dialog on top', () => {
|
||||
const fixture = makeSpriteScene();
|
||||
const texture = new CreateAssetCommand('texture', 'Tiles');
|
||||
fixture.stack.execute(texture);
|
||||
const frame = agentView(
|
||||
facts(fixture, { selection: { kind: 'frame', assetId: texture.asset.id, frameId: 'f1' } }),
|
||||
);
|
||||
expect(frame.selection).toEqual({
|
||||
kind: 'frame',
|
||||
in: { id: texture.asset.id, name: 'Tiles', type: 'texture' },
|
||||
frameId: 'f1',
|
||||
});
|
||||
const asset = agentView(
|
||||
facts(fixture, {
|
||||
selection: { kind: 'asset', assetId: fixture.spriteId },
|
||||
popups: [
|
||||
{ kind: 'menu', title: 'New project' },
|
||||
{ kind: 'dialog', title: 'Agent tokens' },
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(viewSummary(asset)).toBe(
|
||||
'project "P"; tab Scene; selection: sprite "Box"; editing; dialog "Agent tokens" is open',
|
||||
);
|
||||
});
|
||||
|
||||
it('has a line that does not move with the camera', () => {
|
||||
const fixture = makeSpriteScene();
|
||||
const view = agentView(facts(fixture));
|
||||
expect(viewSummary({ ...view, camera: { x: 1, y: 2, zoom: 3 } })).toBe(viewSummary(view));
|
||||
});
|
||||
});
|
||||
|
||||
describe('the tools', () => {
|
||||
const host = {
|
||||
view: () => agentView(facts(makeSpriteScene())),
|
||||
screenshot: (maxWidth: number) =>
|
||||
Promise.resolve({ data: 'iVBOR', width: Math.min(maxWidth, 1600), height: 900 }),
|
||||
};
|
||||
|
||||
it('are the ones the relay lists, each with a schema', () => {
|
||||
expect(TOOLS.map((tool) => tool.name)).toEqual(['get_view', 'screenshot']);
|
||||
for (const tool of TOOLS) expect(tool.inputSchema.type).toBe('object');
|
||||
});
|
||||
|
||||
it('get_view answers the view as text and as structured content', async () => {
|
||||
const result = await runTool('get_view', {}, host);
|
||||
expect(result.structuredContent).toMatchObject({ tab: { id: 'scene' }, play: 'editing' });
|
||||
expect(result.content[0]).toEqual({ type: 'text', text: JSON.stringify(result.structuredContent) });
|
||||
});
|
||||
|
||||
it('screenshot answers a PNG at 1280 pixels unless told otherwise, and refuses a width out of range', async () => {
|
||||
const shot = await runTool('screenshot', {}, host);
|
||||
expect(shot.content[0]).toEqual({ type: 'image', data: 'iVBOR', mimeType: 'image/png' });
|
||||
expect(shot.content[1]).toEqual({
|
||||
type: 'text',
|
||||
text: 'The editor as the user sees it, 1280x900 pixels.',
|
||||
});
|
||||
expect((await runTool('screenshot', { maxWidth: 100 }, host)).isError).toBe(true);
|
||||
expect((await runTool('screenshot', { maxWidth: 'big' }, host)).isError).toBe(true);
|
||||
});
|
||||
|
||||
it('an unknown tool says to reload', async () => {
|
||||
const answer = (await runTool('run_command', {}, host)).content[0];
|
||||
expect(answer?.type === 'text' && answer.text).toMatch(/reload/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('agent tokens', () => {
|
||||
it('are listed by day made, day of expiry and last use', () => {
|
||||
const day = Date.UTC(2026, 9, 17) / 1000;
|
||||
expect(tokenLine({ id: 'a', name: 'x', createdAt: day, expiresAt: day + 90 * 86400, usedAt: null })).toBe(
|
||||
'made 2026-10-17 · expires 2027-01-15 · never used',
|
||||
);
|
||||
expect(
|
||||
tokenLine({ id: 'a', name: 'x', createdAt: day, expiresAt: day + 86400, usedAt: day + 3600 }),
|
||||
).toBe('made 2026-10-17 · expires 2026-10-18 · last used 2026-10-17');
|
||||
});
|
||||
|
||||
it('are set up in Claude Code with one line', () => {
|
||||
expect(claudeCommand('https://feuerwerk.nockal.com/mcp', 'fwa_00')).toBe(
|
||||
'claude mcp add --transport http feuerwerk https://feuerwerk.nockal.com/mcp --header "Authorization: Bearer fwa_00"',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,477 @@
|
||||
import { createServer, type Server } from 'node:http';
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
import { type ApiAnswer, CLOSE, type Relay, startRelay, VIEW_URI } from '../../tools/mcp/relay.mjs';
|
||||
|
||||
/**
|
||||
* The MCP relay (#429) between a fake API, agents speaking MCP over HTTP, and fake editor tabs on
|
||||
* WebSockets: no browser, no PHP.
|
||||
*/
|
||||
|
||||
const ORIGIN = 'https://editor.test';
|
||||
const TOOLS = [
|
||||
{ name: 'get_view', description: 'view', inputSchema: { type: 'object' } },
|
||||
{ name: 'screenshot', description: 'picture', inputSchema: { type: 'object' } },
|
||||
];
|
||||
const TOKENS: Record<string, string> = { fwa_alice: 'alice', fwa_bob: 'bob', fwa_alice2: 'Alice' };
|
||||
|
||||
let relay!: Relay;
|
||||
let running = false;
|
||||
let tickets: Map<string, string>;
|
||||
let addresses: string[];
|
||||
let throttled: boolean;
|
||||
const open: WebSocket[] = [];
|
||||
|
||||
async function start(extra: Partial<Parameters<typeof startRelay>[0]> = {}): Promise<void> {
|
||||
if (running) await relay.close();
|
||||
relay = await startRelay({
|
||||
api: {
|
||||
verifyToken: (token: string, address: string): Promise<ApiAnswer> => {
|
||||
addresses.push(address);
|
||||
if (throttled) return Promise.resolve({ status: 429, retryAfter: '600', error: 'Too many' });
|
||||
const user = TOKENS[token];
|
||||
return Promise.resolve(user ? { status: 200, user } : { status: 401 });
|
||||
},
|
||||
redeemTicket: (ticket: string): Promise<ApiAnswer> => {
|
||||
const user = tickets.get(ticket);
|
||||
tickets.delete(ticket);
|
||||
return Promise.resolve(user ? { status: 200, user } : { status: 401 });
|
||||
},
|
||||
},
|
||||
tools: () => Promise.resolve(TOOLS),
|
||||
origins: [ORIGIN],
|
||||
...extra,
|
||||
});
|
||||
running = true;
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
tickets = new Map();
|
||||
addresses = [];
|
||||
throttled = false;
|
||||
await start();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
for (const socket of open.splice(0)) socket.close();
|
||||
await relay.close();
|
||||
running = false;
|
||||
});
|
||||
|
||||
const url = () => `http://127.0.0.1:${relay.port}/mcp`;
|
||||
|
||||
interface Content {
|
||||
type: string;
|
||||
text?: string;
|
||||
data?: string;
|
||||
mimeType?: string;
|
||||
}
|
||||
interface ToolResult {
|
||||
content: Content[];
|
||||
isError?: boolean;
|
||||
structuredContent?: object;
|
||||
}
|
||||
interface Reply {
|
||||
result?: Record<string, unknown>;
|
||||
error?: { code: number; message: string };
|
||||
}
|
||||
/** What a fake tab hears from the relay. */
|
||||
interface TabMessage {
|
||||
type: string;
|
||||
user?: string;
|
||||
count?: number;
|
||||
id?: number;
|
||||
tool?: string;
|
||||
}
|
||||
|
||||
const toolResult = (body: Reply | null) => body?.result as unknown as ToolResult;
|
||||
const textOf = (body: Reply | null, at = 0) => toolResult(body).content[at]?.text;
|
||||
const viewText = (body: Reply | null) =>
|
||||
(body?.result as unknown as { contents: { text: string }[] }).contents[0]?.text ?? '';
|
||||
|
||||
async function rpc(
|
||||
token: string | null,
|
||||
method: string,
|
||||
params: object = {},
|
||||
session?: string,
|
||||
headers: Record<string, string> = {},
|
||||
) {
|
||||
const res = await fetch(url(), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json, text/event-stream',
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
...(session ? { 'Mcp-Session-Id': session } : {}),
|
||||
...headers,
|
||||
},
|
||||
body: JSON.stringify({ jsonrpc: '2.0', id: 1, method, params }),
|
||||
});
|
||||
const text = await res.text();
|
||||
return {
|
||||
status: res.status,
|
||||
headers: res.headers,
|
||||
body: (text ? JSON.parse(text) : null) as Reply | null,
|
||||
};
|
||||
}
|
||||
|
||||
async function initialize(token: string, protocolVersion = '2025-06-18'): Promise<string> {
|
||||
const answer = await rpc(token, 'initialize', {
|
||||
protocolVersion,
|
||||
capabilities: {},
|
||||
clientInfo: { name: 'test' },
|
||||
});
|
||||
expect(answer.status).toBe(200);
|
||||
return answer.headers.get('mcp-session-id') ?? '';
|
||||
}
|
||||
|
||||
interface FakeTab {
|
||||
socket: WebSocket;
|
||||
received: TabMessage[];
|
||||
closed: Promise<number>;
|
||||
/** Answers every call with what `answer` makes of it; null leaves it unanswered. */
|
||||
answer: (call: TabMessage) => object | null;
|
||||
next(type: string): Promise<TabMessage>;
|
||||
}
|
||||
|
||||
function connectTab(
|
||||
user: string,
|
||||
origin: string | null = ORIGIN,
|
||||
ticket = `ticket-${Math.random()}`,
|
||||
): FakeTab {
|
||||
tickets.set(ticket, user);
|
||||
const socket = new WebSocket(url().replace('http', 'ws'), {
|
||||
headers: origin ? { Origin: origin } : {},
|
||||
} as unknown as string[]);
|
||||
open.push(socket);
|
||||
const tab: FakeTab = {
|
||||
socket,
|
||||
received: [],
|
||||
// Node's client reports a refused handshake as an error alone, without the close a browser adds.
|
||||
closed: new Promise((resolve) => {
|
||||
socket.addEventListener('close', (event) => resolve(event.code));
|
||||
socket.addEventListener('error', () => resolve(1006));
|
||||
}),
|
||||
answer: (call) => ({ content: [{ type: 'text', text: `${user} did ${call.tool ?? ''}` }] }),
|
||||
next: (type) =>
|
||||
new Promise((resolve) => {
|
||||
const seen = tab.received.find((message) => message.type === type);
|
||||
if (seen) {
|
||||
tab.received.splice(tab.received.indexOf(seen), 1);
|
||||
resolve(seen);
|
||||
return;
|
||||
}
|
||||
const listener = (event: MessageEvent) => {
|
||||
const message = JSON.parse(String(event.data)) as TabMessage;
|
||||
if (message.type !== type) return;
|
||||
tab.received.splice(tab.received.indexOf(message), 1);
|
||||
socket.removeEventListener('message', listener);
|
||||
resolve(message);
|
||||
};
|
||||
socket.addEventListener('message', listener);
|
||||
}),
|
||||
};
|
||||
socket.addEventListener('open', () => socket.send(JSON.stringify({ type: 'hello', ticket })));
|
||||
socket.addEventListener('message', (event) => {
|
||||
const message = JSON.parse(String(event.data)) as TabMessage;
|
||||
if (message.type === 'call') {
|
||||
const result = tab.answer(message);
|
||||
if (result) socket.send(JSON.stringify({ type: 'result', id: message.id, ...result }));
|
||||
return;
|
||||
}
|
||||
tab.received.push(message);
|
||||
});
|
||||
return tab;
|
||||
}
|
||||
|
||||
async function onTab(user: string): Promise<FakeTab> {
|
||||
const tab = connectTab(user);
|
||||
expect((await tab.next('welcome')).user).toBe(user);
|
||||
return tab;
|
||||
}
|
||||
|
||||
const call = (token: string, session: string, name: string, args: object = {}) =>
|
||||
rpc(token, 'tools/call', { name, arguments: args }, session);
|
||||
|
||||
describe('agents', () => {
|
||||
it('need a token the API knows, and hear 429 while the API brakes them', async () => {
|
||||
const none = await rpc(null, 'initialize');
|
||||
const wrong = await rpc('fwa_nobody', 'initialize');
|
||||
throttled = true;
|
||||
const braked = await rpc('fwa_alice', 'initialize');
|
||||
expect([none.status, wrong.status, braked.status]).toEqual([401, 401, 429]);
|
||||
expect(none.headers.get('www-authenticate')).toMatch(/^Bearer/);
|
||||
expect(braked.headers.get('retry-after')).toBe('600');
|
||||
});
|
||||
|
||||
it('pass their own address to the brake, or nginx’s word for it only when told to trust it', async () => {
|
||||
await rpc('fwa_alice', 'initialize', {}, undefined, { 'X-Real-IP': '203.0.113.9' });
|
||||
await start({ trustProxy: true });
|
||||
await rpc('fwa_alice', 'initialize', {}, undefined, { 'X-Real-IP': '203.0.113.9' });
|
||||
expect(addresses).toHaveLength(2);
|
||||
expect(addresses[0]).not.toBe('203.0.113.9');
|
||||
expect(addresses[1]).toBe('203.0.113.9');
|
||||
});
|
||||
|
||||
it('start a session in the protocol version they ask for, or the newest', async () => {
|
||||
const asked = await rpc('fwa_alice', 'initialize', { protocolVersion: '2025-03-26' });
|
||||
const unknown = await rpc('fwa_alice', 'initialize', { protocolVersion: '1999-01-01' });
|
||||
expect(asked.body?.result).toMatchObject({
|
||||
protocolVersion: '2025-03-26',
|
||||
capabilities: { tools: {}, resources: { subscribe: true } },
|
||||
serverInfo: { name: 'feuerwerk' },
|
||||
});
|
||||
expect(asked.headers.get('mcp-session-id')).toMatch(/^[0-9a-f-]{36}$/);
|
||||
expect(unknown.body?.result?.protocolVersion).toBe('2025-11-25');
|
||||
});
|
||||
|
||||
it('get 202 for a notification and the tool list on asking', async () => {
|
||||
const session = await initialize('fwa_alice');
|
||||
const res = await fetch(url(), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: 'Bearer fwa_alice',
|
||||
'Mcp-Session-Id': session,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ jsonrpc: '2.0', method: 'notifications/initialized' }),
|
||||
});
|
||||
expect(res.status).toBe(202);
|
||||
expect((await rpc('fwa_alice', 'tools/list', {}, session)).body?.result).toEqual({ tools: TOOLS });
|
||||
});
|
||||
|
||||
it('cannot use a session of another account, nor one that is not there', async () => {
|
||||
const session = await initialize('fwa_alice');
|
||||
expect((await rpc('fwa_bob', 'tools/list', {}, session)).status).toBe(404);
|
||||
expect((await rpc('fwa_alice', 'tools/list', {}, 'no-such-session')).status).toBe(404);
|
||||
expect((await rpc('fwa_alice2', 'tools/list', {}, session)).status).toBe(200);
|
||||
});
|
||||
|
||||
it('are refused from a page of another site, not from the editor’s own', async () => {
|
||||
const foreign = await rpc('fwa_alice', 'initialize', {}, undefined, { Origin: 'https://evil.test' });
|
||||
const own = await rpc('fwa_alice', 'initialize', {}, undefined, { Origin: ORIGIN });
|
||||
expect([foreign.status, own.status]).toEqual([403, 200]);
|
||||
});
|
||||
|
||||
it('hear about unknown tools and methods in JSON-RPC terms', async () => {
|
||||
const session = await initialize('fwa_alice');
|
||||
expect((await call('fwa_alice', session, 'rm_rf')).body?.error?.code).toBe(-32602);
|
||||
expect((await rpc('fwa_alice', 'prompts/list', {}, session)).body?.error?.code).toBe(-32601);
|
||||
});
|
||||
|
||||
it('end a session with DELETE', async () => {
|
||||
const session = await initialize('fwa_alice');
|
||||
const res = await fetch(url(), {
|
||||
method: 'DELETE',
|
||||
headers: { Authorization: 'Bearer fwa_alice', 'Mcp-Session-Id': session },
|
||||
});
|
||||
expect(res.status).toBe(204);
|
||||
expect((await rpc('fwa_alice', 'tools/list', {}, session)).status).toBe(404);
|
||||
});
|
||||
|
||||
it('send no body larger than the limit', async () => {
|
||||
await start({ maxBodyBytes: 1000 });
|
||||
const res = await fetch(url(), {
|
||||
method: 'POST',
|
||||
headers: { Authorization: 'Bearer fwa_alice', 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
jsonrpc: '2.0',
|
||||
id: 1,
|
||||
method: 'initialize',
|
||||
params: { pad: 'x'.repeat(2000) },
|
||||
}),
|
||||
});
|
||||
expect(res.status).toBe(413);
|
||||
});
|
||||
});
|
||||
|
||||
describe('tabs', () => {
|
||||
it('get a call through to the tab of the same account and its answer back', async () => {
|
||||
await onTab('alice');
|
||||
const session = await initialize('fwa_alice');
|
||||
const answer = await call('fwa_alice', session, 'get_view');
|
||||
expect(answer.body?.result).toEqual({
|
||||
content: [{ type: 'text', text: 'alice did get_view' }],
|
||||
isError: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('without a tab, a call says so and how to fix it', async () => {
|
||||
const session = await initialize('fwa_alice');
|
||||
const answer = await call('fwa_alice', session, 'get_view');
|
||||
expect(toolResult(answer.body).isError).toBe(true);
|
||||
expect(textOf(answer.body)).toMatch(/No Feuerwerk editor is connected.*"Agent"/);
|
||||
});
|
||||
|
||||
it('never reach the tab of another account', async () => {
|
||||
const bobs = await onTab('bob');
|
||||
const calls: string[] = [];
|
||||
bobs.answer = (message) => {
|
||||
calls.push(message.tool ?? '');
|
||||
return { content: [{ type: 'text', text: 'bob' }] };
|
||||
};
|
||||
const session = await initialize('fwa_alice');
|
||||
const answer = await call('fwa_alice', session, 'get_view');
|
||||
expect(toolResult(answer.body).isError).toBe(true);
|
||||
expect(calls).toEqual([]);
|
||||
});
|
||||
|
||||
it('are refused from another origin or none, before a ticket is asked for', async () => {
|
||||
const foreign = connectTab('alice', 'https://evil.test');
|
||||
const none = connectTab('alice', null);
|
||||
expect(await foreign.closed).toBe(1006);
|
||||
expect(await none.closed).toBe(1006);
|
||||
expect(tickets.size).toBe(2);
|
||||
});
|
||||
|
||||
it('need a ticket the API takes, once', async () => {
|
||||
const tab = connectTab('alice', ORIGIN, 'shared');
|
||||
await tab.next('welcome');
|
||||
tickets.delete('shared');
|
||||
const again = connectTab('alice', ORIGIN, 'other');
|
||||
tickets.delete('other');
|
||||
expect(await again.closed).toBe(CLOSE.refused);
|
||||
expect(relay.stats().tabs).toEqual(['alice']);
|
||||
});
|
||||
|
||||
it('that say no hello in time are let go', async () => {
|
||||
await start({ helloTimeoutMs: 100 });
|
||||
const socket = new WebSocket(url().replace('http', 'ws'), {
|
||||
headers: { Origin: ORIGIN },
|
||||
} as unknown as string[]);
|
||||
open.push(socket);
|
||||
const code = await new Promise((resolve) =>
|
||||
socket.addEventListener('close', (event) => resolve(event.code)),
|
||||
);
|
||||
expect(code).toBe(CLOSE.helloTimeout);
|
||||
});
|
||||
|
||||
it('one per account: the next takes over, and a call open on the first ends', async () => {
|
||||
const first = await onTab('alice');
|
||||
first.answer = () => null;
|
||||
const session = await initialize('fwa_alice');
|
||||
const pending = call('fwa_alice', session, 'get_view');
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
const second = await onTab('Alice');
|
||||
expect((await first.next('replaced')).type).toBe('replaced');
|
||||
expect(await first.closed).toBe(CLOSE.replaced);
|
||||
expect(textOf((await pending).body)).toBe('Another editor tab took over');
|
||||
expect(textOf((await call('fwa_alice', session, 'get_view')).body)).toBe('Alice did get_view');
|
||||
second.socket.close();
|
||||
});
|
||||
|
||||
it('that do not answer in time end the call with an error', async () => {
|
||||
await start({ callTimeoutMs: 100 });
|
||||
const tab = await onTab('alice');
|
||||
tab.answer = () => null;
|
||||
const session = await initialize('fwa_alice');
|
||||
const answer = await call('fwa_alice', session, 'screenshot');
|
||||
expect(answer.body?.result).toEqual({
|
||||
content: [{ type: 'text', text: 'The editor did not answer within 0 s' }],
|
||||
isError: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('pass a picture of several hundred kilobytes through, and only content the protocol knows', async () => {
|
||||
const tab = await onTab('alice');
|
||||
const data = Buffer.alloc(300_000, 7).toString('base64');
|
||||
tab.answer = () => ({
|
||||
content: [
|
||||
{ type: 'image', data, mimeType: 'image/png' },
|
||||
{ type: 'script', text: 'x' },
|
||||
],
|
||||
structuredContent: { width: 10 },
|
||||
});
|
||||
const session = await initialize('fwa_alice');
|
||||
const answer = await call('fwa_alice', session, 'screenshot');
|
||||
expect(toolResult(answer.body).content).toEqual([{ type: 'image', data, mimeType: 'image/png' }]);
|
||||
expect(toolResult(answer.body).structuredContent).toEqual({ width: 10 });
|
||||
});
|
||||
|
||||
it('learn how many agents their account has on, and an agent gone quiet counts out', async () => {
|
||||
await start({ agentRecentMs: 150, pingMs: 50 });
|
||||
await initialize('fwa_alice');
|
||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||
const tab = await onTab('alice');
|
||||
expect((await tab.next('agents')).count).toBe(0);
|
||||
await initialize('fwa_alice');
|
||||
expect((await tab.next('agents')).count).toBe(1);
|
||||
expect((await tab.next('agents')).count).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('the view', () => {
|
||||
it('is read as a resource, and a subscribed agent hears when it changes', async () => {
|
||||
const tab = await onTab('alice');
|
||||
const session = await initialize('fwa_alice');
|
||||
expect((await rpc('fwa_alice', 'resources/list', {}, session)).body?.result).toMatchObject({
|
||||
resources: [{ uri: VIEW_URI }],
|
||||
});
|
||||
expect((await rpc('fwa_alice', 'resources/subscribe', { uri: VIEW_URI }, session)).body?.result).toEqual(
|
||||
{},
|
||||
);
|
||||
|
||||
const stream = await fetch(url(), {
|
||||
headers: { Authorization: 'Bearer fwa_alice', 'Mcp-Session-Id': session, Accept: 'text/event-stream' },
|
||||
});
|
||||
expect(stream.headers.get('content-type')).toBe('text/event-stream');
|
||||
const reader = stream.body!.getReader();
|
||||
const heard = (async () => {
|
||||
let text = '';
|
||||
while (!text.includes('notifications/resources/updated')) {
|
||||
const { value, done } = await reader.read();
|
||||
if (done) break;
|
||||
text += new TextDecoder().decode(value);
|
||||
}
|
||||
return text;
|
||||
})();
|
||||
tab.socket.send(JSON.stringify({ type: 'view', view: { tab: 'graph' }, summary: 'tab graph' }));
|
||||
expect(await heard).toContain(`"params":{"uri":"${VIEW_URI}"}`);
|
||||
await reader.cancel();
|
||||
|
||||
const read = await rpc('fwa_alice', 'resources/read', { uri: VIEW_URI }, session);
|
||||
expect(JSON.parse(viewText(read.body))).toEqual({ tab: 'graph' });
|
||||
});
|
||||
|
||||
it('read without a tab says it is not connected', async () => {
|
||||
const session = await initialize('fwa_alice');
|
||||
const read = await rpc('fwa_alice', 'resources/read', { uri: VIEW_URI }, session);
|
||||
expect(JSON.parse(viewText(read.body))).toMatchObject({ connected: false });
|
||||
});
|
||||
|
||||
it('changed since the agent last looked is told with the next answer, once', async () => {
|
||||
const tab = await onTab('alice');
|
||||
const session = await initialize('fwa_alice');
|
||||
tab.socket.send(JSON.stringify({ type: 'view', view: { tab: 'scene' }, summary: 'tab scene' }));
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
await call('fwa_alice', session, 'get_view');
|
||||
const same = await call('fwa_alice', session, 'screenshot');
|
||||
tab.socket.send(JSON.stringify({ type: 'view', view: { tab: 'graph' }, summary: 'tab graph' }));
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
const changed = await call('fwa_alice', session, 'screenshot');
|
||||
const after = await call('fwa_alice', session, 'screenshot');
|
||||
expect(toolResult(same.body).content).toHaveLength(1);
|
||||
expect(textOf(changed.body, 1)).toBe("The user's view has changed since you last looked: tab graph");
|
||||
expect(toolResult(after.body).content).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('in front of a plain server', () => {
|
||||
let backend: Server;
|
||||
afterEach(() => new Promise<void>((resolve) => backend.close(() => resolve())));
|
||||
|
||||
it('passes everything off its own path on', async () => {
|
||||
backend = createServer((req, res) =>
|
||||
res.end(`backend ${req.method} ${req.url} ${req.headers.range ?? ''}`),
|
||||
);
|
||||
await new Promise<void>((resolve) => backend.listen(0, '127.0.0.1', () => resolve()));
|
||||
const port = (backend.address() as { port: number }).port;
|
||||
await start({ backend: `http://127.0.0.1:${port}` });
|
||||
const page = await fetch(`http://127.0.0.1:${relay.port}/feuerwerk/data/x.mp4?y=1`, {
|
||||
headers: { Range: 'bytes=0-9' },
|
||||
});
|
||||
expect(await page.text()).toBe('backend GET /feuerwerk/data/x.mp4?y=1 bytes=0-9');
|
||||
expect((await fetch(`http://127.0.0.1:${relay.port}/feuerwerk/mcp`, { method: 'POST' })).status).toBe(
|
||||
401,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
@@ -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