From fa29968aa64069e2a43862ee53bed890e7d59fe6 Mon Sep 17 00:00:00 2001 From: weekbin Date: Tue, 22 Sep 2026 12:55:13 +0800 Subject: [PATCH] feat(webui): default to 18090 and fall back to the next free port MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `mcode-web` / `mcode webui` used to die on a taken port — and because the global uncaughtException handler only logs, the process could also stay alive without listening at all. The default port is now 18090, and it is a preference rather than a promise: when it is taken the server walks forward to the next free port (20 attempts) and logs the port it bound, which is the URL the launcher opens. 8080 was chosen in v0.5.bx-38 for its "web alt" reading, but it collides with desktop clients and other dev servers often enough that the fallback would fire regularly. An explicitly configured port stays exact — `PORT` set, or `--port` passed. That is deliberate: docker port publishing, the container healthcheck and the webui integration tests all address the configured value and cannot discover a fallback. A taken pinned port now exits 1 with an actionable message instead of a stack trace plus a live-but-not-listening process. An out-of-range or non-integer port throws synchronously out of `server.listen`, so that path now reaches the same handler instead of escaping to the global logger. The serving port is exposed through getServingPort() because it is read at request time, not at import time. Otherwise the CORS origin trust set would keep trusting the configured port while the browser talks to the fallback one, and /api/health, the state payload, the LAN share URL and the LAN 403 page would name the wrong port. isPortPinned is the single implementation of the pin rule. Tests: - test/port.test.js: the pin rule, the attempt budget, and the retry mechanics (a free port is used as-is; a taken one walks forward; a pinned one reports EADDRINUSE; an exhausted budget reports EADDRINUSE; an out-of-range port reports ERR_SOCKET_BAD_PORT instead of throwing out of the helper). - test/integration/port-fallback.test.js: a real server.js boot on a taken 18090 reports the next free port, /api/health agrees, and that port's browser Origin is trusted while the configured-but-unused port's is not. --- README.md | 6 +- README_ZH.md | 6 +- docs/webui.md | 6 +- docs/webui.zh-CN.md | 6 +- packages/tui/src/cli/mcode-web-entry.ts | 4 +- packages/tui/src/cli/program.ts | 4 +- packages/webui/README.md | 4 +- packages/webui/README.zh-CN.md | 4 +- packages/webui/docs/API.md | 12 +- packages/webui/docs/API.zh-CN.md | 12 +- packages/webui/docs/CHANGELOG.md | 14 ++ packages/webui/docs/DEVELOPMENT.md | 19 +- packages/webui/docs/DEVELOPMENT.zh-CN.md | 18 +- packages/webui/docs/HTTPS-REVERSE-PROXY.md | 22 +- .../webui/docs/HTTPS-REVERSE-PROXY.zh-CN.md | 22 +- packages/webui/docs/TROUBLESHOOTING.md | 34 ++- packages/webui/docs/TROUBLESHOOTING.zh-CN.md | 32 ++- packages/webui/package.json | 4 +- packages/webui/references/SECURITY-NOTES.md | 4 +- packages/webui/server.js | 46 +++- packages/webui/server/lib/config.js | 31 ++- packages/webui/server/lib/lan.js | 2 +- packages/webui/server/lib/port.js | 110 +++++++++ packages/webui/server/lib/settings.js | 14 +- packages/webui/server/router.js | 4 +- packages/webui/server/routes/health.js | 4 +- .../test/integration/port-fallback.test.js | 195 ++++++++++++++++ .../test/integration/router-boot.test.js | 4 +- .../test/integration/upload-limits.test.js | 2 +- packages/webui/test/lib-settings.test.js | 8 +- packages/webui/test/port.test.js | 214 ++++++++++++++++++ packages/webui/test/server-startup.test.js | 6 +- release/public-source.json | 3 + 33 files changed, 756 insertions(+), 120 deletions(-) create mode 100644 packages/webui/server/lib/port.js create mode 100644 packages/webui/test/integration/port-fallback.test.js create mode 100644 packages/webui/test/port.test.js diff --git a/README.md b/README.md index 983d1988..0c44395a 100644 --- a/README.md +++ b/README.md @@ -257,12 +257,14 @@ For now, code and documentation pull requests are accepted only from repository The same engine that powers the TUI also drives a browser frontend: ```bash -mcode-web # http://127.0.0.1:8080 (loopback by default) +mcode-web # http://127.0.0.1:18090 (loopback by default) mcode web # same thing — `web` and `webui` both work mcode webui --port 8123 # custom port, prints the URL ``` -From a source checkout use `pnpm mcode-web`. +From a source checkout use `pnpm mcode-web`. Without `--port` the server starts +on 18090 and moves to the next free port when 18090 is taken, printing the URL it +bound; an explicit `--port` is pinned and never moves. The Web UI streams chat over SSE, renders tool calls and permission prompts, manages sessions and workspaces (with a modal directory picker over `/api/fs/*`, confined to allowed workspace roots), shows the current model in the selector, and mounts a read-only session **trajectory studio** at `/trajectory/`. It binds loopback by default; LAN exposure is explicit opt-in and token-gated. See [packages/webui](packages/webui/README.md) and [docs/webui.md](docs/webui.md). diff --git a/README_ZH.md b/README_ZH.md index 67a1d211..ddb7bd72 100644 --- a/README_ZH.md +++ b/README_ZH.md @@ -256,12 +256,14 @@ node /absolute/path/to/minimax-code/dist/cli.js 驱动 TUI 的同一引擎也可以驱动浏览器前端: ```bash -mcode-web # http://127.0.0.1:8080(默认只绑定回环地址) +mcode-web # http://127.0.0.1:18090(默认只绑定回环地址) mcode web # 等价写法 —— `web` 与 `webui` 均可 mcode webui --port 8123 # 自定义端口,启动后打印访问地址 ``` -源码构建目录下使用 `pnpm mcode-web`。 +源码构建目录下使用 `pnpm mcode-web`。不带 `--port` 时服务器从 18090 启动, +18090 被占用就换下一个空闲端口,并打印实际绑定的地址;显式传入的 `--port` +会被钉住,不会自动后移。 Web UI 通过 SSE 流式输出对话,渲染工具调用与权限确认,管理会话与工作区(含模态目录选择器,接口限制在允许的工作区根内),模型选择器常显当前模型,并在 `/trajectory/` 挂载只读的会话**轨迹工作室**。默认仅绑定回环地址;局域网暴露需显式开启并通过令牌鉴权。详见 [packages/webui](packages/webui/README.md) 与 [docs/webui.md](docs/webui.md)。 diff --git a/docs/webui.md b/docs/webui.md index 555b572d..dadc0969 100644 --- a/docs/webui.md +++ b/docs/webui.md @@ -7,7 +7,7 @@ The Web UI (`packages/webui`) is the browser frontend for MiniMax Code. It uses ## Launch ```bash -mcode-web # http://127.0.0.1:8080 +mcode-web # http://127.0.0.1:18090 mcode web # equivalent — `web` and `webui` both resolve mcode webui --port 8123 --host 127.0.0.1 mcode webui --token "$(openssl rand -hex 16)" --host 0.0.0.0 # LAN, token-gated @@ -17,6 +17,8 @@ node packages/webui/server.js # direct, from a checkout The command resolves the webui package (installed `dist/webui/` or source `packages/webui/`), spawns the server as a child process, and points it back at the running CLI through `MCODE_WEBUI_SELF_ENTRY`. The webui then spawns `node acp` per active browser tab. +Without `--port` the server starts on 18090 and moves to the next free port when 18090 is taken, logging the URL it bound — the launcher opens that one. An explicit `--port` (or `PORT`) is pinned: it never moves, so a taken port exits with EADDRINUSE instead. + ## Running a development build The development Web UI runs alongside an installed official mcode without @@ -29,7 +31,7 @@ From a checkout of this repository: ```bash corepack pnpm install && corepack pnpm build # once, and after engine changes -node dist/cli.js webui # dev Web UI on 127.0.0.1:8080 +node dist/cli.js webui # dev Web UI on 127.0.0.1:18090 node dist/cli.js webui --port 8123 # keep the installed one free ``` diff --git a/docs/webui.zh-CN.md b/docs/webui.zh-CN.md index 0ac873fa..ad8ea652 100644 --- a/docs/webui.zh-CN.md +++ b/docs/webui.zh-CN.md @@ -7,7 +7,7 @@ Web UI(`packages/webui`)是 MiniMax Code 的浏览器前端。它使用与 T ## 启动 ```bash -mcode-web # http://127.0.0.1:8080 +mcode-web # http://127.0.0.1:18090 mcode web # equivalent — `web` and `webui` both resolve mcode webui --port 8123 --host 127.0.0.1 mcode webui --token "$(openssl rand -hex 16)" --host 0.0.0.0 # LAN, token-gated @@ -17,6 +17,8 @@ node packages/webui/server.js # direct, from a checkout 该命令解析 webui 包(已安装的 `dist/webui/` 或源码 `packages/webui/`),把服务器作为子进程启动,并通过 `MCODE_WEBUI_SELF_ENTRY` 将其指回正在运行的 CLI。然后 webui 会为每个活动的浏览器标签页生成一个 `node acp`。 +不传 `--port` 时服务器从 18090 启动,若 18090 被占用就换下一个空闲端口,并打印实际绑定的地址 —— 启动器打开的就是这个地址。显式指定的 `--port`(或 `PORT`)会被钉住:不会自动后移,端口被占用时以 EADDRINUSE 退出。 + ## 运行开发构建 开发版 Web UI 可以与已安装的官方 mcode 并行运行而不冲突: @@ -29,7 +31,7 @@ node packages/webui/server.js # direct, from a checkout ```bash corepack pnpm install && corepack pnpm build # once, and after engine changes -node dist/cli.js webui # dev Web UI on 127.0.0.1:8080 +node dist/cli.js webui # dev Web UI on 127.0.0.1:18090 node dist/cli.js webui --port 8123 # keep the installed one free ``` diff --git a/packages/tui/src/cli/mcode-web-entry.ts b/packages/tui/src/cli/mcode-web-entry.ts index 51ffb47c..25a9244a 100644 --- a/packages/tui/src/cli/mcode-web-entry.ts +++ b/packages/tui/src/cli/mcode-web-entry.ts @@ -10,7 +10,9 @@ import { parseArgs } from 'node:util'; import { runTuiWebuiCommand } from './run-webui-command.js'; const usage = `Usage: mcode-web [--port ] [--host
] [--token ] [--no-open] -Starts the MiniMax Code Web UI (same as 'mcode webui' / 'mcode web').`; +Starts the MiniMax Code Web UI (same as 'mcode webui' / 'mcode web'). +Without --port the server starts on 18090 and moves to the next free port when +18090 is taken; an explicit --port is pinned and never moves.`; let values: ReturnType>['values']; try { diff --git a/packages/tui/src/cli/program.ts b/packages/tui/src/cli/program.ts index 5ee74a8e..8164f19c 100644 --- a/packages/tui/src/cli/program.ts +++ b/packages/tui/src/cli/program.ts @@ -131,7 +131,9 @@ export function createTuiProgram(options: CreateTuiProgramOptions): Command { // `web` reads as the Web UI, not as a TUI prompt — accept both spellings. .alias('web') .description('Start the MiniMax Code Web UI (browser frontend driven by the same engine)') - .option('--port ', 'HTTP port (default 8080)', parsePort) + // An explicit --port is pinned: the webui binds it or exits, because + // docker port publishing and healthchecks address the configured value. + .option('--port ', 'HTTP port (default 18090, which moves to the next free port when taken)', parsePort) .option('--host
', 'bind address (default 127.0.0.1; LAN exposure is opt-in)') .option('--token ', 'auth token required for non-local requests') .option('--no-open', 'print the URL without opening a browser') diff --git a/packages/webui/README.md b/packages/webui/README.md index 3eaaba0c..876c7d2d 100644 --- a/packages/webui/README.md +++ b/packages/webui/README.md @@ -20,7 +20,7 @@ repository root for the full people & history record. ```bash # From a built checkout (or an installed mcode CLI): -mcode webui # http://127.0.0.1:8080 +mcode webui # http://127.0.0.1:18090 mcode webui --port 8123 --host 127.0.0.1 # Direct launch (development): @@ -37,7 +37,7 @@ Recommended on non-loopback networks: ```bash export TOKEN="$(openssl rand -hex 16)" mcode webui --host 0.0.0.0 -# open http://:8080/?token=$TOKEN +# open http://:18090/?token=$TOKEN ``` ## What's in the box diff --git a/packages/webui/README.zh-CN.md b/packages/webui/README.zh-CN.md index d5a2f1b4..6a0853f0 100644 --- a/packages/webui/README.zh-CN.md +++ b/packages/webui/README.zh-CN.md @@ -20,7 +20,7 @@ PR #56 的轨迹工作室),并作为 `packages/webui` 迁移入产品。完 ```bash # 从已构建的检出(或已安装的 mcode CLI): -mcode webui # http://127.0.0.1:8080 +mcode webui # http://127.0.0.1:18090 mcode webui --port 8123 --host 127.0.0.1 # 直接启动(开发): @@ -37,7 +37,7 @@ node packages/webui/server.js ```bash export TOKEN="$(openssl rand -hex 16)" mcode webui --host 0.0.0.0 -# 打开 http://:8080/?token=$TOKEN +# 打开 http://:18090/?token=$TOKEN ``` ## 目录内容 diff --git a/packages/webui/docs/API.md b/packages/webui/docs/API.md index cc1aba2e..2d4327ba 100644 --- a/packages/webui/docs/API.md +++ b/packages/webui/docs/API.md @@ -10,7 +10,7 @@ All non-API routes return static files (`server.js` → `serveStatic` / ## Conventions -- **Base URL**: `http://127.0.0.1:8080` (or LAN IP if enabled) +- **Base URL**: `http://127.0.0.1:18090` (or LAN IP if enabled) - **Path prefix**: `/api/` - **Content-Type**: `application/json; charset=utf-8` for both request and response - **Auth header**: if `TOKEN` env is set, every request must include either @@ -36,7 +36,7 @@ Returns server status. No auth required, no CID required. ```json { "ok": true, - "port": 8080, + "port": 18090, "defaultModel": "minimax_api/MiniMax-M3", "defaultWorkspace": "C:\\Users\\you\\.minimax-code\\webui", "mcodeCmd": "C:\\Users\\you\\.minimax-code\\mcode.cmd", @@ -341,12 +341,12 @@ state changes (see [ARCHITECTURE.md §5 SSE state push](./ARCHITECTURE.md#5-sse- { "ok": true, "lanBroadcast": true, - "port": 8080, + "port": 18090, "host": "127.0.0.1", "lanIp": "192.168.1.50", - "lanUrl": "http://192.168.1.50:8080", - "lanUrlWithToken": "http://192.168.1.50:8080/?token=…", // 🔒 v2 — FIRST-RUN BOOTSTRAP ONLY: present while tokenAcknowledged=false, omitted entirely after ack (UI falls back to lanUrl); re-issued once per rotation - "localUrl": "http://127.0.0.1:8080", + "lanUrl": "http://192.168.1.50:18090", + "lanUrlWithToken": "http://192.168.1.50:18090/?token=…", // 🔒 v2 — FIRST-RUN BOOTSTRAP ONLY: present while tokenAcknowledged=false, omitted entirely after ack (UI falls back to lanUrl); re-issued once per rotation + "localUrl": "http://127.0.0.1:18090", "lanBind": false, // 🔒 v2 — persisted LAN-bind opt-in; true binds 0.0.0.0 on next boot (env HOST still wins) "bindHost": "127.0.0.1", // 🔒 v2 — what the NEXT boot resolves to (env HOST > lanBind > loopback) "lanExposed": false, // 🔒 v2 — effective bind is not loopback diff --git a/packages/webui/docs/API.zh-CN.md b/packages/webui/docs/API.zh-CN.md index fb16761b..6312ca5d 100644 --- a/packages/webui/docs/API.zh-CN.md +++ b/packages/webui/docs/API.zh-CN.md @@ -10,7 +10,7 @@ ## 约定 -- **基础 URL**:`http://127.0.0.1:8080`(若启用则为局域网 IP) +- **基础 URL**:`http://127.0.0.1:18090`(若启用则为局域网 IP) - **路径前缀**:`/api/` - **Content-Type**:请求与响应均为 `application/json; charset=utf-8` - **认证头**:若设置了 `TOKEN` 环境变量,每个请求必须包含以下二者之一 @@ -36,7 +36,7 @@ ```json { "ok": true, - "port": 8080, + "port": 18090, "defaultModel": "minimax_api/MiniMax-M3", "defaultWorkspace": "C:\\Users\\you\\.minimax-code\\webui", "mcodeCmd": "C:\\Users\\you\\.minimax-code\\mcode.cmd", @@ -339,12 +339,12 @@ Linux 上为 `/`) { "ok": true, "lanBroadcast": true, - "port": 8080, + "port": 18090, "host": "127.0.0.1", "lanIp": "192.168.1.50", - "lanUrl": "http://192.168.1.50:8080", - "lanUrlWithToken": "http://192.168.1.50:8080/?token=…", // 🔒 v2 — FIRST-RUN BOOTSTRAP ONLY: present while tokenAcknowledged=false, omitted entirely after ack (UI falls back to lanUrl); re-issued once per rotation - "localUrl": "http://127.0.0.1:8080", + "lanUrl": "http://192.168.1.50:18090", + "lanUrlWithToken": "http://192.168.1.50:18090/?token=…", // 🔒 v2 — FIRST-RUN BOOTSTRAP ONLY: present while tokenAcknowledged=false, omitted entirely after ack (UI falls back to lanUrl); re-issued once per rotation + "localUrl": "http://127.0.0.1:18090", "lanBind": false, // 🔒 v2 — persisted LAN-bind opt-in; true binds 0.0.0.0 on next boot (env HOST still wins) "bindHost": "127.0.0.1", // 🔒 v2 — what the NEXT boot resolves to (env HOST > lanBind > loopback) "lanExposed": false, // 🔒 v2 — effective bind is not loopback diff --git a/packages/webui/docs/CHANGELOG.md b/packages/webui/docs/CHANGELOG.md index e6526912..051223e3 100644 --- a/packages/webui/docs/CHANGELOG.md +++ b/packages/webui/docs/CHANGELOG.md @@ -9,6 +9,20 @@ This project follows [Keep a Changelog](https://keepachangelog.com/). The `## Unreleased` section at the top tracks changes that have landed on the development branch but are not yet cut into a release. +## Unreleased + +### Changed + +- **默认端口 8080 → 18090**。8080 在桌面机与开发机上被各类服务占用得太频繁。 + 显式设置的 `PORT`(或 `mcode-web --port`)仍按精确值处理,不受此影响。 +- **默认端口被占用时自动回退**到下一个空闲端口(最多尝试 20 个),并打印实际绑定 + 的端口 —— 启动器(`mcode-web` / `mcode webui`)打开的就是这个地址。此前端口被 + 占用会以 EADDRINUSE 退出;又因为全局 `uncaughtException` 处理器只记录不退出, + 进程还可能停在“活着但没有在监听”的状态。 +- CORS origin 信任集、`/api/health`、state 快照与 LAN 分享 URL 改为按**实际监听 + 端口**(`getServingPort()`)计算。否则回退之后的浏览器 origin 会被自身的 CSRF + 网关拒绝,分享 URL 也会指向没有服务在听的端口。 + ## v2.0.0 — 2026-09-20 (工业化重写,同步自 MiniMax-Code-Plugins PR #55 @ 7b4aae8) v1.x 单体 `server.js` 的工业化重写。本轮同步包含 PR #55 全量 26 提交, diff --git a/packages/webui/docs/DEVELOPMENT.md b/packages/webui/docs/DEVELOPMENT.md index a6f0f859..3dc08bdd 100644 --- a/packages/webui/docs/DEVELOPMENT.md +++ b/packages/webui/docs/DEVELOPMENT.md @@ -20,7 +20,7 @@ Zero npm install. Clone, run: ```powershell cd ~/.minimax-code/webui node server.js -# → http://127.0.0.1:8080 +# → http://127.0.0.1:18090 ``` If you want a debug session (verbose SSE, no cache, injectable events): @@ -145,7 +145,7 @@ command; the webui picks it up on connect. ## Testing without mcode 1. Set `$env:DEBUG_INJECT = '1'` before `node server.js`. -2. Open `http://127.0.0.1:8080/?debug=1` (or just check the right +2. Open `http://127.0.0.1:18090/?debug=1` (or just check the right panel — the debug panel is always visible). 3. In the browser console: ```js @@ -186,9 +186,18 @@ After changing `public/app/main.js`: 2. Bump N. (Current value: see the comment above the script tag.) ### Change the default port -```powershell -$env:PORT = 8080 +18090 is a default, not a pinned value: when it is taken the server walks +forward to the next free port and logs the one it bound. Setting `PORT` (or +passing `--port` to `mcode-web`) pins the port instead — a taken pinned port +exits with EADDRINUSE rather than moving, so docker port publishing and +healthchecks keep addressing the configured value. + +```bash +# default port: 18090, or the next free port when 18090 is taken node server.js + +# pinned to 7891 — never moves +PORT=7891 node server.js ``` ### Enable LAN sharing @@ -214,7 +223,7 @@ The webui will spawn a fresh one on the next send. The `/api/settings` endpoint is exempt from the LAN guard by design. From any machine on the LAN, even with `lanBroadcast: false`: ```bash -curl -X POST http://192.168.1.50:8080/api/settings \ +curl -X POST http://192.168.1.50:18090/api/settings \ -H 'Content-Type: application/json' \ -d '{"lanBroadcast": true}' ``` diff --git a/packages/webui/docs/DEVELOPMENT.zh-CN.md b/packages/webui/docs/DEVELOPMENT.zh-CN.md index dc2d8d4b..d498214d 100644 --- a/packages/webui/docs/DEVELOPMENT.zh-CN.md +++ b/packages/webui/docs/DEVELOPMENT.zh-CN.md @@ -20,7 +20,7 @@ ```powershell cd ~/.minimax-code/webui node server.js -# → http://127.0.0.1:8080 +# → http://127.0.0.1:18090 ``` 如果想要调试会话(详细 SSE、无缓存、可注入事件): @@ -146,7 +146,7 @@ webui 会在连接时获取它。 ## 在没有 mcode 的情况下测试 1. 在 `node server.js` 之前设置 `$env:DEBUG_INJECT = '1'`。 -2. 打开 `http://127.0.0.1:8080/?debug=1`(或者直接查看右侧 +2. 打开 `http://127.0.0.1:18090/?debug=1`(或者直接查看右侧 面板——调试面板始终可见)。 3. 在浏览器控制台中: ```js @@ -187,9 +187,17 @@ mcode 侧的会话存储是 SQLite: 2. 增大 N。(当前值:见 script 标签上方的注释。) ### 修改默认端口 -```powershell -$env:PORT = 8080 +18090 只是默认值,不是被钉住的值:它被占用时服务器会往后找下一个空闲 +端口,并打印实际绑定的端口。设置 `PORT`(或给 `mcode-web` 传 `--port`) +则是把端口钉住 —— 被占用的钉住端口会以 EADDRINUSE 退出而不是自动后移, +这样 docker 端口发布和健康检查仍然按配置值寻址。 + +```bash +# 默认端口:18090,被占用时换下一个空闲端口 node server.js + +# 钉在 7891 —— 不会移动 +PORT=7891 node server.js ``` ### 启用局域网共享 @@ -215,7 +223,7 @@ webui 会在下一次发送时启动一个新的子进程。 `/api/settings` 端点在设计上豁免局域网守卫。 从局域网上的任何机器,即使 `lanBroadcast: false`: ```bash -curl -X POST http://192.168.1.50:8080/api/settings \ +curl -X POST http://192.168.1.50:18090/api/settings \ -H 'Content-Type: application/json' \ -d '{"lanBroadcast": true}' ``` diff --git a/packages/webui/docs/HTTPS-REVERSE-PROXY.md b/packages/webui/docs/HTTPS-REVERSE-PROXY.md index 6c2179ec..75e1813a 100644 --- a/packages/webui/docs/HTTPS-REVERSE-PROXY.md +++ b/packages/webui/docs/HTTPS-REVERSE-PROXY.md @@ -25,7 +25,7 @@ | Capability | Status | Why | |---|---|---| | Built-in HTTPS server | ❌ | Node's `https.createServer` needs cert + key files; ops would have to be re-implemented for cert rotation, SNI, OCSP stapling, ALPN. The Node stdlib also lacks ACME. We deliberately don't ship a TLS stack. | -| Reverse-proxy-friendly | ✅ | The webui serves the full HTTP API on `PORT` (default 8080). 🔒 v2 (PR #55 review point 2): the default bind is now loopback `127.0.0.1` — for a same-host proxy that is exactly right (proxy → `127.0.0.1:8080`). If the proxy lives on another host, opt into a wider bind explicitly: `HOST=0.0.0.0` env or the persisted `lanBind` setting (`POST /api/settings {lanBind: true}`). nginx / caddy / Traefik in front is the recommended deployment shape. | +| Reverse-proxy-friendly | ✅ | The webui serves the full HTTP API on `PORT` (default 18090). 🔒 v2 (PR #55 review point 2): the default bind is now loopback `127.0.0.1` — for a same-host proxy that is exactly right (proxy → `127.0.0.1:18090`). If the proxy lives on another host, opt into a wider bind explicitly: `HOST=0.0.0.0` env or the persisted `lanBind` setting (`POST /api/settings {lanBind: true}`). nginx / caddy / Traefik in front is the recommended deployment shape. | | mTLS (client certs) | ❌ | Same as HTTPS — not in scope; configure mTLS at the proxy layer. | | Rate limiting | ✅ | Per-`{IP,token}` fixed-window limiter (see [`server/lib/rate-limit.js`](../server/lib/rate-limit.js)). Token holders get 2x. Loopback bypasses entirely. | @@ -60,14 +60,14 @@ present but untrusted is rejected 403 *before* every other gate. Behind a reverse proxy the browser's `Origin` is your **external** origin — `https://webui.example.com` — not the webui's own -`http://127.0.0.1:8080`. The webui only trusts its own serving origins +`http://127.0.0.1:18090`. The webui only trusts its own serving origins (loopback + LAN address while LAN sharing is on) plus an explicit allowlist, so **you must register the external origin** or the SPA's own authenticated `POST`s will 403 and cross-origin reads will fail: ```bash # one-time, against the local webui (adjust scheme/host/port): -curl -X POST http://127.0.0.1:8080/api/settings \ +curl -X POST http://127.0.0.1:18090/api/settings \ -H 'Content-Type: application/json' \ -H "Authorization: Bearer " \ -d '{"trustedOrigins": ["https://webui.example.com"]}' @@ -112,17 +112,17 @@ SSE long connections (`/api/events`, `/api/alerts`) are the #1 source of "my pro ## 4. nginx -Tested against nginx 1.24.x. Replace every `example.com`, `/path/to/`, and `127.0.0.1:8080` with your values. +Tested against nginx 1.24.x. Replace every `example.com`, `/path/to/`, and `127.0.0.1:18090` with your values. ```nginx # /etc/nginx/sites-available/mcode-webui.conf -# Upstream: the webui binds 127.0.0.1:8080 by default (v2 loopback +# Upstream: the webui binds 127.0.0.1:18090 by default (v2 loopback # default) — ideal for a same-host proxy. If the proxy runs on another # host, widen the bind explicitly on the webui process (HOST=0.0.0.0 # env, or POST /api/settings {lanBind: true}). Remember to register # the external origin in trustedOrigins — see §2.1. upstream mcode_webui_upstream { - server 127.0.0.1:8080; + server 127.0.0.1:18090; keepalive 32; } @@ -235,7 +235,7 @@ webui.example.com { } # ----- Reverse proxy base config ----- - reverse_proxy http://127.0.0.1:8080 { + reverse_proxy http://127.0.0.1:18090 { # SSE: keep the upstream connection open. Caddy's default is # 30s; bump to 1h to match the webui's keepalive. transport http { @@ -269,7 +269,7 @@ webui.example.com { path /api/events /api/alerts } handle @sse_paths { - reverse_proxy http://127.0.0.1:8080 { + reverse_proxy http://127.0.0.1:18090 { transport http { read_timeout 1h # SSE: do NOT buffer. Caddy 2.7+ default is fine. @@ -287,12 +287,12 @@ webui.example.com { # /api/health is exempted from the webui's rate limiter (router.js # Gate 4), so orchestrators won't trip on it. handle /api/health { - reverse_proxy http://127.0.0.1:8080 + reverse_proxy http://127.0.0.1:18090 } # ----- Catch-all for SPA + remaining API ----- handle { - reverse_proxy http://127.0.0.1:8080 + reverse_proxy http://127.0.0.1:18090 } } ``` @@ -336,7 +336,7 @@ http: mcode-webui: loadBalancer: servers: - - url: "http://127.0.0.1:8080" + - url: "http://127.0.0.1:18090" # SSE: keep the connection alive longer than Traefik's 30s default. # Traefik's `serversTransport` controls this. serversTransport: mcode-webui-transport diff --git a/packages/webui/docs/HTTPS-REVERSE-PROXY.zh-CN.md b/packages/webui/docs/HTTPS-REVERSE-PROXY.zh-CN.md index a77a1a68..28cf55dc 100644 --- a/packages/webui/docs/HTTPS-REVERSE-PROXY.zh-CN.md +++ b/packages/webui/docs/HTTPS-REVERSE-PROXY.zh-CN.md @@ -25,7 +25,7 @@ | 能力 | 状态 | 原因 | |---|---|---| | 内置 HTTPS 服务器 | ❌ | Node 的 `https.createServer` 需要证书 + 私钥文件;证书轮换、SNI、OCSP stapling、ALPN 等运维工作都得重新实现。Node 标准库还缺少 ACME。我们刻意不附带 TLS 栈。 | -| 对反向代理友好 | ✅ | webui 在 `PORT`(默认 8080)上提供完整的 HTTP API。🔒 v2(PR #55 评审意见第 2 点):默认绑定现在是回环 `127.0.0.1` —— 对于同主机代理来说这正好合适(proxy → `127.0.0.1:8080`)。如果代理在另一台主机上,请显式选择更宽的绑定:`HOST=0.0.0.0` 环境变量,或持久化的 `lanBind` 设置(`POST /api/settings {lanBind: true}`)。在前面架 nginx / caddy / Traefik 是推荐的部署形态。 | +| 对反向代理友好 | ✅ | webui 在 `PORT`(默认 18090)上提供完整的 HTTP API。🔒 v2(PR #55 评审意见第 2 点):默认绑定现在是回环 `127.0.0.1` —— 对于同主机代理来说这正好合适(proxy → `127.0.0.1:18090`)。如果代理在另一台主机上,请显式选择更宽的绑定:`HOST=0.0.0.0` 环境变量,或持久化的 `lanBind` 设置(`POST /api/settings {lanBind: true}`)。在前面架 nginx / caddy / Traefik 是推荐的部署形态。 | | mTLS(客户端证书) | ❌ | 与 HTTPS 相同 —— 不在范围内;请在代理层配置 mTLS。 | | 速率限制 | ✅ | 按 `{IP,token}` 的固定窗口限流器(见 [`server/lib/rate-limit.js`](../server/lib/rate-limit.js))。令牌持有者获得 2 倍配额。回环完全绕过。 | @@ -60,14 +60,14 @@ webui 接受两种令牌载体(见 [`server/lib/auth.js`](../server/lib/auth.j 在反向代理之后,浏览器的 `Origin` 是你的**外部** 源 —— `https://webui.example.com` —— 而不是 webui 自己的 -`http://127.0.0.1:8080`。webui 只信任自己的服务源 +`http://127.0.0.1:18090`。webui 只信任自己的服务源 (回环 + 局域网共享开启时的局域网地址)加上显式的 允许清单,所以**你必须注册外部源**,否则 SPA 自己的 已认证 `POST` 会 403,跨源读取也会失败: ```bash # one-time, against the local webui (adjust scheme/host/port): -curl -X POST http://127.0.0.1:8080/api/settings \ +curl -X POST http://127.0.0.1:18090/api/settings \ -H 'Content-Type: application/json' \ -H "Authorization: Bearer " \ -d '{"trustedOrigins": ["https://webui.example.com"]}' @@ -110,17 +110,17 @@ SSE 长连接(`/api/events`、`/api/alerts`)是"我的代理除了实时推 ## 4. nginx -已针对 nginx 1.24.x 测试。把所有 `example.com`、`/path/to/` 和 `127.0.0.1:8080` 替换为你自己的值。 +已针对 nginx 1.24.x 测试。把所有 `example.com`、`/path/to/` 和 `127.0.0.1:18090` 替换为你自己的值。 ```nginx # /etc/nginx/sites-available/mcode-webui.conf -# Upstream: the webui binds 127.0.0.1:8080 by default (v2 loopback +# Upstream: the webui binds 127.0.0.1:18090 by default (v2 loopback # default) — ideal for a same-host proxy. If the proxy runs on another # host, widen the bind explicitly on the webui process (HOST=0.0.0.0 # env, or POST /api/settings {lanBind: true}). Remember to register # the external origin in trustedOrigins — see §2.1. upstream mcode_webui_upstream { - server 127.0.0.1:8080; + server 127.0.0.1:18090; keepalive 32; } @@ -233,7 +233,7 @@ webui.example.com { } # ----- Reverse proxy base config ----- - reverse_proxy http://127.0.0.1:8080 { + reverse_proxy http://127.0.0.1:18090 { # SSE: keep the upstream connection open. Caddy's default is # 30s; bump to 1h to match the webui's keepalive. transport http { @@ -267,7 +267,7 @@ webui.example.com { path /api/events /api/alerts } handle @sse_paths { - reverse_proxy http://127.0.0.1:8080 { + reverse_proxy http://127.0.0.1:18090 { transport http { read_timeout 1h # SSE: do NOT buffer. Caddy 2.7+ default is fine. @@ -285,12 +285,12 @@ webui.example.com { # /api/health is exempted from the webui's rate limiter (router.js # Gate 4), so orchestrators won't trip on it. handle /api/health { - reverse_proxy http://127.0.0.1:8080 + reverse_proxy http://127.0.0.1:18090 } # ----- Catch-all for SPA + remaining API ----- handle { - reverse_proxy http://127.0.0.1:8080 + reverse_proxy http://127.0.0.1:18090 } } ``` @@ -334,7 +334,7 @@ http: mcode-webui: loadBalancer: servers: - - url: "http://127.0.0.1:8080" + - url: "http://127.0.0.1:18090" # SSE: keep the connection alive longer than Traefik's 30s default. # Traefik's `serversTransport` controls this. serversTransport: mcode-webui-transport diff --git a/packages/webui/docs/TROUBLESHOOTING.md b/packages/webui/docs/TROUBLESHOOTING.md index a1ebb0cf..1db6c3cf 100644 --- a/packages/webui/docs/TROUBLESHOOTING.md +++ b/packages/webui/docs/TROUBLESHOOTING.md @@ -49,7 +49,7 @@ of date and the browser is running an old main.js. the line number. Look up the element ID in that line and either restore it in `index.html` or remove the JS reference. -## `Failed to load resource: net::ERR_CONNECTION_REFUSED` to `127.0.0.1:8080` +## `Failed to load resource: net::ERR_CONNECTION_REFUSED` to `127.0.0.1:18090` **Symptoms**: devtools shows the SSE or `/api/state` request failing with "connection refused". UI shows "init fail" or is stuck on @@ -59,7 +59,7 @@ with "connection refused". UI shows "init fail" or is stuck on port. **Fix**: -1. Check the server is up: `curl http://127.0.0.1:8080/api/health` +1. Check the server is up: `curl http://127.0.0.1:18090/api/health` should return JSON. 2. If not running, start it: `cd webui; node server.js`. 3. If running on a different port, set `$env:PORT = ` and @@ -86,7 +86,7 @@ appear in the webui's session list. mcode sqlite via `GET /api/acp-sessions` on init. **Fix**: -1. Check `curl 'http://127.0.0.1:8080/api/acp-sessions?cid='` +1. Check `curl 'http://127.0.0.1:18090/api/acp-sessions?cid='` — should return a list of sessions. 2. If empty, the mcode database is empty or the path is wrong. Check `$env:USERPROFILE\.minimax\v2\sqlite\runtime-state.sqlite` @@ -215,7 +215,7 @@ or shows the wrong value. `state` events. **Fix**: -1. Check `curl 'http://127.0.0.1:8080/api/state?cid=' | jq .permissions` +1. Check `curl 'http://127.0.0.1:18090/api/state?cid=' | jq .permissions` 2. If empty, mcode hasn't reported the current permission mode. Send any message — the next SSE event will include it. 3. If the webui shows the wrong value, it's because mcode 0.1.5 @@ -235,20 +235,30 @@ cache. the main.js response — it should include the version comment "v0.5.bx-NN". -## Server won't start: "EADDRINUSE :::8080" +## Server won't start: "cannot listen on 127.0.0.1:18090 — EADDRINUSE" -**Symptoms**: `node server.js` exits with EADDRINUSE. +**Symptoms**: `node server.js` prints +`[webui] cannot listen on 127.0.0.1:18090 — EADDRINUSE` and exits 1. -**Cause**: another process is already listening on port 8080. -Usually a previous webui process that didn't clean up. +**Cause**: the port was pinned — `PORT` was set, or `mcode-web --port` +was passed — and another process is already listening on it. Usually a +previous webui process that didn't clean up. + +A pinned port never moves: docker port publishing, container +healthchecks and deployment scripts address the configured value and +cannot discover a fallback. The **default** port (nothing configured) +behaves differently — it walks forward to the next free port and logs +`[webui] port 18090 is already in use — trying 18091`. **Fix**: 1. Find the conflicting process: ```powershell - Get-NetTCPConnection -State Listen -LocalPort 8080 + Get-NetTCPConnection -State Listen -LocalPort 18090 ``` 2. Kill it: `Stop-Process -Id -Force` 3. Or use a different port: `$env:PORT = 7891; node server.js` +4. Or stop pinning the port: unset `PORT` and run `mcode-web` without + `--port`, so the server takes the next free port itself. ## Token auth: 401 Unauthorized @@ -258,13 +268,13 @@ Usually a previous webui process that didn't clean up. client isn't sending it. Or the token mismatch. **Fix**: -1. Check `curl http://127.0.0.1:8080/api/health` — should work +1. Check `curl http://127.0.0.1:18090/api/health` — should work without auth (health is exempt). 2. Check the URL the webui is using: it should have `?token=…` appended, OR the request should have `Authorization: Bearer …`. 3. The webui auto-injects the token from the URL query string. - Make sure you opened `http://127.0.0.1:8080/?token=…`, not - `http://127.0.0.1:8080/`. + Make sure you opened `http://127.0.0.1:18090/?token=…`, not + `http://127.0.0.1:18090/`. ## Server starts but no mcode subprocess spawns diff --git a/packages/webui/docs/TROUBLESHOOTING.zh-CN.md b/packages/webui/docs/TROUBLESHOOTING.zh-CN.md index 79df1742..da6e1009 100644 --- a/packages/webui/docs/TROUBLESHOOTING.zh-CN.md +++ b/packages/webui/docs/TROUBLESHOOTING.zh-CN.md @@ -47,7 +47,7 @@ HTML 元素;或者 `cache-bust?v=N` 查询参数已过期, 找到元素 ID,然后要么在 `index.html` 中恢复它, 要么移除对应的 JS 引用。 -## `Failed to load resource: net::ERR_CONNECTION_REFUSED` 指向 `127.0.0.1:8080` +## `Failed to load resource: net::ERR_CONNECTION_REFUSED` 指向 `127.0.0.1:18090` **症状**:开发者工具显示 SSE 或 `/api/state` 请求失败, 提示 "connection refused"。UI 显示 "init fail" 或卡在 @@ -56,7 +56,7 @@ HTML 元素;或者 `cache-bust?v=N` 查询参数已过期, **根因**:服务器没有运行,或者运行在不同的端口上。 **修复**: -1. 检查服务器是否在运行:`curl http://127.0.0.1:8080/api/health` +1. 检查服务器是否在运行:`curl http://127.0.0.1:18090/api/health` 应返回 JSON。 2. 如果没有运行,启动它:`cd webui; node server.js`。 3. 如果运行在不同端口,设置 `$env:PORT = ` 并重启。 @@ -81,7 +81,7 @@ webui 的会话列表中。 `GET /api/acp-sessions` 查询 mcode sqlite。 **修复**: -1. 检查 `curl 'http://127.0.0.1:8080/api/acp-sessions?cid='` +1. 检查 `curl 'http://127.0.0.1:18090/api/acp-sessions?cid='` —— 应返回会话列表。 2. 如果为空,说明 mcode 数据库为空或路径不对。 检查 `$env:USERPROFILE\.minimax\v2\sqlite\runtime-state.sqlite` @@ -204,7 +204,7 @@ SIGTERM,但子进程需要一点时间才会退出,而进行中的 `state.permissions` 中。webui 从 SSE `state` 事件中读取它。 **修复**: -1. 检查 `curl 'http://127.0.0.1:8080/api/state?cid=' | jq .permissions` +1. 检查 `curl 'http://127.0.0.1:18090/api/state?cid=' | jq .permissions` 2. 如果为空,说明 mcode 还没有上报当前权限模式。 发送任意消息 —— 下一个 SSE 事件就会包含它。 3. 如果 webui 显示错误的值,那是因为 mcode 0.1.5 的 @@ -223,20 +223,28 @@ SIGTERM,但子进程需要一点时间才会退出,而进行中的 main.js 的响应 —— 它应该包含版本注释 "v0.5.bx-NN"。 -## 服务器无法启动:"EADDRINUSE :::8080" +## 服务器无法启动:"cannot listen on 127.0.0.1:18090 — EADDRINUSE" -**症状**:`node server.js` 以 EADDRINUSE 退出。 +**症状**:`node server.js` 打印 +`[webui] cannot listen on 127.0.0.1:18090 — EADDRINUSE` 并以 1 退出。 -**根因**:另一个进程已经在监听 8080 端口。 -通常是没有清理干净的旧 webui 进程。 +**根因**:端口被"钉住"了 —— 设置了 `PORT`,或传了 `mcode-web --port` +—— 而另一个进程已经在监听该端口。通常是没有清理干净的旧 webui 进程。 + +被钉住的端口不会自动后移:docker 端口发布、容器健康检查以及部署脚本 +都按配置值寻址,无法发现回退。**默认**端口(未做任何配置)的行为不同 +—— 它会往后找下一个空闲端口,并打印 +`[webui] port 18090 is already in use — trying 18091`。 **修复**: 1. 找到冲突的进程: ```powershell - Get-NetTCPConnection -State Listen -LocalPort 8080 + Get-NetTCPConnection -State Listen -LocalPort 18090 ``` 2. 杀掉它:`Stop-Process -Id -Force` 3. 或者使用不同的端口:`$env:PORT = 7891; node server.js` +4. 或者不再钉住端口:取消 `PORT`,并且运行 `mcode-web` 时不带 + `--port`,由服务器自己取下一个空闲端口。 ## 令牌认证:401 Unauthorized @@ -246,13 +254,13 @@ main.js 的响应 —— 它应该包含版本注释 发送它。或者令牌不匹配。 **修复**: -1. 检查 `curl http://127.0.0.1:8080/api/health` —— 不带认证 +1. 检查 `curl http://127.0.0.1:18090/api/health` —— 不带认证 应该也能工作(health 是豁免的)。 2. 检查 webui 使用的 URL:应该附加了 `?token=…`, 或者请求应带有 `Authorization: Bearer …`。 3. webui 会从 URL 查询字符串自动注入令牌。请确保你打开的 - 是 `http://127.0.0.1:8080/?token=…`,而不是 - `http://127.0.0.1:8080/`。 + 是 `http://127.0.0.1:18090/?token=…`,而不是 + `http://127.0.0.1:18090/`。 ## 服务器启动了但没有生成 mcode 子进程 diff --git a/packages/webui/package.json b/packages/webui/package.json index ba9d80d9..29583f2d 100644 --- a/packages/webui/package.json +++ b/packages/webui/package.json @@ -23,7 +23,7 @@ "node": ">=22.19 <23 || >=24.2 <27" }, "mcodeWebui": { - "defaultPort": 8080, + "defaultPort": 18090, "dataDir": "~/.mcode-webui", "history": "Originally the mcode-webui plugin by Wzdhehe et al.; see co-builders.md at the repository root.", "capabilities": [ @@ -69,7 +69,7 @@ }, { "name": "lan-sharing", - "description": "Binds loopback `127.0.0.1:8080` by default; LAN exposure is an explicit opt-in — the `HOST` env var or the persisted `lanBind` setting (`POST /api/settings {lanBind: true}`, effective on restart). A runtime toggle (`POST /api/settings {lanBroadcast: false}`) additionally closes the LAN gate with a bilingual 403 page. The settings snapshot discloses the live exposure via `lanExposed` / `bindRestartPending` / `lanExposureNotice`." + "description": "Binds loopback `127.0.0.1:18090` by default; LAN exposure is an explicit opt-in — the `HOST` env var or the persisted `lanBind` setting (`POST /api/settings {lanBind: true}`, effective on restart). A runtime toggle (`POST /api/settings {lanBroadcast: false}`) additionally closes the LAN gate with a bilingual 403 page. The settings snapshot discloses the live exposure via `lanExposed` / `bindRestartPending` / `lanExposureNotice`." }, { "name": "token-auth", diff --git a/packages/webui/references/SECURITY-NOTES.md b/packages/webui/references/SECURITY-NOTES.md index ad63368f..56e0bfe3 100644 --- a/packages/webui/references/SECURITY-NOTES.md +++ b/packages/webui/references/SECURITY-NOTES.md @@ -54,7 +54,7 @@ | Surface | Default | Override | Notes | |---------|---------|----------|-------| -| HTTP bind | `127.0.0.1:8080` | `HOST` / `PORT` env; persisted `lanBind` setting | v2 security hardening (PR #55 review point 2): the socket bind is loopback unless the operator explicitly opts into LAN exposure. Resolution order: env `HOST` (trimmed, non-empty) > `lanBind === true` → `0.0.0.0` > `127.0.0.1` (see `server/lib/config.js#resolveBindHost`). Existing deployments with an explicit `HOST` keep their bind unchanged. | +| HTTP bind | `127.0.0.1:18090` | `HOST` / `PORT` env; persisted `lanBind` setting | v2 security hardening (PR #55 review point 2): the socket bind is loopback unless the operator explicitly opts into LAN exposure. Resolution order: env `HOST` (trimmed, non-empty) > `lanBind === true` → `0.0.0.0` > `127.0.0.1` (see `server/lib/config.js#resolveBindHost`). Existing deployments with an explicit `HOST` keep their bind unchanged. | | LAN exposure opt-in | off | `POST /api/settings {lanBind: true}` (persisted; binds `0.0.0.0` on the next boot) or `HOST` env | The settings snapshot discloses the real exposure surface: `lanBind`, `bindHost` (what the next boot resolves to), `lanExposed`, `bindRestartPending`, and a bilingual `lanExposureNotice` string. | | LAN broadcast toggle | `true` (UI) | `/api/settings` POST `{lanBroadcast: false}` | Server returns 403 with a friendly page when off and request is non-local. Toggle is **runtime**; resets to default on restart. | | Browser origin boundary | trusted-origin reflection only | `trustedOrigins` setting (§ CORS) | Mutating requests (`POST` / `DELETE`) carrying an untrusted `Origin` header are 403'd before every other gate, **including the loopback exemption**. Origin-less clients (curl, MCP, CLI) are unaffected. | @@ -97,7 +97,7 @@ The webui only forwards stdin / parses stdout / renders the SSE stream. way to configure the auth token is the `TOKEN` environment variable. ### 2.3 Token in URL query string -- Browser opens `http://:8080/?token=` and the webui +- Browser opens `http://:18090/?token=` and the webui auto-injects the token into every `fetch` / `EventSource` call as `?token=` AND as `Authorization: Bearer`. - **Risk**: query string ends up in browser history, server access logs diff --git a/packages/webui/server.js b/packages/webui/server.js index 5096ffcd..c6f05c59 100644 --- a/packages/webui/server.js +++ b/packages/webui/server.js @@ -21,7 +21,8 @@ import http from 'node:http' import { existsSync, mkdirSync } from 'node:fs' -import { installGlobalErrorHandlers, MCODE_CMD, UPLOAD_DIR, WEBUI_DATA_DIR, PORT, HOST, DEFAULT_MODEL, DEFAULT_WORKSPACE, SESSIONS_DB, TOKEN_STDOUT } from './server/lib/config.js' +import { installGlobalErrorHandlers, MCODE_CMD, UPLOAD_DIR, WEBUI_DATA_DIR, PORT, PORT_PINNED, setServingPort, HOST, DEFAULT_MODEL, DEFAULT_WORKSPACE, SESSIONS_DB, TOKEN_STDOUT } from './server/lib/config.js' +import { listenWithPortFallback, MAX_PORT_ATTEMPTS } from './server/lib/port.js' import { LAN_IP } from './server/lib/lan.js' import { handleRequest } from './server/router.js' import { runStartupCleanup } from './server/cleanup.js' @@ -78,15 +79,40 @@ initSettings({ setAuthTokenEnabled(getTokenEnabled()) const server = http.createServer(handleRequest) -server.listen(PORT, HOST, () => { - console.log(`[webui] listening on http://${HOST}:${PORT}`) - console.log(`[webui] LAN url: http://${LAN_IP}:${PORT}`) - console.log(`[webui] mcode cmd: ${MCODE_CMD}`) - console.log(`[webui] default model: ${DEFAULT_MODEL}`) - console.log(`[webui] default workspace: ${DEFAULT_WORKSPACE}`) - console.log(`[webui] uploads: ${UPLOAD_DIR}`) - console.log(`[webui] sessions: ${SESSIONS_DB}`) - console.log(`[webui] settings: ${getPersistPath()}`) + +// 端口回退 (见 server/lib/port.js): 默认端口被占用时向后找空闲端口, 显式 PORT +// 不回退。日志里的端口必须是实际绑定值 —— 启动器 (mcode-web / mcode webui) 正是 +// 从 "listening on" 这一行取要打开的 URL, 而 origin 信任集 / share URL 按 +// setServingPort() 记录的端口计算。 +listenWithPortFallback(server, { + port: PORT, + host: HOST, + pinned: PORT_PINNED, + onListening: (boundPort) => { + setServingPort(boundPort) + console.log(`[webui] listening on http://${HOST}:${boundPort}`) + console.log(`[webui] LAN url: http://${LAN_IP}:${boundPort}`) + console.log(`[webui] mcode cmd: ${MCODE_CMD}`) + console.log(`[webui] default model: ${DEFAULT_MODEL}`) + console.log(`[webui] default workspace: ${DEFAULT_WORKSPACE}`) + console.log(`[webui] uploads: ${UPLOAD_DIR}`) + console.log(`[webui] sessions: ${SESSIONS_DB}`) + console.log(`[webui] settings: ${getPersistPath()}`) + }, + // 监听失败必须显式退出: uncaughtException 处理器只记录不退出, 之前端口被占用 + // 时会留下一个"没在监听"的活进程。端口被显式指定时给出可操作的提示。 + onUnavailable: (error) => { + const reason = (error && (error.code || error.message)) || String(error) + console.error(`[webui] cannot listen on ${HOST}:${PORT} — ${reason}`) + if (error && error.code === 'EADDRINUSE') { + console.error( + PORT_PINNED + ? `[webui] port ${PORT} is taken and was pinned (PORT / --port), so no fallback was attempted. Free it, or pass another value.` + : `[webui] ports ${PORT}-${PORT + MAX_PORT_ATTEMPTS - 1} are all taken. Free one, or pass --port .`, + ) + } + process.exit(1) + }, }) process.on('SIGINT', () => { diff --git a/packages/webui/server/lib/config.js b/packages/webui/server/lib/config.js index 920c5bc9..725aa06a 100644 --- a/packages/webui/server/lib/config.js +++ b/packages/webui/server/lib/config.js @@ -7,6 +7,8 @@ import { homedir } from "node:os"; import { existsSync, readFileSync } from "node:fs"; import { spawnSync } from "node:child_process"; +import { isPortPinned } from "./port.js"; + const __dirname = dirname(fileURLToPath(import.meta.url)); // __dirname here = packages/webui/server/lib/. The webui package root is 2 levels up. // In-product layout (mcode-webui migration): the webui ships inside the MiniMax Code @@ -55,10 +57,31 @@ export const MCODE_CMD = (() => { if (existsSync(homeLayout)) return homeLayout; return "mcode"; // PATH fallback })(); -// v0.5.bx-38: 默认端口 8080 — 之前 7890 太常被 desktop / mavis 桌面端占, 端口冲突频繁 -// 8080 是常见 HTTP alt, 也跟 web UI 语义对得上 (web = 80, 8080 = alt) -// 仍可被 process.env.PORT 覆盖 (比如临时用 7890 跑测试) -export const PORT = Number(process.env.PORT) || 8080; +// 默认端口历史: 7890 (最初) → 8080 (v0.5.bx-38, "web alt" 语义清晰, 但 8080 被 +// desktop / mavis 桌面端以及各类开发服务器占用得太频繁) → 18090 (当前)。高位 +// 端口在桌面/开发机上冲突概率低得多, 也和容器默认端口 (docker-compose 的 +// WEBUI_PORT=18080) 同属高位段, 不再跟常见 HTTP 服务抢。 +// 仍可被 process.env.PORT 覆盖 (比如临时用 7890 跑测试)。 +// +// 端口回退: 默认端口只是默认值, 不再是承诺 — 它被占用时服务器会往后找下一个 +// 空闲端口 (见 server/lib/port.js)。显式配置的端口 (PORT>0, 或启动器的 --port) +// 仍按精确值处理: docker 端口发布 / 容器健康检查 / webui 集成测试都按配置值 +// 寻址, 无法发现回退。 +export const PORT = Number(process.env.PORT) || 18090; +// 显式指定端口时禁止回退。未设置 / 空 / 0 / 非数值都视为"未指定", 走默认端口 +// 并允许回退 —— 与上面的 `Number(...) || 18090` 判定保持一致 (isPortPinned 是 +// 这条规则的唯一实现, 见 server/lib/port.js)。 +export const PORT_PINNED = isPortPinned(process.env.PORT); +// 实际对外提供服务的端口。默认端口回退之后它不再等于 PORT, 所以请求期消费者 +// (CORS origin 信任集、health/state/share URL、LAN 提示文案) 必须调用 +// getServingPort(), 不能在 import 期把 PORT 快照成常量。 +let servingPort = PORT; +export function getServingPort() { + return servingPort; +} +export function setServingPort(port) { + if (Number.isInteger(port) && port > 0) servingPort = port; +} // v2 security fix (PR #55 review point 2): default bind is now loopback. // v0.5.ao 默认 0.0.0.0("浏览器/手机/局域网访问是主场景"),但本服务是 // 高权限面(agent / filesystem / session 控制),网络可达必须是运营者 diff --git a/packages/webui/server/lib/lan.js b/packages/webui/server/lib/lan.js index 72ab4eb0..eafe9c1b 100644 --- a/packages/webui/server/lib/lan.js +++ b/packages/webui/server/lib/lan.js @@ -34,7 +34,7 @@ export function isLocalRequest(req) { // as "local", which feeds the token bypass in auth.js. That is correct // for socket identity — curl on 127.0.0.1 is the operator — but it // must NEVER double as a browser-origin exemption: a page on -// evil.com targeting http://127.0.0.1:8080 also arrives from a +// evil.com targeting http://127.0.0.1:18090 also arrives from a // loopback socket. The functions below give router.js a separate, // Origin-header-based trust set so the two dimensions stay distinct: // - socket identity (isLocalRequest) → token bypass diff --git a/packages/webui/server/lib/port.js b/packages/webui/server/lib/port.js new file mode 100644 index 00000000..e2806b66 --- /dev/null +++ b/packages/webui/server/lib/port.js @@ -0,0 +1,110 @@ +// webui/server/lib/port.js +// Listen-port selection for the standalone server. +// +// The default port is a preference, not a promise. Other dev servers and a +// webui left over from a previous run may already hold it, so the default port +// walks forward to the next free port instead of dying with EADDRINUSE. +// +// An explicitly configured port (the PORT env var, or `--port` through the +// `mcode webui` / `mcode-web` launcher) stays exact. Deployments that pin it +// cannot discover a fallback: docker publishes and healthchecks the configured +// port, and the integration tests dial the port they chose. +// +// This module only owns the mechanics. The port contract — the configured +// value, whether it is pinned, and the port actually serving — lives in +// config.js (PORT / PORT_PINNED / getServingPort). + +/** Ports tried in total, including the configured one. */ +export const MAX_PORT_ATTEMPTS = 20; + +/** + * True when `rawPort` is an explicit, usable PORT value. + * + * Unset, empty, `0`, and non-numeric values all mean "not configured": they + * fall through to the default port, which may fall back. This mirrors + * `Number(process.env.PORT) || 18090` in config.js. + */ +export function isPortPinned(rawPort) { + return Number(rawPort) > 0; +} + +/** + * The port to try after `port` failed with EADDRINUSE, or `undefined` when + * there is nothing left to try. + * + * `attempt` counts the ports already tried, so `port` itself is attempt 1. + */ +export function fallbackPort(port, attempt, maxAttempts = MAX_PORT_ATTEMPTS) { + const next = port + 1; + if (attempt >= maxAttempts || next > 65535) return undefined; + return next; +} + +/** + * Listen on `port`, walking forward while the port is taken. + * + * Unless `pinned` is set, at most `maxAttempts` ports are tried. When pinned, + * or once the budget is exhausted, `onUnavailable` receives the last listen + * error and the caller decides how to exit — this helper never exits on its + * own. + * + * `onListening` receives the port actually bound, which is not necessarily the + * requested one: a fallback moves it. + */ +export function listenWithPortFallback(server, options) { + const { + port, + host, + pinned = false, + maxAttempts = MAX_PORT_ATTEMPTS, + onListening, + onUnavailable, + log = console, + } = options; + + let attemptPort = port; + let attempt = 1; + + const handleListening = () => { + const address = server.address(); + const boundPort = + address && typeof address === "object" ? address.port : attemptPort; + onListening(boundPort); + }; + + const handleError = (error) => { + const next = + error && error.code === "EADDRINUSE" && !pinned + ? fallbackPort(attemptPort, attempt, maxAttempts) + : undefined; + if (next !== undefined) { + log.warn(`[webui] port ${attemptPort} is already in use — trying ${next}`); + attemptPort = next; + attempt += 1; + // The server object is reusable after a failed listen; both handlers stay + // attached, so the retry needs no rewiring. + listen(next); + return; + } + server.removeListener("listening", handleListening); + server.removeListener("error", handleError); + onUnavailable(error); + }; + + // `server.listen` validates the port and throws synchronously + // (ERR_SOCKET_BAD_PORT) rather than emitting 'error' for an out-of-range or + // non-integer value, so a bad PORT must reach the same terminal path — + // otherwise it escapes to the global uncaughtException handler, which only + // logs, leaving a live process that never listens. + const listen = (target) => { + try { + server.listen(target, host); + } catch (error) { + handleError(error); + } + }; + + server.on("listening", handleListening); + server.on("error", handleError); + listen(attemptPort); +} diff --git a/packages/webui/server/lib/settings.js b/packages/webui/server/lib/settings.js index 03dd9646..2c07a928 100644 --- a/packages/webui/server/lib/settings.js +++ b/packages/webui/server/lib/settings.js @@ -15,7 +15,7 @@ import { homedir } from "node:os"; import { join, dirname, resolve } from "node:path"; import { randomBytes } from "node:crypto"; -import { PORT, HOST } from "./config.js"; +import { getServingPort, HOST } from "./config.js"; import { LAN_IP, isLoopbackHost } from "./lan.js"; import { MCODE_CMD, DEFAULT_WORKSPACE, DEFAULT_MODEL } from "./config.js"; @@ -874,7 +874,7 @@ export function rotateToken() { // switching. Per user feedback: a user on a Chinese host might be // browsing in English (or vice versa); they want both visible at once. // Dynamic PORT (was hardcoded 7890 which broke when PORT was changed -// to 8080 default). +// to 18090 default). // ----------------------------------------------------------------------- // Single bilingual HTML — both languages always visible. Each block is @@ -920,7 +920,7 @@ export function rejectLan(res, pathname, remoteIp, _acceptLanguage) { const isApi = pathname.startsWith("/api/"); const isSettings = pathname === "/api/settings"; // 让用户能远程切回 if (isSettings) return false; - const localUrl = `http://127.0.0.1:${PORT}/`; + const localUrl = `http://127.0.0.1:${getServingPort()}/`; if (isApi) { res.writeHead(403, { "Content-Type": "application/json; charset=utf-8" }); res.end(JSON.stringify(LAN_REJECT_JSON)); @@ -959,7 +959,7 @@ export function getSettingsSnapshot(availableInterfaces = null) { // d.lanUrlWithToken || d.lanUrl). A fresh token-bearing URL can be // minted again only via token rotation (resetToken), which returns // the new token exactly once and resets tokenAcknowledged. - const baseUrl = `http://${LAN_IP}:${PORT}`; + const baseUrl = `http://${LAN_IP}:${getServingPort()}`; const shareToken = _effectiveShareToken(); const lanUrlWithToken = shareToken ? `${baseUrl}/?token=${encodeURIComponent(shareToken)}` @@ -978,7 +978,7 @@ export function getSettingsSnapshot(availableInterfaces = null) { const lanExposed = !isLoopbackHost(bindHost); const bindRestartPending = !envHost && bindHost !== HOST; const lanExposureNotice = lanExposed - ? `LAN exposure ON: webui binds ${bindHost}:${PORT} and is reachable from the network. / 局域网暴露已开启:webui 监听 ${bindHost}:${PORT},网络内设备均可访问。` + ? `LAN exposure ON: webui binds ${bindHost}:${getServingPort()} and is reachable from the network. / 局域网暴露已开启:webui 监听 ${bindHost}:${getServingPort()},网络内设备均可访问。` : bindRestartPending ? `Bind change pending restart: next boot binds ${bindHost}. / 绑定变更待重启:下次启动监听 ${bindHost}。` : ""; @@ -1010,14 +1010,14 @@ export function getSettingsSnapshot(availableInterfaces = null) { hasTokenPlanKey: !!getTokenPlanApiKey(), tokenPlanApiKeySource: getTokenPlanApiKeySource(), tokenPlanApiKeyFilePath: getTokenPlanApiKeyFilePath(), - port: PORT, + port: getServingPort(), host: HOST, lanIp: LAN_IP, lanUrl: baseUrl, // v2 security fix (PR #55 review point 1): only present while // !tokenAcknowledged (first-run bootstrap). Omitted after ack. ...(includeToken ? { lanUrlWithToken } : {}), - localUrl: `http://127.0.0.1:${PORT}`, + localUrl: `http://127.0.0.1:${getServingPort()}`, // v2 security fix (PR #55 review point 2): explicit exposure // disclosure surfaced whenever LAN sharing is (or is about to be) // in effect. diff --git a/packages/webui/server/router.js b/packages/webui/server/router.js index fa0c8f4e..e18d8823 100644 --- a/packages/webui/server/router.js +++ b/packages/webui/server/router.js @@ -19,7 +19,7 @@ // `/api/settings` is exempted from (2) so users can flip the LAN switch // back on from a remote device. -import { PORT } from "./lib/config.js"; +import { getServingPort } from "./lib/config.js"; import { isLocalRequest, buildTrustedOrigins, @@ -423,7 +423,7 @@ export async function handleRequest(req, res) { // no CORS headers — they never needed them; zero regression. const originHeader = normalizeOriginHeader(req.headers.origin); const trustedOrigins = buildTrustedOrigins({ - port: PORT, + port: getServingPort(), lanBroadcast: getLanBroadcast(), extra: getTrustedOrigins(), }); diff --git a/packages/webui/server/routes/health.js b/packages/webui/server/routes/health.js index 78c81a21..11dc8539 100644 --- a/packages/webui/server/routes/health.js +++ b/packages/webui/server/routes/health.js @@ -2,7 +2,7 @@ // GET /api/health — basic service info. import { - PORT, + getServingPort, MAX_CONCURRENT, MCODE_CMD, DEFAULT_MODEL, @@ -14,7 +14,7 @@ export function handleHealth(_req, res) { return res.end( JSON.stringify({ ok: true, - port: PORT, + port: getServingPort(), defaultModel: DEFAULT_MODEL, defaultWorkspace: DEFAULT_WORKSPACE, mcodeCmd: MCODE_CMD, diff --git a/packages/webui/test/integration/port-fallback.test.js b/packages/webui/test/integration/port-fallback.test.js new file mode 100644 index 00000000..f9d37ec8 --- /dev/null +++ b/packages/webui/test/integration/port-fallback.test.js @@ -0,0 +1,195 @@ +// webui/test/integration/port-fallback.test.js +// Live-boot proof for the default-port fallback (server/lib/port.js): +// +// 1. with no PORT configured and 18090 taken, server.js walks forward and +// reports the port it actually bound +// 2. /api/health agrees with that port — a request-time reader, not a +// snapshot of the configured value +// 3. the browser Origin for that port is trusted (CORS reflection), which +// only holds if router.js reads the serving port rather than PORT +// +// The test takes 18090 itself when it is free, so the fallback is guaranteed +// rather than left to whatever the developer's machine happens to be running. +// When 18090 is already taken by something else the assertions still hold: the +// server cannot use it either way. + +import { test } from "node:test"; +import { strict as assert } from "node:assert"; +import { spawn } from "node:child_process"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; +import http from "node:http"; + +import { MAX_PORT_ATTEMPTS } from "../../server/lib/port.js"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const serverJsPath = join(__dirname, "..", "..", "server.js"); +const DEFAULT_PORT = 18090; + +/** Hold 18090 so the boot below has to fall back; undefined when already taken. */ +function holdDefaultPort() { + return new Promise((resolve) => { + const holder = http.createServer((_req, res) => res.end("busy")); + holder.once("error", () => resolve(undefined)); + holder.listen(DEFAULT_PORT, "127.0.0.1", () => resolve(holder)); + }); +} + +function release(server) { + return new Promise((resolve) => { + if (!server || !server.listening) return resolve(); + server.close(() => resolve()); + }); +} + +function request(port, path, headers) { + return new Promise((resolve, reject) => { + const req = http.request({ host: "127.0.0.1", port, path, headers }, (res) => { + let body = ""; + res.on("data", (c) => (body += c)); + res.on("end", () => resolve({ status: res.statusCode, headers: res.headers, body })); + }); + req.on("error", reject); + req.end(); + }); +} + +/** + * Boot server.js with no PORT configured and isolated state. Resolves with the + * port it reported, a `stop()` that tears the child down completely, and the + * captured output for failure messages. + */ +function bootWithDefaultPort() { + const tmpDir = mkdtempSync(join(tmpdir(), "mcode-webui-port-fallback-")); + const env = { + ...process.env, + MCODE_WEBUI_SETTINGS_PATH: join(tmpDir, "settings.json"), + MCODE_WEBUI_EVENTS_PATH: join(tmpDir, "events.ndjson"), + MCODE_WEBUI_UPLOAD_DIR: join(tmpDir, "uploads"), + MCODE_WEBUI_SESSIONS_DB: join(tmpDir, "sessions.json"), + TOKEN: "", + MCODE_WEBUI_TOKEN_STDOUT: "0", + }; + delete env.PORT; // the default port is what this test is about + delete env.HOST; // default bind, so the URL is loopback + + return new Promise((resolve, reject) => { + const proc = spawn("node", [serverJsPath], { + stdio: ["ignore", "pipe", "pipe"], + cwd: join(__dirname, "..", ".."), + env, + }); + let stdout = ""; + let stderr = ""; + let settled = false; + + const killChild = async () => { + if (proc.exitCode !== null || proc.signalCode !== null) return; + const exited = new Promise((r) => proc.once("exit", r)); + proc.kill("SIGTERM"); + const hardKill = setTimeout(() => { + try { + proc.kill("SIGKILL"); + } catch {} + }, 1500); + await exited; + clearTimeout(hardKill); + }; + const stop = async () => { + await killChild(); + try { + rmSync(tmpDir, { recursive: true, force: true }); + } catch {} + }; + + const bail = setTimeout(async () => { + if (settled) return; + settled = true; + const output = { stdout, stderr }; + await stop(); + reject(new Error(`no listening line within 6s. stdout:\n${output.stdout}\nstderr:\n${output.stderr}`)); + }, 6000); + + proc.stdout.on("data", (d) => { + stdout += d.toString(); + const match = /listening on http:\/\/127\.0\.0\.1:(\d+)/.exec(stdout); + if (settled || !match) return; + settled = true; + clearTimeout(bail); + // The child stays up: the caller makes live requests and then stops it. + resolve({ port: Number(match[1]), output: () => ({ stdout, stderr }), stop }); + }); + proc.stderr.on("data", (d) => (stderr += d.toString())); + proc.on("error", async (error) => { + if (settled) return; + settled = true; + clearTimeout(bail); + await stop(); + reject(error); + }); + proc.on("exit", async (code) => { + if (settled) return; + settled = true; + clearTimeout(bail); + const output = { stdout, stderr }; + await stop(); + reject( + new Error( + `server exited with ${code} before listening. stdout:\n${output.stdout}\nstderr:\n${output.stderr}`, + ), + ); + }); + }); +} + +test("taken default port: server.js binds the next free port and reports it", async () => { + const holder = await holdDefaultPort(); + let booted; + try { + booted = await bootWithDefaultPort(); + const { stdout, stderr } = booted.output(); + + assert.ok( + booted.port > DEFAULT_PORT, + `expected a port above ${DEFAULT_PORT}, got ${booted.port}. stdout:\n${stdout}`, + ); + assert.ok( + booted.port <= DEFAULT_PORT + MAX_PORT_ATTEMPTS - 1, + `fallback must stay inside the attempt budget, got ${booted.port}`, + ); + assert.match( + stderr, + new RegExp(`port ${DEFAULT_PORT} is already in use`), + `the fallback must be announced. stderr:\n${stderr}`, + ); + + // /api/health and the browser-Origin trust set are both request-time + // readers of the serving port; a PORT snapshot would answer 18090 here and + // refuse the Origin below. + const health = await request(booted.port, "/api/health"); + assert.equal(health.status, 200); + assert.equal(JSON.parse(health.body).port, booted.port); + + const origin = `http://127.0.0.1:${booted.port}`; + const sameOrigin = await request(booted.port, "/api/health", { Origin: origin }); + assert.equal( + String(sameOrigin.headers["access-control-allow-origin"] || "").toLowerCase(), + origin, + "the fallback port's own Origin must be trusted", + ); + + const staleOrigin = await request(booted.port, "/api/health", { + Origin: `http://127.0.0.1:${DEFAULT_PORT}`, + }); + assert.equal( + staleOrigin.headers["access-control-allow-origin"], + undefined, + "the configured-but-unused port must not be trusted", + ); + } finally { + if (booted) await booted.stop(); + await release(holder); + } +}); diff --git a/packages/webui/test/integration/router-boot.test.js b/packages/webui/test/integration/router-boot.test.js index 94ffa075..65383e38 100644 --- a/packages/webui/test/integration/router-boot.test.js +++ b/packages/webui/test/integration/router-boot.test.js @@ -2,7 +2,7 @@ // D02 lease: end-to-end router boot test. // // Spawn the real server.js on a random high port (avoids collision with -// any real webui / dev server on 8080). Talk to it with node:http and +// any real webui / dev server on 18090). Talk to it with node:http and // hit each documented route from server/router.js. Each route has at // least one happy path + one error path. The mcode subprocess is NOT // exercised here — the routes we hit (health / state / alerts / @@ -36,7 +36,7 @@ import { decideNextAuthorization } from "../_setup.js"; const __dirname = dirname(fileURLToPath(import.meta.url)); const serverJsPath = join(__dirname, "..", "..", "server.js"); -// Port-pick: 19500..19600 — outside the dev range (8080), outside the +// Port-pick: 19500..19600 — outside the dev range (18090), outside the // C08 helper range (18080/18081), outside privileged (<1024). Even on // busy machines this range is usually free; if it isn't, the test // fails loudly with EADDRINUSE which is the right signal. diff --git a/packages/webui/test/integration/upload-limits.test.js b/packages/webui/test/integration/upload-limits.test.js index cc85d79a..675a29dd 100644 --- a/packages/webui/test/integration/upload-limits.test.js +++ b/packages/webui/test/integration/upload-limits.test.js @@ -31,7 +31,7 @@ const __dirname = dirname(fileURLToPath(import.meta.url)); const serverJsPath = join(__dirname, "..", "..", "server.js"); function pickPort() { - // 19700..19799 — outside the dev range (8080) and the ranges other + // 19700..19799 — outside the dev range (18090) and the ranges other // integration files pick (18080/181, 19500..19600). return 19700 + Math.floor(Math.random() * 100); } diff --git a/packages/webui/test/lib-settings.test.js b/packages/webui/test/lib-settings.test.js index 1321368a..c09bdcec 100644 --- a/packages/webui/test/lib-settings.test.js +++ b/packages/webui/test/lib-settings.test.js @@ -57,6 +57,8 @@ after(async () => { }); const settings = await import(absPath("lib/settings.js")); +// The port the server would serve on: the config default, or PORT when set. +const config = await import(absPath("lib/config.js")); // Build a minimal fake res that captures writeHead/end function fakeRes() { @@ -145,11 +147,13 @@ describe("settings — rejectLan", () => { assert.match(res._body, /192\.168\.1\.100/); // remote IP }); - test("HTML page uses dynamic PORT (not hardcoded 7890)", () => { + test("HTML page uses the serving port (not a hardcoded 7890)", () => { settings.setLanBroadcast(false); const res = fakeRes(); settings.rejectLan(res, "/some/page", "10.0.0.1"); - assert.match(res._body, /127\.0\.0\.1:8080/); + // Assert against the port this process would serve on, so changing the + // default port cannot leave this pinned to a stale literal. + assert.match(res._body, new RegExp(`127\\.0\\.0\\.1:${config.getServingPort()}`)); assert.doesNotMatch(res._body, /7890/); }); diff --git a/packages/webui/test/port.test.js b/packages/webui/test/port.test.js new file mode 100644 index 00000000..cabe8b06 --- /dev/null +++ b/packages/webui/test/port.test.js @@ -0,0 +1,214 @@ +// webui/test/port.test.js +// Unit coverage for the listen-port fallback (server/lib/port.js). +// +// The live-boot proof — a real server.js taking the next free port and then +// trusting that port's browser Origin — lives in +// test/integration/port-fallback.test.js. Here we pin only the pure rules and +// the listen/retry mechanics, on ports taken from the OS so nothing collides +// with a developer's own webui on the default port. + +import { test, describe } from "node:test"; +import assert from "node:assert/strict"; +import http from "node:http"; + +import { + MAX_PORT_ATTEMPTS, + fallbackPort, + isPortPinned, + listenWithPortFallback, +} from "../server/lib/port.js"; + +/** Bind a bare listener and hand back the port it got. */ +function startServer(handler) { + return new Promise((resolve) => { + const server = http.createServer(handler ?? ((_req, res) => res.end("ok"))); + server.listen(0, "127.0.0.1", () => resolve({ server, port: server.address().port })); + }); +} + +function stopServer(server) { + return new Promise((resolve) => { + if (!server.listening) return resolve(); + server.close(() => resolve()); + }); +} + +function get(port, path, headers) { + return new Promise((resolve, reject) => { + const req = http.request({ host: "127.0.0.1", port, path, headers }, (res) => { + let body = ""; + res.on("data", (c) => (body += c)); + res.on("end", () => resolve({ status: res.statusCode, headers: res.headers, body })); + }); + req.on("error", reject); + req.end(); + }); +} + +describe("isPortPinned", () => { + test("a positive PORT value pins the port", () => { + assert.equal(isPortPinned("18090"), true); + assert.equal(isPortPinned(18090), true); + }); + + test("unset, empty, zero and non-numeric values count as not configured", () => { + // These fall through to the default port and may fall back — the same set + // that `Number(process.env.PORT) || 18090` maps onto the default. + assert.equal(isPortPinned(undefined), false); + assert.equal(isPortPinned(""), false); + assert.equal(isPortPinned(" "), false); + assert.equal(isPortPinned("0"), false); + assert.equal(isPortPinned("-1"), false); + assert.equal(isPortPinned("abc"), false); + }); +}); + +describe("fallbackPort", () => { + test("walks one port at a time", () => { + assert.equal(fallbackPort(18090, 1), 18091); + assert.equal(fallbackPort(18091, 2), 18092); + }); + + test("stops once the attempt budget is spent", () => { + assert.equal(fallbackPort(18090, MAX_PORT_ATTEMPTS), undefined); + assert.equal(fallbackPort(18090, 1, 1), undefined); + assert.equal(fallbackPort(18090, 1, 2), 18091); + }); + + test("stops at the end of the port range", () => { + assert.equal(fallbackPort(65535, 1), undefined); + }); +}); + +describe("listenWithPortFallback", () => { + test("a free port is used as-is and reported as bound", async () => { + const { server: blocker, port } = await startServer(); + await stopServer(blocker); // the port is ours again, and definitely free + + const warnings = []; + const server = http.createServer((_req, res) => res.end("served")); + let bound; + await new Promise((resolve) => { + listenWithPortFallback(server, { + port, + host: "127.0.0.1", + onListening: (p) => { + bound = p; + resolve(); + }, + onUnavailable: (error) => resolve(error), + log: { warn: (m) => warnings.push(m) }, + }); + }); + + assert.equal(bound, port); + assert.equal(warnings.length, 0); + assert.equal((await get(port, "/")).body, "served"); + await stopServer(server); + }); + + test("a taken default port walks forward to the next free port", async () => { + const { server: blocker, port } = await startServer(); + + const warnings = []; + const server = http.createServer((_req, res) => res.end("served")); + let bound; + await new Promise((resolve) => { + listenWithPortFallback(server, { + port, + host: "127.0.0.1", + pinned: false, + onListening: (p) => { + bound = p; + resolve(); + }, + onUnavailable: (error) => resolve(error), + log: { warn: (m) => warnings.push(m) }, + }); + }); + + assert.ok(bound > port, `expected a port above the taken one, got ${bound}`); + assert.ok( + bound - port <= MAX_PORT_ATTEMPTS, + `fallback must stay inside the attempt budget, got ${bound - port}`, + ); + assert.match(warnings.join("\n"), new RegExp(`port ${port} is already in use`)); + // The reported port is the one actually serving — the contract server.js + // publishes to CORS origin trust and to the launcher's URL. + assert.equal((await get(bound, "/")).body, "served"); + await stopServer(server); + await stopServer(blocker); + }); + + test("a pinned port is never moved; the caller gets the listen error", async () => { + const { server: blocker, port } = await startServer(); + + const server = http.createServer((_req, res) => res.end("served")); + let reported; + await new Promise((resolve) => { + listenWithPortFallback(server, { + port, + host: "127.0.0.1", + pinned: true, + onListening: resolve, + onUnavailable: (error) => { + reported = error; + resolve(); + }, + log: { warn: () => {} }, + }); + }); + + assert.equal(reported && reported.code, "EADDRINUSE"); + assert.equal(server.listening, false); + await stopServer(blocker); + }); + + test("an exhausted budget reports the listen error instead of spinning", async () => { + const { server: blocker, port } = await startServer(); + + const server = http.createServer((_req, res) => res.end("served")); + let reported; + await new Promise((resolve) => { + listenWithPortFallback(server, { + port, + host: "127.0.0.1", + maxAttempts: 1, + onListening: resolve, + onUnavailable: (error) => { + reported = error; + resolve(); + }, + log: { warn: () => {} }, + }); + }); + + assert.equal(reported && reported.code, "EADDRINUSE"); + assert.equal(server.listening, false); + await stopServer(blocker); + }); + + test("an out-of-range port reports the error instead of throwing out of the helper", async () => { + const server = http.createServer((_req, res) => res.end("served")); + let reported; + // `server.listen` throws synchronously (ERR_SOCKET_BAD_PORT) for this + // rather than emitting 'error'. An escaped throw reaches the global handler, + // which only logs — the live-but-not-listening process this change removes. + await new Promise((resolve) => { + listenWithPortFallback(server, { + port: 70000, + host: "127.0.0.1", + pinned: true, + onListening: resolve, + onUnavailable: (error) => { + reported = error; + resolve(); + }, + log: { warn: () => {} }, + }); + }); + + assert.equal(reported && reported.code, "ERR_SOCKET_BAD_PORT"); + assert.equal(server.listening, false); + }); +}); diff --git a/packages/webui/test/server-startup.test.js b/packages/webui/test/server-startup.test.js index 23123aab..82d778c2 100644 --- a/packages/webui/test/server-startup.test.js +++ b/packages/webui/test/server-startup.test.js @@ -34,7 +34,7 @@ const serverJsPath = join(__dirname, "..", "server.js"); // init() takes the "first run" branch (generate + persist + printToken). // Waits up to 2s for startup, returns { stdout, stderr, ok }. // -// Uses PORT=18080 + 18081 to avoid colliding with the default 8080 +// Uses PORT=18080 + 18081 to avoid colliding with the default 18090 // (which is often already taken on dev machines running a real webui // or another test's lingering process). The port stays in the kernel // "high enough" range to avoid root-privileged port surprises. @@ -112,11 +112,11 @@ async function _spawnFirstRun({ tokenStdout, port } = {}) { test("server.js bootstrap does not throw ESM load-time error", async () => { // G03: pin to a high port so the bootstrap test doesn't conflict with - // port 8080 (default in config.js) when some other process already + // port 18090 (default in config.js) when some other process already // holds it — common on shared CI runners and any machine that has // another webui instance running. The test's intent is "server.js // imports succeed and the process reaches 'listening on'", not - // "listens on 8080 specifically". 18082 keeps the high-port + // "listens on 18090 specifically". 18082 keeps the high-port // convention used by the C08 sub-tests (18080 / 18081) so a single // operator can see the pattern at a glance. const proc = spawn("node", [serverJsPath], { diff --git a/release/public-source.json b/release/public-source.json index b7597ec2..64469cf5 100644 --- a/release/public-source.json +++ b/release/public-source.json @@ -3424,6 +3424,7 @@ "packages/webui/server/lib/mcode-exec.js", "packages/webui/server/lib/mcode-rpc.js", "packages/webui/server/lib/models.js", + "packages/webui/server/lib/port.js", "packages/webui/server/lib/quota-forecast.js", "packages/webui/server/lib/rate-limit.js", "packages/webui/server/lib/sessions.js", @@ -3480,6 +3481,7 @@ "packages/webui/test/integration/check-docs-alignment.test.js", "packages/webui/test/integration/default-bind.test.js", "packages/webui/test/integration/event-chain.test.js", + "packages/webui/test/integration/port-fallback.test.js", "packages/webui/test/integration/router-boot.test.js", "packages/webui/test/integration/sse-channel.test.js", "packages/webui/test/integration/upload-limits.test.js", @@ -3512,6 +3514,7 @@ "packages/webui/test/lib-upload.test.js", "packages/webui/test/main-static.test.js", "packages/webui/test/matrix/transports.test.js", + "packages/webui/test/port.test.js", "packages/webui/test/render-static.test.js", "packages/webui/test/router-cors.test.js", "packages/webui/test/router-dispatch.test.js",