mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-03 05:45:04 +03:00
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:
committed by
GitHub
parent
b7160e9fc5
commit
dc7892c4b0
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -37,6 +37,11 @@ async function getOrInitNamedSupervisor(name: string) {
|
||||
return getOrInitSupervisor();
|
||||
}
|
||||
|
||||
if (name === "mux") {
|
||||
const { getOrInitSupervisor } = await import("../../mux/_lib");
|
||||
return getOrInitSupervisor();
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
32
src/app/api/services/mux/_lib.ts
Normal file
32
src/app/api/services/mux/_lib.ts
Normal 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;
|
||||
}
|
||||
28
src/app/api/services/mux/auto-start/route.ts
Normal file
28
src/app/api/services/mux/auto-start/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
6
src/app/api/services/mux/install/route.ts
Normal file
6
src/app/api/services/mux/install/route.ts
Normal 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);
|
||||
}
|
||||
22
src/app/api/services/mux/restart/route.ts
Normal file
22
src/app/api/services/mux/restart/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
22
src/app/api/services/mux/start/route.ts
Normal file
22
src/app/api/services/mux/start/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
39
src/app/api/services/mux/status/route.ts
Normal file
39
src/app/api/services/mux/status/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
19
src/app/api/services/mux/stop/route.ts
Normal file
19
src/app/api/services/mux/stop/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
45
src/app/api/services/mux/update/route.ts
Normal file
45
src/app/api/services/mux/update/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
12
src/lib/db/migrations/114_mux_service_seed.sql
Normal file
12
src/lib/db/migrations/114_mux_service_seed.sql
Normal 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);
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
178
src/lib/services/installers/mux.ts
Normal file
178
src/lib/services/installers/mux.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
17
tests/unit/dashboard/providers/services/mux-tab.test.ts
Normal file
17
tests/unit/dashboard/providers/services/mux-tab.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
105
tests/unit/services/installers/mux.test.ts
Normal file
105
tests/unit/services/installers/mux.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user