fix(web): move the app origin off 6276, bound the publish route

Copilot review round 2 on #2100, plus a collision the review did not find.

The default app-origin port was 6276 — which is already
RUNNER_OAUTH_CALLBACK_DEFAULT_PORT, the CLI/TUI loopback OAuth callback.
That port is fixed precisely so an OAuth app can pre-register
http://127.0.0.1:6276/oauth/callback, which makes it the one listener here
that cannot move when something takes it first. A running --web would hold
it and break a later --cli/--tui OAuth flow at its registered redirect URI,
and docs/mcp-app-review.md has those two running side by side. This
listener has a documented dynamic fallback, so this listener yields: the
default is now 6278 (6277 skipped — v1's retired proxy port, which the
migration guide tells people to stop forwarding). A test asserts the two
defaults stay distinct.

Review comments:

- POST /api/app-document now applies hono's bodyLimit BEFORE parsing. The
  8MiB check ran after c.req.json() had buffered and parsed the whole
  request, and bounded only `html` — so bulk in `csp`, or in keys the route
  never reads, was fully materialized before the 413.
- `csp` is validated as an HTTP header value and length-bounded. The
  controller hands it to res.writeHead() verbatim, where Node throws
  ERR_INVALID_CHAR synchronously — inside the handler for a later,
  unrelated request, outside this route's error handling.
- The app-origin port now propagates everywhere the other two do: the CLI
  --print-handoff port-forward command (and its test), the Dockerfile's ENV
  and EXPOSE, and the remote-review SSH recipe in docs/mcp-app-review.md.
  Without it an app declaring _meta.ui.domain is unreachable through every
  documented remote workflow.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0177mPHAdECD18nLLCTwR5rh
Signed-off-by: cliffhall <cliff@futurescale.com>
This commit is contained in:
cliffhall 2026-08-24 00:07:15 -04:00 • committed by GitHub
commit fafc8367b8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194

View file

@ -21,6 +21,7 @@ import {
resolveAppOriginPort,
appDocumentEmbedders,
} from "../../../../server/app-origin-controller.js";
import { RUNNER_OAUTH_CALLBACK_DEFAULT_PORT } from "@inspector/core/auth/node/runner-oauth-callback.js";
describe("resolveAppOriginPort", () => {
const saved = process.env.MCP_APP_ORIGIN_PORT;
@ -34,6 +35,19 @@ describe("resolveAppOriginPort", () => {
expect(resolveAppOriginPort()).toBe(DEFAULT_APP_ORIGIN_PORT);
});
it("does not default to the runners' fixed OAuth callback port", () => {
// Both listeners are fixed-by-default and both live in the 627x family, so
// the next free number is the obvious pick for each — which is how they
// collided. This one can move (it warns and retries on a dynamic port);
// the OAuth callback cannot, because apps pre-register
// `http://127.0.0.1:<that port>/oauth/callback`. A `--web` holding the
// port would therefore break a later `--cli`/`--tui` OAuth flow, and the
// handoff recipe runs those side by side.
expect(DEFAULT_APP_ORIGIN_PORT).not.toBe(
RUNNER_OAUTH_CALLBACK_DEFAULT_PORT,
);
});
it("honors a valid MCP_APP_ORIGIN_PORT, including an explicit 0", () => {
process.env.MCP_APP_ORIGIN_PORT = "9200";
expect(resolveAppOriginPort()).toBe(9200);
@ -41,7 +55,7 @@ describe("resolveAppOriginPort", () => {
expect(resolveAppOriginPort()).toBe(0);
});
it.each(["6276abc", "70000", "-1", "nope", " "])(
it.each(["6278abc", "70000", "-1", "nope", " "])(
"warns and falls back for an invalid value %j",
(value) => {
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
@ -357,7 +371,7 @@ describe("createAppOriginController", () => {
address: () => ({
address: "127.0.0.1",
family: "IPv4",
port: 6276,
port: 6278,
}),
close: (cb?: () => void) => cb?.(),
});
@ -368,11 +382,11 @@ describe("createAppOriginController", () => {
try {
const mod = await import("../../../../server/app-origin-controller.js");
const c = mod.createAppOriginController({
port: 6276,
port: 6278,
host: "127.0.0.1",
});
await c.start();
expect(c.getOrigin()).toBe("http://127.0.0.1:6276");
expect(c.getOrigin()).toBe("http://127.0.0.1:6278");
expect(c.publish({ html: "<p>x</p>" })).not.toBeNull();
emitter.emit(