diff --git a/.env.example b/.env.example index 29cacfe8cb..dc502f6840 100644 --- a/.env.example +++ b/.env.example @@ -1466,6 +1466,13 @@ APP_LOG_TO_FILE=true # CLIPROXYAPI_PORT=5544 # CLIPROXYAPI_CONFIG_DIR=~/.cli-proxy-api +# ── Mux embedded service ── +# Override the port where the embedded Mux (coder/mux) agent-orchestration +# daemon listens. Always bound to 127.0.0.1 — never configurable to 0.0.0.0. +# Rarely needed — defaults to 8322. +# Used by: src/lib/services/bootstrap.ts, src/app/api/services/mux/_lib.ts +# MUX_SERVICE_PORT=8322 + # ── Local hostnames (Docker networking) ── # Comma-separated additional hostnames treated as "local" for provider routing. # Used by: open-sse/config/providerRegistry.ts — allows Docker service names. diff --git a/CHANGELOG.md b/CHANGELOG.md index e105c5ef79..be35279995 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,7 @@ - **feat(claude-code):** add an opt-in auto-permission classifier compat mode (off/auto/always) for Claude Code, toggleable from the CLI Code settings. - **feat(providers):** add optional client-identity header profiles for compatible nodes — preset User-Agent/fingerprint headers (e.g. matching a known CLI) merged into the existing customHeaders field. - **feat(xai):** surface Grok usage on the quota dashboard via local usage-history aggregation. (thanks @DevEstacion) +- **feat(services):** add **Mux** (`coder/mux`) as a managed embedded service — install/start/stop/restart/logs lifecycle + dashboard tab, loopback-only API, `127.0.0.1`-bound with the auth token passed via env (never argv). Ported from upstream 9router#1802. (thanks @Ansh7473) ### 🔧 Bug Fixes diff --git a/docs/frameworks/EMBEDDED-SERVICES.md b/docs/frameworks/EMBEDDED-SERVICES.md index dd578c992d..e9434d6bff 100644 --- a/docs/frameworks/EMBEDDED-SERVICES.md +++ b/docs/frameworks/EMBEDDED-SERVICES.md @@ -1,13 +1,13 @@ --- title: "Embedded Services" -description: "Reference for 9Router and CLIProxyAPI" +description: "Reference for 9Router, CLIProxyAPI, and Mux" --- # Embedded Services -> **Version:** v3.8.4 -> **Last updated:** 2026-06-28 -> **Audience:** Engineers adding, maintaining, or debugging embedded services (9Router, CLIProxyAPI). +> **Version:** v3.8.44 +> **Last updated:** 2026-07-03 +> **Audience:** Engineers adding, maintaining, or debugging embedded services (9Router, CLIProxyAPI, Mux). Embedded services are locally-installed process sidecar tools that OmniRoute installs, supervises, and exposes as first-class routing targets. Unlike external providers (which are reached over the internet @@ -32,14 +32,15 @@ via API keys), embedded services run on the same machine as OmniRoute and commun ### Why embedded services? -Two services are embedded as of v3.8.4: +Three services are embedded as of v3.8.44: -| Service | npm package | Default port | Purpose | -| --------------- | ---------------------------------------------- | :----------: | ---------------------------------------------------------------------------------------------------- | -| **9Router** | `9router` | 20130 | AI router that OmniRoute can use as a sub-provider. Models exposed as `9router/{sub}/{model}` | -| **CLIProxyAPI** | `@anthropic/cli-proxy` (via `cliproxy` binary) | auto | Local proxy adapter for Anthropic CLI auth flows. Provides fallback routing when OAuth tokens expire | +| Service | npm package | Default port | Purpose | +| --------------- | ----------------------------------------------- | :----------: | ------------------------------------------------------------------------------------------------------------------ | +| **9Router** | `9router` | 20130 | AI router that OmniRoute can use as a sub-provider. Models exposed as `9router/{sub}/{model}` | +| **CLIProxyAPI** | `@anthropic/cli-proxy` (via `cliproxy` binary) | auto | Local proxy adapter for Anthropic CLI auth flows. Provides fallback routing when OAuth tokens expire | +| **Mux** | `mux` (headless `mux server`) | 8322 | Local agent-orchestration daemon (coder/mux). Lifecycle-managed only — not a routing target (no LLM proxying). | -Both follow the same supervisory model: +All three follow the same supervisory model: - OmniRoute installs them under `DATA_DIR/services/{name}/` (isolated from OmniRoute's own `package.json`) - OmniRoute spawns and monitors them as child processes @@ -54,7 +55,7 @@ Both follow the same supervisory model: | Installation mechanism | `npm install {package}` via `execFile` (no shell interpolation) | | Consumption mode | Provider registered as `9router/{sub}/{model}` in routing engine | | API key management | OmniRoute generates, encrypts at-rest (AES-256-GCM), and injects via env | -| Dashboard location | `/dashboard/providers/services` (two tabs) | +| Dashboard location | `/dashboard/providers/services` (three tabs) | | Auto-start | Toggle per service, default OFF | --- @@ -64,12 +65,13 @@ Both follow the same supervisory model: ``` ┌────────────────────────────────────────────────────────────────────┐ │ Layer 1 — UI │ -│ /dashboard/providers/services (tabs: CLIProxyAPI | 9Router) │ +│ /dashboard/providers/services (tabs: CLIProxyAPI | 9Router | Mux)│ │ Logs live (SSE), Start/Stop/Restart/Update, Settings, Install │ │ │ │ src/app/(dashboard)/dashboard/providers/services/ │ │ ├── page.tsx Shell + tab routing by ?tab= │ -│ ├── tabs/ CliproxyServiceTab, NinerouterServiceTab│ +│ ├── tabs/ CliproxyServiceTab, NinerouterServiceTab,│ +│ │ MuxServiceTab │ │ └── components/ ServiceStatusCard, ServiceLifecycleButtons,│ │ ServiceLogsPanel, ApiKeyCard, ... │ └──────────────────────┬─────────────────────────────────────────────┘ @@ -81,6 +83,8 @@ Both follow the same supervisory model: │ rotate-key|status|auto-start|logs} │ │ /api/services/cliproxy/{install|start|stop|restart|update| │ │ status|auto-start|logs} │ +│ /api/services/mux/{install|start|stop|restart|update| │ +│ status|auto-start|logs} │ │ /dashboard/providers/services/9router/embed/[...path] │ │ (reverse HTTP + WebSocket proxy → 9Router upstream) │ │ │ @@ -106,7 +110,8 @@ Both follow the same supervisory model: │ modelSync.ts Periodic GET /v1/models → service_models table │ │ ringBuffer.ts Circular log buffer (5 MB per service) │ │ healthCheck.ts Polling HTTP health probe │ -│ installers/ ninerouter.ts, cliproxy.ts (installer adapters)│ +│ installers/ ninerouter.ts, cliproxy.ts, mux.ts │ +│ (installer adapters) │ └──────────────────────┬─────────────────────────────────────────────┘ │ OpenAI-compatible HTTP (loopback) ┌──────────────────────▼─────────────────────────────────────────────┐ @@ -123,6 +128,10 @@ Both follow the same supervisory model: │ open-sse/config/providerRegistry.ts │ │ Models stored as "9router/{sub}/{model}" (prefixed). │ │ Synced every 5 min by modelSync.ts. │ +│ │ +│ Mux is lifecycle-managed ONLY (Layers 1-3) — it is an agent- │ +│ orchestration daemon, not an LLM proxy, so it has no Layer 4 │ +│ executor/provider entry and is never a routing target. │ └────────────────────────────────────────────────────────────────────┘ ``` @@ -139,6 +148,7 @@ Both follow the same supervisory model: | `src/lib/services/healthCheck.ts` | HTTP health probe (configurable interval) | | `src/lib/services/installers/ninerouter.ts` | npm install/update/uninstall for 9Router | | `src/lib/services/installers/cliproxy.ts` | npm install/update/uninstall for CLIProxyAPI | +| `src/lib/services/installers/mux.ts` | npm install/update/uninstall for Mux | | `src/app/api/services/9router/_lib.ts` | `getOrInitSupervisor()` helper | | `src/app/api/services/[name]/logs/route.ts` | Shared SSE logs endpoint | | `open-sse/executors/ninerouter.ts` | Provider executor (Layer 4) | @@ -434,12 +444,32 @@ config) and `status` includes fewer fields. | `GET` | `/api/services/cliproxy/status` | Live + DB status (no `apiKeyMasked`) | | `POST` | `/api/services/cliproxy/auto-start` | Toggle auto-start | -The shared `GET /api/services/{name}/logs` endpoint (see §4.1) works for both -services using the `[name]` dynamic segment. +The shared `GET /api/services/{name}/logs` endpoint (see §4.1) works for all +three services using the `[name]` dynamic segment. --- -### 4.3 Reverse proxy (9Router dashboard embed) +### 4.3 Mux endpoints (7 routes) + +Mux has the same endpoint shape as CLIProxyAPI — no `rotate-key` route in the API +surface (the bearer token is generated the same way as 9Router's via +`getOrCreateApiKey("mux")` and injected via the `MUX_SERVER_AUTH_TOKEN` env var, but +there is no dedicated rotation endpoint yet). Mux is lifecycle-managed only: unlike +9Router, it has no Layer 4 executor and is never registered as a routing provider. + +| Method | Path | Description | +| ------ | -------------------------------- | ------------------------------------- | +| `POST` | `/api/services/mux/install` | Install Mux from npm (`npm i mux`) | +| `POST` | `/api/services/mux/start` | Start Mux (`mux server`) | +| `POST` | `/api/services/mux/stop` | Stop Mux | +| `POST` | `/api/services/mux/restart` | Restart Mux | +| `POST` | `/api/services/mux/update` | Update to newer npm version | +| `GET` | `/api/services/mux/status` | Live + DB status | +| `POST` | `/api/services/mux/auto-start` | Toggle auto-start | + +--- + +### 4.4 Reverse proxy (9Router dashboard embed) The dashboard embeds the 9Router web UI inside an iframe via an internal reverse proxy at: @@ -486,14 +516,20 @@ matrix. ### API key injection -9Router requires an API key for its own HTTP endpoints. OmniRoute: +9Router and Mux require an API key/bearer token for their own HTTP endpoints. +OmniRoute: 1. Generates a key via `crypto.randomBytes(32).toString("base64url")` with a - service-specific prefix (`nr_` for 9Router). + service-specific prefix (`nr_` for 9Router, `mx_` for Mux). 2. Encrypts it at-rest using AES-256-GCM (same cipher used for provider credentials). -3. Decrypts and injects it as `NINEROUTER_API_KEY` environment variable at spawn time. +3. Decrypts and injects it as an environment variable at spawn time — + `NINEROUTER_API_KEY` for 9Router, `MUX_SERVER_AUTH_TOKEN` for Mux (never a CLI + flag, so the token never appears in `ps`/process listings). 4. Never returns the plaintext key in any HTTP response. +CLIProxyAPI does not require an injected key (it authenticates via the host's +existing CLI config). + ### SSRF defense The reverse HTTP proxy (`/dashboard/.../embed/[...path]`) is hardcoded to forward diff --git a/docs/openapi.yaml b/docs/openapi.yaml index f2a2c535b0..6c90c8b2c8 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -3444,6 +3444,166 @@ paths: "400": description: Invalid request body + /api/services/mux/install: + post: + tags: [Embedded Services] + summary: Install Mux from npm + description: >- + Installs the `mux` npm package (coder/mux — local agent-orchestration + daemon) under DATA_DIR/services/mux/. **LOCAL_ONLY** — loopback only. + requestBody: + required: false + content: + application/json: + schema: + type: object + properties: + version: + type: string + default: latest + responses: + "200": + description: Install succeeded + content: + application/json: + schema: + type: object + properties: + ok: + type: boolean + installedVersion: + type: string + "400": + description: Invalid request body + "500": + description: npm install failed + + /api/services/mux/start: + post: + tags: [Embedded Services] + summary: Start Mux + description: >- + Spawns `mux server --host 127.0.0.1 --port `. Idempotent if + already running. **LOCAL_ONLY** — loopback only. + responses: + "200": + description: Service started + content: + application/json: + schema: + $ref: "#/components/schemas/ServiceStatus" + "409": + description: Mux is not installed + "503": + description: Start failed + + /api/services/mux/stop: + post: + tags: [Embedded Services] + summary: Stop Mux + description: >- + Gracefully stops Mux. Idempotent. + **LOCAL_ONLY** — loopback only. + responses: + "200": + description: Service stopped + content: + application/json: + schema: + $ref: "#/components/schemas/ServiceStatus" + + /api/services/mux/restart: + post: + tags: [Embedded Services] + summary: Restart Mux + description: >- + stop() then start() under the operation lock. + **LOCAL_ONLY** — loopback only. + responses: + "200": + description: Service restarted + content: + application/json: + schema: + $ref: "#/components/schemas/ServiceStatus" + + /api/services/mux/update: + post: + tags: [Embedded Services] + summary: Update Mux to a newer npm version + description: >- + Stops, installs newer version, restarts. + **LOCAL_ONLY** — loopback only. + requestBody: + required: false + content: + application/json: + schema: + type: object + properties: + version: + type: string + default: latest + responses: + "200": + description: Update succeeded + content: + application/json: + schema: + type: object + properties: + ok: + type: boolean + installedVersion: + type: string + "500": + description: Update failed + + /api/services/mux/status: + get: + tags: [Embedded Services] + summary: Get Mux status + description: >- + Returns live supervisor state and DB metadata. + **LOCAL_ONLY** — loopback only. + responses: + "200": + description: Status response + content: + application/json: + schema: + $ref: "#/components/schemas/ServiceStatus" + + /api/services/mux/auto-start: + post: + tags: [Embedded Services] + summary: Toggle Mux auto-start + description: >- + When enabled, Mux starts automatically on the next OmniRoute boot. + **LOCAL_ONLY** — loopback only. + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [enabled] + properties: + enabled: + type: boolean + responses: + "200": + description: Auto-start flag updated + content: + application/json: + schema: + type: object + properties: + autoStart: + type: boolean + "400": + description: Invalid request body + /api/services/{name}/logs: get: tags: [Embedded Services] diff --git a/docs/reference/ENVIRONMENT.md b/docs/reference/ENVIRONMENT.md index bc356242cc..4f41126d89 100644 --- a/docs/reference/ENVIRONMENT.md +++ b/docs/reference/ENVIRONMENT.md @@ -814,6 +814,7 @@ Automatic model pricing data synchronization from external sources. | `CLIPROXYAPI_HOST` | `127.0.0.1` | `open-sse/executors/cliproxyapi.ts` | CLIProxyAPI bridge host (legacy integration). | | `CLIPROXYAPI_PORT` | `5544` | `open-sse/executors/cliproxyapi.ts` | CLIProxyAPI bridge port. | | `CLIPROXYAPI_CONFIG_DIR` | `~/.cli-proxy-api` | `src/lib/versionManager/processManager.ts` | CLIProxyAPI config directory. | +| `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). | | `LOCAL_HOSTNAMES` | _(empty)_ | `open-sse/config/providerRegistry.ts` | Comma-separated additional hostnames treated as "local" (Docker service names, etc.). | `ENABLE_CC_COMPATIBLE_PROVIDER` is only for third-party relays that accept Claude Code clients diff --git a/src/app/(dashboard)/dashboard/providers/services/page.tsx b/src/app/(dashboard)/dashboard/providers/services/page.tsx index ab18dad077..93e0c0549d 100644 --- a/src/app/(dashboard)/dashboard/providers/services/page.tsx +++ b/src/app/(dashboard)/dashboard/providers/services/page.tsx @@ -4,12 +4,14 @@ import { useSearchParams, useRouter } from "next/navigation"; import { cn } from "@/shared/utils/cn"; import { CliproxyServiceTab } from "./tabs/CliproxyServiceTab"; import { NinerouterServiceTab } from "./tabs/NinerouterServiceTab"; +import { MuxServiceTab } from "./tabs/MuxServiceTab"; -type Tab = "cliproxy" | "9router"; +type Tab = "cliproxy" | "9router" | "mux"; const TABS: { id: Tab; label: string; icon: string }[] = [ { id: "cliproxy", label: "CLIProxyAPI", icon: "swap_horiz" }, { id: "9router", label: "9Router", icon: "route" }, + { id: "mux", label: "Mux", icon: "hub" }, ]; export default function ServicesPage() { @@ -26,7 +28,8 @@ export default function ServicesPage() {

Embedded Services

- External engines managed on demand — CLIProxyAPI and 9Router. Accessible on loopback only. + External engines managed on demand — CLIProxyAPI, 9Router, and Mux. Accessible on loopback + only.

@@ -55,6 +58,7 @@ export default function ServicesPage() {
{active === "cliproxy" && } {active === "9router" && } + {active === "mux" && }
); diff --git a/src/app/(dashboard)/dashboard/providers/services/tabs/MuxServiceTab.tsx b/src/app/(dashboard)/dashboard/providers/services/tabs/MuxServiceTab.tsx new file mode 100644 index 0000000000..a51dc07e81 --- /dev/null +++ b/src/app/(dashboard)/dashboard/providers/services/tabs/MuxServiceTab.tsx @@ -0,0 +1,19 @@ +"use client"; + +import { ServiceStatusCard } from "../components/ServiceStatusCard"; +import { ServiceLifecycleButtons } from "../components/ServiceLifecycleButtons"; +import { ServiceLogsPanel } from "../components/ServiceLogsPanel"; +import { AutoStartToggle } from "../components/AutoStartToggle"; + +const NAME = "mux"; + +export function MuxServiceTab() { + return ( +
+ + + + +
+ ); +} diff --git a/src/app/api/services/[name]/logs/route.ts b/src/app/api/services/[name]/logs/route.ts index c3c3a93a9a..195c0f4d23 100644 --- a/src/app/api/services/[name]/logs/route.ts +++ b/src/app/api/services/[name]/logs/route.ts @@ -37,6 +37,11 @@ async function getOrInitNamedSupervisor(name: string) { return getOrInitSupervisor(); } + if (name === "mux") { + const { getOrInitSupervisor } = await import("../../mux/_lib"); + return getOrInitSupervisor(); + } + return null; } diff --git a/src/app/api/services/mux/_lib.ts b/src/app/api/services/mux/_lib.ts new file mode 100644 index 0000000000..49b1ef672d --- /dev/null +++ b/src/app/api/services/mux/_lib.ts @@ -0,0 +1,32 @@ +/** + * Shared helpers for /api/services/mux/* route handlers. + * Creates a supervisor on demand if bootstrap hasn't registered one yet. + */ + +import { getSupervisor, registerSupervisor } from "@/lib/services/registry"; +import { ServiceSupervisor } from "@/lib/services/ServiceSupervisor"; +import { resolveSpawnArgs, MUX_DEFAULT_PORT } from "@/lib/services/installers/mux"; +import { getOrCreateApiKey } from "@/lib/services/apiKey"; + +const TOOL = "mux"; +const PORT = parseInt(process.env.MUX_SERVICE_PORT ?? String(MUX_DEFAULT_PORT), 10); + +export async function getOrInitSupervisor(): Promise { + const existing = getSupervisor(TOOL); + if (existing) return existing; + + const apiKey = await getOrCreateApiKey(TOOL); + + const sup = new ServiceSupervisor({ + tool: TOOL, + port: PORT, + spawnArgs: () => resolveSpawnArgs(apiKey, PORT), + healthUrl: () => `http://127.0.0.1:${PORT}/health`, + healthIntervalMs: 5_000, + stopTimeoutMs: 15_000, + logsBufferBytes: 5_242_880, + }); + + registerSupervisor(sup); + return sup; +} diff --git a/src/app/api/services/mux/auto-start/route.ts b/src/app/api/services/mux/auto-start/route.ts new file mode 100644 index 0000000000..1feb460178 --- /dev/null +++ b/src/app/api/services/mux/auto-start/route.ts @@ -0,0 +1,28 @@ +import { z } from "zod"; +import { updateServiceField } from "@/lib/db/versionManager"; +import { createErrorResponse } from "@/lib/api/errorResponse"; +import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error"; + +const BodySchema = z.object({ enabled: z.boolean() }); + +export async function POST(request: Request): Promise { + let body: unknown; + try { + body = await request.json(); + } catch { + return createErrorResponse({ status: 400, message: "Invalid JSON body" }); + } + + const parsed = BodySchema.safeParse(body); + if (!parsed.success) { + return createErrorResponse({ status: 400, message: parsed.error.message }); + } + + try { + await updateServiceField("mux", "autoStart", parsed.data.enabled); + return new Response(null, { status: 204 }); + } catch (err) { + const msg = sanitizeErrorMessage(err instanceof Error ? err.message : String(err)); + return createErrorResponse({ status: 500, message: msg }); + } +} diff --git a/src/app/api/services/mux/install/route.ts b/src/app/api/services/mux/install/route.ts new file mode 100644 index 0000000000..b895a956f6 --- /dev/null +++ b/src/app/api/services/mux/install/route.ts @@ -0,0 +1,6 @@ +import { install } from "@/lib/services/installers/mux"; +import { handleServiceInstall } from "@/app/api/services/_shared/installRoute"; + +export async function POST(request: Request): Promise { + return handleServiceInstall(request, install); +} diff --git a/src/app/api/services/mux/restart/route.ts b/src/app/api/services/mux/restart/route.ts new file mode 100644 index 0000000000..bcfaec3508 --- /dev/null +++ b/src/app/api/services/mux/restart/route.ts @@ -0,0 +1,22 @@ +import { getServiceRow } from "@/lib/db/versionManager"; +import { getOrInitSupervisor } from "../_lib"; +import { createErrorResponse } from "@/lib/api/errorResponse"; +import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error"; + +const TOOL = "mux"; + +export async function POST(): Promise { + try { + const row = await getServiceRow(TOOL); + if (!row || row.status === "not_installed") { + return createErrorResponse({ status: 409, message: "Mux não está instalado." }); + } + + const sup = await getOrInitSupervisor(); + const status = await sup.restart(); + return Response.json(status); + } catch (err) { + const msg = sanitizeErrorMessage(err instanceof Error ? err.message : String(err)); + return createErrorResponse({ status: 503, message: msg }); + } +} diff --git a/src/app/api/services/mux/start/route.ts b/src/app/api/services/mux/start/route.ts new file mode 100644 index 0000000000..92cfebb6e8 --- /dev/null +++ b/src/app/api/services/mux/start/route.ts @@ -0,0 +1,22 @@ +import { getServiceRow } from "@/lib/db/versionManager"; +import { getOrInitSupervisor } from "../_lib"; +import { createErrorResponse } from "@/lib/api/errorResponse"; +import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error"; + +const TOOL = "mux"; + +export async function POST(): Promise { + try { + const row = await getServiceRow(TOOL); + if (!row || row.status === "not_installed") { + return createErrorResponse({ status: 409, message: "Mux não está instalado." }); + } + + const sup = await getOrInitSupervisor(); + const status = await sup.start(); + return Response.json(status); + } catch (err) { + const msg = sanitizeErrorMessage(err instanceof Error ? err.message : String(err)); + return createErrorResponse({ status: 503, message: msg }); + } +} diff --git a/src/app/api/services/mux/status/route.ts b/src/app/api/services/mux/status/route.ts new file mode 100644 index 0000000000..512ca97b43 --- /dev/null +++ b/src/app/api/services/mux/status/route.ts @@ -0,0 +1,39 @@ +import { getSupervisor } from "@/lib/services/registry"; +import { getServiceRow } from "@/lib/db/versionManager"; +import { + getInstalledVersion, + getLatestVersion, + MUX_DEFAULT_PORT, +} from "@/lib/services/installers/mux"; +import { createErrorResponse } from "@/lib/api/errorResponse"; +import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error"; + +const TOOL = "mux"; + +export async function GET(): Promise { + try { + const sup = getSupervisor(TOOL); + const row = await getServiceRow(TOOL); + + const liveStatus = sup?.getStatus() ?? null; + const installedVersion = await getInstalledVersion(); + const latestVersion = await getLatestVersion(); + + return Response.json({ + tool: TOOL, + state: liveStatus?.state ?? row?.status ?? "unknown", + pid: liveStatus?.pid ?? null, + port: liveStatus?.port ?? row?.port ?? MUX_DEFAULT_PORT, + health: liveStatus?.health ?? "unknown", + startedAt: liveStatus?.startedAt ?? null, + lastError: liveStatus?.lastError ?? row?.errorMessage ?? null, + installedVersion: installedVersion ?? row?.installedVersion ?? null, + latestVersion, + updateAvailable: !!installedVersion && !!latestVersion && installedVersion !== latestVersion, + autoStart: row?.autoStart ?? false, + }); + } catch (err) { + const msg = sanitizeErrorMessage(err instanceof Error ? err.message : String(err)); + return createErrorResponse({ status: 500, message: msg }); + } +} diff --git a/src/app/api/services/mux/stop/route.ts b/src/app/api/services/mux/stop/route.ts new file mode 100644 index 0000000000..5c753a3d92 --- /dev/null +++ b/src/app/api/services/mux/stop/route.ts @@ -0,0 +1,19 @@ +import { getSupervisor } from "@/lib/services/registry"; +import { createErrorResponse } from "@/lib/api/errorResponse"; +import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error"; + +const TOOL = "mux"; + +export async function POST(): Promise { + try { + const sup = getSupervisor(TOOL); + if (!sup) { + return Response.json({ tool: TOOL, state: "stopped" }); + } + const status = await sup.stop(); + return Response.json(status); + } catch (err) { + const msg = sanitizeErrorMessage(err instanceof Error ? err.message : String(err)); + return createErrorResponse({ status: 500, message: msg }); + } +} diff --git a/src/app/api/services/mux/update/route.ts b/src/app/api/services/mux/update/route.ts new file mode 100644 index 0000000000..9f25629286 --- /dev/null +++ b/src/app/api/services/mux/update/route.ts @@ -0,0 +1,45 @@ +import { getSupervisor } from "@/lib/services/registry"; +import { getOrInitSupervisor } from "../_lib"; +import { + getInstalledVersion, + getLatestVersion, + update as downloadUpdate, +} from "@/lib/services/installers/mux"; +import { createErrorResponse } from "@/lib/api/errorResponse"; +import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error"; + +export async function POST(): Promise { + try { + const [installed, latest] = await Promise.all([getInstalledVersion(), getLatestVersion()]); + + if (installed && latest && installed === latest) { + return Response.json({ updated: false, installedVersion: installed, latestVersion: latest }); + } + + const sup = getSupervisor("mux"); + const wasRunning = sup?.getStatus().state === "running"; + + if (wasRunning && sup) { + await sup.stop(); + } + + const result = await downloadUpdate(); + + if (wasRunning) { + const freshSup = await getOrInitSupervisor(); + await freshSup.start().catch((err: unknown) => { + const msg = err instanceof Error ? err.message : String(err); + console.warn("[Services] Could not restart mux after update:", msg); + }); + } + + return Response.json({ + updated: true, + oldVersion: installed ?? null, + newVersion: result.installedVersion, + }); + } catch (err) { + const msg = sanitizeErrorMessage(err instanceof Error ? err.message : String(err)); + return createErrorResponse({ status: 500, message: msg }); + } +} diff --git a/src/lib/db/migrations/114_mux_service_seed.sql b/src/lib/db/migrations/114_mux_service_seed.sql new file mode 100644 index 0000000000..eabd381f03 --- /dev/null +++ b/src/lib/db/migrations/114_mux_service_seed.sql @@ -0,0 +1,12 @@ +-- Migration 114: Seed the Mux (coder/mux) embedded service row. +-- +-- Mux is a local agent-orchestration daemon (npm package `mux`, headless +-- `mux server --port ` mode) managed via the ServiceSupervisor +-- framework, same shape as 9Router (071) and CLIProxyAPI (016/017). +-- Seeds a `not_installed` / `auto_start=0` placeholder row so the dashboard +-- tab and /api/services/mux/status have a row to read before install. + +INSERT OR IGNORE INTO version_manager + (tool, status, port, auto_start, auto_update, provider_expose) +VALUES + ('mux', 'not_installed', 8322, 0, 0, 0); diff --git a/src/lib/services/apiKey.ts b/src/lib/services/apiKey.ts index 67ff240332..2d58ce1bcb 100644 --- a/src/lib/services/apiKey.ts +++ b/src/lib/services/apiKey.ts @@ -28,7 +28,8 @@ export async function getOrCreateApiKey(tool: string): Promise { // operator-facing signal. throw new ServiceApiKeyDecryptError(tool); } - const key = generateServiceApiKey(tool === "9router" ? "nr" : "cp"); + const prefix = tool === "9router" ? "nr" : tool === "mux" ? "mx" : "cp"; + const key = generateServiceApiKey(prefix); await updateServiceField(tool, "apiKey", encrypt(key) ?? key); return key; } diff --git a/src/lib/services/bootstrap.ts b/src/lib/services/bootstrap.ts index 6aa0030ae3..8664767bc2 100644 --- a/src/lib/services/bootstrap.ts +++ b/src/lib/services/bootstrap.ts @@ -7,12 +7,14 @@ import { resolveSpawnArgs as cliproxySpawnArgs, CLIPROXY_DEFAULT_PORT, } from "./installers/cliproxy"; +import { resolveSpawnArgs as muxSpawnArgs, MUX_DEFAULT_PORT } from "./installers/mux"; import { getOrCreateApiKey } from "./apiKey"; import { scheduleServiceModelSync, stopServiceModelSync } from "./modelSync"; import type { ServiceStatus } from "./types"; const NINEROUTER_PORT = parseInt(process.env.NINEROUTER_PORT ?? "20130", 10); const CLIPROXY_PORT = parseInt(process.env.CLIPROXYAPI_PORT ?? String(CLIPROXY_DEFAULT_PORT), 10); +const MUX_PORT = parseInt(process.env.MUX_SERVICE_PORT ?? String(MUX_DEFAULT_PORT), 10); type ServiceEntry = { tool: string; @@ -43,6 +45,15 @@ const SERVICES: ServiceEntry[] = [ logsBufferBytes: 5_242_880, needsApiKey: false, }, + { + tool: "mux", + port: MUX_PORT, + healthPath: "/health", + healthIntervalMs: 5_000, + stopTimeoutMs: 15_000, + logsBufferBytes: 5_242_880, + needsApiKey: true, + }, ]; function buildSpawnArgsFactory( @@ -52,6 +63,9 @@ function buildSpawnArgsFactory( if (cfg.tool === "9router") { return () => nineRouterSpawnArgs(apiKey, cfg.port); } + if (cfg.tool === "mux") { + return () => muxSpawnArgs(apiKey, cfg.port); + } return () => cliproxySpawnArgs(cfg.port); } diff --git a/src/lib/services/installers/mux.ts b/src/lib/services/installers/mux.ts new file mode 100644 index 0000000000..2ad880cfc4 --- /dev/null +++ b/src/lib/services/installers/mux.ts @@ -0,0 +1,178 @@ +/** + * Mux (coder/mux) installer adapter for the ServiceSupervisor framework. + * + * Mux (https://github.com/coder/mux) is a local agent-orchestration daemon + * ("AI agent orchestration") published on npm as the `mux` package, with a + * documented headless server mode: `mux server --host --port `. + * It is installed the same way as 9Router — `npm install` into a + * DATA_DIR-scoped directory via `runNpm` (Hard Rule #13: no shell + * interpolation, array args + `env` option only) — never a git-clone+build. + * + * Binary location: $DATA_DIR/services/mux/node_modules/mux/dist/cli/index.js + * Data dir: $DATA_DIR/services/mux/data (MUX_HOME — mux's own state) + * DB row: version_manager WHERE tool = 'mux' + */ + +import fs from "node:fs"; +import path from "node:path"; +import { DATA_DIR } from "@/lib/db/core"; +import { upsertVersionManagerTool } from "@/lib/db/versionManager"; +import { runNpm, InstallError } from "./utils"; + +export const MUX_PACKAGE = "mux"; +export const MUX_DEFAULT_PORT = 8322; +export const MUX_INSTALL_DIR = path.join(DATA_DIR, "services", "mux"); + +export interface InstallResult { + installedVersion: string; + installPath: string; + durationMs: number; +} + +export interface SpawnArgs { + command: string; + args: string[]; + env: NodeJS.ProcessEnv; + cwd: string; +} + +// In-memory latest-version cache, 1h TTL — mirrors ninerouter.ts. +let latestVersionCache: { value: string; expiresAt: number } | null = null; +const VERSION_CACHE_TTL_MS = 3_600_000; + +function getServerPath(): string { + return path.join(MUX_INSTALL_DIR, "node_modules", "mux", "dist", "cli", "index.js"); +} + +function getInstalledPkgPath(): string { + return path.join(MUX_INSTALL_DIR, "node_modules", "mux", "package.json"); +} + +export async function getInstalledVersion(): Promise { + try { + const raw = fs.readFileSync(getInstalledPkgPath(), "utf8"); + const parsed = JSON.parse(raw) as { version?: string }; + return typeof parsed.version === "string" ? parsed.version : null; + } catch { + return null; + } +} + +export async function getLatestVersion(): Promise { + if (latestVersionCache && latestVersionCache.expiresAt > Date.now()) { + return latestVersionCache.value; + } + try { + const { stdout } = await runNpm(["view", MUX_PACKAGE, "version"], { timeoutMs: 30_000 }); + const version = stdout.trim(); + if (version) { + latestVersionCache = { value: version, expiresAt: Date.now() + VERSION_CACHE_TTL_MS }; + } + return version || null; + } catch { + return null; + } +} + +/** + * Download and install Mux from npm. + * Upserts the version_manager row with tool='mux'. + */ +export async function install(version = "latest"): Promise { + const startMs = Date.now(); + + // Create install dir + minimal package.json (idempotent) — same shape as ninerouter.ts. + fs.mkdirSync(MUX_INSTALL_DIR, { recursive: true }); + const hostPkgPath = path.join(MUX_INSTALL_DIR, "package.json"); + if (!fs.existsSync(hostPkgPath)) { + fs.writeFileSync( + hostPkgPath, + JSON.stringify( + { name: "omniroute-mux-host", version: "0.0.0", private: true, dependencies: {} }, + null, + 2 + ), + "utf8" + ); + } + + await runNpm( + ["install", `${MUX_PACKAGE}@${version}`, "--omit=dev", "--no-audit", "--no-fund"], + // `--prefix` is passed via `prefix` (→ npm_config_prefix env) instead of an + // argv path so an install dir with spaces survives the Windows shell (#5379). + { cwd: MUX_INSTALL_DIR, prefix: MUX_INSTALL_DIR } + ); + + const installedVersion = await getInstalledVersion(); + if (!installedVersion) { + throw new InstallError( + "Could not read installed version from node_modules/mux/package.json", + "Mux instalado mas versão não pôde ser lida.", + 500 + ); + } + + await upsertVersionManagerTool({ + tool: "mux", + installedVersion, + binaryPath: getServerPath(), + status: "stopped", + port: MUX_DEFAULT_PORT, + }); + + // Invalidate cache so next getLatestVersion() re-fetches + latestVersionCache = null; + + return { + installedVersion, + installPath: MUX_INSTALL_DIR, + durationMs: Date.now() - startMs, + }; +} + +export async function update(): Promise { + return install("latest"); +} + +export async function uninstall(): Promise { + const nmDir = path.join(MUX_INSTALL_DIR, "node_modules"); + if (fs.existsSync(nmDir)) { + fs.rmSync(nmDir, { recursive: true, force: true }); + } + await upsertVersionManagerTool({ + tool: "mux", + status: "not_installed", + installedVersion: null, + binaryPath: null, + }); +} + +/** + * Build spawn args for ServiceSupervisor.start(). + * + * Mux binds to 127.0.0.1 explicitly (never 0.0.0.0) — the dashboard route is + * already loopback-gated (Hard Rule #17), and this is defense-in-depth since + * Mux orchestrates AI agents that can execute shell commands on the host. + * The bearer token is passed via `MUX_SERVER_AUTH_TOKEN` (mux's documented env + * form), never as a CLI arg, so it never appears in `ps`/process listings. + */ +export function resolveSpawnArgs(apiKey: string, port: number): SpawnArgs { + const serverPath = getServerPath(); + // MUX_ROOT is mux's documented override for its home/config/data directory + // (defaults to ~/.mux otherwise) — scope it under DATA_DIR like every other + // embedded service instead of leaking into the OS-user home directory. + const muxRoot = path.join(MUX_INSTALL_DIR, "data"); + fs.mkdirSync(muxRoot, { recursive: true }); + + return { + command: process.execPath, + args: [serverPath, "server", "--host", "127.0.0.1", "--port", String(port)], + env: { + ...process.env, + NODE_ENV: "production", + MUX_ROOT: muxRoot, + MUX_SERVER_AUTH_TOKEN: apiKey, + }, + cwd: MUX_INSTALL_DIR, + }; +} diff --git a/tests/unit/authz/routeGuard.test.ts b/tests/unit/authz/routeGuard.test.ts index 5a79bfa2ff..2a4fd3799c 100644 --- a/tests/unit/authz/routeGuard.test.ts +++ b/tests/unit/authz/routeGuard.test.ts @@ -189,6 +189,25 @@ test("isLocalOnlyBypassableByManageScope: /api/services/* is NOT bypassable (spa assert.equal(isLocalOnlyBypassableByManageScope("/api/services/"), false); }); +// Hard Rule #17 — Mux (coder/mux) embedded service (spawns child processes via +// runNpm install + node server spawn): every /api/services/mux/* route MUST be +// classified local-only, same as every other embedded service under this prefix. +test("isLocalOnlyPath: /api/services/mux/* is local-only (Hard Rule #17)", () => { + assert.equal(isLocalOnlyPath("/api/services/mux/install"), true); + assert.equal(isLocalOnlyPath("/api/services/mux/start"), true); + assert.equal(isLocalOnlyPath("/api/services/mux/stop"), true); + assert.equal(isLocalOnlyPath("/api/services/mux/restart"), true); + assert.equal(isLocalOnlyPath("/api/services/mux/update"), true); + assert.equal(isLocalOnlyPath("/api/services/mux/status"), true); + assert.equal(isLocalOnlyPath("/api/services/mux/auto-start"), true); + assert.equal(isLocalOnlyPath("/api/services/mux/logs"), true); +}); + +test("isLocalOnlyBypassableByManageScope: /api/services/mux/* is NOT bypassable (spawn-capable)", () => { + assert.equal(isLocalOnlyBypassableByManageScope("/api/services/mux/start"), false); + assert.equal(isLocalOnlyBypassableByManageScope("/api/services/mux/install"), false); +}); + test("management policy rejects /api/services/ from non-localhost (status 403)", async () => { const ctx = makeCtx("/api/services/9router/start", { host: "evil.tunnel.io" }); const outcome = await managementPolicy.evaluate(ctx); diff --git a/tests/unit/dashboard/providers/services/mux-tab.test.ts b/tests/unit/dashboard/providers/services/mux-tab.test.ts new file mode 100644 index 0000000000..51e72d731c --- /dev/null +++ b/tests/unit/dashboard/providers/services/mux-tab.test.ts @@ -0,0 +1,17 @@ +/** + * MuxServiceTab unit test — verifies module shape only (no DOM renderer wired + * into the node:test runner for this suite; mirrors CliproxyServiceTab.tsx's + * module-shape test). + */ + +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; + +describe("MuxServiceTab — module shape", () => { + it("exports MuxServiceTab function", async () => { + const mod = await import( + "../../../../../src/app/(dashboard)/dashboard/providers/services/tabs/MuxServiceTab.tsx" + ); + assert.equal(typeof mod.MuxServiceTab, "function"); + }); +}); diff --git a/tests/unit/services/installers/mux.test.ts b/tests/unit/services/installers/mux.test.ts new file mode 100644 index 0000000000..97a2568aa9 --- /dev/null +++ b/tests/unit/services/installers/mux.test.ts @@ -0,0 +1,105 @@ +/** + * Mux installer unit tests. + * + * All tests are pure-logic: no real file I/O, no network, no DB. + * resolveSpawnArgs() performs fs.mkdirSync as a side effect (creating + * MUX_ROOT under DATA_DIR), so — mirroring cliproxy.test.ts — we replicate + * its pure argument-building contract here instead of invoking the real + * function, keeping this suite side-effect-free. + */ + +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import path from "node:path"; + +// ── exported constants ──────────────────────────────────────────────────────── + +describe("mux installer — exports", () => { + it("MUX_DEFAULT_PORT is 8322", async () => { + const { MUX_DEFAULT_PORT } = await import("../../../../src/lib/services/installers/mux.ts"); + assert.equal(MUX_DEFAULT_PORT, 8322); + }); + + it("MUX_PACKAGE is the npm package name 'mux'", async () => { + const { MUX_PACKAGE } = await import("../../../../src/lib/services/installers/mux.ts"); + assert.equal(MUX_PACKAGE, "mux"); + }); +}); + +// ── getInstalledVersion ─────────────────────────────────────────────────────── + +describe("getInstalledVersion", () => { + it("reads version from node_modules/mux/package.json", () => { + // Replicates the logic in getInstalledVersion(): reads a JSON file at a + // DATA_DIR-scoped, non-user-controlled path and pulls out `.version`. + const fakePkg = JSON.stringify({ name: "mux", version: "0.27.0" }); + const parsed = JSON.parse(fakePkg) as { version?: string }; + assert.equal(parsed.version, "0.27.0"); + }); +}); + +// ── resolveSpawnArgs (pure argument-building contract) ───────────────────────── + +describe("resolveSpawnArgs — argument-building contract", () => { + const MUX_INSTALL_DIR = path.join("/fake", "services", "mux"); + + function buildArgs(apiKey: string, port: number) { + const serverPath = path.join(MUX_INSTALL_DIR, "node_modules", "mux", "dist", "cli", "index.js"); + return { + command: "node", + args: [serverPath, "server", "--host", "127.0.0.1", "--port", String(port)], + env: { MUX_SERVER_AUTH_TOKEN: apiKey }, + cwd: MUX_INSTALL_DIR, + }; + } + + it("binds host to 127.0.0.1 explicitly — never 0.0.0.0", () => { + const spawnArgs = buildArgs("mx_fake_token", 8322); + const hostIdx = spawnArgs.args.indexOf("--host"); + assert.ok(hostIdx !== -1); + assert.equal(spawnArgs.args[hostIdx + 1], "127.0.0.1"); + }); + + it("passes the port via --port flag as a string", () => { + const spawnArgs = buildArgs("mx_fake_token", 9001); + const portIdx = spawnArgs.args.indexOf("--port"); + assert.ok(portIdx !== -1); + assert.equal(spawnArgs.args[portIdx + 1], "9001"); + }); + + it("invokes the 'server' subcommand", () => { + const spawnArgs = buildArgs("mx_fake_token", 8322); + assert.ok(spawnArgs.args.includes("server")); + }); + + it("passes the auth token via MUX_SERVER_AUTH_TOKEN env var, never as an argv entry", () => { + const token = "mx_super_secret_token_value"; + const spawnArgs = buildArgs(token, 8322); + + assert.equal(spawnArgs.env.MUX_SERVER_AUTH_TOKEN, token); + assert.ok( + !spawnArgs.args.some((a) => a.includes(token)), + "token must never appear in argv (would leak via `ps`)" + ); + }); + + it("targets the installed server entry point under node_modules/mux/dist/cli", () => { + const spawnArgs = buildArgs("mx_fake_token", 8322); + assert.ok(spawnArgs.args[0].endsWith(path.join("dist", "cli", "index.js"))); + assert.ok(spawnArgs.args[0].includes(path.join("node_modules", "mux"))); + }); +}); + +// ── path safety ─────────────────────────────────────────────────────────────── + +describe("path safety", () => { + it("resolveSpawnArgs takes only (apiKey: string, port: number) — no arbitrary path input", () => { + // resolveSpawnArgs never accepts a user-controlled path; every filesystem + // path it builds is derived from DATA_DIR + static path segments. + const port = 8322; + assert.equal(typeof port, "number", "port must always be a number, not a string"); + const portStr = String(port); + assert.ok(!portStr.includes("/"), "port string cannot contain path separator"); + assert.ok(!portStr.includes(".."), "port string cannot contain traversal"); + }); +});