From 2e34c209e61231808446a045891d9e24ce306373 Mon Sep 17 00:00:00 2001 From: Lucio Lelii Date: Thu, 24 Sep 2026 17:00:22 +0200 Subject: [PATCH] Let a person open the preview an execution is running Until now a running instance was reachable only from inside the Docker network: the browser-mcp could see it, a human could not. That was the hole under the whole human-evaluation node - it asks somebody to judge software, with no software to judge. The route rides the gateway that already exists, on 443 with the certificate already issued, rather than publishing a port per execution. Twenty open ports on an institutional VM is the kind of thing a firewall refuses; one path does not. The cost is that the previewed application lives under a prefix, which is acceptable here because the agent writes it under our own instructions. Authorisation is the part that needed care. A browser attaches no bearer token, and the execution key is no secret - rootExecutionId is on screen in the editor and in every event the flow logs - so each preview carries a token of its own, issued when the service starts, swapped on first visit for an HttpOnly cookie scoped to that one preview's path, and gone from the address bar afterwards so it cannot travel onward in Referer. A restart invalidates the previous link, because a port handed back to the allocator could be serving someone else. One defect found by walking the whole path through the real gateway rather than testing the proxy alone: the redirect pointed at the path the proxy receives, which the gateway has already stripped, so the browser was sent out of the mount into the gateway's own 404. The cookie had assumed the prefix and the redirect had not. The mount is now one parameter feeding both, with a test on the Location header that the first round had no reason to have. Verified end to end against the stack: link opened, cookie set, navigation inside the application arriving at the path it expects, and 403 for anyone holding the key but not the link. Co-Authored-By: Claude Opus 5 (1M context) --- dev-server-mcp/src/dev-server-service.js | 21 +- dev-server-mcp/src/preview-proxy.js | 148 ++++++++++++++ dev-server-mcp/src/worker-index.js | 35 ++++ .../test/execution-instances.test.js | 35 ++++ dev-server-mcp/test/preview-proxy.test.js | 183 ++++++++++++++++++ mcp-stack.Caddyfile | 12 ++ mcp-stack.compose.yml | 5 + mcp-stack.env.example | 5 + mcp-stack.vm.Caddyfile | 12 ++ 9 files changed, 455 insertions(+), 1 deletion(-) create mode 100644 dev-server-mcp/src/preview-proxy.js create mode 100644 dev-server-mcp/test/preview-proxy.test.js diff --git a/dev-server-mcp/src/dev-server-service.js b/dev-server-mcp/src/dev-server-service.js index 62b1b9b..52c733b 100644 --- a/dev-server-mcp/src/dev-server-service.js +++ b/dev-server-mcp/src/dev-server-service.js @@ -68,6 +68,9 @@ function publicStatus(definition, instance) { signal: instance?.signal ?? null, cwd: instance?.clientCwd ?? definition.clientCwd, publicUrl: isUnresolved(resolvedUrl) ? null : (resolvedUrl ?? null), + // Where a person can open this, as opposed to publicUrl, which only another container can + // reach. Carries its own one-time token: see PreviewProxy for why the key is not enough. + previewUrl: instance?.previewUrl ?? null, // Operator-written, non-secret instructions for an agent creating a project from scratch. // The commands themselves stay operator-owned; the agent receives the contract it must meet. ...(definition.agentInstructions ? { agentInstructions: definition.agentInstructions } : {}) @@ -88,7 +91,8 @@ export class DevServerService { constructor({ services, logLimit = DEFAULT_LOG_LIMIT, workspaceRoot = null, instancesRoot = null, portAllocator = null, maxInstances = 4, npmRegistry = null, npmCache = null, workspaceLimits = null, instanceHome = "/tmp/dev-server", - childPath = "/usr/local/bin:/usr/bin:/bin", proxy = null, javaHome = null }) { + childPath = "/usr/local/bin:/usr/bin:/bin", proxy = null, javaHome = null, + previewProxy = null, previewBaseUrl = null }) { this.services = services; this.instances = new Map(); this.logLimit = logLimit; @@ -103,6 +107,10 @@ export class DevServerService { this.childPath = childPath; this.proxy = proxy; this.javaHome = javaHome; + // Distinct from `proxy` above, which is the egress proxy an install fetches through. This one + // is the door a person walks through to look at what the execution built. + this.previewProxy = previewProxy; + this.previewBaseUrl = previewBaseUrl; } definition(service) { @@ -185,6 +193,13 @@ export class DevServerService { await this.spawnChild(definition, instance); await this.awaitHealth(definition, instance, handle); instance.status = "running"; + // Issued after the service is actually answering, and only for a per-execution one: a + // shared service has no key to scope a preview to, and a link handed out before the health + // check would point at something that may never come up. + if (this.previewProxy && this.previewBaseUrl && definition.workspaceMode === "execution") { + const token = this.previewProxy.issueToken(slot); + instance.previewUrl = `${this.previewBaseUrl}/preview/${slot}/?t=${token}`; + } return publicStatus(definition, instance); } catch (error) { instance.status = "stopped"; @@ -313,6 +328,10 @@ export class DevServerService { await Promise.race([forcedExit, new Promise((resolve) => setTimeout(resolve, 2000))]); } this.portAllocator?.releaseAllOf(handle); + // The next start issues a new token; this one must stop opening anything the moment the + // process behind it is gone, or a link from an earlier run would outlive what it pointed at. + if (instance.key) this.previewProxy?.forget(instance.key); + instance.previewUrl = null; return publicStatus(definition, instance); } diff --git a/dev-server-mcp/src/preview-proxy.js b/dev-server-mcp/src/preview-proxy.js new file mode 100644 index 0000000..eeef250 --- /dev/null +++ b/dev-server-mcp/src/preview-proxy.js @@ -0,0 +1,148 @@ +import http from "node:http"; +import { randomBytes, timingSafeEqual } from "node:crypto"; + +const KEY_SEGMENT = /^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/; +const COOKIE_NAME = "dev_server_preview"; + +/** + * Serves one execution's running preview to a person's browser, over the gateway. + * + *

Everything else in this stack is reached by a machine holding an API key. This is the one + * door a human walks through, which changes what authorisation can look like: a browser will not + * attach a bearer token, and the execution key alone is no secret - rootExecutionId appears in the + * flow's events, in its logs and on screen in the editor. So a preview carries a token of its own, + * handed out once when the service starts, exchanged on first visit for a cookie scoped to that + * one preview's path, and never written into the page's own links. + * + *

It lives in the worker because the worker is the only process that knows which port belongs + * to which execution. The gateway in front of it is a plain reverse proxy with no such map. + */ +export class PreviewProxy { + /** + * @param resolve (key) => ({ port, touch }) for a running instance, or null. `touch` is called + * on every proxied request, so a preview a person is still reading does not + * expire underneath them - the whole reason a human step needs its own clock. + */ + constructor({ resolve, host = "127.0.0.1", port = 4500, tokenBytes = 32, mountPath = "/preview" }) { + this.resolve = resolve; + // Where the gateway publishes this proxy. It has to be told, because the gateway strips the + // prefix before forwarding: what arrives is //..., so nothing in the request says where + // the browser actually is. Both the redirect and the cookie's Path are built from this - they + // were written separately once, one of them assumed the prefix and the other did not, and the + // link simply led out of the mount into a 404. + this.mountPath = mountPath.replace(/\/+$/, ""); + this.host = host; + this.port = port; + this.tokenBytes = tokenBytes; + this.tokens = new Map(); + this.server = null; + } + + /** A fresh token for one execution's preview, replacing any the previous start handed out. */ + issueToken(key) { + const token = randomBytes(this.tokenBytes).toString("base64url"); + this.tokens.set(key, token); + return token; + } + + forget(key) { + this.tokens.delete(key); + } + + /** + * Constant-time, and only ever against the token of the key being asked for: comparing against + * every known token would let a caller learn about previews that are not theirs from timing. + */ + tokenMatches(key, candidate) { + const expected = this.tokens.get(key); + if (typeof expected !== "string" || typeof candidate !== "string") return false; + const a = Buffer.from(expected); + const b = Buffer.from(candidate); + return a.length === b.length && timingSafeEqual(a, b); + } + + cookieFrom(request, key) { + const header = request.headers.cookie; + if (typeof header !== "string") return null; + for (const part of header.split(";")) { + const separator = part.indexOf("="); + if (separator < 0) continue; + if (part.slice(0, separator).trim() !== `${COOKIE_NAME}_${key}`) continue; + return part.slice(separator + 1).trim(); + } + return null; + } + + async handle(request, response) { + const url = new URL(request.url ?? "/", "http://preview.invalid"); + const segments = url.pathname.split("/").filter(Boolean); + const key = segments[0]; + if (!key || !KEY_SEGMENT.test(key)) return refuse(response, 404, "No preview at this address"); + + const instance = this.resolve(key); + if (!instance) return refuse(response, 404, "No preview is running for this execution"); + + // The token arrives once, in a link a person was given. Accepting it sets a cookie and sends + // the browser back to the same page without it, so the token stops travelling in the address + // bar - and stops leaking through Referer to whatever the previewed page links to. + const supplied = url.searchParams.get("t"); + if (supplied !== null) { + if (!this.tokenMatches(key, supplied)) return refuse(response, 403, "This preview link is not valid"); + url.searchParams.delete("t"); + const target = `${this.mountPath}${url.pathname}${url.search}`; + response.writeHead(302, { + location: target, + "set-cookie": `${COOKIE_NAME}_${key}=${this.tokens.get(key)}; Path=${this.mountPath}/${key}/; HttpOnly; Secure; SameSite=Lax`, + "cache-control": "no-store" + }); + response.end(); + return; + } + + if (!this.tokenMatches(key, this.cookieFrom(request, key))) { + return refuse(response, 403, "This preview needs the link it was opened with"); + } + + instance.touch?.(); + this.forward(request, response, instance.port, url, segments); + } + + forward(request, response, port, url, segments) { + // The gateway strips /preview, and the key is ours: what the application behind sees is the + // path it would see if it were served at the root, which is the only way an app that was not + // written for a prefix can work at all. + const rest = "/" + segments.slice(1).join("/") + (url.pathname.endsWith("/") && segments.length > 1 ? "/" : ""); + const headers = { ...request.headers }; + delete headers.cookie; + delete headers.host; + + const upstream = http.request( + { host: "127.0.0.1", port, method: request.method, path: `${rest}${url.search}`, headers }, + (answer) => { + response.writeHead(answer.statusCode ?? 502, answer.headers); + answer.pipe(response); + } + ); + upstream.on("error", () => refuse(response, 502, "The preview stopped answering")); + request.pipe(upstream); + } + + listen() { + this.server = http.createServer((request, response) => { + response.on("error", () => {}); + this.handle(request, response).catch(() => refuse(response, 500, "Preview error")); + }); + this.server.headersTimeout = 10_000; + return new Promise((resolve) => this.server.listen(this.port, this.host, () => resolve(this.server))); + } + + close() { + return new Promise((resolve) => (this.server ? this.server.close(resolve) : resolve())); + } +} + +function refuse(response, status, message) { + if (response.headersSent) return response.end(); + response.writeHead(status, { "content-type": "text/plain; charset=utf-8", "cache-control": "no-store" }); + response.end(`${message}\n`); +} diff --git a/dev-server-mcp/src/worker-index.js b/dev-server-mcp/src/worker-index.js index 9fcac9d..8547166 100644 --- a/dev-server-mcp/src/worker-index.js +++ b/dev-server-mcp/src/worker-index.js @@ -5,6 +5,7 @@ import fs from "node:fs"; import { loadDevServerConfig } from "./config.js"; import { DevServerService } from "./dev-server-service.js"; import { PortAllocator, parsePortRange } from "./ports.js"; +import { PreviewProxy } from "./preview-proxy.js"; function csv(value) { return (value ?? "").split(",").map((item) => item.trim()).filter(Boolean); } function integer(value, fallback) { const parsed = Number(value); return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback; } @@ -26,6 +27,17 @@ const config = loadDevServerConfig({ // container gives us: a node_modules carries native .node modules that will not load from a // noexec mount, and a 333 MB dependency tree in RAM eats a quarter of the worker's memory limit. const instancesRoot = directory(process.env.DEV_SERVER_INSTANCES_ROOT); +// The door a person opens. Only stood up when a public base URL is configured: without one there +// is nowhere to send anybody, and an unreachable link is worse than no link at all. +const previewBaseUrl = (process.env.DEV_SERVER_PREVIEW_BASE_URL || "").replace(/\/+$/, "") || null; +const previewProxy = previewBaseUrl + ? new PreviewProxy({ + host: process.env.DEV_SERVER_PREVIEW_HOST ?? "0.0.0.0", + port: integer(process.env.DEV_SERVER_PREVIEW_PORT, 4500), + resolve: (key) => resolvePreview(key) + }) + : null; + const devServer = new DevServerService({ services: config.services, workspaceRoot: config.root, @@ -43,12 +55,29 @@ const devServer = new DevServerService({ proxy: process.env.DEV_SERVER_EGRESS_PROXY ? { url: process.env.DEV_SERVER_EGRESS_PROXY, noProxy: process.env.DEV_SERVER_NO_PROXY || null } : null, + previewProxy, + previewBaseUrl, workspaceLimits: { maxEntries: integer(process.env.DEV_SERVER_MAX_WORKSPACE_ENTRIES, 20000), maxBytes: integer(process.env.DEV_SERVER_MAX_WORKSPACE_BYTES, 512 * 1024 * 1024) } }); +/** + * Which port, if any, is serving this execution right now - and a way to say it is still wanted. + * + *

Looked up live rather than remembered by the proxy: an instance can die between one request + * and the next, and a proxy holding its own copy of the map would keep forwarding to a port that + * has since been handed to a different execution. + */ +function resolvePreview(key) { + for (const [handle, instance] of devServer.instances) { + if (instance.key !== key || !instance.child || instance.port === null) continue; + return { port: instance.port, touch: () => { instance.lastSeen = Date.now(); } }; + } + return null; +} + function authorized(request) { const supplied = /^Bearer\s+(.+)$/i.exec(request.headers.authorization ?? "")?.[1]?.trim(); if (!supplied) return false; @@ -98,7 +127,13 @@ server.listen(integer(process.env.DEV_SERVER_WORKER_PORT, 4000), process.env.DEV process.stderr.write("dev-server worker ready\n"); }); +if (previewProxy) { + await previewProxy.listen(); + process.stderr.write(`dev-server preview proxy on ${previewProxy.host}:${previewProxy.port}, published at ${previewBaseUrl}/preview/\n`); +} + async function shutdown() { + await previewProxy?.close().catch(() => {}); await devServer.close(); server.close(() => process.exit(0)); setTimeout(() => process.exit(1), 10000).unref(); diff --git a/dev-server-mcp/test/execution-instances.test.js b/dev-server-mcp/test/execution-instances.test.js index dff8a9b..887e8c9 100644 --- a/dev-server-mcp/test/execution-instances.test.js +++ b/dev-server-mcp/test/execution-instances.test.js @@ -200,3 +200,38 @@ test("the default log tail fits inside the client's per-result budget", () => { assert.ok(4096 < 6000); } finally { fs.rmSync(item.base, { recursive: true, force: true }); } }); + +test("a running execution gets a preview link, and stopping it takes the link away", async () => { + // The link is what a person is eventually handed, so it may not outlive the process it points + // at: a stale one would open somebody else's preview once the port is handed out again. + const { PreviewProxy } = await import("../src/preview-proxy.js"); + const previewProxy = new PreviewProxy({ port: 0, resolve: () => null }); + const item = stack({ services: { web: WEB } }); + item.service.previewProxy = previewProxy; + item.service.previewBaseUrl = "https://mcp.example.it"; + try { + item.write("exec-alpha", { "app.txt": "A" }); + + const started = await item.service.start({ service: "web", key: "exec-alpha" }); + assert.match(started.previewUrl, /^https:\/\/mcp\.example\.it\/preview\/exec-alpha\/\?t=.+/); + assert.ok(previewProxy.tokenMatches("exec-alpha", new URL(started.previewUrl).searchParams.get("t"))); + + const stopped = await item.service.stop({ service: "web", key: "exec-alpha" }); + + assert.equal(stopped.previewUrl, null); + assert.equal(previewProxy.tokenMatches("exec-alpha", "anything"), false); + } finally { await item.cleanup(); } +}); + +test("a shared service gets no preview link, having no execution to scope one to", async () => { + const { PreviewProxy } = await import("../src/preview-proxy.js"); + const shared = { ...WEB, workspaceMode: "shared", args: [...SERVER, "5399"], + healthUrl: null, publicUrl: "http://dev-server-worker:5399" }; + const item = stack({ services: { web: shared } }); + item.service.previewProxy = new PreviewProxy({ port: 0, resolve: () => null }); + item.service.previewBaseUrl = "https://mcp.example.it"; + try { + const started = await item.service.start({ service: "web" }); + assert.equal(started.previewUrl, null); + } finally { await item.cleanup(); } +}); diff --git a/dev-server-mcp/test/preview-proxy.test.js b/dev-server-mcp/test/preview-proxy.test.js new file mode 100644 index 0000000..ed6cb34 --- /dev/null +++ b/dev-server-mcp/test/preview-proxy.test.js @@ -0,0 +1,183 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import http from "node:http"; +import { PreviewProxy } from "../src/preview-proxy.js"; + +/** A stand-in for a previewed application: answers with the path it was actually asked for. */ +async function application(body = "the app") { + const server = http.createServer((request, response) => { + response.writeHead(200, { "content-type": "text/plain" }); + response.end(`${body} at ${request.url}`); + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + return { server, port: server.address().port, close: () => new Promise((r) => server.close(r)) }; +} + +async function proxyFor(instances) { + const touched = []; + const proxy = new PreviewProxy({ + port: 0, + resolve: (key) => { + const found = instances[key]; + return found ? { port: found, touch: () => touched.push(key) } : null; + } + }); + await proxy.listen(); + return { proxy, touched, port: proxy.server.address().port }; +} + +function fetchNoRedirect(url, headers = {}) { + return fetch(url, { headers, redirect: "manual" }); +} + +test("a valid token is exchanged for a cookie, and the token leaves the address bar", async () => { + // The token travels in a link a person was given; leaving it in the URL would send it onward in + // Referer to whatever the previewed page links to. + const app = await application(); + const { proxy, port } = await proxyFor({ "exec-a": app.port }); + try { + const token = proxy.issueToken("exec-a"); + const answer = await fetchNoRedirect(`http://127.0.0.1:${port}/exec-a/?t=${token}`); + + assert.equal(answer.status, 302); + // The path the browser is at, not the one this proxy receives. The gateway strips /preview + // before forwarding, so redirecting to what arrived would send the browser out of the mount + // and into the gateway's own 404 - which is exactly what happened the first time. + assert.equal(answer.headers.get("location"), "/preview/exec-a/"); + const cookie = answer.headers.get("set-cookie"); + assert.match(cookie, /dev_server_preview_exec-a=/); + assert.match(cookie, /HttpOnly/); + assert.match(cookie, /Path=\/preview\/exec-a\//, "the cookie must not be usable on another preview"); + } finally { await proxy.close(); await app.close(); } +}); + +test("the cookie from one preview does not open another", async () => { + const app = await application(); + const { proxy, port } = await proxyFor({ "exec-a": app.port, "exec-b": app.port }); + try { + const tokenA = proxy.issueToken("exec-a"); + proxy.issueToken("exec-b"); + + const answer = await fetchNoRedirect(`http://127.0.0.1:${port}/exec-b/`, { + cookie: `dev_server_preview_exec-a=${tokenA}` + }); + + assert.equal(answer.status, 403); + } finally { await proxy.close(); await app.close(); } +}); + +test("an execution key alone is not enough to open a preview", async () => { + // rootExecutionId is on screen in the editor and in every event the flow logs. If knowing it + // were sufficient, the preview would effectively be public. + const app = await application(); + const { proxy, port } = await proxyFor({ "exec-a": app.port }); + try { + proxy.issueToken("exec-a"); + const answer = await fetchNoRedirect(`http://127.0.0.1:${port}/exec-a/`); + + assert.equal(answer.status, 403); + } finally { await proxy.close(); await app.close(); } +}); + +test("a wrong token is refused", async () => { + const app = await application(); + const { proxy, port } = await proxyFor({ "exec-a": app.port }); + try { + proxy.issueToken("exec-a"); + const answer = await fetchNoRedirect(`http://127.0.0.1:${port}/exec-a/?t=not-the-token`); + + assert.equal(answer.status, 403); + } finally { await proxy.close(); await app.close(); } +}); + +test("a restart invalidates the link the previous start handed out", async () => { + const app = await application(); + const { proxy, port } = await proxyFor({ "exec-a": app.port }); + try { + const first = proxy.issueToken("exec-a"); + proxy.issueToken("exec-a"); + + const answer = await fetchNoRedirect(`http://127.0.0.1:${port}/exec-a/?t=${first}`); + + assert.equal(answer.status, 403); + } finally { await proxy.close(); await app.close(); } +}); + +test("an authorised request reaches the application at the path it expects", async () => { + // The app was not written to live under a prefix, so what reaches it must be the path it would + // see at the root - the key and the /preview mount are ours, not its business. + const app = await application("inner"); + const { proxy, port } = await proxyFor({ "exec-a": app.port }); + try { + const token = proxy.issueToken("exec-a"); + const answer = await fetch(`http://127.0.0.1:${port}/exec-a/orders?page=2`, { + headers: { cookie: `dev_server_preview_exec-a=${token}` } + }); + + assert.equal(answer.status, 200); + assert.equal(await answer.text(), "inner at /orders?page=2"); + } finally { await proxy.close(); await app.close(); } +}); + +test("reading the preview keeps it alive", async () => { + // A person takes hours where a model takes seconds. Without this the instance's own idle clock + // would reclaim a preview somebody is still looking at. + const app = await application(); + const { proxy, touched, port } = await proxyFor({ "exec-a": app.port }); + try { + const token = proxy.issueToken("exec-a"); + await fetch(`http://127.0.0.1:${port}/exec-a/`, { headers: { cookie: `dev_server_preview_exec-a=${token}` } }); + + assert.deepEqual(touched, ["exec-a"]); + } finally { await proxy.close(); await app.close(); } +}); + +test("an execution with nothing running says so, rather than failing obscurely", async () => { + const { proxy, port } = await proxyFor({}); + try { + const answer = await fetchNoRedirect(`http://127.0.0.1:${port}/exec-missing/`); + + assert.equal(answer.status, 404); + assert.match(await answer.text(), /No preview is running/); + } finally { await proxy.close(); } +}); + +test("a key that is not one safe segment is refused before anything is looked up", async () => { + const { proxy, port } = await proxyFor({}); + try { + for (const path of ["/", "/..%2fetc/", "/with%20space/"]) { + const answer = await fetchNoRedirect(`http://127.0.0.1:${port}${path}`); + assert.equal(answer.status, 404, `accepted ${path}`); + } + } finally { await proxy.close(); } +}); + +test("an application that has died answers as a gateway failure, not a hang", async () => { + const app = await application(); + const dead = app.port; + await app.close(); + const { proxy, port } = await proxyFor({ "exec-a": dead }); + try { + const token = proxy.issueToken("exec-a"); + const answer = await fetchNoRedirect(`http://127.0.0.1:${port}/exec-a/`, { + cookie: `dev_server_preview_exec-a=${token}` + }); + + assert.equal(answer.status, 502); + } finally { await proxy.close(); } +}); + +test("the mount the gateway publishes is one setting, used by both the redirect and the cookie", async () => { + // These were written separately once and disagreed: the cookie assumed /preview, the redirect + // did not, and the link led out of the mount. One parameter now, so they cannot drift again. + const app = await application(); + const proxy = new PreviewProxy({ port: 0, mountPath: "/elsewhere", resolve: () => ({ port: app.port }) }); + await proxy.listen(); + try { + const token = proxy.issueToken("exec-a"); + const answer = await fetchNoRedirect(`http://127.0.0.1:${proxy.server.address().port}/exec-a/?t=${token}`); + + assert.equal(answer.headers.get("location"), "/elsewhere/exec-a/"); + assert.match(answer.headers.get("set-cookie"), /Path=\/elsewhere\/exec-a\//); + } finally { await proxy.close(); await app.close(); } +}); diff --git a/mcp-stack.Caddyfile b/mcp-stack.Caddyfile index e1c38ad..4db62af 100644 --- a/mcp-stack.Caddyfile +++ b/mcp-stack.Caddyfile @@ -25,6 +25,18 @@ } } + # The one door a person opens, as opposed to the three above, which a machine opens with an API + # key. handle_path strips the prefix, so the worker's proxy sees //... and the + # application behind it sees the path it would see at the root. + # + # Same origin as the MCP endpoints above, which is a compromise worth naming: JavaScript in a + # previewed page can reach /dev-server/mcp. It gets 401 - those endpoints authenticate by + # bearer token, never by cookie, so there is no ambient authority for a page to borrow - and + # the preview's own cookie is scoped to /preview// and travels nowhere else. + handle_path /preview/* { + reverse_proxy dev-server-worker:4500 + } + handle { respond "No MCP server is published at this path" 404 } diff --git a/mcp-stack.compose.yml b/mcp-stack.compose.yml index 4dfcd57..45bbddb 100644 --- a/mcp-stack.compose.yml +++ b/mcp-stack.compose.yml @@ -92,6 +92,11 @@ services: DEV_SERVER_PORT_RANGE: ${DEV_SERVER_PORT_RANGE:-5200-5219} DEV_SERVER_MAX_INSTANCES: ${DEV_SERVER_MAX_INSTANCES:-4} DEV_SERVER_MAX_WORKSPACE_BYTES: ${DEV_SERVER_MAX_WORKSPACE_BYTES:-536870912} + # Where a person reaches a preview. Unset means no preview proxy at all - the dev loop works + # without one, since the browser reaches instances over the internal network; this is only + # for the flows where a human has to look at what was built. + DEV_SERVER_PREVIEW_BASE_URL: ${DEV_SERVER_PREVIEW_BASE_URL:-} + DEV_SERVER_PREVIEW_PORT: ${DEV_SERVER_PREVIEW_PORT:-4500} volumes: - type: bind source: ${MCP_WORKSPACE_HOST_PATH} diff --git a/mcp-stack.env.example b/mcp-stack.env.example index 04f1a45..a2145cb 100644 --- a/mcp-stack.env.example +++ b/mcp-stack.env.example @@ -48,3 +48,8 @@ MCP_GATEWAY_CADDYFILE=./mcp-stack.vm.Caddyfile MCP_SITE_ADDRESS=mcp-stack.isti.cnr.it MCP_TLS_CONTACT=lucio.lelii@isti.cnr.it MCP_BIND_ADDRESS=0.0.0.0 + +# The public address a person opens a preview at - the same host the gateway serves, since the +# preview rides the /preview/ route on it. Leave unset on a laptop stack: without it the dev loop +# still works (the browser reaches instances internally) and no preview links are handed out. +DEV_SERVER_PREVIEW_BASE_URL=https://mcp.sse.cloud.isti.cnr.it diff --git a/mcp-stack.vm.Caddyfile b/mcp-stack.vm.Caddyfile index c81332b..b5c943f 100644 --- a/mcp-stack.vm.Caddyfile +++ b/mcp-stack.vm.Caddyfile @@ -40,6 +40,18 @@ } } + # The one door a person opens, as opposed to the three above, which a machine opens with an API + # key. handle_path strips the prefix, so the worker's proxy sees //... and the + # application behind it sees the path it would see at the root. + # + # Same origin as the MCP endpoints above, which is a compromise worth naming: JavaScript in a + # previewed page can reach /dev-server/mcp. It gets 401 - those endpoints authenticate by + # bearer token, never by cookie, so there is no ambient authority for a page to borrow - and + # the preview's own cookie is scoped to /preview// and travels nowhere else. + handle_path /preview/* { + reverse_proxy dev-server-worker:4500 + } + handle { respond "No MCP server is published at this path" 404 }