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 / 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 /