feat(services): add Mux managed embedded service (#6034)

Adds Mux (coder/mux — local agent-orchestration daemon) as a fourth-tier
embedded service built on the existing ServiceSupervisor framework, the
same shape as 9Router and CLIProxyAPI:

- Installer (src/lib/services/installers/mux.ts): npm install/update via
  runNpm (array args + env-based prefix, no shell interpolation), modeled
  on ninerouter.ts. Mux ships an npm package (`mux`) with a documented
  headless `mux server --host <host> --port <port>` mode, so no
  git-clone+build path was needed.
- Registered in bootstrap.ts (SERVICES[] + buildSpawnArgsFactory).
- DB seed migration 113 (version_manager row, not_installed/auto_start=0).
- 7 API endpoints under /api/services/mux/ (install/start/stop/restart/
  update/status/auto-start) plus the shared [name]/logs SSE endpoint,
  mirroring the cliproxy route shape and delegating errors through
  createErrorResponse().
- Dashboard tab (MuxServiceTab) reusing ServiceStatusCard,
  ServiceLifecycleButtons, AutoStartToggle, ServiceLogsPanel.
- Docs: EMBEDDED-SERVICES.md (service table, architecture diagram, API
  reference, key-injection section), openapi.yaml, ENVIRONMENT.md,
  .env.example.

Security:
- Every /api/services/mux/* route is covered by the existing
  LOCAL_ONLY_API_PREFIXES "/api/services/" prefix (Hard Rule #17);
  added an explicit isLocalOnlyPath regression test for all 8 routes.
- Mux binds to 127.0.0.1 explicitly (never 0.0.0.0) as defense-in-depth,
  since it orchestrates AI agents that can execute host commands.
- The bearer token is generated the same way as 9Router's key
  (getOrCreateApiKey) and injected via MUX_SERVER_AUTH_TOKEN (mux's
  documented env form) rather than a CLI flag, so it never appears in
  `ps`/process listings.
- No shell interpolation anywhere in the installer (Hard Rule #13): all
  npm/spawn args are static arrays; the install prefix and auth token
  travel via the env option.


Inspired-by: https://github.com/decolua/9router/pull/1802

Co-authored-by: Ansh7473 <Ansh7473@users.noreply.github.com>
This commit is contained in:
Diego Rodrigues de Sa e Souza
2026-07-03 08:57:02 -03:00
committed by GitHub
parent b7160e9fc5
commit dc7892c4b0
23 changed files with 815 additions and 23 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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() {
<header>
<h1 className="text-xl font-semibold text-text-primary">Embedded Services</h1>
<p className="text-sm text-text-muted mt-1">
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.
</p>
</header>
@@ -55,6 +58,7 @@ export default function ServicesPage() {
<div>
{active === "cliproxy" && <CliproxyServiceTab />}
{active === "9router" && <NinerouterServiceTab />}
{active === "mux" && <MuxServiceTab />}
</div>
</div>
);

View File

@@ -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 (
<div className="space-y-4">
<ServiceStatusCard name={NAME} />
<ServiceLifecycleButtons name={NAME} />
<AutoStartToggle name={NAME} description="Launch Mux automatically when OmniRoute starts" />
<ServiceLogsPanel name={NAME} />
</div>
);
}

View File

@@ -37,6 +37,11 @@ async function getOrInitNamedSupervisor(name: string) {
return getOrInitSupervisor();
}
if (name === "mux") {
const { getOrInitSupervisor } = await import("../../mux/_lib");
return getOrInitSupervisor();
}
return null;
}

View File

@@ -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<ServiceSupervisor> {
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;
}

View File

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

View File

@@ -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<Response> {
return handleServiceInstall(request, install);
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -28,7 +28,8 @@ export async function getOrCreateApiKey(tool: string): Promise<string> {
// 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;
}

View File

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

View File

@@ -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 <host> --port <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<string | null> {
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<string | null> {
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<InstallResult> {
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<InstallResult> {
return install("latest");
}
export async function uninstall(): Promise<void> {
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,
};
}

View File

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

View File

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

View File

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