mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 10:53:59 +00:00
Load the God's Eye View panel through the MCP server
Hosts serve panels from their own sites and may refuse other addresses; Codex refuses any address on the user's machine. The panel now never requests the app's server: it loads the app's panel build and data through panel_request, a tool only the panel may call, which requests the path from the app's server. One path in every host; only map providers load directly. npm run build:panel writes the panel build to dist/panel, served at /panel/: one app script, one stylesheet, Cesium's script, and Cesium's workers as one script Cesium runs from memory. Tools may now be marked app-only. The panel CORS rules are gone.
This commit is contained in:
1 parent
b11d54d8b8
commit
996b95eb45
20 files changed
+1265
-437
No files matched your search
@@ -0,0 +1,184 @@
|
||||
/**
|
||||
* The God's Eye View panel's requests. A panel cannot reach the app's server
|
||||
* itself: hosts serve panels from their own sites and may refuse other
|
||||
* addresses, such as a server on the user's machine. The panel asks this
|
||||
* tool instead, which only an app may call, and it requests the path from
|
||||
* the app's server. Large responses come back in parts.
|
||||
*/
|
||||
|
||||
import { defineTool, ToolError } from '../catalog.js';
|
||||
|
||||
/** Bytes of response body per call, before base64. */
|
||||
export const PANEL_PART_BYTES = 512 * 1024;
|
||||
const HELD_MS = 2 * 60 * 1000;
|
||||
const HELD_LIMIT_BYTES = 256 * 1024 * 1024;
|
||||
const METHODS = new Set(['GET', 'HEAD', 'POST']);
|
||||
// Provider Settings write the app's keys; only the app's own page may.
|
||||
const REFUSED_PATHS = [/^\/api\/setup(?:\/|$)/];
|
||||
const FORWARDED_REQUEST_HEADERS = ['accept', 'content-type'];
|
||||
const DROPPED_RESPONSE_HEADERS = new Set([
|
||||
'connection',
|
||||
'content-encoding',
|
||||
'content-length',
|
||||
'keep-alive',
|
||||
'set-cookie',
|
||||
'transfer-encoding',
|
||||
]);
|
||||
// Already compressed, so gzip would only cost time.
|
||||
const INCOMPRESSIBLE =
|
||||
/^(?:image\/(?!svg)|video\/|audio\/|font\/woff2)|zip|compressed/;
|
||||
const COMPRESS_MIN_BYTES = 1024;
|
||||
|
||||
// Bodies too large for one call, kept for the calls that read the rest.
|
||||
const held = new Map();
|
||||
let heldBytes = 0;
|
||||
|
||||
function base64(bytes) {
|
||||
let binary = '';
|
||||
for (let start = 0; start < bytes.length; start += 0x8000)
|
||||
binary += String.fromCharCode(...bytes.subarray(start, start + 0x8000));
|
||||
return btoa(binary);
|
||||
}
|
||||
|
||||
function fromBase64(text) {
|
||||
return Uint8Array.from(atob(text), (char) => char.charCodeAt(0));
|
||||
}
|
||||
|
||||
async function gzip(bytes) {
|
||||
const stream = new Blob([bytes])
|
||||
.stream()
|
||||
.pipeThrough(new CompressionStream('gzip'));
|
||||
return new Uint8Array(await new Response(stream).arrayBuffer());
|
||||
}
|
||||
|
||||
function forget(now = Date.now()) {
|
||||
for (const [id, entry] of held) {
|
||||
if (entry.expires > now && heldBytes <= HELD_LIMIT_BYTES) continue;
|
||||
held.delete(id);
|
||||
heldBytes -= entry.bytes.length;
|
||||
}
|
||||
}
|
||||
|
||||
function part(response, bytes, offset) {
|
||||
const end = Math.min(bytes.length, offset + PANEL_PART_BYTES);
|
||||
return {
|
||||
...response,
|
||||
offset,
|
||||
body: base64(bytes.subarray(offset, end)),
|
||||
...(end < bytes.length ? { nextOffset: end } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function checkedPath(path) {
|
||||
if (
|
||||
typeof path !== 'string' ||
|
||||
!path.startsWith('/') ||
|
||||
path.startsWith('//')
|
||||
)
|
||||
throw new ToolError('invalid_arguments', 'path must start with one /');
|
||||
const { pathname } = new URL(path, 'http://app.invalid');
|
||||
if (REFUSED_PATHS.some((pattern) => pattern.test(pathname)))
|
||||
throw new ToolError('invalid_arguments', `${pathname} is not available`);
|
||||
return path;
|
||||
}
|
||||
|
||||
export const panelRequest = defineTool({
|
||||
name: 'panel_request',
|
||||
title: "God's Eye View panel request",
|
||||
description:
|
||||
"Loads a file or data for the God's Eye View panel from the app's " +
|
||||
'server. Only the panel calls this; it does not answer questions.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
path: {
|
||||
type: 'string',
|
||||
description: "A path on the app's server, starting with /.",
|
||||
},
|
||||
method: { type: 'string', enum: [...METHODS] },
|
||||
headers: {
|
||||
type: 'object',
|
||||
description: 'Request headers; only Accept and Content-Type are sent.',
|
||||
},
|
||||
body: { type: 'string', description: 'The request body, base64.' },
|
||||
id: {
|
||||
type: 'string',
|
||||
description: 'Continue a response an earlier call returned in parts.',
|
||||
},
|
||||
offset: { type: 'integer', minimum: 0 },
|
||||
},
|
||||
additionalProperties: false,
|
||||
},
|
||||
requires: ['app'],
|
||||
ui: { visibility: ['app'] },
|
||||
async run(args, { services, signal }) {
|
||||
forget();
|
||||
if (args.id !== undefined) {
|
||||
const entry = held.get(args.id);
|
||||
if (!entry)
|
||||
throw new ToolError(
|
||||
'invalid_arguments',
|
||||
'That response is no longer held; request the path again',
|
||||
);
|
||||
return {
|
||||
summary: `Part of ${entry.path}`,
|
||||
data: part(entry.response, entry.bytes, args.offset ?? 0),
|
||||
};
|
||||
}
|
||||
const path = checkedPath(args.path);
|
||||
const method = args.method ?? 'GET';
|
||||
if (!METHODS.has(method))
|
||||
throw new ToolError('invalid_arguments', `Unsupported method ${method}`);
|
||||
const headers = {};
|
||||
for (const [name, value] of Object.entries(args.headers ?? {})) {
|
||||
if (FORWARDED_REQUEST_HEADERS.includes(name.toLowerCase()))
|
||||
headers[name] = String(value);
|
||||
}
|
||||
let answer;
|
||||
try {
|
||||
answer = await services.app.fetch(path, {
|
||||
method,
|
||||
headers,
|
||||
...(args.body !== undefined && method === 'POST'
|
||||
? { body: fromBase64(args.body) }
|
||||
: {}),
|
||||
signal,
|
||||
});
|
||||
} catch (error) {
|
||||
if (signal?.aborted) throw error;
|
||||
throw new ToolError('unavailable', "The app's server did not answer");
|
||||
}
|
||||
let bytes = new Uint8Array(await answer.arrayBuffer());
|
||||
const type = answer.headers.get('content-type') || '';
|
||||
let encoding = 'identity';
|
||||
if (bytes.length >= COMPRESS_MIN_BYTES && !INCOMPRESSIBLE.test(type)) {
|
||||
bytes = await gzip(bytes);
|
||||
encoding = 'gzip';
|
||||
}
|
||||
const responseHeaders = {};
|
||||
answer.headers.forEach((value, name) => {
|
||||
if (!DROPPED_RESPONSE_HEADERS.has(name)) responseHeaders[name] = value;
|
||||
});
|
||||
const response = {
|
||||
status: answer.status,
|
||||
statusText: answer.statusText,
|
||||
headers: responseHeaders,
|
||||
encoding,
|
||||
totalBytes: bytes.length,
|
||||
};
|
||||
if (bytes.length > PANEL_PART_BYTES) {
|
||||
response.id = crypto.randomUUID();
|
||||
held.set(response.id, {
|
||||
path,
|
||||
response,
|
||||
bytes,
|
||||
expires: Date.now() + HELD_MS,
|
||||
});
|
||||
heldBytes += bytes.length;
|
||||
}
|
||||
return {
|
||||
summary: `${answer.status} ${method} ${path}`,
|
||||
data: part(response, bytes, 0),
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,109 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { composeCatalog, coreTools } from '../index.js';
|
||||
import { PANEL_PART_BYTES } from './panelRequest.js';
|
||||
|
||||
const fromBase64 = (text) => Buffer.from(text, 'base64');
|
||||
const gunzip = async (bytes) =>
|
||||
new Uint8Array(
|
||||
await new Response(
|
||||
new Blob([bytes]).stream().pipeThrough(new DecompressionStream('gzip')),
|
||||
).arrayBuffer(),
|
||||
);
|
||||
|
||||
function catalogWith(handler) {
|
||||
const requests = [];
|
||||
const fetchImpl = async (path, init) => {
|
||||
requests.push({ path, ...init });
|
||||
return handler(path, init);
|
||||
};
|
||||
return {
|
||||
requests,
|
||||
catalog: composeCatalog({
|
||||
tools: coreTools,
|
||||
services: {
|
||||
app: { baseUrl: 'http://localhost:4173/', fetch: fetchImpl },
|
||||
},
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
test('panel_request is for the panel only', () => {
|
||||
const { catalog } = catalogWith(() => new Response('x'));
|
||||
assert.deepEqual(catalog.get('panel_request').ui, { visibility: ['app'] });
|
||||
});
|
||||
|
||||
test('a small response returns whole, compressed when that helps', async () => {
|
||||
const text = 'body { color: red; }\n'.repeat(200);
|
||||
const { catalog, requests } = catalogWith(
|
||||
() =>
|
||||
new Response(text, {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'text/css', 'set-cookie': 'a=b' },
|
||||
}),
|
||||
);
|
||||
const { data } = await catalog.call('panel_request', {
|
||||
path: '/panel/assets/style.css',
|
||||
headers: { Accept: 'text/css', Cookie: 'secret', 'X-Other': '1' },
|
||||
});
|
||||
assert.deepEqual(requests[0].headers, { Accept: 'text/css' });
|
||||
assert.equal(data.status, 200);
|
||||
assert.equal(data.encoding, 'gzip');
|
||||
assert.equal(data.nextOffset, undefined);
|
||||
assert.equal(data.headers['content-type'], 'text/css');
|
||||
assert.equal(data.headers['set-cookie'], undefined);
|
||||
const body = await gunzip(fromBase64(data.body));
|
||||
assert.equal(new TextDecoder().decode(body), text);
|
||||
});
|
||||
|
||||
test('a large response comes in parts that join to the original', async () => {
|
||||
const bytes = new Uint8Array(PANEL_PART_BYTES * 2 + 10);
|
||||
for (let index = 0; index < bytes.length; index++)
|
||||
bytes[index] = (index * 7919) % 251;
|
||||
const { catalog } = catalogWith(
|
||||
() => new Response(bytes, { headers: { 'content-type': 'image/png' } }),
|
||||
);
|
||||
let { data } = await catalog.call('panel_request', { path: '/a.png' });
|
||||
assert.equal(data.encoding, 'identity');
|
||||
assert.equal(data.totalBytes, bytes.length);
|
||||
const parts = [fromBase64(data.body)];
|
||||
while (data.nextOffset !== undefined) {
|
||||
({ data } = await catalog.call('panel_request', {
|
||||
id: data.id,
|
||||
offset: data.nextOffset,
|
||||
}));
|
||||
parts.push(fromBase64(data.body));
|
||||
}
|
||||
assert.equal(parts.length, 3);
|
||||
assert.deepEqual(new Uint8Array(Buffer.concat(parts)), bytes);
|
||||
});
|
||||
|
||||
test('a request body and method pass through; settings and other sites do not', async () => {
|
||||
const { catalog, requests } = catalogWith(
|
||||
() => new Response('{}', { status: 201 }),
|
||||
);
|
||||
const { data } = await catalog.call('panel_request', {
|
||||
path: '/api/openai/hud-summary',
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: Buffer.from('{"a":1}').toString('base64'),
|
||||
});
|
||||
assert.equal(data.status, 201);
|
||||
assert.equal(new TextDecoder().decode(requests[0].body), '{"a":1}');
|
||||
for (const path of [
|
||||
'/api/setup/status',
|
||||
'/api/setup',
|
||||
'//evil.example/x',
|
||||
'http://evil.example/',
|
||||
])
|
||||
await assert.rejects(
|
||||
catalog.call('panel_request', { path }),
|
||||
(error) => error.code === 'invalid_arguments',
|
||||
path,
|
||||
);
|
||||
await assert.rejects(
|
||||
catalog.call('panel_request', { id: 'unknown' }),
|
||||
(error) => error.code === 'invalid_arguments',
|
||||
);
|
||||
assert.equal(requests.length, 1);
|
||||
});
|
||||
Reference in new issue
Block a user