Merge pull request #899 from bilawalsidhu/fix/mcp-embed-hardening

Tighten the local MCP server and embed mode
This commit is contained in:
Sameh Khamis authored and GitHub committed 2026-10-02 20:04:56 -07:00
commit 9954e9f991
22 files changed
+742 -130

No files matched your search

+2 -2
View File
@@ -188,6 +188,6 @@ AISSTREAM_API_KEY=
OVERPASS_UPSTREAMS=
# Optional: who may frame the app in embed mode (?embed=1), as CSP
# frame-ancestors sources such as "https://example.com". Unset allows any
# page; every other page of the app can never be framed.
# frame-ancestors sources such as "https://example.com", or * for any page.
# Unset, no page may frame it. Other pages of the app can never be framed.
# GEV_EMBED_FRAME_ANCESTORS=
+12
View File
@@ -2,6 +2,18 @@
## [Unreleased]
- Tighten the local MCP server and embed mode. The panel's `panel_request`
needs the key in its MCP server's panel page, and refuses Provider
Settings, credential and model endpoints, `/mcp` and development server
routes in any letter case or encoding; panels on one server share six
requests in flight, and further requests wait their turn. `/mcp` applies Provider Settings' proxy and sharing checks,
requires a Host on the port it reached and an Origin from that host, and
times out request bodies after 30 seconds; stdio honors
`notifications/cancelled`. Embed mode is no longer framable unless
`GEV_EMBED_FRAME_ANCESTORS` allows the framing page, and view answers go
only to the origin that sent the view. SECURITY.md describes the MCP surface
and the panel's browser keys.
## [0.2.0] — 2026-10-02 — God's Eye View through Your Agent
- Show God's Eye View inside AI conversations. Answers that can be shown
+9
View File
@@ -90,6 +90,15 @@ The dev server is a **key broker**: every server-side key above is spendable by
loopback only. Use a separately reviewed authentication proxy for remote
access and keep provider-side quotas as the spend backstop.
## MCP server and panel
God's Eye View's tools are also served to MCP clients: over stdio (`npm run mcp`) and at `/mcp` on the development and preview servers.
- **`/mcp` answers only direct local requests**: a loopback connection naming a loopback host on the port it reached, a browser `Origin` (when sent) from that same host, no proxy forwarding headers, and launcher sharing off. It accepts only JSON, so a web page cannot post to it cross-site. This is transport safety, not authentication: any program on your machine can use the tools, and some of them spend the same provider quotas as the app.
- **What the panel's requests can reach.** In clients that display MCP Apps, the panel loads the app through the `panel_request` tool, which requests paths on the app's server. It is marked for the panel only and requires a key that each MCP server puts in its panel page, which keeps it from clients that list tools to the model without loading the panel. That key is not access control: any MCP client can read the panel page, key included, and then reach the app's files and data routes through `panel_request`, much as its other tools reach the data. Provider Settings, credential and model endpoints, `/mcp` and the development server's internal routes are refused to every caller.
- **The panel carries the two browser keys.** The panel build (`npm run build:panel`) includes the same Google Maps key and Cesium ion token as the app, and they run inside the client's panel page, on the client's site. A key restricted by HTTP referrer must also allow that page, or the panel cannot load tiles. To keep the app's own keys narrower, build the panel with separately restricted keys in the environment of `npm run build:panel`.
- **Embed mode is not framable by default.** `?embed=1` documents keep `X-Frame-Options: DENY` and `frame-ancestors 'none'` unless `GEV_EMBED_FRAME_ANCESTORS` names the pages that may frame them; a framing page can change what the app shows. Other documents can never be framed.
## Scope & expectations
- The Vite server is a **development/preview** server. If you expose it beyond localhost, put it behind your own auth/proxy and review the bindings (see the threat model above).
+13 -9
View File
@@ -1,11 +1,12 @@
/**
* Lets other pages frame the app in embed mode (`?embed=1`). Every other
* Lets chosen pages frame the app in embed mode (`?embed=1`). Every
* document keeps the server's `X-Frame-Options: DENY` and
* `frame-ancestors 'none'`, which protect Provider Settings. Embed mode
* shows only the globe, so for those documents framing is limited to
* `ancestors` instead: `GEV_EMBED_FRAME_ANCESTORS`, or any frame by default.
* "Any" sends no framing restriction at all: `frame-ancestors *` would still
* refuse sandboxed frames with an opaque origin, which panel hosts use.
* `frame-ancestors 'none'` unless `GEV_EMBED_FRAME_ANCESTORS` names who may
* frame embed-mode documents: CSP frame-ancestors sources, or `*` for any
* page. A framing page can change what the app shows, so no page may frame
* it by default. Other documents, including Provider Settings, are never
* framable. "Any" sends no framing restriction at all: `frame-ancestors *`
* would still refuse sandboxed frames with an opaque origin.
*/
const FRAMING_HEADERS = new Set(['x-frame-options', 'content-security-policy']);
@@ -26,10 +27,13 @@ export function isEmbedDocumentRequest(url) {
/** Vite plugin applying the embed framing policy on dev and preview servers. */
export function embedFramingPlugin({
ancestors = process.env.GEV_EMBED_FRAME_ANCESTORS || '*',
ancestors = process.env.GEV_EMBED_FRAME_ANCESTORS || '',
} = {}) {
const anywhere = ancestors.trim() === '*';
const policy = `frame-ancestors ${ancestors}`;
const allowed = ancestors.trim();
// Unset: embed documents keep the server's framing protection.
if (!allowed) return { name: 'embed-framing' };
const anywhere = allowed === '*';
const policy = `frame-ancestors ${allowed}`;
const install = (server) => {
server.middlewares.use((req, res, next) => {
if (!isEmbedDocumentRequest(req.url)) return next();
+5 -2
View File
@@ -10,7 +10,8 @@ The panel runs the app's panel build (`npm run build:panel`, served at
`/panel/`) and loads it, with its data, through the MCP server, so it works
in Claude Desktop and in the Codex and ChatGPT desktop apps with a local
server and no HTTPS. `?embed=1` shows the app as the globe alone and takes
new views from the page that frames it. MCP leads with the tools that find
new views from the page that frames it; framing is off unless
`GEV_EMBED_FRAME_ANCESTORS` allows the framing page. MCP leads with the tools that find
what to show. See [tools and the MCP server](TOOLS.md).
## Tools and local MCP server — October 1, 2026
@@ -20,7 +21,9 @@ and `gods-eye-view/tools/mcp` exposes a composed catalog over the Model Context
Protocol. `npm run mcp` serves Core's tools over stdio to a local MCP client,
reading from a running app's `/api` routes (default `http://localhost:4173`).
The development and preview servers also serve the tools over HTTP at `/mcp`,
accepting only local requests with loopback hosts and origins.
accepting only direct local requests: a loopback host on the port reached,
an `Origin` (when sent) from that same host, no proxy forwarding headers, and
launcher sharing off.
Queries cover earthquakes, active fires, recent launches, aircraft (in an
area, by identifier, tracks, and type and route lookups), ships (in an area,
by identifier, and tracks), satellites (next
+27 -16
View File
@@ -125,13 +125,18 @@ the host to open the link (`ui/open-link`).
Hosts serve panels from their own sites and may refuse other addresses;
Codex, for one, refuses any address on the user's machine. So the panel
never requests the app's server itself. It loads everything from the app's
own paths through `panel_request`, a tool only the panel may call
(`_meta.ui.visibility: ["app"]`): the MCP server requests the path from the
app's server and returns the response, compressed and in parts when large.
own paths through `panel_request`, a tool meant for the panel: it is
marked `_meta.ui.visibility: ["app"]`, and each call must carry the key the
MCP server puts in its panel page. Any client can read that page, so the key
keeps the tool from clients that only list it, and is not access control. The
MCP server requests the path from the app's server and returns the response,
compressed and in parts when large.
The same path works in every host. Only
map imagery, tiles and fonts load directly, from the providers the
resource's `csp` lists. `panel_request` refuses `/api/setup`, which writes
provider keys.
resource's `csp` lists. `panel_request` refuses Provider Settings
(`/api/setup`), credential and model endpoints (`/api/realtime`,
`/api/openai`), `/mcp` and the development server's internal routes, in any
letter case or encoding.
The panel loads the app's panel build, which `npm run build:panel` writes to
`dist/panel` and the servers serve at `/panel/`: one app script, one
@@ -164,14 +169,15 @@ The app applies it through its own actions (style, map, exactly the view's
layers, annotations, then the followed entity, retried until its layer has
it, or the camera when nothing is followed or the entity is not there yet,
since a camera flight would end the follow; then cockpit view when asked) and
answers `{ type: 'gev:view-applied', id, ok, steps }`. It
posts `{ type: 'gev:ready' }` once it can take views, and only its parent
page can send them. See `src/app/embed.js`.
answers `{ type: 'gev:view-applied', id, ok, steps }` to the origin that
sent the view. It posts `{ type: 'gev:ready' }` once it can take views, and
only its parent page can send them. See `src/app/embed.js`.
The development and preview servers let other pages frame embed-mode
documents only; every other document keeps `X-Frame-Options: DENY` and
`frame-ancestors 'none'`. `GEV_EMBED_FRAME_ANCESTORS` restricts which pages
may frame them (any by default).
No page may frame the app by default: every document keeps
`X-Frame-Options: DENY` and `frame-ancestors 'none'`. Setting
`GEV_EMBED_FRAME_ANCESTORS` lets the pages it names (CSP frame-ancestors
sources, or `*` for any page) frame embed-mode documents only. The MCP Apps
panel loads the app into its own page and needs no framing.
Answers that have something to show include `data.view`: the view that
shows them, with the matching layers on, an area framed from above, and a
@@ -240,10 +246,15 @@ claude mcp add --transport http gods-eye-view http://localhost:4173/mcp
```
The route accepts only requests from this machine that name a loopback host
and, when a browser sends an `Origin`, come from a loopback origin. This is
local transport safety, not authentication. The
local server makes no requests other than to the app's `/api` routes and the
public feeds the sources already use.
on the port they reached and, when a browser sends an `Origin`, come from
that same host. It refuses requests a proxy forwarded and refuses all
requests while launcher sharing is on. This is local transport safety, not
authentication. The
local server's tools request the app's `/api` routes and the public feeds
the sources already use. The panel's `panel_request` also requests the app's
own files and data routes for the panel: it requires the key in the panel's
page, and refuses Provider Settings, credential and model endpoints, `/mcp`
and the development server's internal routes. See SECURITY.md.
## Tools
+62 -13
View File
@@ -2,20 +2,38 @@
* Serves the local MCP server at /mcp on the development and preview servers.
*
* This is local transport safety, not authentication: requests must come from
* this machine, name a loopback host (blocking DNS rebinding) and, when a
* browser sends an Origin, come from a loopback origin.
* this machine, carry no proxy forwarding headers, arrive while sharing is
* off, name a loopback host on the port they arrived at (blocking DNS
* rebinding) and, when a browser sends an Origin, come from that same host.
*/
import { hasProxySignals, isSharingEnabled } from '../../src/keySetupCore.mjs';
import { createMcpHttpHandler } from '../../src/tools/mcp/index.js';
import { createLocalMcpServer } from './server.js';
const LOOPBACK_HOSTS = new Set(['localhost', '127.0.0.1', '[::1]']);
const LOOPBACK_ADDRESSES = new Set(['127.0.0.1', '::1', '::ffff:127.0.0.1']);
const MAX_BODY_BYTES = 1024 * 1024;
/** How long a client may take to send its request body. */
const BODY_TIMEOUT_MS = 30 * 1000;
/** Whether a request may reach the local MCP server. */
export function isLocalMcpRequest({ remoteAddress, host, origin }) {
/**
* Whether a request may reach the local MCP server. `localPort` is the port
* the connection arrived at; `headers` are the request's, keyed by lower-case
* name; `env` is checked for launcher sharing.
*/
export function isLocalMcpRequest({
remoteAddress,
localPort,
host,
origin,
headers = {},
env = {},
}) {
if (!LOOPBACK_ADDRESSES.has(remoteAddress)) return false;
// A proxy or tunnel in front of the server can make a remote request look
// local, and sharing exposes the server beyond this machine.
if (hasProxySignals(headers) || isSharingEnabled(env)) return false;
let hostUrl;
try {
hostUrl = new URL(`http://${host}`);
@@ -23,12 +41,16 @@ export function isLocalMcpRequest({ remoteAddress, host, origin }) {
return false;
}
if (!LOOPBACK_HOSTS.has(hostUrl.hostname)) return false;
// The Host names the port the request reached, so tools are never pointed
// at another local service.
if (localPort != null && (hostUrl.port || '80') !== String(localPort))
return false;
if (origin == null) return true;
try {
const originUrl = new URL(origin);
return (
['http:', 'https:'].includes(originUrl.protocol) &&
LOOPBACK_HOSTS.has(originUrl.hostname)
originUrl.host === hostUrl.host
);
} catch {
return false;
@@ -36,7 +58,10 @@ export function isLocalMcpRequest({ remoteAddress, host, origin }) {
}
/** Vite plugin that mounts the local MCP server at /mcp. */
export function localMcpPlugin({ createServer = createLocalMcpServer } = {}) {
export function localMcpPlugin({
createServer = createLocalMcpServer,
bodyTimeoutMs = BODY_TIMEOUT_MS,
} = {}) {
const handlers = new Map();
const handlerFor = (apiBase) => {
if (!handlers.has(apiBase))
@@ -49,8 +74,11 @@ export function localMcpPlugin({ createServer = createLocalMcpServer } = {}) {
if (
!isLocalMcpRequest({
remoteAddress: req.socket?.remoteAddress,
localPort: req.socket?.localPort,
host,
origin: req.headers.origin,
headers: req.headers,
env: process.env,
})
) {
res.writeHead(403, {
@@ -71,7 +99,10 @@ export function localMcpPlugin({ createServer = createLocalMcpServer } = {}) {
};
res.on('close', onClose);
try {
const body = req.method === 'POST' ? await readBody(req) : undefined;
const body =
req.method === 'POST'
? await readBody(req, bodyTimeoutMs)
: undefined;
const request = new Request(`http://${host}/mcp`, {
method: req.method,
headers: Object.entries(req.headers).flatMap(([name, value]) =>
@@ -86,13 +117,18 @@ export function localMcpPlugin({ createServer = createLocalMcpServer } = {}) {
} catch (error) {
if (disconnect.signal.aborted) return;
if (res.headersSent) return res.destroy();
res.writeHead(error?.status === 413 ? 413 : 400, {
res.writeHead([408, 413].includes(error?.status) ? error.status : 400, {
'Content-Type': 'application/json',
'Cache-Control': 'no-store',
});
res.end(
JSON.stringify({
error: error?.status === 413 ? 'Request too large' : 'Bad request',
error:
error?.status === 413
? 'Request too large'
: error?.status === 408
? 'Request timed out'
: 'Bad request',
}),
);
} finally {
@@ -107,11 +143,21 @@ export function localMcpPlugin({ createServer = createLocalMcpServer } = {}) {
};
}
function readBody(req) {
function readBody(req, timeoutMs = BODY_TIMEOUT_MS) {
return new Promise((resolve, reject) => {
const chunks = [];
let size = 0;
let tooLarge = false;
// A client that stops sending must not hold the request open.
const timer = setTimeout(
() =>
reject(Object.assign(new Error('Request timed out'), { status: 408 })),
timeoutMs,
);
const fail = (error) => {
clearTimeout(timer);
reject(error);
};
req.on('data', (chunk) => {
size += chunk.length;
// Keep draining without storing, so the 413 response can still be sent.
@@ -121,9 +167,12 @@ function readBody(req) {
}
tooLarge = true;
chunks.length = 0;
reject(Object.assign(new Error('Request too large'), { status: 413 }));
fail(Object.assign(new Error('Request too large'), { status: 413 }));
});
req.on('end', () => resolve(Buffer.concat(chunks)));
req.on('error', reject);
req.on('end', () => {
clearTimeout(timer);
resolve(Buffer.concat(chunks));
});
req.on('error', fail);
});
}
+7 -2
View File
@@ -1,5 +1,6 @@
/** The local MCP server: Core's tools over services backed by a running app. */
import { randomBytes } from 'node:crypto';
import { readFileSync } from 'node:fs';
import {
composeCatalog,
@@ -28,17 +29,21 @@ export function createLocalMcpServer({
const { version } = JSON.parse(
readFileSync(new URL('../../package.json', import.meta.url), 'utf8'),
);
// Each server makes a new key for its panel. The panel's page carries it
// and panel_request requires it; any client may read the page, so it keeps
// the tool from clients that only list it, and is not access control.
const panelKey = randomBytes(32).toString('base64url');
return createMcpServer({
catalog: catalogForSurface(
composeCatalog({
tools: coreTools,
services: createLocalToolServices({ apiBase, fetchImpl }),
services: createLocalToolServices({ apiBase, fetchImpl, panelKey }),
}),
'mcp',
),
name: 'gods-eye-view',
version,
instructions: INSTRUCTIONS,
resources: [createGlobePanelResource({ runtime: panelRuntime })],
resources: [createGlobePanelResource({ runtime: panelRuntime, panelKey })],
});
}
+1
View File
@@ -59,5 +59,6 @@ export function createLocalToolServices(options = {}) {
return createToolServices({
fetchImpl: createApiFetch(options),
appUrl: options.apiBase ?? DEFAULT_API_BASE,
panelKey: options.panelKey,
});
}
+23 -4
View File
@@ -30,13 +30,18 @@ export function describeRequest(message) {
return typeof subject === 'string' ? `${method} ${subject}` : method;
}
/** Serve newline-delimited JSON-RPC until `input` ends. */
/**
* Serve newline-delimited JSON-RPC until `input` ends. A client's
* `notifications/cancelled` aborts the named request, which then gets no
* response, as the protocol asks.
*/
export async function serveStdio(server, { input, output, log = () => {} }) {
const lines = createInterface({ input, crlfDelay: Infinity });
const pending = new Set();
const running = new Map();
for await (const line of lines) {
if (!line.trim()) continue;
const work = respond(server, line, log).then((response) => {
const work = respond(server, line, log, running).then((response) => {
if (response) output.write(`${JSON.stringify(response)}\n`);
});
pending.add(work);
@@ -45,16 +50,30 @@ export async function serveStdio(server, { input, output, log = () => {} }) {
await Promise.all(pending);
}
async function respond(server, line, log) {
async function respond(server, line, log, running) {
let message;
try {
message = JSON.parse(line);
} catch {
return parseErrorResponse();
}
if (message?.method === 'notifications/cancelled') {
running.get(message.params?.requestId)?.abort();
return null;
}
const described = describeRequest(message);
if (described) log(described);
const response = await server.handle(message);
const id = message?.id;
const cancellable = id !== undefined && id !== null && !running.has(id);
const controller = new AbortController();
if (cancellable) running.set(id, controller);
let response;
try {
response = await server.handle(message, { signal: controller.signal });
} finally {
if (cancellable) running.delete(id);
}
if (controller.signal.aborted) return null;
// A failed tool call answers the client, not the log; say why here too.
if (response?.result?.isError)
log(` ${described} failed: ${response.result.content?.[0]?.text}`);
+26 -14
View File
@@ -249,9 +249,11 @@ export function installViews({
// A framing page talks to the app across frames; an inline panel shares
// the page with it and talks through the page's own window.
const peer = isEmbeddedInline() ? windowRef : windowRef.parent;
const post = (message) => {
// Answers go back only to the origin that asked. An inline panel posts to
// its own window, and a sandboxed framing page has no origin to name.
const post = (message, origin = '*') => {
if (peer && (peer !== windowRef || isEmbeddedInline()))
peer.postMessage(message, '*');
peer.postMessage(message, origin);
};
// Views apply one at a time, in the order they arrive.
let queue = ready;
@@ -259,27 +261,37 @@ export function installViews({
if (event.source !== peer || event.data?.type !== EMBED_VIEW_MESSAGE)
return;
const { id = null } = event.data;
const replyOrigin =
isEmbeddedInline() || !event.origin || event.origin === 'null'
? '*'
: event.origin;
let view;
try {
view = createView(event.data.view);
} catch (error) {
post({
type: EMBED_APPLIED_MESSAGE,
id,
ok: false,
error: error.message,
});
post(
{
type: EMBED_APPLIED_MESSAGE,
id,
ok: false,
error: error.message,
},
replyOrigin,
);
return;
}
queue = queue.then(async () => {
if (signal?.aborted) return;
const steps = await applyView(view, { viewer, dataManager, run, signal });
post({
type: EMBED_APPLIED_MESSAGE,
id,
ok: steps.every((step) => step.ok),
steps,
});
post(
{
type: EMBED_APPLIED_MESSAGE,
id,
ok: steps.every((step) => step.ok),
steps,
},
replyOrigin,
);
});
};
windowRef.addEventListener('message', onMessage);
+16 -3
View File
@@ -102,7 +102,13 @@ test('a followed entity is retried until its layer has it', async () => {
test('an embedded page takes views only from its parent and answers it', async () => {
const posted = [];
const parent = { postMessage: (message) => posted.push(message) };
const targets = [];
const parent = {
postMessage: (message, target) => {
posted.push(message);
targets.push(target);
},
};
const windowRef = new EventTarget();
windowRef.parent = parent;
windowRef.document = { body: { classList: new Set() } };
@@ -126,9 +132,9 @@ test('an embedded page takes views only from its parent and answers it', async (
await new Promise((resolve) => setTimeout(resolve, 0));
assert.equal(shell.clean, true);
assert.deepEqual(posted, [{ type: 'gev:ready' }]);
const send = (source, data) => {
const send = (source, data, origin = 'https://host.example') => {
const event = new Event('message');
Object.assign(event, { source, data });
Object.assign(event, { source, data, origin });
windowRef.dispatchEvent(event);
};
send({}, { type: 'gev:view', id: 1, view: { camera: { lat: 1, lon: 2 } } });
@@ -154,6 +160,13 @@ test('an embedded page takes views only from its parent and answers it', async (
),
);
assert.equal(posted.filter((message) => message.id === 1).length, 0);
// Ready names no data and goes to any parent; answers go only to the
// origin that asked.
assert.deepEqual(targets, [
'*',
'https://host.example',
'https://host.example',
]);
remove();
});
+2 -1
View File
@@ -45,7 +45,8 @@ export function panelRuntime(config) {
try {
const result = await request('tools/call', {
name: config.toolName,
arguments: args,
// The server refuses the panel's requests without its key.
arguments: { ...args, key: config.panelKey },
});
if (result?.isError || !result?.structuredContent)
throw new Error(result?.content?.[0]?.text || 'The request failed');
+44 -21
View File
@@ -184,6 +184,48 @@ export function parseWindowsUserSid(stdout) {
: null;
}
/** Headers that reverse proxies, tunnels and CDNs add to a request. */
export const PROXY_SIGNAL_HEADERS = Object.freeze([
'forwarded',
'via',
'x-forwarded-for',
'x-forwarded-host',
'x-forwarded-port',
'x-forwarded-proto',
'x-real-ip',
'cf-connecting-ip',
'cf-ray',
]);
/**
* Whether a request carries reverse-proxy or CDN forwarding headers, so it
* did not originate on this machine whatever its socket says. `headers` is
* keyed by lower-case header name.
*/
export function hasProxySignals(headers = {}) {
return PROXY_SIGNAL_HEADERS.some(
(name) => String(headers[name] || '').trim() !== '',
);
}
/**
* Whether the launcher has sharing on. Every sharing signal the launcher
* recognizes (scripts/pinokio-preflight.mjs) counts, so the two sets cannot
* drift apart. One DELIBERATE divergence: preflight is a boot check that
* treats an empty PINOKIO_SHARE_VAR as sharing-on (fail closed before Start),
* but here an empty/unset value is the NORMAL git-clone and Pinokio state, so
* a bare/sentinel value is not sharing; only a real tunnel var is.
*/
export function isSharingEnabled(env = {}) {
const shareVar = String(env.PINOKIO_SHARE_VAR ?? '').trim();
return (
['PINOKIO_SHARE_CLOUDFLARE', 'PINOKIO_SHARE_LOCAL'].some((name) =>
/^(1|true)$/i.test(String(env[name] || '').trim()),
) ||
(shareVar !== '' && shareVar !== '__gev_sharing_disabled__')
);
}
/**
* The admission gate for the Provider Settings endpoints — pure, exported so
* every refusal below is pinned by a unit assertion rather than a review note.
@@ -219,20 +261,7 @@ export function admitKeySetupRequest({
// on this machine, whatever its socket says. Refuse them outright as defense
// in depth — the shipped tunnel (Pinokio) is force-closed at boot, so these
// only appear when someone has deliberately fronted the dev server.
const PROXY_SIGNALS = [
'forwarded',
'via',
'x-forwarded-for',
'x-forwarded-host',
'x-forwarded-port',
'x-forwarded-proto',
'x-real-ip',
'cf-connecting-ip',
'cf-ray',
];
if (
PROXY_SIGNALS.some((name) => String(proxyHeaders[name] || '').trim() !== '')
) {
if (hasProxySignals(proxyHeaders)) {
return {
ok: false,
status: 403,
@@ -249,13 +278,7 @@ export function admitKeySetupRequest({
// tunnel var is. This is defense in depth regardless: the loopback+Host checks
// below independently refuse LAN/tunnel traffic, and under Pinokio the launcher
// refuses to boot at all when sharing is genuinely on.
const shareVar = String(env.PINOKIO_SHARE_VAR ?? '').trim();
const sharingEnabled =
['PINOKIO_SHARE_CLOUDFLARE', 'PINOKIO_SHARE_LOCAL'].some((name) =>
/^(1|true)$/i.test(String(env[name] || '').trim()),
) ||
(shareVar !== '' && shareVar !== '__gev_sharing_disabled__');
if (sharingEnabled) {
if (isSharingEnabled(env)) {
return {
ok: false,
status: 403,
+7
View File
@@ -135,4 +135,11 @@ test('only embed-mode documents may be framed, and only by the allowed ancestors
'x-frame-options': 'DENY',
'content-security-policy': "frame-ancestors 'none'",
});
// Unset, no page may frame even embed-mode documents.
for (const ancestors of ['', ' ']) {
const plugin = embedFramingPlugin({ ancestors });
assert.equal(plugin.name, 'embed-framing');
assert.equal(plugin.configureServer, undefined);
assert.equal(plugin.configurePreviewServer, undefined);
}
});
+8 -4
View File
@@ -55,12 +55,13 @@ export const PANEL_REQUEST_TOOL = 'panel_request';
* the panel's script, `runtime`, which loads the app through the MCP server
* and shows each view a tool returns.
*/
function panelHtml(runtime) {
function panelHtml(runtime, panelKey) {
const config = {
appBaseUrl: PANEL_APP_BASE_URL,
loadTimeoutMs: LOAD_TIMEOUT_MS,
panelBase: PANEL_BASE,
panelHeight: PANEL_HEIGHT_PX,
panelKey,
protocolVersion: MCP_APPS_PROTOCOL_VERSION,
toolName: PANEL_REQUEST_TOOL,
workerPreludePath: PANEL_WORKER_PRELUDE_PATH,
@@ -103,18 +104,21 @@ function panelHtml(runtime) {
* it (src/app/globePanelRuntime.js), as it is browser code. The security
* policy lets the panel load map imagery, tiles and fonts from their
* providers; everything from the app's own server arrives through the MCP
* server instead.
* server instead. `panelKey` is the key the panel's requests must carry
* (see src/tools/queries/panelRequest.js), which this page holds.
*/
export function createGlobePanelResource({ runtime }) {
export function createGlobePanelResource({ runtime, panelKey }) {
if (typeof runtime !== 'function')
throw new TypeError('The globe panel needs its runtime script');
if (typeof panelKey !== 'string' || panelKey.length === 0)
throw new TypeError('The globe panel needs its request key');
return Object.freeze({
uri: GLOBE_PANEL_URI,
name: 'globe',
title: "God's Eye View globe",
description: "Live God's Eye View, showing the view a tool returns.",
mimeType: MCP_APP_MIME_TYPE,
text: panelHtml(runtime),
text: panelHtml(runtime, panelKey),
_meta: {
ui: {
csp: {
+15 -1
View File
@@ -9,7 +9,10 @@ import { panelRuntime } from '../app/globePanelRuntime.js';
import { composeCatalog, coreTools } from './index.js';
test('the globe panel is an MCP Apps resource that loads the app through its server', () => {
const resource = createGlobePanelResource({ runtime: panelRuntime });
const resource = createGlobePanelResource({
runtime: panelRuntime,
panelKey: 'test-key',
});
assert.equal(resource.uri, GLOBE_PANEL_URI);
assert.equal(resource.mimeType, MCP_APP_MIME_TYPE);
assert.equal(MCP_APP_MIME_TYPE, 'text/html;profile=mcp-app');
@@ -24,6 +27,9 @@ test('the globe panel is an MCP Apps resource that loads the app through its ser
assert.equal(csp.frameDomains, undefined);
assert.equal(csp.baseUriDomains, undefined);
assert.match(resource.text, /"toolName":"panel_request"/);
// The page carries the key its requests need, and sends it with each one.
assert.match(resource.text, /"panelKey":"test-key"/);
assert.match(resource.text, /key: config\.panelKey/);
assert.match(resource.text, /"panelBase":"\/panel\/"/);
assert.match(resource.text, /'ui\/initialize'/);
assert.match(resource.text, /'ui\/notifications\/tool-result'/);
@@ -39,6 +45,14 @@ test('the globe panel is an MCP Apps resource that loads the app through its ser
assert.doesNotThrow(() => new Function(script));
});
test('the globe panel needs a request key', () => {
for (const panelKey of [undefined, '', 42])
assert.throws(
() => createGlobePanelResource({ runtime: panelRuntime, panelKey }),
TypeError,
);
});
test('show_in_gods_eye_view names the panel and shows a view another answer returned', async () => {
const catalog = composeCatalog({
tools: coreTools,
+75 -3
View File
@@ -3,15 +3,23 @@ import { createServer, request as httpRequest } from 'node:http';
import test from 'node:test';
import { isLocalMcpRequest, localMcpPlugin } from '../../server/mcp/plugin.js';
test('only loopback connections with loopback hosts and origins are local', () => {
const local = { remoteAddress: '127.0.0.1', host: 'localhost:4173' };
test('only direct loopback connections to this server are local', () => {
const local = {
remoteAddress: '127.0.0.1',
localPort: 4173,
host: 'localhost:4173',
};
assert.equal(isLocalMcpRequest(local), true);
assert.equal(
isLocalMcpRequest({ ...local, remoteAddress: '::1', host: '[::1]:4173' }),
true,
);
assert.equal(
isLocalMcpRequest({ ...local, origin: 'http://127.0.0.1:5173' }),
isLocalMcpRequest({ ...local, origin: 'http://localhost:4173' }),
true,
);
assert.equal(
isLocalMcpRequest({ ...local, env: { PINOKIO_SHARE_LOCAL: 'false' } }),
true,
);
for (const request of [
@@ -19,9 +27,20 @@ test('only loopback connections with loopback hosts and origins are local', () =
{ ...local, host: 'attacker.example:4173' },
{ ...local, host: 'localhost.attacker.example' },
{ ...local, host: '' },
// Another local port, as the Host or as the page's origin.
{ ...local, host: 'localhost:9000' },
{ ...local, localPort: 80 },
{ ...local, origin: 'http://127.0.0.1:5173' },
{ ...local, origin: 'http://localhost:5173' },
{ ...local, origin: 'https://attacker.example' },
{ ...local, origin: 'null' },
{ ...local, origin: 'file:///tmp/page.html' },
// Proxied or shared: not from this machine, whatever the socket says.
{ ...local, headers: { 'x-forwarded-for': '203.0.113.9' } },
{ ...local, headers: { forwarded: 'for=203.0.113.9' } },
{ ...local, headers: { 'cf-connecting-ip': '203.0.113.9' } },
{ ...local, env: { PINOKIO_SHARE_LOCAL: 'true' } },
{ ...local, env: { PINOKIO_SHARE_VAR: 'GEV_SHARE_URL' } },
])
assert.equal(isLocalMcpRequest(request), false, JSON.stringify(request));
});
@@ -111,6 +130,18 @@ test('the /mcp route answers local MCP requests and refuses others', async (t) =
(await post({}, { Host: `localhost.attacker.example:${port}` })).status,
403,
);
// A Host naming another local port, a page on another port, and a request
// a proxy forwarded are all refused, and no server is made for them.
assert.equal((await post({}, { Host: `localhost:${port + 1}` })).status, 403);
assert.equal(
(await post({}, { Origin: `http://localhost:${port + 1}` })).status,
403,
);
assert.equal(
(await post({}, { 'X-Forwarded-For': '203.0.113.9' })).status,
403,
);
assert.deepEqual(created, [`http://localhost:${port}`]);
assert.equal((await post('x'.repeat(1024 * 1024 + 1))).status, 413);
assert.equal(
(
@@ -164,3 +195,44 @@ test('a client that disconnects cancels its tool call', async (t) => {
await aborted.promise;
assert.equal(seen.aborted, true);
});
test('a client that stops sending its request body is answered with a timeout', async (t) => {
let middleware;
const plugin = localMcpPlugin({
bodyTimeoutMs: 20,
createServer: () => ({ handle: async () => null }),
});
plugin.configureServer({
middlewares: { use: (path, handler) => (middleware = handler) },
});
const http = createServer((req, res) => middleware(req, res));
await new Promise((resolve) => http.listen(0, '127.0.0.1', resolve));
t.after(() => http.close());
const { port } = http.address();
const status = await new Promise((resolve, reject) => {
const request = httpRequest(
{
host: '127.0.0.1',
port,
path: '/mcp',
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Content-Length': '100',
Host: `localhost:${port}`,
},
},
(response) => {
response.resume();
resolve(response.statusCode);
request.destroy();
},
);
request.on('error', (error) => {
if (error.code !== 'ECONNRESET') reject(error);
});
// Part of the body, then nothing more.
request.write('{"jsonrpc"');
});
assert.equal(status, 408);
});
+80
View File
@@ -131,3 +131,83 @@ test('a failed tool call is logged with its reason', async () => {
' tools/call show_in_gods_eye_view failed: bad area',
]);
});
test("only the key in the server's own panel page opens panel requests", async () => {
const requested = [];
const server = createLocalMcpServer({
apiBase: 'http://127.0.0.1:5000',
fetchImpl: async (url) => {
requested.push(new URL(url).pathname);
return new Response('ok');
},
});
const call = (id, args) =>
server.handle({
jsonrpc: '2.0',
id,
method: 'tools/call',
params: { name: 'panel_request', arguments: args },
});
const page = await server.handle({
jsonrpc: '2.0',
id: 1,
method: 'resources/read',
params: { uri: 'ui://gods-eye-view/globe' },
});
const [, key] = page.result.contents[0].text.match(/"panelKey":"([^"]+)"/);
assert.ok(key.length >= 40);
const refused = await call(2, { path: '/panel/index.html' });
assert.equal(refused.result.isError, true);
const opened = await call(3, { key, path: '/panel/index.html' });
assert.equal(opened.result.isError, false);
assert.deepEqual(requested, ['/panel/index.html']);
// Every server makes its own key.
const other = createLocalMcpServer({ apiBase: 'http://127.0.0.1:5000' });
const otherPage = await other.handle({
jsonrpc: '2.0',
id: 4,
method: 'resources/read',
params: { uri: 'ui://gods-eye-view/globe' },
});
assert.doesNotMatch(otherPage.result.contents[0].text, new RegExp(key));
});
test('a request the client cancels over stdio is aborted and gets no answer', async () => {
let seen;
const server = {
handle: (message, { signal } = {}) => {
if (message.id === 1) {
seen = signal;
return new Promise((resolve) =>
signal.addEventListener(
'abort',
() => resolve({ jsonrpc: '2.0', id: 1, result: {} }),
{ once: true },
),
);
}
return Promise.resolve(
message.id === undefined
? null
: { jsonrpc: '2.0', id: message.id, result: {} },
);
},
};
const input = new PassThrough();
const output = new PassThrough();
let written = '';
output.on('data', (chunk) => (written += chunk));
const served = serveStdio(server, { input, output });
input.write('{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{}}\n');
input.write('{"jsonrpc":"2.0","id":2,"method":"ping"}\n');
input.end(
'{"jsonrpc":"2.0","method":"notifications/cancelled","params":{"requestId":1}}\n',
);
await served;
assert.equal(seen.aborted, true);
const ids = written
.trim()
.split('\n')
.map((line) => JSON.parse(line).id);
assert.deepEqual(ids, [2]);
});
+123 -24
View File
@@ -2,8 +2,16 @@
* 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.
* tool instead, and it requests the path from the app's server. Large
* responses come back in parts.
*
* It is meant for the panel. `visibility: ['app']` asks hosts to keep it
* from the model, and every call must also carry the key in the panel's page
* (`services.app.panelKey`), so a client that lists the tool to the model
* without loading the panel cannot use it. The key is not access control:
* any MCP client can read the panel's page, key included. What protects
* sensitive routes is that those the panel never needs are refused to every
* caller.
*/
import { defineTool, ToolError } from '../catalog.js';
@@ -17,8 +25,15 @@ const HELD_LIMIT_BYTES = 256 * 1024 * 1024;
export const PANEL_RESPONSE_LIMIT_BYTES = 64 * 1024 * 1024;
const REQUEST_TIMEOUT_MS = 60 * 1000;
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(?:\/|$)/];
// Routes the panel never loads, matched without regard to case as the
// server routes them: Provider Settings, which only the app's own page may
// use; credential and model endpoints; the MCP server itself; and the
// development server's internal routes.
const REFUSED_PATHS = [
/^\/api\/(?:setup|realtime|openai)(?:\/|$)/i,
/^\/mcp(?:\/|$)/i,
/^\/(?:@|__)/,
];
const FORWARDED_REQUEST_HEADERS = ['accept', 'content-type'];
const DROPPED_RESPONSE_HEADERS = new Set([
'connection',
@@ -33,9 +48,67 @@ 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;
/**
* Requests in flight at once for one server's panels. Several panels (one
* per conversation) share a server, so further requests wait their turn
* rather than fail; past `PANEL_QUEUED_REQUESTS` waiting, they are refused.
*/
export const PANEL_CONCURRENT_REQUESTS = 6;
export const PANEL_QUEUED_REQUESTS = 256;
/**
* Each server's panel state: bodies too large for one call, kept for the
* calls that read the rest, and the requests in flight. Kept per server
* (its `services.app`), so one server's panel never reads another's.
*/
const panels = new WeakMap();
function panelState(app) {
if (!panels.has(app))
panels.set(app, {
held: new Map(),
heldBytes: 0,
inFlight: 0,
waiting: [],
});
return panels.get(app);
}
/** Take a place among the requests in flight, waiting for one if need be. */
async function acquire(state, signal) {
if (state.inFlight < PANEL_CONCURRENT_REQUESTS) {
state.inFlight += 1;
return;
}
if (state.waiting.length >= PANEL_QUEUED_REQUESTS)
throw new ToolError(
'retry_later',
'The panel has too many requests waiting',
{
retryAfterSeconds: 1,
},
);
await new Promise((resolve, reject) => {
const waiter = { resolve };
const abort = () => {
const index = state.waiting.indexOf(waiter);
if (index !== -1) state.waiting.splice(index, 1);
reject(signal.reason);
};
waiter.resolve = () => {
signal?.removeEventListener('abort', abort);
resolve();
};
signal?.addEventListener('abort', abort, { once: true });
state.waiting.push(waiter);
});
}
/** Give up a place, handing it straight to the next waiting request. */
function releaseSlot(state) {
const next = state.waiting.shift();
if (next) next.resolve();
else state.inFlight -= 1;
}
function base64(bytes) {
let binary = '';
@@ -48,6 +121,17 @@ function fromBase64(text) {
return Uint8Array.from(atob(text), (char) => char.charCodeAt(0));
}
/** Compare a caller's key with the panel's, in time independent of where they differ. */
function isPanelKey(given, expected) {
if (typeof expected !== 'string' || expected.length === 0) return false;
if (typeof given !== 'string' || given.length !== expected.length)
return false;
let difference = 0;
for (let index = 0; index < expected.length; index += 1)
difference |= given.charCodeAt(index) ^ expected.charCodeAt(index);
return difference === 0;
}
async function gzip(bytes) {
const stream = new Blob([bytes])
.stream()
@@ -55,26 +139,27 @@ async function gzip(bytes) {
return new Uint8Array(await new Response(stream).arrayBuffer());
}
function release(id) {
heldBytes -= held.get(id).bytes.length;
held.delete(id);
function release(state, id) {
state.heldBytes -= state.held.get(id).bytes.length;
state.held.delete(id);
}
/** Drop held responses nobody continued in time. */
function forgetExpired(now = Date.now()) {
for (const [id, entry] of held) if (entry.expires <= now) release(id);
function forgetExpired(state, now = Date.now()) {
for (const [id, entry] of state.held)
if (entry.expires <= now) release(state, id);
}
/** Hold a response for its later parts, first making room for it. */
function hold(id, entry) {
forgetExpired();
function hold(state, id, entry) {
forgetExpired(state);
// Oldest first; a response being read is renewed, so it is the newest.
for (const oldest of held.keys()) {
if (heldBytes + entry.bytes.length <= HELD_LIMIT_BYTES) break;
release(oldest);
for (const oldest of state.held.keys()) {
if (state.heldBytes + entry.bytes.length <= HELD_LIMIT_BYTES) break;
release(state, oldest);
}
held.set(id, entry);
heldBytes += entry.bytes.length;
state.held.set(id, entry);
state.heldBytes += entry.bytes.length;
}
/** Read a response body, refusing one larger than the panel may load. */
@@ -158,6 +243,10 @@ export const panelRequest = defineTool({
inputSchema: {
type: 'object',
properties: {
key: {
type: 'string',
description: "The panel's key, from its own page.",
},
path: {
type: 'string',
description: "A path on the app's server, starting with /.",
@@ -179,13 +268,19 @@ export const panelRequest = defineTool({
requires: ['app'],
ui: { visibility: ['app'] },
async run(args, { services, signal }) {
forgetExpired();
if (!isPanelKey(args.key, services.app.panelKey))
throw new ToolError(
'invalid_arguments',
"Only the God's Eye View panel may make this request",
);
const state = panelState(services.app);
forgetExpired(state);
if (args.id !== undefined) {
const entry = held.get(args.id);
const entry = state.held.get(args.id);
if (entry) {
// Still being read: renew it, and keep it newest.
held.delete(args.id);
held.set(args.id, { ...entry, expires: Date.now() + HELD_MS });
state.held.delete(args.id);
state.held.set(args.id, { ...entry, expires: Date.now() + HELD_MS });
}
if (!entry)
throw new ToolError(
@@ -206,6 +301,8 @@ export const panelRequest = defineTool({
if (FORWARDED_REQUEST_HEADERS.includes(name.toLowerCase()))
headers[name] = String(value);
}
signal?.throwIfAborted();
await acquire(state, signal);
const deadline = AbortSignal.timeout(REQUEST_TIMEOUT_MS);
let answer;
let bytes;
@@ -225,6 +322,8 @@ export const panelRequest = defineTool({
if (signal?.aborted) throw error;
if (error instanceof ToolError) throw error;
throw new ToolError('unavailable', "The app's server did not answer");
} finally {
releaseSlot(state);
}
const type = answer.headers.get('content-type') || '';
let encoding = 'identity';
@@ -245,7 +344,7 @@ export const panelRequest = defineTool({
};
if (bytes.length > PANEL_PART_BYTES) {
response.id = crypto.randomUUID();
hold(response.id, {
hold(state, response.id, {
path,
response,
bytes,
+181 -9
View File
@@ -2,11 +2,13 @@ import assert from 'node:assert/strict';
import test from 'node:test';
import { composeCatalog, coreTools } from '../index.js';
import {
PANEL_CONCURRENT_REQUESTS,
PANEL_PART_BYTES,
PANEL_RESPONSE_LIMIT_BYTES,
} from './panelRequest.js';
const fromBase64 = (text) => Buffer.from(text, 'base64');
const KEY = 'panel-key-for-tests';
const gunzip = async (bytes) =>
new Uint8Array(
await new Response(
@@ -25,7 +27,11 @@ function catalogWith(handler) {
catalog: composeCatalog({
tools: coreTools,
services: {
app: { baseUrl: 'http://localhost:4173/', fetch: fetchImpl },
app: {
baseUrl: 'http://localhost:4173/',
fetch: fetchImpl,
panelKey: KEY,
},
},
}),
};
@@ -46,6 +52,7 @@ test('a small response returns whole, compressed when that helps', async () => {
}),
);
const { data } = await catalog.call('panel_request', {
key: KEY,
path: '/panel/assets/style.css',
headers: { Accept: 'text/css', Cookie: 'secret', 'X-Other': '1' },
});
@@ -66,12 +73,16 @@ test('a large response comes in parts that join to the original', async () => {
const { catalog } = catalogWith(
() => new Response(bytes, { headers: { 'content-type': 'image/png' } }),
);
let { data } = await catalog.call('panel_request', { path: '/a.png' });
let { data } = await catalog.call('panel_request', {
key: KEY,
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', {
key: KEY,
id: data.id,
offset: data.nextOffset,
}));
@@ -86,7 +97,8 @@ test('a request body and method pass through; settings and other sites do not',
() => new Response('{}', { status: 201 }),
);
const { data } = await catalog.call('panel_request', {
path: '/api/openai/hud-summary',
key: KEY,
path: '/api/overpass',
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: Buffer.from('{"a":1}').toString('base64'),
@@ -103,12 +115,12 @@ test('a request body and method pass through; settings and other sites do not',
'http://evil.example/',
])
await assert.rejects(
catalog.call('panel_request', { path }),
catalog.call('panel_request', { key: KEY, path }),
(error) => error.code === 'invalid_arguments',
path,
);
await assert.rejects(
catalog.call('panel_request', { id: 'unknown' }),
catalog.call('panel_request', { key: KEY, id: 'unknown' }),
(error) => error.code === 'invalid_arguments',
);
assert.equal(requests.length, 1);
@@ -116,7 +128,11 @@ test('a request body and method pass through; settings and other sites do not',
test('the path is requested as the parser reads it, without following redirects', async () => {
const { catalog, requests } = catalogWith(() => new Response('ok'));
await catalog.call('panel_request', { path: '/panel/a/../b.js?x=1' });
await catalog.call('panel_request', {
key: KEY,
key: KEY,
path: '/panel/a/../b.js?x=1',
});
assert.equal(requests[0].path, '/panel/b.js?x=1');
assert.equal(requests[0].redirect, 'manual');
assert.ok(requests[0].signal instanceof AbortSignal);
@@ -137,7 +153,7 @@ test('a response larger than the panel may load is refused while reading', async
),
);
await assert.rejects(
catalog.call('panel_request', { path: '/huge' }),
catalog.call('panel_request', { key: KEY, path: '/huge' }),
(error) => error.code === 'unavailable' && /too large/.test(error.message),
);
assert.ok(sent <= PANEL_RESPONSE_LIMIT_BYTES + 2 * chunk.length, `${sent}`);
@@ -154,16 +170,172 @@ test('large responses read in turn keep their own parts', async () => {
headers: { 'content-type': 'image/png' },
}),
);
const first = (await catalog.call('panel_request', { path: '/a.png' })).data;
const second = (await catalog.call('panel_request', { path: '/b.png' })).data;
const first = (
await catalog.call('panel_request', { key: KEY, path: '/a.png' })
).data;
const second = (
await catalog.call('panel_request', { key: KEY, path: '/b.png' })
).data;
for (const [start, fill] of [
[first, 1],
[second, 2],
]) {
const { data } = await catalog.call('panel_request', {
key: KEY,
id: start.id,
offset: start.nextOffset,
});
assert.deepEqual([...fromBase64(data.body)], [1, 1, 1, 1, 1].fill(fill));
}
});
test('a call without the panel key is refused before any request', async () => {
const { catalog, requests } = catalogWith(() => new Response('ok'));
for (const key of [undefined, '', 'wrong', `${KEY}x`, KEY.toUpperCase()])
await assert.rejects(
catalog.call('panel_request', {
...(key === undefined ? {} : { key }),
path: '/panel/index.html',
}),
(error) => error.code === 'invalid_arguments',
String(key),
);
await assert.rejects(
catalog.call('panel_request', { id: 'held-elsewhere', offset: 0 }),
(error) => error.code === 'invalid_arguments',
);
assert.equal(requests.length, 0);
});
test('without a panel key in its services the tool refuses every call', async () => {
const catalog = composeCatalog({
tools: coreTools,
services: {
app: {
baseUrl: 'http://localhost:4173/',
fetch: async () => new Response('ok'),
},
},
});
await assert.rejects(
catalog.call('panel_request', { key: '', path: '/panel/index.html' }),
(error) => error.code === 'invalid_arguments',
);
});
test('routes the panel never loads are refused in any case or encoding', async () => {
const { catalog, requests } = catalogWith(() => new Response('ok'));
for (const path of [
'/API/setup/status',
'/Api/Setup',
'/api/realtime/token',
'/API/REALTIME/token',
'/api/openai/hud-summary',
'/api/%4fpenai/hud-summary',
'/mcp',
'/MCP',
'/@fs/etc/passwd',
'/@vite/client',
'/%40fs/x',
'/__open-in-editor?file=x',
])
await assert.rejects(
catalog.call('panel_request', { key: KEY, path }),
(error) => error.code === 'invalid_arguments',
path,
);
// Similar names the panel does load stay available.
await catalog.call('panel_request', { key: KEY, path: '/api/setups-help' });
await catalog.call('panel_request', { key: KEY, path: '/mcpx.json' });
assert.deepEqual(
requests.map((request) => request.path),
['/api/setups-help', '/mcpx.json'],
);
});
test('requests past the limit wait their turn instead of failing', async () => {
const gates = [];
const { catalog, requests } = catalogWith(
() =>
new Promise((resolve) => gates.push(() => resolve(new Response('ok')))),
);
const tick = () => new Promise((resolve) => setTimeout(resolve, 0));
// Two panels on one server, each sending as many requests as it may.
const calls = Array.from({ length: PANEL_CONCURRENT_REQUESTS * 2 }, (_, n) =>
catalog.call('panel_request', { key: KEY, path: `/panel/${n}.js` }),
);
await tick();
assert.equal(requests.length, PANEL_CONCURRENT_REQUESTS);
// Each finished request lets one waiting request start.
for (let started = PANEL_CONCURRENT_REQUESTS; gates.length;) {
gates.shift()();
await tick();
if (started < calls.length) {
started += 1;
assert.equal(requests.length, started);
}
}
const results = await Promise.all(calls);
assert.ok(results.every(({ data }) => data.status === 200));
});
test('a waiting request its caller cancels leaves the queue', async () => {
const gates = [];
const { catalog, requests } = catalogWith(
() =>
new Promise((resolve) => gates.push(() => resolve(new Response('ok')))),
);
const tick = () => new Promise((resolve) => setTimeout(resolve, 0));
const busy = Array.from({ length: PANEL_CONCURRENT_REQUESTS }, (_, n) =>
catalog.call('panel_request', { key: KEY, path: `/panel/${n}.js` }),
);
const cancel = new AbortController();
const waiting = catalog.call(
'panel_request',
{ key: KEY, path: '/panel/cancelled.js' },
{ signal: cancel.signal },
);
const after = catalog.call('panel_request', {
key: KEY,
path: '/panel/after.js',
});
await tick();
cancel.abort();
await assert.rejects(waiting);
gates.shift()();
await tick();
// The cancelled request never reached the server; the next one did.
assert.equal(requests.at(-1).path, '/panel/after.js');
while (gates.length) gates.shift()();
await Promise.all([...busy, after]);
assert.ok(
!requests.some((request) => request.path === '/panel/cancelled.js'),
);
});
test("one server's held responses are not readable through another's", async () => {
const big = new Uint8Array(PANEL_PART_BYTES * 2).fill(7);
const one = catalogWith(
() => new Response(big, { headers: { 'content-type': 'image/png' } }),
);
const other = catalogWith(() => new Response('x'));
const { data } = await one.catalog.call('panel_request', {
key: KEY,
path: '/big.png',
});
assert.ok(data.id);
await assert.rejects(
other.catalog.call('panel_request', {
key: KEY,
id: data.id,
offset: data.nextOffset,
}),
(error) => error.code === 'invalid_arguments',
);
const rest = await one.catalog.call('panel_request', {
key: KEY,
id: data.id,
offset: data.nextOffset,
});
assert.equal(rest.data.offset, data.nextOffset);
});
+4 -2
View File
@@ -4,6 +4,8 @@
* the sources' relative `/api/...` paths; in a browser the page's own fetch
* does, and elsewhere a caller supplies a resolving fetch. `appUrl` is the
* address the app is served from; `app.fetch` requests the app's own paths.
* `panelKey`, when the panel is offered, is the key its page carries, which
* the panel's requests must include.
*/
import { createAlprTileSource } from '../layers/alpr/source.js';
@@ -40,12 +42,12 @@ import {
} from './places.js';
/** Construct every service Core's tools read. */
export function createToolServices({ fetchImpl, appUrl }) {
export function createToolServices({ fetchImpl, appUrl, panelKey }) {
if (typeof fetchImpl !== 'function')
throw new TypeError('A fetch implementation is required');
const requests = createApplicationRequestServices({ fetchImpl });
return {
app: { baseUrl: appUrl, fetch: fetchImpl },
app: { baseUrl: appUrl, fetch: fetchImpl, panelKey },
earthquakes: createUsgsEarthquakeSource({ fetchImpl }),
fires: createFirmsSource({ fetchImpl }),
launches: createLaunchSource({ fetchImpl }),