mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-09-12 01:42:27 +03:00
Compare commits
2 Commits
fix/12568-
...
feat/i18n-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
455aade660 | ||
|
|
e89a4da2b0 |
24
.env.example
24
.env.example
@@ -124,21 +124,6 @@ DISABLE_SQLITE_AUTO_BACKUP=false
|
||||
# Host port for the compose Redis sidecar. Default: 6379.
|
||||
# REDIS_PORT=6379
|
||||
|
||||
# Host interface docker-compose publishes the app's own ports (dashboard,
|
||||
# API, live-WS) on for the base/web/cli/host profiles and docker-compose.prod.yml.
|
||||
# Default: 127.0.0.1 (loopback only). Combined with REQUIRE_API_KEY=false
|
||||
# (the default below), an unqualified publish spec would expose the anonymous
|
||||
# /v1 LLM proxy to your whole LAN/WAN. Only set this to 0.0.0.0 once you've
|
||||
# confirmed REQUIRE_API_KEY=true, or that a reverse proxy in front of this
|
||||
# instance already enforces its own authentication. (#12568)
|
||||
# APP_BIND_HOST=127.0.0.1
|
||||
# Host interface docker-compose publishes the Qdrant memory sidecar on.
|
||||
# Default: 127.0.0.1 (loopback only). Same LAN-exposure reasoning as Redis.
|
||||
# QDRANT_BIND_HOST=127.0.0.1
|
||||
# Host interface docker-compose publishes the Bifrost router sidecar on.
|
||||
# Default: 127.0.0.1 (loopback only). Same LAN-exposure reasoning as Redis.
|
||||
# BIFROST_BIND_HOST=127.0.0.1
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
# 3. NETWORK & PORTS
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
@@ -388,8 +373,6 @@ AUTH_COOKIE_SECURE=false
|
||||
# Require an API key for all /v1/* proxy endpoints.
|
||||
# Used by: API middleware — rejects unauthenticated requests to the proxy API.
|
||||
# Default: false | Set true for multi-user/public deployments.
|
||||
# Leaving this false is only safe when the app is reachable on loopback only
|
||||
# (see APP_BIND_HOST above) or sits behind a reverse proxy doing its own auth.
|
||||
REQUIRE_API_KEY=false
|
||||
|
||||
# Allow revealing full API key values in the Dashboard UI.
|
||||
@@ -2094,13 +2077,6 @@ APP_LOG_TO_FILE=true
|
||||
# Management key for an externally managed instance. Embedded instances use
|
||||
# OmniRoute's encrypted service key.
|
||||
# CLIPROXYAPI_MANAGEMENT_KEY=
|
||||
# Host interface docker-compose publishes the cliproxyapi sidecar on (the
|
||||
# --profile cliproxyapi Docker service, port 8317). Default: 127.0.0.1
|
||||
# (loopback only) — its data volume holds provider OAuth/API credentials, and
|
||||
# the pinned image has no env-based data-plane api-keys override (only a
|
||||
# mounted config.yaml), so an unqualified publish spec would put a
|
||||
# credential-bearing service on your whole LAN. (#12578)
|
||||
# CLIPROXY_BIND_HOST=127.0.0.1
|
||||
|
||||
# ── Mux embedded service ──
|
||||
# Override the port where the embedded Mux (coder/mux) agent-orchestration
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
- fix(docker): default docker-compose app ports (dashboard/API/live-WS) to loopback instead of `0.0.0.0`, closing the anonymous `/v1` LAN/WAN exposure gap left open by `REQUIRE_API_KEY=false` (#12568)
|
||||
@@ -1 +0,0 @@
|
||||
- fix(docker): scope the cliproxyapi/qdrant/bifrost sidecars to loopback by default and forward `CLIPROXYAPI_MANAGEMENT_KEY` into the cliproxyapi container so its management API is not left both unauthenticated and LAN-published (#12578)
|
||||
@@ -63,22 +63,17 @@ services:
|
||||
- DASHBOARD_PORT=${DASHBOARD_PORT:-${PORT:-20128}}
|
||||
- API_PORT=${API_PORT:-20129}
|
||||
- LIVE_WS_PORT=${LIVE_WS_PORT:-20132}
|
||||
- LIVE_WS_HOST=${LIVE_WS_HOST:-127.0.0.1}
|
||||
- LIVE_WS_HOST=${LIVE_WS_HOST:-0.0.0.0}
|
||||
- LIVE_WS_ALLOWED_ORIGINS=${LIVE_WS_ALLOWED_ORIGINS:-http://localhost:${PROD_DASHBOARD_PORT:-20130},http://127.0.0.1:${PROD_DASHBOARD_PORT:-20130}}
|
||||
- API_HOST=${API_HOST:-127.0.0.1}
|
||||
# HOSTNAME intentionally not hardcoded to 0.0.0.0 (#12568) — let the
|
||||
# app's own loopback-first default apply unless the operator sets it.
|
||||
- API_HOST=${API_HOST:-0.0.0.0}
|
||||
- HOSTNAME=0.0.0.0
|
||||
- DATA_DIR=/app/data
|
||||
- OMNIROUTE_BASE_PATH=${OMNIROUTE_BASE_PATH:-}
|
||||
- CHATGPT_WEB_CODEX_CDP_URL=http://chatgpt-web-codex-browser:9223
|
||||
ports:
|
||||
# Loopback-only by default (#12568) — see docker-compose.yml's
|
||||
# APP_BIND_HOST comment for the rationale. Override for a LAN/WAN prod
|
||||
# deployment only once REQUIRE_API_KEY=true or a reverse proxy in front
|
||||
# of this instance is confirmed to enforce its own auth.
|
||||
- "${APP_BIND_HOST:-127.0.0.1}:${PROD_DASHBOARD_PORT:-20130}:${DASHBOARD_PORT:-${PORT:-20128}}"
|
||||
- "${APP_BIND_HOST:-127.0.0.1}:${PROD_API_PORT:-20131}:${API_PORT:-20129}"
|
||||
- "${APP_BIND_HOST:-127.0.0.1}:${PROD_LIVE_WS_PORT:-20132}:${LIVE_WS_PORT:-20132}"
|
||||
- "${PROD_DASHBOARD_PORT:-20130}:${DASHBOARD_PORT:-${PORT:-20128}}"
|
||||
- "${PROD_API_PORT:-20131}:${API_PORT:-20129}"
|
||||
- "${PROD_LIVE_WS_PORT:-20132}:${LIVE_WS_PORT:-20132}"
|
||||
volumes:
|
||||
- omniroute-prod-data:/app/data
|
||||
healthcheck:
|
||||
|
||||
@@ -37,9 +37,9 @@ x-common: &common
|
||||
- PORT=${PORT:-20128}
|
||||
- DASHBOARD_PORT=${DASHBOARD_PORT:-20128}
|
||||
- API_PORT=${API_PORT:-20129}
|
||||
- API_HOST=${API_HOST:-127.0.0.1}
|
||||
- API_HOST=${API_HOST:-0.0.0.0}
|
||||
- LIVE_WS_PORT=${LIVE_WS_PORT:-20132}
|
||||
- LIVE_WS_HOST=${LIVE_WS_HOST:-127.0.0.1}
|
||||
- LIVE_WS_HOST=${LIVE_WS_HOST:-0.0.0.0}
|
||||
- LIVE_WS_ALLOWED_ORIGINS=${LIVE_WS_ALLOWED_ORIGINS:-http://localhost:20128,http://127.0.0.1:20128}
|
||||
- REDIS_URL=${REDIS_URL:-redis://redis:6379}
|
||||
- NODE_OPTIONS=--max-old-space-size=2048
|
||||
@@ -99,14 +99,9 @@ services:
|
||||
OMNIROUTE_BASE_PATH: ${OMNIROUTE_BASE_PATH:-}
|
||||
image: omniroute:base
|
||||
ports:
|
||||
# Loopback-only by default (#12568): with REQUIRE_API_KEY=false shipping
|
||||
# as the .env.example default, an unqualified publish spec here binds
|
||||
# 0.0.0.0 and exposes the anonymous /v1 LLM proxy on every LAN/WAN
|
||||
# interface. Set APP_BIND_HOST=0.0.0.0 only once you've confirmed
|
||||
# REQUIRE_API_KEY=true or an upstream reverse proxy enforces its own auth.
|
||||
- "${APP_BIND_HOST:-127.0.0.1}:${DASHBOARD_PORT:-20128}:${DASHBOARD_PORT:-20128}"
|
||||
- "${APP_BIND_HOST:-127.0.0.1}:${API_PORT:-20129}:${API_PORT:-20129}"
|
||||
- "${APP_BIND_HOST:-127.0.0.1}:${LIVE_WS_PORT:-20132}:${LIVE_WS_PORT:-20132}"
|
||||
- "${DASHBOARD_PORT:-20128}:${DASHBOARD_PORT:-20128}"
|
||||
- "${API_PORT:-20129}:${API_PORT:-20129}"
|
||||
- "${LIVE_WS_PORT:-20132}:${LIVE_WS_PORT:-20132}"
|
||||
profiles:
|
||||
- base
|
||||
|
||||
@@ -131,17 +126,17 @@ services:
|
||||
- PORT=${PORT:-20128}
|
||||
- DASHBOARD_PORT=${DASHBOARD_PORT:-20128}
|
||||
- API_PORT=${API_PORT:-20129}
|
||||
- API_HOST=${API_HOST:-127.0.0.1}
|
||||
- API_HOST=${API_HOST:-0.0.0.0}
|
||||
- LIVE_WS_PORT=${LIVE_WS_PORT:-20132}
|
||||
- LIVE_WS_HOST=${LIVE_WS_HOST:-127.0.0.1}
|
||||
- LIVE_WS_HOST=${LIVE_WS_HOST:-0.0.0.0}
|
||||
- LIVE_WS_ALLOWED_ORIGINS=${LIVE_WS_ALLOWED_ORIGINS:-http://localhost:20128,http://127.0.0.1:20128}
|
||||
- REDIS_URL=${REDIS_URL:-redis://redis:6379}
|
||||
- OMNIROUTE_BASE_PATH=${OMNIROUTE_BASE_PATH:-}
|
||||
- CHATGPT_WEB_CODEX_CDP_URL=http://chatgpt-web-codex-browser:9223
|
||||
ports:
|
||||
- "${APP_BIND_HOST:-127.0.0.1}:${DASHBOARD_PORT:-20128}:${DASHBOARD_PORT:-20128}"
|
||||
- "${APP_BIND_HOST:-127.0.0.1}:${API_PORT:-20129}:${API_PORT:-20129}"
|
||||
- "${APP_BIND_HOST:-127.0.0.1}:${LIVE_WS_PORT:-20132}:${LIVE_WS_PORT:-20132}"
|
||||
- "${DASHBOARD_PORT:-20128}:${DASHBOARD_PORT:-20128}"
|
||||
- "${API_PORT:-20129}:${API_PORT:-20129}"
|
||||
- "${LIVE_WS_PORT:-20132}:${LIVE_WS_PORT:-20132}"
|
||||
profiles:
|
||||
- web
|
||||
|
||||
@@ -170,9 +165,9 @@ services:
|
||||
OMNIROUTE_BASE_PATH: ${OMNIROUTE_BASE_PATH:-}
|
||||
image: omniroute:cli
|
||||
ports:
|
||||
- "${APP_BIND_HOST:-127.0.0.1}:${DASHBOARD_PORT:-20128}:${DASHBOARD_PORT:-20128}"
|
||||
- "${APP_BIND_HOST:-127.0.0.1}:${API_PORT:-20129}:${API_PORT:-20129}"
|
||||
- "${APP_BIND_HOST:-127.0.0.1}:${LIVE_WS_PORT:-20132}:${LIVE_WS_PORT:-20132}"
|
||||
- "${DASHBOARD_PORT:-20128}:${DASHBOARD_PORT:-20128}"
|
||||
- "${API_PORT:-20129}:${API_PORT:-20129}"
|
||||
- "${LIVE_WS_PORT:-20132}:${LIVE_WS_PORT:-20132}"
|
||||
volumes:
|
||||
- ./data:/app/data
|
||||
# SECURITY: mounting the host Docker socket gives this container full
|
||||
@@ -199,17 +194,17 @@ services:
|
||||
OMNIROUTE_BASE_PATH: ${OMNIROUTE_BASE_PATH:-}
|
||||
image: omniroute:base
|
||||
ports:
|
||||
- "${APP_BIND_HOST:-127.0.0.1}:${DASHBOARD_PORT:-20128}:${DASHBOARD_PORT:-20128}"
|
||||
- "${APP_BIND_HOST:-127.0.0.1}:${API_PORT:-20129}:${API_PORT:-20129}"
|
||||
- "${APP_BIND_HOST:-127.0.0.1}:${LIVE_WS_PORT:-20132}:${LIVE_WS_PORT:-20132}"
|
||||
- "${DASHBOARD_PORT:-20128}:${DASHBOARD_PORT:-20128}"
|
||||
- "${API_PORT:-20129}:${API_PORT:-20129}"
|
||||
- "${LIVE_WS_PORT:-20132}:${LIVE_WS_PORT:-20132}"
|
||||
environment:
|
||||
- DATA_DIR=/app/data
|
||||
- PORT=${PORT:-20128}
|
||||
- DASHBOARD_PORT=${DASHBOARD_PORT:-20128}
|
||||
- API_PORT=${API_PORT:-20129}
|
||||
- API_HOST=${API_HOST:-127.0.0.1}
|
||||
- API_HOST=${API_HOST:-0.0.0.0}
|
||||
- LIVE_WS_PORT=${LIVE_WS_PORT:-20132}
|
||||
- LIVE_WS_HOST=${LIVE_WS_HOST:-127.0.0.1}
|
||||
- LIVE_WS_HOST=${LIVE_WS_HOST:-0.0.0.0}
|
||||
- LIVE_WS_ALLOWED_ORIGINS=${LIVE_WS_ALLOWED_ORIGINS:-http://localhost:20128,http://127.0.0.1:20128}
|
||||
- CLI_MODE=host
|
||||
- CLI_EXTRA_PATHS=/host-local/bin:/host-node/bin
|
||||
@@ -248,8 +243,8 @@ services:
|
||||
container_name: omniroute-qdrant
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "${QDRANT_BIND_HOST:-127.0.0.1}:${QDRANT_PORT:-6333}:6333"
|
||||
- "${QDRANT_BIND_HOST:-127.0.0.1}:${QDRANT_GRPC_PORT:-6334}:6334"
|
||||
- "${QDRANT_PORT:-6333}:6333"
|
||||
- "${QDRANT_GRPC_PORT:-6334}:6334"
|
||||
volumes:
|
||||
- qdrant-data:/qdrant/storage
|
||||
environment:
|
||||
@@ -276,7 +271,7 @@ services:
|
||||
container_name: omniroute-bifrost
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "${BIFROST_BIND_HOST:-127.0.0.1}:${BIFROST_PORT:-8080}:8080"
|
||||
- "${BIFROST_PORT:-8080}:8080"
|
||||
volumes:
|
||||
- bifrost-data:/data
|
||||
environment:
|
||||
@@ -299,22 +294,12 @@ services:
|
||||
image: docker.io/eceasy/cli-proxy-api:v6.9.7
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
# Loopback-only by default: this sidecar's data volume
|
||||
# (cliproxiapi-data:/root/.cli-proxy-api) holds provider OAuth/API
|
||||
# credentials, and the pinned image only reads api-keys from a mounted
|
||||
# config.yaml (not env vars), so an unqualified "8317:8317" publish spec
|
||||
# would put a credential-bearing service with no compose-configured
|
||||
# data-plane auth on every LAN interface. Same reasoning as Redis above.
|
||||
- "${CLIPROXY_BIND_HOST:-127.0.0.1}:${CLIPROXYAPI_PORT:-8317}:${CLIPROXYAPI_PORT:-8317}"
|
||||
- "${CLIPROXYAPI_PORT:-8317}:${CLIPROXYAPI_PORT:-8317}"
|
||||
volumes:
|
||||
- cliproxyapi-data:/root/.cli-proxy-api
|
||||
environment:
|
||||
- PORT=${CLIPROXYAPI_PORT:-8317}
|
||||
- HOST=0.0.0.0
|
||||
# Forwards to the one auth-related env var the pinned binary actually
|
||||
# reads (MANAGEMENT_PASSWORD) — secures the management API only; the
|
||||
# data-plane completions endpoints have no env-based override upstream.
|
||||
- MANAGEMENT_PASSWORD=${CLIPROXYAPI_MANAGEMENT_KEY:-}
|
||||
healthcheck:
|
||||
test:
|
||||
["CMD", "wget", "--spider", "-q", "http://127.0.0.1:${CLIPROXYAPI_PORT:-8317}/v1/models"]
|
||||
|
||||
@@ -338,8 +338,6 @@ Beyond the defaults documented in [ENVIRONMENT.md](../reference/ENVIRONMENT.md),
|
||||
| `AUTO_UPDATE_HOST_REPO_DIR` | Host path mounted into `cli` profile at `/workspace/omniroute` for self-update workflows | `.` (current directory) |
|
||||
| `OMNIROUTE_MEMORY_MB` | Runtime Node heap ceiling for the Docker standalone server; overrides the image default above. Coding agents: `8192`+ (see [runtime RAM](#runtime-ram-for-coding-agents)). | `1024` |
|
||||
| `DASHBOARD_PORT` / `API_PORT` | Override exposed ports for dashboard (20128) and API (20129) | `20128` / `20129` |
|
||||
| `APP_BIND_HOST` | Host interface docker-compose publishes the dashboard/API/live-WS ports on. With `REQUIRE_API_KEY=false` (the default), `0.0.0.0` exposes the anonymous `/v1` proxy to the LAN — only widen with `REQUIRE_API_KEY=true` or a reverse proxy in front. | `127.0.0.1` |
|
||||
| `CLIPROXY_BIND_HOST` | Host interface docker-compose publishes the `cliproxyapi` sidecar on — its data volume holds provider credentials. | `127.0.0.1` |
|
||||
| `OMNIROUTE_PLUGINS_DIR` | Directory the runtime plugin scanner reads and installs into. Set it when plugins are bind-mounted: the default follows `HOME`, which an image need not export. | `~/.omniroute/plugins` |
|
||||
| `OMNIROUTE_BASE_PATH` | URL subpath when the app is published behind a reverse proxy (e.g. `/omniroute`) | _(empty = root)_ |
|
||||
| `NEXT_PUBLIC_BASE_URL` | Public browser origin including the subpath (e.g. `https://host/omniroute`) | unset |
|
||||
|
||||
@@ -1072,7 +1072,6 @@ desktop install.
|
||||
| `CLIPROXYAPI_API_KEY` | _(empty)_ | `open-sse/handlers/chatCore/cliproxyapiCredentials.ts` | Data-plane key fallback when the `cliproxyapi_api_key` setting is absent. |
|
||||
| `CLIPROXYAPI_MANAGEMENT_KEY` | _(empty)_ | `src/lib/services/cliproxyAccountHealth.ts` | Management key for account-health reads from an externally managed CLIProxyAPI instance. |
|
||||
| `CLIPROXYAPI_CONFIG_DIR` | `~/.cli-proxy-api` | `src/lib/versionManager/processManager.ts` | CLIProxyAPI config directory. |
|
||||
| `CLIPROXY_BIND_HOST` | `127.0.0.1` | `docker-compose.yml` | Host interface docker-compose publishes the `cliproxyapi` sidecar on (#12578). Its data volume holds provider OAuth/API credentials and the pinned image has no env-based data-plane `api-keys` override (only a mounted `config.yaml`), so `0.0.0.0` exposes a credential-bearing service to the whole LAN. |
|
||||
| `MUX_SERVICE_PORT` | `8322` | `src/lib/services/bootstrap.ts` | Override the port where the embedded Mux (coder/mux) agent-orchestration daemon listens (always 127.0.0.1). |
|
||||
| `DARIO_HOST` | `127.0.0.1` | `open-sse/executors/dario.ts` | Dario embedded-service bind/connect host (loopback only by default). |
|
||||
| `DARIO_PORT` | `3456` | `open-sse/executors/dario.ts` | Dario embedded-service port. |
|
||||
@@ -1382,9 +1381,6 @@ Provider quota endpoints, network tunnels (Tailscale, Ngrok, MITM debug proxy),
|
||||
| `OMNIROUTE_REDIS_BIND_HOST` | `127.0.0.1` | `bin/cli/commands/redis.mjs` | Host interface the 1-click Redis launcher publishes on. The launcher starts Redis WITHOUT a password, so binding `0.0.0.0` hands every host on your LAN an unauthenticated Redis — only widen this if you also set a password on the instance yourself. |
|
||||
| `REDIS_BIND_HOST` | `127.0.0.1` | `docker-compose.yml` | Host interface docker-compose publishes the Redis sidecar on (#9286). The compose Redis runs without `requirepass`; app containers reach it over the compose network (`redis:6379`) — the published port exists only for host-side tooling. `0.0.0.0` exposes an unauthenticated Redis to the whole LAN. |
|
||||
| `REDIS_PORT` | `6379` | `docker-compose.yml` | Host port for the compose Redis sidecar. |
|
||||
| `APP_BIND_HOST` | `127.0.0.1` | `docker-compose.yml`, `docker-compose.prod.yml` | Host interface docker-compose publishes the app's own dashboard/API/live-WS ports on (#12568). With `REQUIRE_API_KEY=false` shipping as the `.env.example` default, `0.0.0.0` exposes the anonymous `/v1` LLM proxy to the whole LAN/WAN — only widen once `REQUIRE_API_KEY=true` or a reverse proxy in front enforces its own auth. |
|
||||
| `QDRANT_BIND_HOST` | `127.0.0.1` | `docker-compose.yml` | Host interface docker-compose publishes the Qdrant memory sidecar on (#12578). Same LAN-exposure reasoning as `REDIS_BIND_HOST`. |
|
||||
| `BIFROST_BIND_HOST` | `127.0.0.1` | `docker-compose.yml` | Host interface docker-compose publishes the Bifrost router sidecar on (#12578). Same LAN-exposure reasoning as `REDIS_BIND_HOST`. |
|
||||
| `REDIS_KEY_PREFIX` | `omniroute:` | `src/shared/utils/rateLimiter.ts` | Namespace prefix applied to every OmniRoute Redis key (rate limiter, auth cache, quota store). Prevents key collisions when the Redis instance is shared with other apps (#11042). |
|
||||
| `OMNIROUTE_INTERNAL_SERVICE_TOKEN` | _(unset — mechanism disabled)_ | `src/lib/api/internalServiceAuth.ts` | Shared secret for identity-preserving internal REST hops (#9260): OmniRoute components calling other local OmniRoute routes send it as `x-omniroute-internal-service-token` so the original caller identity is preserved. Compared with `timingSafeEqual`. |
|
||||
| `OMNIROUTE_INTERNAL_SERVICE_TOKEN_FILE` | _(unset)_ | `src/lib/api/internalServiceAuth.ts` | Secret-file variant of the internal service token: path to a file whose trimmed content is the token. Only consulted when the inline var is unset. |
|
||||
|
||||
@@ -119,6 +119,32 @@ export const TRANSLATION_SYSTEM = (englishName, native) =>
|
||||
`Keep punctuation and trailing whitespace identical to the source.`,
|
||||
].join(" ");
|
||||
|
||||
/**
|
||||
* Restores the ICU literal escape the backends drop around angle placeholders.
|
||||
*
|
||||
* English writes `'<name>'`: those single quotes are ICU's escape, so the span
|
||||
* renders as the literal text `<name>`. Translations come back as a bare
|
||||
* `<nome>`, which ICU then parses as an (unclosed) tag and the message fails to
|
||||
* compile — every locale in the first batch shipped two of these.
|
||||
*
|
||||
* Only messages whose English side quotes EVERY angle span are touched: when the
|
||||
* source mixes real markup (`<b>`) with a literal span there is no safe way to
|
||||
* tell which is which, so the translation is left exactly as it came back. An
|
||||
* apostrophe inside the span is doubled, otherwise it closes the literal early.
|
||||
*/
|
||||
export function preserveIcuLiteralQuotes(englishValue, translated) {
|
||||
if (typeof englishValue !== "string" || typeof translated !== "string") return translated;
|
||||
if (!englishValue.includes("'<")) return translated;
|
||||
// Every "<" in the source must be the start of an escaped span.
|
||||
for (let i = 0; i < englishValue.length; i++) {
|
||||
if (englishValue[i] === "<" && englishValue[i - 1] !== "'") return translated;
|
||||
}
|
||||
return translated.replace(
|
||||
/(?<!')<([^<>]*)>(?!')/g,
|
||||
(_m, inner) => `'<${inner.replace(/'/g, "''")}>'`
|
||||
);
|
||||
}
|
||||
|
||||
export async function translateString(englishValue, localeEntry, backend) {
|
||||
const englishName = localeEntry.english ?? localeEntry.name;
|
||||
const native = localeEntry.native ?? localeEntry.name;
|
||||
@@ -127,7 +153,7 @@ export async function translateString(englishValue, localeEntry, backend) {
|
||||
{ role: "user", content: englishValue },
|
||||
];
|
||||
const out = await callChat(messages, backend);
|
||||
return out.trim();
|
||||
return preserveIcuLiteralQuotes(englishValue, out.trim());
|
||||
}
|
||||
|
||||
// ----- Batch mode ----------------------------------------------------------
|
||||
@@ -198,8 +224,14 @@ export async function translateBatch(entries, localeEntry, backend) {
|
||||
{ role: "user", content: JSON.stringify(payload) },
|
||||
];
|
||||
const out = await callChat(messages, backend);
|
||||
return parseBatchResponse(
|
||||
const parsed = parseBatchResponse(
|
||||
out,
|
||||
entries.map((e) => e.id)
|
||||
);
|
||||
for (const entry of entries) {
|
||||
if (typeof parsed[entry.id] === "string") {
|
||||
parsed[entry.id] = preserveIcuLiteralQuotes(entry.text, parsed[entry.id]);
|
||||
}
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
@@ -380,16 +380,22 @@ const SYSTEM_PROMPT = (englishName, native) =>
|
||||
`Return ONLY the translated markdown — no preamble, no explanation, no surrounding fences.`,
|
||||
].join(" ");
|
||||
|
||||
// Splits a markdown body into chunks of <= maxChars, breaking on top-level `## ` headings only.
|
||||
function chunkMarkdown(markdown, maxChars = 6000) {
|
||||
// Splits a markdown body into chunks of <= maxChars. Top-level `## ` headings
|
||||
// are the preferred cut; a section that is still longer than maxChars is then
|
||||
// split again on `### ` headings and paragraph boundaries, never inside a
|
||||
// fenced code block. Before the second pass a single long section (README.md
|
||||
// has a 16 KB one, USER_GUIDE.md a 20 KB one) became one oversized request
|
||||
// that the slow fallback model could not answer inside the backend's
|
||||
// 10-minute fetch timeout, and the biggest docs failed on every retry.
|
||||
export function chunkMarkdown(markdown, maxChars = 6000) {
|
||||
if (markdown.length <= maxChars) return [markdown];
|
||||
const lines = markdown.split("\n");
|
||||
const chunks = [];
|
||||
const sections = [];
|
||||
let buf = [];
|
||||
let size = 0;
|
||||
for (const line of lines) {
|
||||
if (line.startsWith("## ") && size > maxChars * 0.5) {
|
||||
chunks.push(buf.join("\n"));
|
||||
sections.push(buf.join("\n"));
|
||||
buf = [line];
|
||||
size = line.length;
|
||||
} else {
|
||||
@@ -397,7 +403,65 @@ function chunkMarkdown(markdown, maxChars = 6000) {
|
||||
size += line.length + 1;
|
||||
}
|
||||
}
|
||||
if (buf.length) chunks.push(buf.join("\n"));
|
||||
if (buf.length) sections.push(buf.join("\n"));
|
||||
return sections.flatMap((section) =>
|
||||
section.length <= maxChars ? [section] : splitOversizedSection(section, maxChars)
|
||||
);
|
||||
}
|
||||
|
||||
const FENCE_LINE = /^\s*(```|~~~)/;
|
||||
|
||||
// Groups a section into blocks — a whole fenced code block, a heading-led run,
|
||||
// or a paragraph ending at a blank line — and packs them greedily. A block that
|
||||
// is itself larger than maxChars stays whole: cutting mid-paragraph or inside a
|
||||
// fence would hand the model a fragment it cannot translate faithfully.
|
||||
function splitOversizedSection(section, maxChars) {
|
||||
const blocks = [];
|
||||
let block = [];
|
||||
let inFence = false;
|
||||
for (const line of section.split("\n")) {
|
||||
const isFence = FENCE_LINE.test(line);
|
||||
if (inFence) {
|
||||
block.push(line);
|
||||
if (isFence) {
|
||||
inFence = false;
|
||||
blocks.push(block);
|
||||
block = [];
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (isFence) {
|
||||
if (block.length) blocks.push(block);
|
||||
block = [line];
|
||||
inFence = true;
|
||||
continue;
|
||||
}
|
||||
if (/^##+ /.test(line) && block.length) {
|
||||
blocks.push(block);
|
||||
block = [];
|
||||
}
|
||||
block.push(line);
|
||||
if (line.trim() === "") {
|
||||
blocks.push(block);
|
||||
block = [];
|
||||
}
|
||||
}
|
||||
if (block.length) blocks.push(block);
|
||||
|
||||
const chunks = [];
|
||||
let current = [];
|
||||
let size = 0;
|
||||
for (const lines of blocks) {
|
||||
const length = lines.join("\n").length + 1;
|
||||
if (size > 0 && size + length > maxChars) {
|
||||
chunks.push(current.join("\n"));
|
||||
current = [];
|
||||
size = 0;
|
||||
}
|
||||
current.push(...lines);
|
||||
size += length;
|
||||
}
|
||||
if (current.length) chunks.push(current.join("\n"));
|
||||
return chunks;
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,6 @@ import http from "http";
|
||||
import type { IncomingMessage, ServerResponse } from "http";
|
||||
import net from "net";
|
||||
import { getRuntimePorts } from "@/lib/runtime/ports";
|
||||
import { warnIfNonLoopbackWithoutApiKey } from "@/lib/startup/nonLoopbackApiKeyGuard";
|
||||
import { getApiBridgeTimeoutConfig } from "@/shared/utils/runtimeTimeouts";
|
||||
import {
|
||||
attachRequestStreamGuards,
|
||||
@@ -185,7 +184,6 @@ export function initApiBridgeServer(): void {
|
||||
if (apiPort === dashboardPort) return;
|
||||
|
||||
const host = process.env.API_HOST || "127.0.0.1";
|
||||
warnIfNonLoopbackWithoutApiKey("API bridge", host);
|
||||
|
||||
const server = http.createServer((req, res) => {
|
||||
// Absorb client-abort errors (browser closes the socket during navigation/
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
// Boot-time guard for issue #12568: docker-compose can be told to bind the
|
||||
// dashboard/API/live-WS ports to a non-loopback interface (APP_BIND_HOST,
|
||||
// API_HOST, LIVE_WS_HOST) while REQUIRE_API_KEY still defaults to `false`.
|
||||
// That combination puts the anonymous /v1 LLM proxy on the LAN/WAN with no
|
||||
// key required. This never hard-fails the boot (a reverse proxy in front of
|
||||
// OmniRoute may already be doing its own auth) — it only logs a loud warning
|
||||
// so the operator notices the exposure instead of discovering it from traffic.
|
||||
|
||||
const LOOPBACK_HOSTS = new Set(["127.0.0.1", "::1", "localhost", "::ffff:127.0.0.1"]);
|
||||
|
||||
function isLoopbackHost(host: string): boolean {
|
||||
return LOOPBACK_HOSTS.has(host.trim().toLowerCase());
|
||||
}
|
||||
|
||||
function isRequireApiKeyDisabled(): boolean {
|
||||
const raw = (process.env.REQUIRE_API_KEY || "").trim().toLowerCase();
|
||||
// Matches the feature-flag default: unset/empty falls back to "false".
|
||||
return raw !== "true" && raw !== "1" && raw !== "yes";
|
||||
}
|
||||
|
||||
/**
|
||||
* Logs a warning when `host` resolves to a non-loopback interface while
|
||||
* REQUIRE_API_KEY is disabled. Never throws and never blocks startup.
|
||||
*/
|
||||
export function warnIfNonLoopbackWithoutApiKey(serverLabel: string, host: string): void {
|
||||
if (isLoopbackHost(host)) return;
|
||||
if (!isRequireApiKeyDisabled()) return;
|
||||
|
||||
console.warn(
|
||||
`[startup] ${serverLabel} is bound to non-loopback host "${host}" while ` +
|
||||
"REQUIRE_API_KEY is disabled — this exposes the anonymous /v1 proxy to " +
|
||||
"every reachable network interface. Set REQUIRE_API_KEY=true, or bind " +
|
||||
"back to 127.0.0.1, unless a reverse proxy in front of this instance " +
|
||||
"already enforces its own authentication."
|
||||
);
|
||||
}
|
||||
@@ -32,7 +32,6 @@ import type { DashboardEventName, DashboardEventMap, DashboardChannel } from "@/
|
||||
|
||||
import { CHANNEL_EVENTS, getChannelForEvent } from "@/lib/events/types";
|
||||
import { isAutomatedTestProcess, isBuildProcess } from "@/shared/utils/testProcess";
|
||||
import { warnIfNonLoopbackWithoutApiKey } from "@/lib/startup/nonLoopbackApiKeyGuard";
|
||||
|
||||
import {
|
||||
attachRequestStreamGuards,
|
||||
@@ -651,7 +650,6 @@ export function isLiveWsEnabled(): boolean {
|
||||
if (!isBuildOrTest() && isLiveWsEnabled()) {
|
||||
const port = parseInt(process.env.LIVE_WS_PORT || String(DEFAULT_PORT), 10);
|
||||
const host = process.env.LIVE_WS_HOST || DEFAULT_HOST;
|
||||
warnIfNonLoopbackWithoutApiKey("Live dashboard WebSocket", host);
|
||||
startLiveDashboardServer(port, host).catch((err) => {
|
||||
console.error("[LiveWS] Failed to start: %s", err instanceof Error ? err.message : String(err));
|
||||
});
|
||||
|
||||
@@ -1,71 +0,0 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
const REPO_ROOT = path.resolve(import.meta.dirname, "../..");
|
||||
|
||||
// docker-compose.yml (base/web/cli/host profiles) and docker-compose.prod.yml
|
||||
// default API_HOST/LIVE_WS_HOST/HOSTNAME to 0.0.0.0 and publish the dashboard/
|
||||
// API/live-WS ports with a bare, unscoped spec — Docker expands that to every
|
||||
// interface. Combined with .env.example shipping REQUIRE_API_KEY=false by
|
||||
// default, this puts the anonymous /v1 LLM proxy on the LAN/WAN. Mirrors the
|
||||
// existing Redis precedent (tests/unit/compose-redis-loopback-bind.test.ts).
|
||||
// Issue #12568.
|
||||
|
||||
function readCompose(file: string): string {
|
||||
return fs.readFileSync(path.join(REPO_ROOT, file), "utf8");
|
||||
}
|
||||
|
||||
test("docker-compose.yml publishes the dashboard/API/live-WS ports on loopback by default", () => {
|
||||
const compose = readCompose("docker-compose.yml");
|
||||
const barePublishSpecs = [
|
||||
/- "\$\{DASHBOARD_PORT:-20128\}:\$\{DASHBOARD_PORT:-20128\}"/,
|
||||
/- "\$\{API_PORT:-20129\}:\$\{API_PORT:-20129\}"/,
|
||||
/- "\$\{LIVE_WS_PORT:-20132\}:\$\{LIVE_WS_PORT:-20132\}"/,
|
||||
];
|
||||
for (const re of barePublishSpecs) {
|
||||
assert.doesNotMatch(compose, re, `unqualified publish spec ${re} binds 0.0.0.0`);
|
||||
}
|
||||
assert.match(
|
||||
compose,
|
||||
/- "\$\{APP_BIND_HOST:-127\.0\.0\.1\}:\$\{DASHBOARD_PORT:-20128\}:\$\{DASHBOARD_PORT:-20128\}"/
|
||||
);
|
||||
assert.doesNotMatch(compose, /API_HOST=\$\{API_HOST:-0\.0\.0\.0\}/);
|
||||
assert.doesNotMatch(compose, /LIVE_WS_HOST=\$\{LIVE_WS_HOST:-0\.0\.0\.0\}/);
|
||||
});
|
||||
|
||||
test("docker-compose.prod.yml publishes the app's ports on loopback by default", () => {
|
||||
const compose = readCompose("docker-compose.prod.yml");
|
||||
assert.doesNotMatch(compose, /API_HOST=\$\{API_HOST:-0\.0\.0\.0\}/);
|
||||
assert.doesNotMatch(compose, /LIVE_WS_HOST=\$\{LIVE_WS_HOST:-0\.0\.0\.0\}/);
|
||||
assert.doesNotMatch(compose, /HOSTNAME=0\.0\.0\.0/);
|
||||
assert.match(compose, /\$\{APP_BIND_HOST:-127\.0\.0\.1\}:\$\{PROD_DASHBOARD_PORT/);
|
||||
});
|
||||
|
||||
test(".env.example does not ship REQUIRE_API_KEY=false without a boot-time non-loopback guard", () => {
|
||||
const env = fs.readFileSync(path.join(REPO_ROOT, ".env.example"), "utf8");
|
||||
const requireApiKeyFalse = /^REQUIRE_API_KEY=false\s*$/m.test(env);
|
||||
if (requireApiKeyFalse) {
|
||||
const guardHits = ["src/server", "src/lib", "open-sse"].some((dir) => {
|
||||
try {
|
||||
const files = fs.readdirSync(path.join(REPO_ROOT, dir), { recursive: true }) as string[];
|
||||
return files.some((f) => {
|
||||
if (!f.endsWith(".ts")) return false;
|
||||
const full = path.join(REPO_ROOT, dir, f);
|
||||
if (!fs.statSync(full).isFile()) return false;
|
||||
const content = fs.readFileSync(full, "utf8");
|
||||
return content.includes("non-loopback") && content.includes("REQUIRE_API_KEY");
|
||||
});
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
assert.ok(guardHits, "REQUIRE_API_KEY=false ships with no boot-time non-loopback guard");
|
||||
}
|
||||
});
|
||||
|
||||
test(".env.example documents APP_BIND_HOST and its default", () => {
|
||||
const env = fs.readFileSync(path.join(REPO_ROOT, ".env.example"), "utf8");
|
||||
assert.match(env, /# APP_BIND_HOST=127\.0\.0\.1/);
|
||||
});
|
||||
@@ -1,59 +0,0 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
const REPO_ROOT = path.resolve(import.meta.dirname, "../..");
|
||||
|
||||
// The optional `cliproxyapi` sidecar (profile `cliproxyapi`) proxies provider
|
||||
// credentials (its data volume is `cliproxyapi-data:/root/.cli-proxy-api`) and
|
||||
// carried no auth-related environment variable in its `environment:` block.
|
||||
// Docker/Podman expand an unqualified "8317:8317" publish spec to 0.0.0.0,
|
||||
// which puts this credential-bearing sidecar on every LAN interface the same
|
||||
// way a bare "6379:6379" would for Redis (see
|
||||
// tests/unit/compose-redis-loopback-bind.test.ts, the precedent this repo
|
||||
// already applied). Issue #12578.
|
||||
|
||||
function readCompose(file: string): string {
|
||||
return fs.readFileSync(path.join(REPO_ROOT, file), "utf8");
|
||||
}
|
||||
|
||||
test("docker-compose publishes cliproxyapi on loopback by default", () => {
|
||||
const compose = readCompose("docker-compose.yml");
|
||||
assert.match(
|
||||
compose,
|
||||
/- "\$\{CLIPROXY_BIND_HOST:-127\.0\.0\.1\}:\$\{CLIPROXYAPI_PORT:-8317\}:\$\{CLIPROXYAPI_PORT:-8317\}"/,
|
||||
"cliproxyapi publish spec must default to 127.0.0.1 (matching the Redis precedent)"
|
||||
);
|
||||
assert.doesNotMatch(
|
||||
compose,
|
||||
/- "\$\{CLIPROXYAPI_PORT:-8317\}:\$\{CLIPROXYAPI_PORT:-8317\}"/,
|
||||
"unqualified cliproxyapi publish spec binds 0.0.0.0"
|
||||
);
|
||||
});
|
||||
|
||||
test("cliproxyapi service forwards a management/auth key into its environment", () => {
|
||||
const compose = readCompose("docker-compose.yml");
|
||||
const serviceMatch = compose.match(/ {2}cliproxyapi:\n(?:.*\n)*?(?=\n {2}\S|$)/);
|
||||
assert.ok(serviceMatch, "cliproxyapi service block must exist in docker-compose.yml");
|
||||
assert.match(
|
||||
serviceMatch![0],
|
||||
/CLIPROXYAPI_MANAGEMENT_KEY/,
|
||||
"cliproxyapi environment block must forward CLIPROXYAPI_MANAGEMENT_KEY (already documented in docs/reference/ENVIRONMENT.md) instead of leaving auth entirely to the upstream image's undocumented default"
|
||||
);
|
||||
});
|
||||
|
||||
test("qdrant and bifrost sidecars also publish on loopback by default", () => {
|
||||
const compose = readCompose("docker-compose.yml");
|
||||
assert.match(compose, /- "\$\{QDRANT_BIND_HOST:-127\.0\.0\.1\}:\$\{QDRANT_PORT:-6333\}:6333"/);
|
||||
assert.match(
|
||||
compose,
|
||||
/- "\$\{QDRANT_BIND_HOST:-127\.0\.0\.1\}:\$\{QDRANT_GRPC_PORT:-6334\}:6334"/
|
||||
);
|
||||
assert.match(compose, /- "\$\{BIFROST_BIND_HOST:-127\.0\.0\.1\}:\$\{BIFROST_PORT:-8080\}:8080"/);
|
||||
});
|
||||
|
||||
test(".env.example documents CLIPROXY_BIND_HOST and its default", () => {
|
||||
const env = fs.readFileSync(path.join(REPO_ROOT, ".env.example"), "utf8");
|
||||
assert.match(env, /# CLIPROXY_BIND_HOST=127\.0\.0\.1/);
|
||||
});
|
||||
65
tests/unit/i18n-run-translation-chunking.test.ts
Normal file
65
tests/unit/i18n-run-translation-chunking.test.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { chunkMarkdown } from "../../scripts/i18n/run-translation.mjs";
|
||||
|
||||
// The docs translator splits a page into chunks so one upstream call stays
|
||||
// short. It used to break only on `## ` headings, so a single long section
|
||||
// (README.md carries a 16 KB one, USER_GUIDE.md a 20 KB one) became one
|
||||
// oversized request that the slow fallback model could not answer inside the
|
||||
// backend's 10-minute fetch timeout — the three biggest docs of every locale
|
||||
// then failed with "fetch failed" on every retry.
|
||||
|
||||
const para = (label: string, n = 12) =>
|
||||
Array.from({ length: n }, (_, i) => `${label} sentence ${i + 1} with some filler text.`).join(
|
||||
" "
|
||||
);
|
||||
|
||||
test("a section longer than maxChars is split on sub-headings and paragraphs", () => {
|
||||
const body = [
|
||||
"## Big",
|
||||
para("a"),
|
||||
"",
|
||||
"### Part one",
|
||||
para("b"),
|
||||
"",
|
||||
para("c"),
|
||||
"",
|
||||
"### Part two",
|
||||
para("d"),
|
||||
].join("\n");
|
||||
const chunks = chunkMarkdown(body, 700);
|
||||
assert.ok(chunks.length > 1, "must split an oversized section");
|
||||
for (const chunk of chunks) assert.ok(chunk.length <= 700, `chunk too big: ${chunk.length}`);
|
||||
// Nothing lost: re-joining with blank lines reproduces every line of the source.
|
||||
const lines = (s: string) => s.split("\n").filter((l) => l.trim() !== "");
|
||||
assert.deepEqual(lines(chunks.join("\n\n")), lines(body));
|
||||
});
|
||||
|
||||
test("never splits inside a fenced code block", () => {
|
||||
const code = [
|
||||
"```ts",
|
||||
...Array.from({ length: 30 }, (_, i) => `const v${i} = ${i};`),
|
||||
"```",
|
||||
].join("\n");
|
||||
const body = ["## Code", para("x"), "", code, "", para("y")].join("\n");
|
||||
const chunks = chunkMarkdown(body, 500);
|
||||
const withFence = chunks.filter((c) => c.includes("```"));
|
||||
for (const chunk of withFence) {
|
||||
assert.equal(
|
||||
(chunk.match(/```/g) ?? []).length % 2,
|
||||
0,
|
||||
"fence must open and close in the same chunk"
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("keeps the old behaviour for short pages and for normal ## sections", () => {
|
||||
assert.deepEqual(chunkMarkdown("# Title\n\nshort", 6000), ["# Title\n\nshort"]);
|
||||
const body = ["## A", para("a", 4), "", "## B", para("b", 4), "", "## C", para("c", 4)].join(
|
||||
"\n"
|
||||
);
|
||||
const chunks = chunkMarkdown(body, 400);
|
||||
assert.ok(chunks.every((c) => c.length <= 400));
|
||||
assert.ok(chunks.length > 1);
|
||||
for (const chunk of chunks.slice(1)) assert.match(chunk, /^## /, "cuts land on ## boundaries");
|
||||
});
|
||||
43
tests/unit/i18n-translate-backend-icu-quotes.test.ts
Normal file
43
tests/unit/i18n-translate-backend-icu-quotes.test.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { preserveIcuLiteralQuotes } from "../../scripts/i18n/lib/translate-backend.mjs";
|
||||
|
||||
// The English catalog escapes angle placeholders for ICU: the single quotes in
|
||||
// '<name>' make the span literal text. Translation backends drop them, and the
|
||||
// message then parses as an unclosed ICU tag — every locale added in batch 1
|
||||
// shipped two such strings before CI caught them.
|
||||
test("re-escapes an angle span the translation left bare", () => {
|
||||
assert.equal(
|
||||
preserveIcuLiteralQuotes(
|
||||
"Regenerate ~/.claude/profiles/'<name>'/settings.json",
|
||||
"Regenerar ~/.claude/profiles/<nome>/settings.json"
|
||||
),
|
||||
"Regenerar ~/.claude/profiles/'<nome>'/settings.json"
|
||||
);
|
||||
});
|
||||
|
||||
test("doubles an apostrophe inside the span, which would close the literal early", () => {
|
||||
assert.equal(
|
||||
preserveIcuLiteralQuotes("'<your OmniRoute API key>'", "<teie OmniRoute'i API-võti>"),
|
||||
"'<teie OmniRoute''i API-võti>'"
|
||||
);
|
||||
});
|
||||
|
||||
test("leaves a translation that already carries the quotes untouched", () => {
|
||||
const already = "'<seu token>'";
|
||||
assert.equal(preserveIcuLiteralQuotes("'<your token>'", already), already);
|
||||
});
|
||||
|
||||
test("does not touch messages whose English has a real unquoted tag", () => {
|
||||
// <b> here is markup the translation must keep as markup, not literal text.
|
||||
const translated = "Clique em <b>Salvar</b> e em '<nome>'";
|
||||
assert.equal(preserveIcuLiteralQuotes("Click <b>Save</b> and '<name>'", translated), translated);
|
||||
});
|
||||
|
||||
test("leaves messages without angle spans alone", () => {
|
||||
assert.equal(preserveIcuLiteralQuotes("Settings", "Nastavitve"), "Nastavitve");
|
||||
assert.equal(
|
||||
preserveIcuLiteralQuotes("Restricted to {count} endpoints", "Omejeno na {count} točk"),
|
||||
"Omejeno na {count} točk"
|
||||
);
|
||||
});
|
||||
@@ -1,76 +0,0 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { warnIfNonLoopbackWithoutApiKey } from "@/lib/startup/nonLoopbackApiKeyGuard";
|
||||
|
||||
// #12568: docker-compose can bind the app's ports to a non-loopback interface
|
||||
// while REQUIRE_API_KEY still defaults to false, exposing the anonymous /v1
|
||||
// proxy to the LAN/WAN. This guard warns (never blocks) when that combination
|
||||
// is detected at server startup.
|
||||
|
||||
function withEnv<T>(vars: Record<string, string | undefined>, fn: () => T): T {
|
||||
const prev: Record<string, string | undefined> = {};
|
||||
for (const key of Object.keys(vars)) {
|
||||
prev[key] = process.env[key];
|
||||
const value = vars[key];
|
||||
if (value === undefined) delete process.env[key];
|
||||
else process.env[key] = value;
|
||||
}
|
||||
try {
|
||||
return fn();
|
||||
} finally {
|
||||
for (const key of Object.keys(prev)) {
|
||||
const value = prev[key];
|
||||
if (value === undefined) delete process.env[key];
|
||||
else process.env[key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function captureWarn(fn: () => void): string[] {
|
||||
const messages: string[] = [];
|
||||
const original = console.warn;
|
||||
console.warn = (...args: unknown[]) => {
|
||||
messages.push(args.map(String).join(" "));
|
||||
};
|
||||
try {
|
||||
fn();
|
||||
} finally {
|
||||
console.warn = original;
|
||||
}
|
||||
return messages;
|
||||
}
|
||||
|
||||
test("warns when bound to 0.0.0.0 with REQUIRE_API_KEY unset (default false)", () => {
|
||||
withEnv({ REQUIRE_API_KEY: undefined }, () => {
|
||||
const messages = captureWarn(() => warnIfNonLoopbackWithoutApiKey("Test server", "0.0.0.0"));
|
||||
assert.equal(messages.length, 1);
|
||||
assert.match(messages[0], /non-loopback host "0\.0\.0\.0"/);
|
||||
assert.match(messages[0], /REQUIRE_API_KEY/);
|
||||
});
|
||||
});
|
||||
|
||||
test("warns when bound to a LAN IP with REQUIRE_API_KEY=false", () => {
|
||||
withEnv({ REQUIRE_API_KEY: "false" }, () => {
|
||||
const messages = captureWarn(() => warnIfNonLoopbackWithoutApiKey("Test server", "192.168.1.5"));
|
||||
assert.equal(messages.length, 1);
|
||||
});
|
||||
});
|
||||
|
||||
test("stays silent when bound to loopback regardless of REQUIRE_API_KEY", () => {
|
||||
withEnv({ REQUIRE_API_KEY: "false" }, () => {
|
||||
const messages = captureWarn(() => warnIfNonLoopbackWithoutApiKey("Test server", "127.0.0.1"));
|
||||
assert.equal(messages.length, 0);
|
||||
});
|
||||
withEnv({ REQUIRE_API_KEY: "false" }, () => {
|
||||
const messages = captureWarn(() => warnIfNonLoopbackWithoutApiKey("Test server", "::1"));
|
||||
assert.equal(messages.length, 0);
|
||||
});
|
||||
});
|
||||
|
||||
test("stays silent when bound to 0.0.0.0 with REQUIRE_API_KEY=true", () => {
|
||||
withEnv({ REQUIRE_API_KEY: "true" }, () => {
|
||||
const messages = captureWarn(() => warnIfNonLoopbackWithoutApiKey("Test server", "0.0.0.0"));
|
||||
assert.equal(messages.length, 0);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user