diff --git a/.env.example b/.env.example
index 2b454d2fb1..5660c922f6 100644
--- a/.env.example
+++ b/.env.example
@@ -1531,6 +1531,14 @@ APP_LOG_TO_FILE=true
# Default: 86400000 (24 hours)
# OPENROUTER_CATALOG_TTL_MS=86400000
+# Enrich the dashboard providers list with OpenRouter weekly ranking stats.
+# ON by default; set false to skip the background fetch entirely (#9324).
+# Used by: src/lib/catalog/openrouterProviderStats.ts
+# OPENROUTER_PROVIDER_STATS_ENABLED=true
+# Cache TTL for the OpenRouter provider stats snapshot, in ms.
+# Default: 86400000 (24 hours)
+# OPENROUTER_PROVIDER_STATS_TTL_MS=86400000
+
# ── Model catalog response shape ──
# Include display-friendly name fields in /v1/models responses.
# Disable for clients that expect model IDs only.
@@ -2162,6 +2170,15 @@ INSPECTOR_MAX_BODY_KB=1024
INSPECTOR_MASK_SECRETS=true
INSPECTOR_LLM_HOSTS_EXTRA=
INSPECTOR_INTERNAL_INGEST_TOKEN=
+# Shared secret for identity-preserving internal REST hops (#9260): when an
+# OmniRoute component calls another local OmniRoute route, this token (sent as
+# x-omniroute-internal-service-token) marks the request as internal so the
+# original caller identity is preserved. OPT-IN: unset disables the mechanism.
+# Used by: src/lib/api/internalServiceAuth.ts
+# OMNIROUTE_INTERNAL_SERVICE_TOKEN=
+# File-based variant (secret-file pattern; wins only when the inline var is
+# unset): path to a file whose trimmed content is the token.
+# OMNIROUTE_INTERNAL_SERVICE_TOKEN_FILE=
# Quota Sharing (Group B — planos 16+22)
QUOTA_STORE_DRIVER=sqlite # sqlite | redis
# QUOTA_STORE_REDIS_URL= # ex.: redis://localhost:6379 (apenas quando driver=redis)
diff --git a/changelog.d/fixes/9553-prepublish-npm-entry-posix.md b/changelog.d/fixes/9553-prepublish-npm-entry-posix.md
new file mode 100644
index 0000000000..af1b199ee3
--- /dev/null
+++ b/changelog.d/fixes/9553-prepublish-npm-entry-posix.md
@@ -0,0 +1 @@
+- **fix(build):** `npm run build:cli` (prepublish) no longer fails on POSIX with "npm-cli.js not found next to the running Node binary". The #8858 shim-free npm resolver only knew the Windows layout (`
\node_modules\npm`); on GitHub hosted runners, nvm and system installs npm lives at `/lib/node_modules/npm` while node is `/bin/node`, so every fresh CI checkout died installing `@omniroute/opencode-plugin` deps (Fast Production Build + dast-smoke red on all PRs). The resolver, extracted to `scripts/build/resolveNpmEntry.ts`, now tries `npm_execpath` (exported by `npm run` itself) first, then the Windows layout, then the POSIX layout — covered by `tests/unit/build/resolve-npm-entry.test.ts` including a live POSIX regression guard.
diff --git a/docs/reference/ENVIRONMENT.md b/docs/reference/ENVIRONMENT.md
index d29d17a67c..3e19495178 100644
--- a/docs/reference/ENVIRONMENT.md
+++ b/docs/reference/ENVIRONMENT.md
@@ -1150,6 +1150,13 @@ Provider quota endpoints, network tunnels (Tailscale, Ngrok, MITM debug proxy),
| `OMNIROUTE_LOCAL_ENDPOINTS_TOKEN` | _(unset)_ | `src/lib/security/localEndpoints.ts` | Bearer token for `/api/local/*` callers that aren't on loopback (e.g. the desktop app). When set, requests from non-loopback IPs must carry `Authorization: Bearer `. Required when `OMNIROUTE_LOCAL_ENDPOINTS_ENABLED=1` in non-loopback deployments. |
| `OMNIROUTE_REDIS_CONTAINER_NAME` | `omniroute-redis` | `bin/cli/commands/redis.mjs` | Container name for the 1-click Redis launcher (`omniroute redis up`). Used by both the CLI and the `RedisLauncherPanel` GUI. |
| `OMNIROUTE_REDIS_HOST_PORT` | `6379` | `bin/cli/commands/redis.mjs` | Host port for the 1-click Redis launcher. Bump if the host already binds 6379. The container's internal port stays 6379. |
+| `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. |
+| `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. |
+| `OPENROUTER_PROVIDER_STATS_ENABLED` | `true` | `src/lib/catalog/openrouterProviderStats.ts` | Enrich the dashboard providers list with OpenRouter weekly ranking stats (#9324). On by default; set `false` to skip the background fetch entirely (non-blocking, never fatal). |
+| `OPENROUTER_PROVIDER_STATS_TTL_MS` | `86400000` (24h) | `src/lib/catalog/openrouterProviderStats.ts` | Cache TTL for the OpenRouter provider-stats snapshot, in milliseconds. |
| `OMNIROUTE_REDIS_IMAGE` | `redis:7-alpine` | `bin/cli/commands/redis.mjs` | Redis image used by the 1-click Redis launcher. Override to `redis:8-alpine` or a private registry mirror as needed. |
| `QDRANT_HOST` | `qdrant` | _(opt-in cluster profile)_ | Hostname of the Qdrant sidecar when `--profile memory` is active. Default points to the in-network qdrant service name; override for an external deployment. Only consumed when `qdrantEnabled` is `true` in code (`src/lib/memory/vectorStore.ts:108`). |
| `QDRANT_PORT` | `6333` | _(opt-in cluster profile)_ | REST port of the Qdrant sidecar. |
diff --git a/scripts/build/prepublish.ts b/scripts/build/prepublish.ts
index c899544d83..309180a2c7 100644
--- a/scripts/build/prepublish.ts
+++ b/scripts/build/prepublish.ts
@@ -27,6 +27,7 @@ import { join, dirname } from "node:path";
import { fileURLToPath } from "node:url";
import { assembleStandalone } from "./assembleStandalone.mjs";
+import { resolveBundledNpmEntry } from "./resolveNpmEntry.ts";
import {
APP_STAGING_ALLOWED_EXACT_PATHS,
APP_STAGING_ALLOWED_PATH_PREFIXES,
@@ -64,11 +65,6 @@ function resolveLocalBinEntry(packageName: string, binName: string): string | nu
}
}
-function resolveBundledNpmEntry(name: "npm-cli.js" | "npx-cli.js"): string | null {
- const candidate = join(dirname(process.execPath), "node_modules", "npm", "bin", name);
- return existsSync(candidate) ? candidate : null;
-}
-
/**
* Runs a build tool without ever touching a `.cmd` shim. `packageName` is where the
* tool lives in the local dependency tree; when it is not installed there the call
diff --git a/scripts/build/resolveNpmEntry.ts b/scripts/build/resolveNpmEntry.ts
new file mode 100644
index 0000000000..b19bcb1fb2
--- /dev/null
+++ b/scripts/build/resolveNpmEntry.ts
@@ -0,0 +1,40 @@
+import { existsSync } from "fs";
+import { dirname, join } from "path";
+
+/** Injectable seams for {@link resolveBundledNpmEntry} (all default to the real ones). */
+export interface ResolveNpmEntryDeps {
+ execPath?: string;
+ /** `process.env.npm_execpath` — set by npm itself when running under `npm run`. */
+ npmExecPath?: string;
+ exists?: (p: string) => boolean;
+}
+
+/**
+ * Locate `npm-cli.js` / `npx-cli.js` so build steps can run npm/npx through
+ * `process.execPath` directly and never touch a `.cmd` shim (#8858), covering
+ * BOTH install layouts:
+ * - Windows: `\node_modules\npm\bin\` (npm beside the binary)
+ * - POSIX: `/../lib/node_modules/npm/bin/` (node under `/bin`,
+ * the shape of GitHub hosted runners, nvm and system installs)
+ * When the script itself runs under `npm run`, npm exports `npm_execpath` pointing at
+ * its own npm-cli.js — the most reliable source, tried first (npx-cli.js is its sibling).
+ */
+export function resolveBundledNpmEntry(
+ name: "npm-cli.js" | "npx-cli.js",
+ deps: ResolveNpmEntryDeps = {}
+): string | null {
+ const execPath = deps.execPath ?? process.execPath;
+ const exists = deps.exists ?? existsSync;
+ const npmExecPath = deps.npmExecPath ?? process.env.npm_execpath;
+
+ const binDir = dirname(execPath);
+ const candidates: string[] = [];
+ if (npmExecPath) candidates.push(join(dirname(npmExecPath), name));
+ candidates.push(join(binDir, "node_modules", "npm", "bin", name));
+ candidates.push(join(binDir, "..", "lib", "node_modules", "npm", "bin", name));
+
+ for (const candidate of candidates) {
+ if (exists(candidate)) return candidate;
+ }
+ return null;
+}
diff --git a/tests/unit/build/resolve-npm-entry.test.ts b/tests/unit/build/resolve-npm-entry.test.ts
new file mode 100644
index 0000000000..23795c0b05
--- /dev/null
+++ b/tests/unit/build/resolve-npm-entry.test.ts
@@ -0,0 +1,71 @@
+/**
+ * #8858 follow-up — `resolveBundledNpmEntry` only knew the WINDOWS npm layout
+ * (`\node_modules\npm\bin\...`). On POSIX installs (GitHub
+ * hosted runners, nvm, system node) npm lives at
+ * `/lib/node_modules/npm/bin/...` while node is `/bin/node`,
+ * so the resolver returned null and `npm run build:cli` (Fast Production
+ * Build, dast-smoke) failed on every fresh CI checkout with
+ * "npm-cli.js not found next to the running Node binary".
+ */
+import { test } from "node:test";
+import assert from "node:assert/strict";
+
+import { resolveBundledNpmEntry } from "../../../scripts/build/resolveNpmEntry.ts";
+
+const POSIX_PREFIX = "/opt/hostedtoolcache/node/24.18.0/x64";
+const POSIX_NODE = `${POSIX_PREFIX}/bin/node`;
+const POSIX_NPM_CLI = `${POSIX_PREFIX}/lib/node_modules/npm/bin/npm-cli.js`;
+
+const WIN_STYLE_DIR = "/fake/nodejs"; // Windows layout shape (npm beside the binary)
+const WIN_STYLE_NODE = `${WIN_STYLE_DIR}/node.exe`;
+const WIN_STYLE_NPM_CLI = `${WIN_STYLE_DIR}/node_modules/npm/bin/npm-cli.js`;
+
+test("POSIX layout: finds npm-cli.js under /lib/node_modules (hosted runner shape)", () => {
+ const resolved = resolveBundledNpmEntry("npm-cli.js", {
+ execPath: POSIX_NODE,
+ npmExecPath: undefined,
+ exists: (p) => p === POSIX_NPM_CLI,
+ });
+ assert.equal(resolved, POSIX_NPM_CLI);
+});
+
+test("Windows layout: still finds npm-cli.js beside the node binary", () => {
+ const resolved = resolveBundledNpmEntry("npm-cli.js", {
+ execPath: WIN_STYLE_NODE,
+ npmExecPath: undefined,
+ exists: (p) => p === WIN_STYLE_NPM_CLI,
+ });
+ assert.equal(resolved, WIN_STYLE_NPM_CLI);
+});
+
+test("npm_execpath (set by `npm run`) wins and resolves npx-cli.js as its sibling", () => {
+ const npmExecPath = `${POSIX_PREFIX}/lib/node_modules/npm/bin/npm-cli.js`;
+ const npxSibling = `${POSIX_PREFIX}/lib/node_modules/npm/bin/npx-cli.js`;
+ const resolved = resolveBundledNpmEntry("npx-cli.js", {
+ execPath: POSIX_NODE,
+ npmExecPath,
+ exists: (p) => p === npxSibling,
+ });
+ assert.equal(resolved, npxSibling);
+});
+
+test("returns null when no layout matches", () => {
+ const resolved = resolveBundledNpmEntry("npm-cli.js", {
+ execPath: POSIX_NODE,
+ npmExecPath: undefined,
+ exists: () => false,
+ });
+ assert.equal(resolved, null);
+});
+
+test("live environment: the real node install can resolve npm-cli.js (POSIX regression guard)", (t) => {
+ if (process.platform === "win32") {
+ t.skip("POSIX-only live check");
+ return;
+ }
+ const resolved = resolveBundledNpmEntry("npm-cli.js");
+ assert.ok(
+ resolved,
+ `real install must resolve npm-cli.js (execPath=${process.execPath}) — the #8858 resolver returned null on POSIX`
+ );
+});