From b1d233069a3d1b0684490bd3ba11276f83c00d79 Mon Sep 17 00:00:00 2001 From: Sancho Neves-Graca Date: Sun, 26 Jul 2026 03:21:09 +0000 Subject: [PATCH 1/2] feat(server): add --host / SIDESHOW_HOST to bind one address MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit serve() was called with no hostname, so node listened on every interface, and the startup line printed "localhost" unconditionally — hiding that. There was no flag or env var to restrict it. The default is unchanged (every interface: what containers and LAN-shared instances need). --host / SIDESHOW_HOST binds one address, and the startup line now reports the address actually bound, calling out the wildcard case. Co-Authored-By: Claude Opus 5 --- .changeset/curly-pans-shave.md | 12 ++++++++++++ bin/sideshow.js | 18 +++++++++++++++--- server/index.ts | 17 +++++++++++++++-- 3 files changed, 42 insertions(+), 5 deletions(-) create mode 100644 .changeset/curly-pans-shave.md diff --git a/.changeset/curly-pans-shave.md b/.changeset/curly-pans-shave.md new file mode 100644 index 00000000..5795d3d7 --- /dev/null +++ b/.changeset/curly-pans-shave.md @@ -0,0 +1,12 @@ +--- +"sideshow": minor +--- + +serve: add `--host` / `SIDESHOW_HOST` to bind one address + +`serve` listened on every interface with no way to restrict it, and printed +`listening on http://localhost:PORT` regardless — so a server sharing a host or +a network with anything else was reachable from it, and the startup line said +otherwise. The default is unchanged (every interface, which is what containers +and LAN-shared instances need); `--host 127.0.0.1` now keeps it off the network +entirely, and the startup line reports the address actually bound. diff --git a/bin/sideshow.js b/bin/sideshow.js index 55083dae..4f8ec7e0 100755 --- a/bin/sideshow.js +++ b/bin/sideshow.js @@ -18,7 +18,10 @@ const SELF = fileURLToPath(import.meta.url); const HELP = `sideshow — a live visual surface for terminal coding agents usage: - sideshow serve [--port N] [--open] start the surface (API + viewer) + sideshow serve [--port N] [--host H] [--open] + start the surface (API + viewer) + --host bind to one address (e.g. 127.0.0.1); default is every + interface sideshow publish [options] publish an HTML post (one html surface) --title post title --md add a markdown surface (prose) — repeatable @@ -146,6 +149,8 @@ environment: SIDESHOW_URL server base URL (default http://localhost:8228; set to a deployed instance, e.g. https://sideshow.you.workers.dev) SIDESHOW_TOKEN bearer token for a deployed instance + SIDESHOW_HOST address serve binds to (default: every interface). Set to + 127.0.0.1 to keep the server off the network entirely SIDESHOW_SESSION fixed session id (overrides auto-detection) SIDESHOW_AGENT agent name used when creating sessions `; @@ -887,14 +892,21 @@ function readStdin() { const commands = { async serve() { const { values: flags } = parse({ - options: { port: { type: "string" }, open: { type: "boolean" } }, + options: { + port: { type: "string" }, + host: { type: "string" }, + open: { type: "boolean" }, + }, }); const port = flags.port ?? process.env.PORT ?? "8228"; + const host = flags.host ?? process.env.SIDESHOW_HOST; const child = spawn(process.execPath, [entrypoint("server", "index.ts")], { stdio: "inherit", - env: { ...process.env, PORT: port }, + env: { ...process.env, PORT: port, ...(host ? { SIDESHOW_HOST: host } : {}) }, }); if (flags.open) { + // Always open localhost: a wildcard or loopback bind is reachable there, + // and it is the only address guaranteed to resolve on this machine. const url = `http://localhost:${port}`; const { opener, openerArgs } = process.platform === "darwin" diff --git a/server/index.ts b/server/index.ts index db522b41..30a07498 100644 --- a/server/index.ts +++ b/server/index.ts @@ -83,7 +83,20 @@ const app = createApp({ }); const port = Number(process.env.PORT ?? 8228); +// SIDESHOW_HOST (or `serve --host`) restricts the listener to one address. +// Unset keeps the previous behaviour — node's default, every interface — because +// that is what a container or a LAN-shared instance needs. Set it to 127.0.0.1 +// when the server shares a host with anything you don't want reaching it; that +// is stronger than the token, which is a single shared secret by design. +const hostname = process.env.SIDESHOW_HOST || undefined; -serve({ fetch: app.fetch, port }, (info) => { - console.log(`sideshow listening on http://localhost:${info.port}`); +serve({ fetch: app.fetch, port, hostname }, (info) => { + // Report the address actually bound. Printing "localhost" unconditionally hid + // the fact that the default listens on every interface. + const shown = hostname ?? "localhost"; + const authority = shown.includes(":") ? `[${shown}]` : shown; + console.log( + `sideshow listening on http://${authority}:${info.port}` + + (hostname ? "" : " (all interfaces — set SIDESHOW_HOST to restrict)"), + ); }); From 94ae8fbe48e429221f42a26a1c9cf0df81d53570 Mon Sep 17 00:00:00 2001 From: Ben Vinegar Date: Sun, 16 Aug 2026 09:13:45 -0400 Subject: [PATCH 2/2] fix(cli): open selected bind address --- bin/serveUrl.d.ts | 1 + bin/serveUrl.js | 7 +++++++ bin/sideshow.js | 5 ++--- test/cli.test.ts | 9 +++++++++ 4 files changed, 19 insertions(+), 3 deletions(-) create mode 100644 bin/serveUrl.d.ts create mode 100644 bin/serveUrl.js diff --git a/bin/serveUrl.d.ts b/bin/serveUrl.d.ts new file mode 100644 index 00000000..ce5e5737 --- /dev/null +++ b/bin/serveUrl.d.ts @@ -0,0 +1 @@ +export function serveUrl(host: string | undefined, port: string): string; diff --git a/bin/serveUrl.js b/bin/serveUrl.js new file mode 100644 index 00000000..9279d63f --- /dev/null +++ b/bin/serveUrl.js @@ -0,0 +1,7 @@ +// A wildcard listener is reachable locally, but its unspecified address is not +// a useful browser destination. Concrete bind addresses should be opened as-is. +export function serveUrl(host, port) { + const address = !host || host === "0.0.0.0" || host === "::" ? "localhost" : host; + const authority = address.includes(":") ? `[${address}]` : address; + return `http://${authority}:${port}`; +} diff --git a/bin/sideshow.js b/bin/sideshow.js index 4f8ec7e0..98112269 100755 --- a/bin/sideshow.js +++ b/bin/sideshow.js @@ -6,6 +6,7 @@ import { homedir, tmpdir, userInfo } from "node:os"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; import { parseArgs } from "node:util"; +import { serveUrl } from "./serveUrl.js"; const BASE = (process.env.SIDESHOW_URL ?? "http://localhost:8228").replace(/\/$/, ""); const TOKEN = process.env.SIDESHOW_TOKEN; @@ -905,9 +906,7 @@ const commands = { env: { ...process.env, PORT: port, ...(host ? { SIDESHOW_HOST: host } : {}) }, }); if (flags.open) { - // Always open localhost: a wildcard or loopback bind is reachable there, - // and it is the only address guaranteed to resolve on this machine. - const url = `http://localhost:${port}`; + const url = serveUrl(host, port); const { opener, openerArgs } = process.platform === "darwin" ? { opener: "open", openerArgs: [url] } diff --git a/test/cli.test.ts b/test/cli.test.ts index 4e989e58..e92c862c 100644 --- a/test/cli.test.ts +++ b/test/cli.test.ts @@ -9,6 +9,7 @@ import { fileURLToPath } from "node:url"; import { serve } from "@hono/node-server"; import { createApp } from "../server/app.ts"; import { JsonFileStore } from "../server/storage.ts"; +import { serveUrl } from "../bin/serveUrl.js"; const CLI = join(dirname(fileURLToPath(import.meta.url)), "..", "bin", "sideshow.js"); @@ -97,6 +98,14 @@ test("version runs end-to-end (update check is best-effort)", async () => { assert.match(stdout, /^sideshow \d+\.\d+\.\d+/); }); +test("serve --open URL uses the concrete bind address", () => { + assert.equal(serveUrl(undefined, "8228"), "http://localhost:8228"); + assert.equal(serveUrl("0.0.0.0", "8228"), "http://localhost:8228"); + assert.equal(serveUrl("::", "8228"), "http://localhost:8228"); + assert.equal(serveUrl("127.0.0.2", "8228"), "http://127.0.0.2:8228"); + assert.equal(serveUrl("::1", "8228"), "http://[::1]:8228"); +}); + // None of these reach the network: --help and option errors resolve in // parsing, before any request (no server needs to be running).