Compare commits

..

6 Commits

Author SHA1 Message Date
diegosouzapw
9769ec51e3 fix(memory): document VECTOR_STORE_DISABLE_VEC test seam (#12745) 2026-09-11 18:17:42 -03:00
diegosouzapw
222bab0abb fix(memory): authenticate internal /v1/rerank loopback call (#12745)
applyRerank() in src/lib/memory/retrieval.ts called OmniRoute's own
/v1/rerank over loopback with no Authorization/x-api-key header. That
route is gated by the global authz proxy's clientApiPolicy, which has
no loopback exemption: with REQUIRE_API_KEY=true the call was
unconditionally 401'd, and because applyRerank() is deliberately
fail-open, retrieval silently kept working unranked instead of
surfacing the failure.

Fix: attach a real, DB-backed API key as a Bearer token, using the
same pickApiKeyForInternalUse() internal-probe selector already used
by combo-health-check / cloud-sync-verify — no exemption was added to
clientApiPolicy, so an unauthenticated external request to
/api/v1/rerank is still rejected exactly as before (see the client-api
policy regression suite, unchanged and green).

Also derives the loopback port from getRuntimePorts() instead of a
hardcoded 20128, matching the pattern already used elsewhere for
internal self-calls (e.g. src/app/api/playground/improve-prompt/route.ts).
2026-09-10 15:27:43 -03:00
Diego Rodrigues de Sa e Souza
fd27ff08c7 chore(deps): drain the Dependabot queue — 10 of 13 alerts (#13213)
* chore(deps): drain the Dependabot queue — 7 of 10 alerts

Lockfile-only bumps; no manifest touched, so nothing changes for consumers.

Root package-lock.json:
  hono      4.13.0 -> 4.13.7  (#215 #216 #217, medium, patched 4.13.5)
  csv-parse 7.0.1  -> 7.0.2   (#213, medium)
  joi       18.2.3 -> 18.2.8  (#211 #212, low, patched 18.2.4/18.2.5)

@omniroute/opencode-plugin:
  toml      4.1.1  -> 4.3.0   (#209, HIGH, patched 4.1.2)

@omniroute/opencode-plugin-v2:
  esbuild   0.28.1 -> 0.28.2  (#210, low) — the direct copy only; see below.

The plugin-v2 diff looks large but is one package: esbuild ships 27 platform
binaries, each carrying version + resolved + integrity.

Three alerts stay open, deliberately:

  #218 extract-zip (HIGH) and #214 adm-zip (medium) have NO published patch.
  Both are dev-scope. Closing them needs an upstream release or a decision to
  replace the dependency — neither belongs in a lockfile bump.

  #210 esbuild is only half-closed. `node_modules/esbuild` is on 0.28.2, but
  `tsup` pins `esbuild: ^0.27.0`, so its nested copy stays at 0.27.7 — inside the
  vulnerable range (>= 0.27.3, < 0.28.1). Updating tsup does not move it (8.5.1
  is already current). Forcing it would take an `overrides` entry pushing a major
  of esbuild inside the bundler, which is exactly the change that breaks a build
  silently, for a LOW dev-only alert. Left for an upstream tsup release.

check:lockfile passes on all three, including the workspace lock/manifest
consistency check. check:tracked-artifacts OK.

* chore(deps): bump js-yaml to 4.3.2 (root + electron)

Two more HIGH alerts arrived after the first sweep:

  #220 js-yaml (root package-lock.json)     >= 4.0.0, < 4.3.2
  #219 js-yaml (electron/package-lock.json) >= 4.0.0, < 4.3.2

The root's own js-yaml was already on 5.4.1; the vulnerable copies were the ones
nested under @yarnpkg/parsers, lockfile-lint, xmlbuilder2 (root) and the direct
dependency in electron. All now 4.3.2. Four version lines, nothing else.

#221 smol-toml (HIGH, <= 1.7.0) is NOT closed here. The root is on 1.8.0; the
vulnerable 1.6.1 sits under @openai/codex-security, which pins it as an EXACT
version rather than a range, so `npm update` cannot move it. Bumping
codex-security itself (0.1.24 -> 0.1.26) does not help — 0.1.26 pins the same
1.6.1 — so that bump was reverted rather than carried along for no benefit.

Closing #221 needs an upstream codex-security release or an `overrides` entry,
the same trade already declined for #210/tsup: forcing a transitive pin from
outside is how a build breaks silently. Note that @openai/codex-security is also
the package carrying the unpatched extract-zip (#218), so one upstream release
would likely clear both.

* chore(deps): override smol-toml to 1.8.0 and raise the js-yaml floor

Closes #221 (smol-toml, HIGH, DoS via malformed TOML, vulnerable <= 1.7.0).

@openai/codex-security pins smol-toml at 1.6.1 as an EXACT version, so no
`npm update` reaches it. This repo already uses `overrides` as its standard tool
for exactly that situation — the block carries 20+ entries, including the
scoped-by-parent form and the `qs`/`fast-uri`/`ip-address` entries that back
earlier security bumps — so a scoped override is the idiomatic fix here, not a
new mechanism:

    "@openai/codex-security": { "smol-toml": "^1.8.0" }

The nested copy deduplicates to the root's existing 1.8.0, which two other
consumers (the root itself and knip) already run, so the version is proven in
this tree. The whole lockfile diff is the 14 lines of the removed 1.6.1 entry.

Also raised the `@yarnpkg/parsers` js-yaml floor from ^4.3.1 to ^4.3.2, so the
override documents the patched version rather than permitting the vulnerable one
it was written against.

Not fixed, and not fixable by version — verified against the npm registry rather
than trusting the advisory metadata:

  #218 extract-zip — latest published IS 2.0.1, the vulnerable version. Dev
       scope, via @openai/codex-security. No release to move to.
  #214 adm-zip — latest published IS 0.6.0, the top of the vulnerable range
       (>= 0.5.9, <= 0.6.0). RUNTIME scope, via onnxruntime-node's ^0.5.16, and
       the repo already overrides adm-zip to ^0.6.0. No release to move to.

Both need an upstream fix or a decision to replace the dependency; neither is a
lockfile change. adm-zip being runtime rather than dev makes it the one worth
tracking.

#210 esbuild stays open too. A flat `overrides: { esbuild: ^0.28.2 }` in
opencode-plugin-v2 does close it — npm then reports 0 vulnerabilities — but it
requires regenerating that lockfile from scratch: 823 lines, 96 packages moved,
for a LOW dev-only alert, and a major esbuild bump inside tsup cannot be
validated here without a real install of that package. Tried, measured,
reverted. Left for an upstream tsup release.

check:lockfile OK on all lockfiles including the workspace consistency check;
check:tracked-artifacts OK; prettier clean.
2026-09-10 13:37:55 -03:00
Diego Rodrigues de Sa e Souza
0549dcfc36 fix(api): scope batch bulk-delete to the calling API key (#13211)
GHSA-wvxc-jp3v-5mg5: `DELETE /api/v1/batches/delete-completed` deleted the
completed batches of EVERY api key on the instance and nulled the contents of
every file those batches referenced. Any ordinary inference key reached it —
including one with `scopes: []` — and no victim key, batch id or file id was
needed.

Two defects stacked in one endpoint:

  - `deleteCompletedBatches()` carried no `api_key_id` predicate. The file
    SELECT, the checkpoint DELETE and the batch DELETE were all instance-wide.
  - The route only checked that SOME key was present (`!scope.apiKeyId` → 401),
    never that the caller owned anything, and called the helper bare.

The helper now takes `apiKeyId` and scopes all three statements to it; the route
passes the caller's key and omits it only for session auth, so the operator's own
dashboard keeps its instance-wide cleanup and an API key clears only its own
completed batches.

None of this is a new pattern. `listBatches(apiKeyId?)` and
`countBatches(apiKeyId?)` in the same module already scope by `api_key_id`, and
`batches/[id]/route.ts` already gates per-record access with `scopeCheck` —
session auth sees everything, a key sees only its own. This one helper was the
one that never got it, which is why the fix reuses the shape instead of
inventing a second convention.

tests/unit/batch-delete-completed-ownership-wvxc.test.ts — 5 tests, 4 red before
the fix, including the two that prove the cross-tenant destruction (another
key's batch survives; another key's file content survives). It also pins the
instance-wide dashboard sweep so the fix cannot be "tightened" into breaking the
operator's own cleanup, and a source guard that the route never calls the helper
bare again.

Reported privately via GHSA-wvxc-jp3v-5mg5.

Closes GHSA-wvxc-jp3v-5mg5
2026-09-10 13:27:10 -03:00
Diego Rodrigues de Sa e Souza
393cfdd660 fix(test): make the ToS heading guard actually require the parentheses (#13228)
CodeQL js/useless-regexp-character-escape (#994-#997) on one line, and it is a
real defect rather than the usual query noise.

The assertion built its pattern in a TEMPLATE literal:

    new RegExp(`\(\s*${String(tos?.actual)}\s*\)`)

JavaScript resolves the escapes before RegExp ever sees the string: `\(` becomes
"(" and `\s` becomes the LETTER "s". The compiled pattern was `(s*16s*)` — a
capture group around optional "s" characters — so it matched any heading merely
CONTAINING the number. The literal parentheses this guard exists to require were
never checked, and it passed on exactly the headings it was written to reject:

    /(s*16s*)/.test("### Caution — clauses worth checking 16")   // true

Doubled the backslashes so they survive the template literal, and routed the
interpolated value through an `escapeRegExp` helper — the count is a number
today, but interpolating an unescaped value into a regex source is the same
class of bug one refactor away.

Added a second test that pins the behaviour rather than the spelling: the
pattern must REJECT a heading carrying the count without parentheses, and accept
it with them (including inner whitespace). Before this fix that test fails.

4/4 green against the real docs/reference/FREE_TIERS.md heading.
2026-09-10 13:27:02 -03:00
Diego Rodrigues de Sa e Souza
d86cf75aef fix(quality): register 4 drifted covering tests in stryker tap.testFiles (#13229)
`check:mutation-test-coverage --strict` has been failing Fast Quality Gates on
every open PR against release/v3.8.51. It grew from 2 missing entries to 5 in
roughly an hour, so it is drifting faster than PRs land.

Four test files cover a mutated module without being listed, so their mutant
kills do not count:

  open-sse/services/accountFallback.ts      <- openai-compatible-per-upstream-402-health
  src/sse/services/auth.ts                  <- openai-compatible-per-upstream-402-health
                                            <- quota-window-label
  src/shared/utils/circuitBreaker.ts        <- combo/execute-target-gates
  open-sse/services/combo/comboStructure.ts <- combo-pin-implicit-allowlist

Registration only — no test or module is touched, and no gate is weakened; the
listing is what makes those kills count in the first place.

Inserted in place, never through a JSON round-trip: re-serializing this file
reorders the ~10 curated entries that are already out of alphabetical order
(learned the hard way in #11438).

check:mutation-test-coverage now reports no drift. check:tracked-artifacts OK,
prettier clean.

Worth noting for whoever adds the next test: this gate fires whenever a NEW test
happens to cover one of the 31 mutated modules, which is easy to do without
realising. Registering it in the same commit is cheaper than a CI round-trip.
2026-09-10 13:26:53 -03:00
26 changed files with 594 additions and 485 deletions

View File

@@ -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
@@ -2745,6 +2721,10 @@ PLAYGROUND_COMPARE_MAX_COLUMNS=4
# MEMORY_VEC_TOP_K=20 # default top-K for vector search
# MEMORY_RRF_K=60 # RRF k constant (sqlite-vec hybrid recipe)
# HF_HUB_ENDPOINT=https://huggingface.co # override Hugging Face Hub base URL for static potion downloads
# Test/diagnostic seam (src/lib/memory/vectorStore.ts) — forces getVectorStore() to
# return null (simulates a cloud/WASM environment without sqlite-vec), degrading
# memory retrieval to FTS5 keyword search. Default off; leave unset in production.
# VECTOR_STORE_DISABLE_VEC=false
# TV6 typed memory decay (OPT-IN, default off — the sweep DELETES decayed memories)
# MEMORY_TYPED_DECAY_ENABLED=false # master switch for the destructive sweep (default off)
# MEMORY_TYPED_DECAY_EPISODIC_DAYS=30 # episodic TTL in days; 0 = episodic immune too

View File

@@ -22,7 +22,7 @@
"node": ">=22.22.3"
},
"peerDependencies": {
"@opencode-ai/plugin": "*"
"@opencode-ai/plugin": ">=1.18.29 <2"
}
},
"node_modules/@ai-sdk/provider": {
@@ -39,9 +39,9 @@
}
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz",
"integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==",
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz",
"integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==",
"cpu": [
"ppc64"
],
@@ -56,9 +56,9 @@
}
},
"node_modules/@esbuild/android-arm": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz",
"integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==",
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz",
"integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==",
"cpu": [
"arm"
],
@@ -73,9 +73,9 @@
}
},
"node_modules/@esbuild/android-arm64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz",
"integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==",
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz",
"integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==",
"cpu": [
"arm64"
],
@@ -90,9 +90,9 @@
}
},
"node_modules/@esbuild/android-x64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz",
"integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==",
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz",
"integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==",
"cpu": [
"x64"
],
@@ -107,9 +107,9 @@
}
},
"node_modules/@esbuild/darwin-arm64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz",
"integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==",
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz",
"integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==",
"cpu": [
"arm64"
],
@@ -124,9 +124,9 @@
}
},
"node_modules/@esbuild/darwin-x64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz",
"integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==",
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz",
"integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==",
"cpu": [
"x64"
],
@@ -141,9 +141,9 @@
}
},
"node_modules/@esbuild/freebsd-arm64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz",
"integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==",
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz",
"integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==",
"cpu": [
"arm64"
],
@@ -158,9 +158,9 @@
}
},
"node_modules/@esbuild/freebsd-x64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz",
"integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==",
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz",
"integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==",
"cpu": [
"x64"
],
@@ -175,9 +175,9 @@
}
},
"node_modules/@esbuild/linux-arm": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz",
"integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==",
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz",
"integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==",
"cpu": [
"arm"
],
@@ -192,9 +192,9 @@
}
},
"node_modules/@esbuild/linux-arm64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz",
"integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==",
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz",
"integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==",
"cpu": [
"arm64"
],
@@ -209,9 +209,9 @@
}
},
"node_modules/@esbuild/linux-ia32": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz",
"integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==",
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz",
"integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==",
"cpu": [
"ia32"
],
@@ -226,9 +226,9 @@
}
},
"node_modules/@esbuild/linux-loong64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz",
"integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==",
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz",
"integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==",
"cpu": [
"loong64"
],
@@ -243,9 +243,9 @@
}
},
"node_modules/@esbuild/linux-mips64el": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz",
"integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==",
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz",
"integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==",
"cpu": [
"mips64el"
],
@@ -260,9 +260,9 @@
}
},
"node_modules/@esbuild/linux-ppc64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz",
"integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==",
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz",
"integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==",
"cpu": [
"ppc64"
],
@@ -277,9 +277,9 @@
}
},
"node_modules/@esbuild/linux-riscv64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz",
"integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==",
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz",
"integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==",
"cpu": [
"riscv64"
],
@@ -294,9 +294,9 @@
}
},
"node_modules/@esbuild/linux-s390x": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz",
"integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==",
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz",
"integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==",
"cpu": [
"s390x"
],
@@ -311,7 +311,9 @@
}
},
"node_modules/@esbuild/linux-x64": {
"version": "0.28.1",
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz",
"integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==",
"cpu": [
"x64"
],
@@ -326,9 +328,9 @@
}
},
"node_modules/@esbuild/netbsd-arm64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz",
"integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==",
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz",
"integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==",
"cpu": [
"arm64"
],
@@ -343,9 +345,9 @@
}
},
"node_modules/@esbuild/netbsd-x64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz",
"integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==",
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz",
"integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==",
"cpu": [
"x64"
],
@@ -360,9 +362,9 @@
}
},
"node_modules/@esbuild/openbsd-arm64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz",
"integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==",
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz",
"integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==",
"cpu": [
"arm64"
],
@@ -377,9 +379,9 @@
}
},
"node_modules/@esbuild/openbsd-x64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz",
"integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==",
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz",
"integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==",
"cpu": [
"x64"
],
@@ -394,9 +396,9 @@
}
},
"node_modules/@esbuild/openharmony-arm64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz",
"integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==",
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz",
"integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==",
"cpu": [
"arm64"
],
@@ -411,9 +413,9 @@
}
},
"node_modules/@esbuild/sunos-x64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz",
"integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==",
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz",
"integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==",
"cpu": [
"x64"
],
@@ -428,9 +430,9 @@
}
},
"node_modules/@esbuild/win32-arm64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz",
"integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==",
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz",
"integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==",
"cpu": [
"arm64"
],
@@ -445,9 +447,9 @@
}
},
"node_modules/@esbuild/win32-ia32": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz",
"integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==",
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz",
"integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==",
"cpu": [
"ia32"
],
@@ -462,9 +464,9 @@
}
},
"node_modules/@esbuild/win32-x64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz",
"integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==",
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz",
"integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==",
"cpu": [
"x64"
],
@@ -1180,7 +1182,9 @@
}
},
"node_modules/esbuild": {
"version": "0.28.1",
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz",
"integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
@@ -1191,32 +1195,32 @@
"node": ">=18"
},
"optionalDependencies": {
"@esbuild/aix-ppc64": "0.28.1",
"@esbuild/android-arm": "0.28.1",
"@esbuild/android-arm64": "0.28.1",
"@esbuild/android-x64": "0.28.1",
"@esbuild/darwin-arm64": "0.28.1",
"@esbuild/darwin-x64": "0.28.1",
"@esbuild/freebsd-arm64": "0.28.1",
"@esbuild/freebsd-x64": "0.28.1",
"@esbuild/linux-arm": "0.28.1",
"@esbuild/linux-arm64": "0.28.1",
"@esbuild/linux-ia32": "0.28.1",
"@esbuild/linux-loong64": "0.28.1",
"@esbuild/linux-mips64el": "0.28.1",
"@esbuild/linux-ppc64": "0.28.1",
"@esbuild/linux-riscv64": "0.28.1",
"@esbuild/linux-s390x": "0.28.1",
"@esbuild/linux-x64": "0.28.1",
"@esbuild/netbsd-arm64": "0.28.1",
"@esbuild/netbsd-x64": "0.28.1",
"@esbuild/openbsd-arm64": "0.28.1",
"@esbuild/openbsd-x64": "0.28.1",
"@esbuild/openharmony-arm64": "0.28.1",
"@esbuild/sunos-x64": "0.28.1",
"@esbuild/win32-arm64": "0.28.1",
"@esbuild/win32-ia32": "0.28.1",
"@esbuild/win32-x64": "0.28.1"
"@esbuild/aix-ppc64": "0.28.2",
"@esbuild/android-arm": "0.28.2",
"@esbuild/android-arm64": "0.28.2",
"@esbuild/android-x64": "0.28.2",
"@esbuild/darwin-arm64": "0.28.2",
"@esbuild/darwin-x64": "0.28.2",
"@esbuild/freebsd-arm64": "0.28.2",
"@esbuild/freebsd-x64": "0.28.2",
"@esbuild/linux-arm": "0.28.2",
"@esbuild/linux-arm64": "0.28.2",
"@esbuild/linux-ia32": "0.28.2",
"@esbuild/linux-loong64": "0.28.2",
"@esbuild/linux-mips64el": "0.28.2",
"@esbuild/linux-ppc64": "0.28.2",
"@esbuild/linux-riscv64": "0.28.2",
"@esbuild/linux-s390x": "0.28.2",
"@esbuild/linux-x64": "0.28.2",
"@esbuild/netbsd-arm64": "0.28.2",
"@esbuild/netbsd-x64": "0.28.2",
"@esbuild/openbsd-arm64": "0.28.2",
"@esbuild/openbsd-x64": "0.28.2",
"@esbuild/openharmony-arm64": "0.28.2",
"@esbuild/sunos-x64": "0.28.2",
"@esbuild/win32-arm64": "0.28.2",
"@esbuild/win32-ia32": "0.28.2",
"@esbuild/win32-x64": "0.28.2"
}
},
"node_modules/fast-check": {

View File

@@ -1745,9 +1745,9 @@
}
},
"node_modules/toml": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/toml/-/toml-4.1.1.tgz",
"integrity": "sha512-EBJnVBr3dTXdA89WVFoAIPUqkBjxPMwRqsfuo1r240tKFHXv3zgca4+NJib/h6TyvGF7vOawz0jGuryJCdNHrw==",
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/toml/-/toml-4.3.0.tgz",
"integrity": "sha512-lVb8X9BsPVuH0M4BKeS91tXAmJvCjQ5UIyAbQFaxkKGyUFK2RPkhwaFSQH8vbpl1d23eu/IBH+dwVMHWaq9A5A==",
"dev": true,
"license": "MIT",
"engines": {

View File

@@ -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)

View File

@@ -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)

View File

@@ -0,0 +1 @@
- fix(memory): authenticate the internal /v1/rerank loopback call so memory reranking no longer silently degrades to unranked order when REQUIRE_API_KEY=true (#12745)

View File

@@ -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:

View File

@@ -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"]

View File

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

View File

@@ -923,6 +923,7 @@ Embedding layer, vector store and reranking knobs for the persistent memory subs
| `HF_HUB_ENDPOINT` | `https://huggingface.co` | Override Hugging Face Hub base URL used by `staticPotion.ts` (e.g. mirror endpoint for air-gapped setups). |
| `MEMORY_VEC_TOP_K` | `20` | Default top-K used by the `sqlite-vec` brute-force vector search inside `src/lib/memory/vectorStore.ts`. |
| `MEMORY_RRF_K` | `60` | Reciprocal Rank Fusion constant `k` for hybrid FTS5 + vector retrieval (sqlite-vec recipe). |
| `VECTOR_STORE_DISABLE_VEC` | `false` | Test/diagnostic seam in `getVectorStore()` (`src/lib/memory/vectorStore.ts`): when `true`, forces the vector store to `null` (simulates a cloud/WASM environment without `sqlite-vec`), degrading memory retrieval to FTS5 keyword search. Leave unset in production. |
| `NOTION_API_KEY` | _(unset)_ | API key for Notion backend (used by `genericBackend.ts` known backend preset). |
| `NOTION_API_URL` | `https://api.notion.com/v1`| Base URL for Notion API (can override for self-hosted Notion alternatives). |
| `OBSIDIAN_API_KEY` | _(unset)_ | API key for Obsidian Vault backend (used by `genericBackend.ts` known backend preset). |
@@ -1072,7 +1073,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 +1382,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. |

View File

@@ -2113,9 +2113,9 @@
}
},
"node_modules/js-yaml": {
"version": "4.3.1",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.1.tgz",
"integrity": "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==",
"version": "4.3.2",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz",
"integrity": "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==",
"funding": [
{
"type": "github",

50
package-lock.json generated
View File

@@ -8687,20 +8687,6 @@
"license": "ISC",
"optional": true
},
"node_modules/@openai/codex-security/node_modules/smol-toml": {
"version": "1.6.1",
"resolved": "https://registry.npmjs.org/smol-toml/-/smol-toml-1.6.1.tgz",
"integrity": "sha512-dWUG8F5sIIARXih1DTaQAX4SsiTXhInKf1buxdY9DIg4ZYPZK5nGM1VRIYmEbDbsHt7USo99xSLFu5Q1IqTmsg==",
"dev": true,
"license": "BSD-3-Clause",
"optional": true,
"engines": {
"node": ">= 18"
},
"funding": {
"url": "https://github.com/sponsors/cyyynthia"
}
},
"node_modules/@openai/codex-security/node_modules/type-fest": {
"version": "5.9.0",
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-5.9.0.tgz",
@@ -15217,9 +15203,9 @@
}
},
"node_modules/@yarnpkg/parsers/node_modules/js-yaml": {
"version": "4.3.1",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.1.tgz",
"integrity": "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==",
"version": "4.3.2",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz",
"integrity": "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==",
"dev": true,
"funding": [
{
@@ -18535,9 +18521,9 @@
"license": "MIT"
},
"node_modules/csv-parse": {
"version": "7.0.1",
"resolved": "https://registry.npmjs.org/csv-parse/-/csv-parse-7.0.1.tgz",
"integrity": "sha512-+2z7Ar0APQ7Uu6fX4cn+pitRmxjZ1WPBcGmZFKmA74FCyi7Et/XZx8cjNQ5CjbZ4HCOxXCOpRBYvYH08Qa003A==",
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/csv-parse/-/csv-parse-7.0.2.tgz",
"integrity": "sha512-uKZghv9UmPkMVLYy//KZ9HFAIJsl7wkhoEdIL0+rhuSY9pZQlhaeGEDPIe+/w7eh81MOql8Q/9+inAGWG6ZHYA==",
"dev": true,
"license": "MIT"
},
@@ -23603,9 +23589,9 @@
"license": "MIT"
},
"node_modules/hono": {
"version": "4.13.0",
"resolved": "https://registry.npmjs.org/hono/-/hono-4.13.0.tgz",
"integrity": "sha512-jhunvfHWxd7J5EFfSgH4xsYJzSe/lfqbUCxiyyeaQasUsXeEHXtzVid+7EOGByc5JnFa23SSFL3Y2RV/z1T+eQ==",
"version": "4.13.7",
"resolved": "https://registry.npmjs.org/hono/-/hono-4.13.7.tgz",
"integrity": "sha512-c8/gF9ac8Y78/agExVocyLevgR+JlpNB444Py0FSX8pJoPdYUfUzRcXtYEYGwt6l19qIlVZPN5Mfsw9jFShmQQ==",
"license": "MIT",
"engines": {
"node": ">=16.9.0"
@@ -26116,9 +26102,9 @@
}
},
"node_modules/joi": {
"version": "18.2.3",
"resolved": "https://registry.npmjs.org/joi/-/joi-18.2.3.tgz",
"integrity": "sha512-N5A3KTWQpPWT4ExxxPlUx7WmykGXRzhNidWhV41d6Abu9YfI2NyWCJuxdPnslJCPWtbRpSVOWSnSS6GakLM/Rg==",
"version": "18.2.8",
"resolved": "https://registry.npmjs.org/joi/-/joi-18.2.8.tgz",
"integrity": "sha512-G2TX62h58ZHuwqetJgP2F4ualakqAmZtBYe3jWen7gxQRw5xApX6crnFtuB91WC0c3ESBnva+kGSnb3+6pIQDQ==",
"dev": true,
"license": "BSD-3-Clause",
"dependencies": {
@@ -27946,9 +27932,9 @@
}
},
"node_modules/lockfile-lint/node_modules/js-yaml": {
"version": "4.3.1",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.1.tgz",
"integrity": "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==",
"version": "4.3.2",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz",
"integrity": "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==",
"dev": true,
"funding": [
{
@@ -39620,9 +39606,9 @@
}
},
"node_modules/xmlbuilder2/node_modules/js-yaml": {
"version": "4.3.1",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.1.tgz",
"integrity": "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==",
"version": "4.3.2",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz",
"integrity": "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==",
"dev": true,
"funding": [
{

View File

@@ -486,7 +486,10 @@
"fast-uri": "^3.1.7",
"body-parser": "^2.3.0",
"@yarnpkg/parsers": {
"js-yaml": "^4.3.1"
"js-yaml": "^4.3.2"
},
"@openai/codex-security": {
"smol-toml": "^1.8.0"
},
"jsdom": {
"undici": "^7.29.0"

View File

@@ -19,7 +19,10 @@ export async function DELETE(request: Request) {
);
}
const result = deleteCompletedBatches();
// Scope the sweep to the caller. Only the operator's own dashboard (session
// auth) may clear the whole instance; an API key clears only its own
// completed batches (GHSA-wvxc-jp3v-5mg5).
const result = deleteCompletedBatches(scope.isSessionAuth ? undefined : scope.apiKeyId);
return NextResponse.json(
{ deleted: true, deletedBatches: result.deletedBatches, deletedFiles: result.deletedFiles },

View File

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

View File

@@ -411,15 +411,37 @@ export function deleteBatch(id: string): boolean {
return result.changes > 0;
}
export function deleteCompletedBatches(): { deletedBatches: number; deletedFiles: number } {
/**
* Bulk-delete completed batches and the files they reference.
*
* `apiKeyId` scopes EVERY statement to that owner. Omitting it keeps the
* instance-wide sweep, which is legitimate for the operator's own dashboard
* (session auth) and for nothing else: without the predicate, an ordinary
* inference key could wipe every tenant's completed batches and null out their
* file contents (GHSA-wvxc-jp3v-5mg5). Same ownership shape as `listBatches`
* and `countBatches` above.
*/
export function deleteCompletedBatches(apiKeyId?: string | null): {
deletedBatches: number;
deletedFiles: number;
} {
const db = getDbInstance();
const scoped = typeof apiKeyId === "string" && apiKeyId.length > 0;
// Collect unique file IDs from all completed batches
const rows = db
.prepare(
"SELECT input_file_id, output_file_id, error_file_id FROM batches WHERE status = 'completed'"
)
.all() as Array<{
// Collect unique file IDs from the completed batches in scope
const rows = (
scoped
? db
.prepare(
"SELECT input_file_id, output_file_id, error_file_id FROM batches WHERE status = 'completed' AND api_key_id = ?"
)
.all(apiKeyId)
: db
.prepare(
"SELECT input_file_id, output_file_id, error_file_id FROM batches WHERE status = 'completed'"
)
.all()
) as Array<{
input_file_id: string | null;
output_file_id: string | null;
error_file_id: string | null;
@@ -441,6 +463,16 @@ export function deleteCompletedBatches(): { deletedBatches: number; deletedFiles
}
}
if (scoped) {
db.prepare(
"DELETE FROM batch_item_checkpoints WHERE batch_id IN (SELECT id FROM batches WHERE status = 'completed' AND api_key_id = ?)"
).run(apiKeyId);
const result = db
.prepare("DELETE FROM batches WHERE status = 'completed' AND api_key_id = ?")
.run(apiKeyId);
return { deletedBatches: result.changes, deletedFiles };
}
db.prepare(
"DELETE FROM batch_item_checkpoints WHERE batch_id IN (SELECT id FROM batches WHERE status = 'completed')"
).run();

View File

@@ -0,0 +1,214 @@
/**
* src/lib/memory/__tests__/rerank-loopback-auth-12745.test.ts
*
* Regression guard for #12745 — applyRerank()'s internal loopback call to
* /v1/rerank used to carry no credential, so with REQUIRE_API_KEY=true the
* global authz proxy's clientApiPolicy would 401 it and rerank silently
* degraded to unranked order (fail-open by design, so nothing ever surfaced
* the failure).
*
* This file proves two things:
* 1. The loopback fetch retrieval.ts's applyRerank() issues now carries a
* real Authorization: Bearer <internal key> header (fixed by attaching
* pickApiKeyForInternalUse() — the same internal-probe selector already
* used by combo-health-check / cloud-sync-verify).
* 2. That fix was NOT done by exempting /v1/rerank from auth: an
* unauthenticated *external* request to /api/v1/rerank is still
* rejected by clientApiPolicy when REQUIRE_API_KEY=true.
*/
import { describe, test, expect, vi, beforeEach, afterEach } from "vitest";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omr-rerank-auth-12745-"));
process.env.DATA_DIR = TEST_DATA_DIR;
process.env.DISABLE_SQLITE_AUTO_BACKUP = "true";
process.env.VECTOR_STORE_DISABLE_VEC = "true";
const INTERNAL_KEY = "sk-internal-test-key-12745";
vi.mock("../settings", () => ({
getMemorySettings: async () => ({
enabled: true,
maxTokens: 2000,
retentionDays: 30,
strategy: "semantic",
skillsEnabled: true,
embeddingSource: "static",
embeddingProviderModel: null,
customBaseUrl: null,
customModelId: null,
transformersEnabled: false,
staticEnabled: true,
rerankEnabled: true,
rerankProviderModel: "test-provider/test-rerank-model",
vectorStore: "sqlite-vec",
primaryBackend: "sqlite",
fallbackBackends: [],
backendConfigs: {},
}),
}));
vi.mock("../embedding", () => ({
resolveEmbeddingSource: () => ({
source: "static",
model: "static-hash-8",
dimensions: 8,
identity: "static",
signature: "static-8",
reason: "test: static embedding, no network",
}),
embed: async () => ({
vector: new Float32Array([1, 0, 0, 0, 0, 0, 0, 0]),
source: "static",
model: "static-hash-8",
dimensions: 8,
latencyMs: 0,
}),
}));
vi.mock("../vectorStore", () => ({
getVectorStore: () => ({
ensureReady: async () => ({ ready: true, reason: "test" }),
upsertVector: async () => undefined,
deleteVector: async () => undefined,
searchVector: async () => [
{ memoryId: "rrk-auth-1", score: 0.91 },
{ memoryId: "rrk-auth-2", score: 0.82 },
],
searchHybrid: async () => [],
stats: async () => ({ rowCount: 2, needsReindex: 0, activeDim: 8 }),
}),
}));
vi.mock("../../db/apiKeys", async (importOriginal) => {
const actual = await importOriginal<typeof import("../../db/apiKeys")>();
return {
...actual,
pickApiKeyForInternalUse: vi.fn(async () => INTERNAL_KEY),
};
});
const core = await import("../../db/core");
const { retrievePreview } = await import("../retrieval");
const { pickApiKeyForInternalUse } = await import("../../db/apiKeys");
function cleanupDb() {
core.resetDbInstance();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
}
function insertMemory(apiKeyId: string, id: string, content: string) {
const db = core.getDbInstance();
db.prepare(
`INSERT INTO memories (id, api_key_id, session_id, type, key, content, metadata, created_at, updated_at, expires_at)
VALUES (?, ?, ?, 'factual', ?, ?, '{}', datetime('now'), datetime('now'), NULL)`
).run(id, apiKeyId, "", `key-${id}`, content);
}
let originalFetch: typeof globalThis.fetch;
beforeEach(() => {
cleanupDb();
originalFetch = globalThis.fetch;
vi.mocked(pickApiKeyForInternalUse).mockClear();
});
afterEach(() => {
globalThis.fetch = originalFetch;
});
describe("#12745 — memory rerank loopback call authentication", () => {
test("applyRerank()'s loopback fetch to /v1/rerank carries an internal Authorization bearer", async () => {
insertMemory("api-rrk-auth", "rrk-auth-1", "The capital of France is Paris.");
insertMemory("api-rrk-auth", "rrk-auth-2", "TypeScript is a superset of JavaScript.");
const calls: Array<{ url: string; headers: Record<string, string> }> = [];
globalThis.fetch = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
const url = typeof input === "string" ? input : input.toString();
const headers: Record<string, string> = {};
new Headers(init?.headers).forEach((value, key) => {
headers[key.toLowerCase()] = value;
});
calls.push({ url, headers });
// Emulate the REAL clientApiPolicy behavior this loopback call hits in
// production: reject without a bearer/x-api-key, accept a valid one.
const hasCredential = Boolean(headers["authorization"] || headers["x-api-key"]);
if (!hasCredential) {
return new Response(JSON.stringify({ error: { message: "Authentication required" } }), {
status: 401,
});
}
return new Response(
JSON.stringify({
results: [
{ index: 1, relevance_score: 0.95 },
{ index: 0, relevance_score: 0.4 },
],
}),
{ status: 200 }
);
}) as unknown as typeof globalThis.fetch;
const bundle = await retrievePreview("api-rrk-auth", "capital of France", {
strategy: "semantic",
maxTokens: 2000,
limit: 5,
});
expect(calls.length).toBeGreaterThan(0);
const rerankCall = calls.find((c) => c.url.includes("/v1/rerank"));
expect(rerankCall).toBeDefined();
const hasCredential = Boolean(
rerankCall?.headers["authorization"] || rerankCall?.headers["x-api-key"]
);
expect(hasCredential).toBe(true);
expect(rerankCall?.headers["authorization"]).toBe(`Bearer ${INTERNAL_KEY}`);
// Functional consequence: with a valid credential the rerank response is
// actually honored (item order follows relevance_score) instead of
// silently keeping pre-rerank vector-search order.
expect(bundle.items[0]?.memory.id).toBe("rrk-auth-2");
});
test("without a credential the same loopback call would still be 401'd (no auth bypass introduced)", async () => {
insertMemory("api-rrk-noauth", "rrk-auth-1", "The capital of France is Paris.");
insertMemory("api-rrk-noauth", "rrk-auth-2", "TypeScript is a superset of JavaScript.");
// Simulate the pre-fix condition: internal key selector finds nothing.
vi.mocked(pickApiKeyForInternalUse).mockResolvedValueOnce(null);
let sawUnauthenticatedRerankCall = false;
globalThis.fetch = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
const url = typeof input === "string" ? input : input.toString();
const headers: Record<string, string> = {};
new Headers(init?.headers).forEach((value, key) => {
headers[key.toLowerCase()] = value;
});
const hasCredential = Boolean(headers["authorization"] || headers["x-api-key"]);
if (url.includes("/v1/rerank") && !hasCredential) {
sawUnauthenticatedRerankCall = true;
return new Response(JSON.stringify({ error: { message: "Authentication required" } }), {
status: 401,
});
}
return new Response(JSON.stringify({ results: [] }), { status: 200 });
}) as unknown as typeof globalThis.fetch;
const bundle = await retrievePreview("api-rrk-noauth", "capital of France", {
strategy: "semantic",
maxTokens: 2000,
limit: 5,
});
expect(sawUnauthenticatedRerankCall).toBe(true);
// Fail-open by design: retrieval keeps working (unranked) rather than throwing.
expect(bundle.items.length).toBe(2);
});
});

View File

@@ -12,6 +12,8 @@ import { getQdrantConfig, checkQdrantHealth, searchSemanticMemory } from "./qdra
import type { MemoryEngineStatus } from "@/shared/schemas/memory";
import { supportsFts5 } from "../db/migrationRunner";
import type { SqliteAdapter } from "../db/adapters/types";
import { pickApiKeyForInternalUse } from "../db/apiKeys";
import { getRuntimePorts } from "../runtime/ports";
import {
estimateTokens,
parseMetadata,
@@ -143,16 +145,29 @@ function buildFtsRows(apiKeyId: string, config: FtsColConfig): MemoryRow[] {
}
}
// Loopback rerank URL — localhost only, never routed over the network.
// nosemgrep: javascript.lang.security.audit.non-literal-regexp.non-literal-regexp
const RERANK_LOOPBACK_URL = "http://127.0.0.1:20128/v1/rerank";
// Loopback rerank URL — localhost only, never routed over the network. The port is
// derived from the same runtime source every other internal self-call uses
// (getRuntimePorts()/process.env.PORT — see src/lib/runtime/ports.ts), never hardcoded,
// so this keeps working when an operator overrides PORT/API_PORT (#12745).
function getRerankLoopbackUrl(): string {
const { apiPort } = getRuntimePorts();
// nosemgrep: javascript.lang.security.audit.non-literal-regexp.non-literal-regexp
return `http://127.0.0.1:${apiPort}/v1/rerank`;
}
/**
* Apply reranking via /v1/rerank (loopback-only) if rerankEnabled + rerankProviderModel is set.
* Returns reordered array (or original order on any error — rerank failure never fails retrieval).
*
* Security note: the URL is a hardcoded loopback address (127.0.0.1:20128) — it never
* carries sensitive data over a network link. HTTP is safe for loopback-only IPC.
* Auth note (#12745): /v1/rerank is a CLIENT_API route gated by clientApiPolicywith
* REQUIRE_API_KEY=true an unauthenticated loopback call gets 401'd by the same policy
* that protects it from the outside, and this call used to send no credential at all,
* silently degrading retrieval to unranked order. Attach a real, DB-backed API key
* (the same internal-probe selector already used by combo-health-check / cloud-sync-verify,
* see pickApiKeyForInternalUse()) as a Bearer token instead of exempting the route.
*
* Security note: the URL is a loopback address (127.0.0.1) — it never carries sensitive
* data over a network link. HTTP is safe for loopback-only IPC.
* nosemgrep: javascript.lang.security.detect-non-literal-url
*/
async function applyRerank<T extends { memory: Memory; score: number }>(
@@ -171,10 +186,14 @@ async function applyRerank<T extends { memory: Memory; score: number }>(
top_n: items.length,
};
const res = await fetch(RERANK_LOOPBACK_URL, {
const internalKey = await pickApiKeyForInternalUse("internal-probe");
const headers: Record<string, string> = { "content-type": "application/json" };
if (internalKey) headers.authorization = `Bearer ${internalKey}`;
const res = await fetch(getRerankLoopbackUrl(), {
// nosemgrep: typescript.react.security.react-insecure-request.react-insecure-request
method: "POST",
headers: { "content-type": "application/json" },
headers,
body: JSON.stringify(body),
signal: AbortSignal.timeout(5000),
});

View File

@@ -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."
);
}

View File

@@ -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));
});

View File

@@ -58,6 +58,8 @@
"tests/unit/account-fallback-retry-after-json.test.ts",
"tests/unit/account-fallback-route-restriction-403.test.ts",
"tests/unit/account-fallback-service.test.ts",
"tests/unit/combo-pin-implicit-allowlist.test.ts",
"tests/unit/combo/execute-target-gates.test.ts",
"tests/unit/moonshot-quota-writeback.test.ts",
"tests/unit/accountfallback-ratelimit-400-4976.test.ts",
"tests/unit/adaptive-admission-route-matrix.test.ts",
@@ -75,6 +77,8 @@
"tests/unit/api-key-policy-noauth-allowed-connections.test.ts",
"tests/unit/api-key-rotator-health.test.ts",
"tests/unit/chat-routing-synced-inventory-11089.test.ts",
"tests/unit/openai-compatible-per-upstream-402-health.test.ts",
"tests/unit/quota-window-label.test.ts",
"tests/unit/repro-combo-persisted-cooldown-preskip.test.ts",
"tests/unit/repro-glm-iso-reset-24h-cap.test.ts",
"tests/unit/security-route-guard-tiers.test.ts",

View File

@@ -0,0 +1,114 @@
/**
* GHSA-wvxc-jp3v-5mg5 — `DELETE /api/v1/batches/delete-completed` deleted the
* completed batches of EVERY api key on the instance, and nulled the contents of
* every file those batches referenced.
*
* Two defects in one endpoint:
* 1. `deleteCompletedBatches()` carried no `api_key_id` predicate — the file
* SELECT, the checkpoint DELETE and the batch DELETE were all instance-wide.
* 2. The route only checked that SOME key was present (`!scope.apiKeyId` →
* 401), never that the caller owned anything. A key with `scopes: []` —
* an ordinary inference key — could wipe another tenant's batches.
*
* The ownership pattern this restores is not new: `listBatches(apiKeyId?)` and
* `countBatches(apiKeyId?)` in the same module already scope by `api_key_id`,
* and `batches/[id]/route.ts` already gates per-record access with `scopeCheck`
* (session auth sees everything, a key sees only its own). This helper was the
* one that never got it.
*
* Run with:
* node --import tsx/esm --test tests/unit/batch-delete-completed-ownership-wvxc.test.ts
*/
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import { createFile, getFile } from "@/lib/db/files";
import { createBatch, getBatch, deleteCompletedBatches } from "@/lib/db/batches";
const KEY_A = "key-wvxc-aaaa";
const KEY_B = "key-wvxc-bbbb";
function seedCompletedBatch(apiKeyId: string | null, tag: string) {
const file = createFile({
bytes: 10,
filename: `wvxc-${tag}.jsonl`,
purpose: "batch",
content: Buffer.from("{}"),
});
const batch = createBatch({
endpoint: "/v1/chat/completions",
completionWindow: "24h",
inputFileId: file.id,
status: "completed",
apiKeyId,
});
return { file, batch };
}
describe("deleteCompletedBatches — ownership scoping (GHSA-wvxc-jp3v-5mg5)", () => {
it("scoped to one key deletes ONLY that key's completed batches", () => {
const a = seedCompletedBatch(KEY_A, "a1");
const b = seedCompletedBatch(KEY_B, "b1");
const result = deleteCompletedBatches(KEY_A);
assert.equal(getBatch(a.batch.id), null, "the caller's own batch should be gone");
assert.ok(getBatch(b.batch.id), "another key's batch must survive");
assert.equal(result.deletedBatches, 1, "must report only what it actually deleted");
});
it("scoped deletion does not touch another key's file contents", () => {
const a = seedCompletedBatch(KEY_A, "a2");
const b = seedCompletedBatch(KEY_B, "b2");
deleteCompletedBatches(KEY_A);
assert.equal(getFile(a.file.id), null, "the caller's own file should be gone");
assert.ok(getFile(b.file.id), "another key's file must survive with its content intact");
});
it("a key with no completed batches deletes nothing at all", () => {
const b = seedCompletedBatch(KEY_B, "b3");
const result = deleteCompletedBatches("key-wvxc-with-nothing");
assert.equal(result.deletedBatches, 0);
assert.equal(result.deletedFiles, 0);
assert.ok(getBatch(b.batch.id), "an unrelated key's batch must survive");
});
it("unscoped (dashboard session) still clears the whole instance", () => {
// The operator's own dashboard legitimately cleans up everything; that is
// the ONLY caller allowed to omit the key. Preserved deliberately.
seedCompletedBatch(KEY_A, "a4");
seedCompletedBatch(KEY_B, "b4");
const result = deleteCompletedBatches();
assert.ok(
result.deletedBatches >= 2,
`expected an instance-wide sweep, got ${result.deletedBatches}`
);
});
});
describe("the route passes the caller's key through", () => {
it("delete-completed scopes by api key instead of calling the helper bare", async () => {
const { readFileSync } = await import("node:fs");
const { fileURLToPath } = await import("node:url");
const src = readFileSync(
fileURLToPath(
new URL("../../src/app/api/v1/batches/delete-completed/route.ts", import.meta.url)
),
"utf8"
);
assert.ok(
!/deleteCompletedBatches\(\s*\)/.test(src),
"the route still calls deleteCompletedBatches() with no owner — every tenant's batches go"
);
assert.ok(
/deleteCompletedBatches\(\s*scope\./.test(src),
"the route must pass the caller's scope into the helper"
);
});
});

View File

@@ -13,6 +13,11 @@ type Check = {
validate?: (content: string, claim?: string) => { ok: boolean; detail: string };
};
/** Escape a value that is interpolated into a RegExp source. */
function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
describe("ToS caution heading count", () => {
it("the Caution heading carries the count the gate checks", () => {
const txt = readFileSync(join(process.cwd(), "docs/reference/FREE_TIERS.md"), "utf8");
@@ -21,7 +26,24 @@ describe("ToS caution heading count", () => {
const tos = (buildChecks() as Check[]).find((c) =>
String(c.docKey ?? "").includes("ToS caution")
);
assert.match(heading, new RegExp(`\(\s*${String(tos?.actual)}\s*\)`));
// The backslashes must survive the TEMPLATE LITERAL to reach the regex.
// Written as `\(\s*…` they did not: JS resolves `\(` to "(" and `\s` to the
// LETTER "s" before RegExp ever sees them, so the pattern compiled to
// `(s*16s*)` — a capture group around optional "s" characters. That matched
// any heading merely containing the number, with no literal parentheses
// required at all, so this guard passed on headings it was written to reject
// (CodeQL js/useless-regexp-character-escape #994-#997).
const count = escapeRegExp(String(tos?.actual));
assert.match(heading, new RegExp(`\\(\\s*${count}\\s*\\)`));
});
it("the heading guard actually requires the parentheses", () => {
// Pins the defect above: the pattern this test builds must REJECT a heading
// that carries the count without parentheses. Before the fix it accepted it.
const pattern = new RegExp(`\\(\\s*${escapeRegExp("16")}\\s*\\)`);
assert.equal(pattern.test("### Caution — clauses worth checking 16"), false);
assert.equal(pattern.test("### Caution — clauses worth checking (16)"), true);
assert.equal(pattern.test("### Caution — clauses worth checking ( 16 )"), true);
});
it("buildChecks exposes a soft ToS entry on FREE_TIERS.md with requireClaim", () => {
const checks = buildChecks() as Check[];

View File

@@ -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/);
});

View File

@@ -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/);
});

View File

@@ -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);
});
});