chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 13:39:12 +08:00
commit d8dcd5f6d1
8604 changed files with 2479390 additions and 0 deletions
+243
View File
@@ -0,0 +1,243 @@
/** Generic supervisor for embedded services (9router, CLIProxyAPI, future). */
import { EventEmitter } from "node:events";
import { spawn } from "node:child_process";
import type { ChildProcess } from "node:child_process";
import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error";
import { getServiceRow, updateServiceField, setToolStatus } from "@/lib/db/versionManager";
import { RingBuffer } from "./ringBuffer";
import { HealthChecker } from "./healthCheck";
import { decidePreSpawn, probeBeforeSpawn } from "./portProbe";
import type { ServiceConfig, ServiceState, ServiceStatus, LogLine, HealthState } from "./types";
const CRASH_FAST_THRESHOLD_MS = 5_000;
export class ServiceSupervisor extends EventEmitter {
private state: ServiceState = "stopped";
private health: HealthState = "unknown";
private pid: number | null = null;
private startedAt: string | null = null;
private lastError: string | null = null;
private childProcess: ChildProcess | null = null;
private readonly buffer: RingBuffer;
private readonly checker: HealthChecker;
private operationLock: Promise<void> = Promise.resolve();
constructor(private readonly config: ServiceConfig) {
super();
this.buffer = new RingBuffer(config.logsBufferBytes);
this.checker = new HealthChecker(config.healthUrl, config.healthIntervalMs, (h) => {
this.health = h;
this.emit("stateChange", this.getStatus());
});
}
getRingBuffer(): RingBuffer {
return this.buffer;
}
getStatus(): ServiceStatus {
return {
tool: this.config.tool,
state: this.state,
pid: this.pid,
port: this.config.port,
health: this.health,
startedAt: this.startedAt,
lastError: this.lastError,
};
}
async start(): Promise<ServiceStatus> {
return this.withLock(async () => {
if (this.state === "running" || this.state === "starting") {
return this.getStatus();
}
const row = await getServiceRow(this.config.tool);
if (row && row.logsBufferPath) {
this.buffer.setFlushPath(row.logsBufferPath);
}
this.setState("starting");
this.lastError = null;
// Pre-spawn probe (#6205): avoid a raw EADDRINUSE crash when a prior
// instance is still holding the port. A healthy instance is adopted; a
// held-but-unhealthy port surfaces a clear error instead of a stack.
// Opt-in per ServiceConfig so the default spawn path is unchanged.
if (this.config.probeBeforeSpawn) {
const probe = await probeBeforeSpawn(this.config.healthUrl(), this.config.port);
const decision = decidePreSpawn(probe, this.config.port);
if (decision.action === "adopt") {
// Something healthy already serves this port — treat it as running
// rather than spawning a duplicate that would die with EADDRINUSE.
this.checker.start();
this.startedAt = new Date().toISOString();
this.setState("running");
await setToolStatus(this.config.tool, "running");
return this.getStatus();
}
if (decision.action === "error") {
this.lastError = sanitizeErrorMessage(decision.message);
this.setState("error");
await setToolStatus(this.config.tool, "error", undefined, this.lastError);
return this.getStatus();
}
}
const { command, args, env, cwd } = this.config.spawnArgs();
const child = spawn(command, args, {
env,
cwd,
detached: false,
stdio: ["ignore", "pipe", "pipe"],
});
this.childProcess = child;
this.pid = child.pid ?? null;
if (this.pid) {
await setToolStatus(this.config.tool, "starting", this.pid);
}
const processLine = (stream: "stdout" | "stderr", raw: Buffer) => {
const lines = raw.toString("utf8").split("\n");
for (const line of lines) {
if (!line.trim()) continue;
const logLine: LogLine = { ts: Date.now(), stream, line };
this.buffer.push(logLine);
this.emit("log", logLine);
}
};
child.stdout?.on("data", (chunk: Buffer) => processLine("stdout", chunk));
child.stderr?.on("data", (chunk: Buffer) => processLine("stderr", chunk));
const spawnTime = Date.now();
child.once("exit", (code, signal) => {
void this.handleExit(code, signal, spawnTime);
});
this.startedAt = new Date().toISOString();
this.checker.start();
await this.waitForHealthy();
this.setState("running");
await setToolStatus(this.config.tool, "running", this.pid ?? undefined);
return this.getStatus();
});
}
async stop(): Promise<ServiceStatus> {
return this.withLock(async () => {
if (this.state === "stopped" || this.state === "stopping") {
return this.getStatus();
}
this.setState("stopping");
this.checker.stop();
await this.killChild();
this.pid = null;
this.childProcess = null;
this.startedAt = null;
this.setState("stopped");
await setToolStatus(this.config.tool, "stopped");
return this.getStatus();
});
}
async restart(): Promise<ServiceStatus> {
await this.stop();
return this.start();
}
private async withLock<T>(fn: () => Promise<T>): Promise<T> {
let resolve!: () => void;
const next = new Promise<void>((r) => (resolve = r));
const current = this.operationLock;
this.operationLock = current.then(() => next);
await current;
try {
return await fn();
} finally {
resolve();
}
}
private async waitForHealthy(): Promise<void> {
const timeoutMs = this.config.healthIntervalMs * 3;
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
if (this.checker.getHealth() === "healthy") return;
if (this.state === "error") throw new Error(this.lastError ?? "Service failed to start");
await new Promise((r) => setTimeout(r, 1_000));
}
// Timeout reached without a healthy probe. Surface this so callers /
// dashboards do not see "running" + "unknown" health silently. We do not
// throw — the service may still be initializing — but we DO record a
// degraded marker so /status returns it and operators can act.
this.lastError = sanitizeErrorMessage(
`Health probe did not succeed within ${timeoutMs}ms — service may still be initializing`
);
this.emit("healthDegraded", {
tool: this.config.tool,
timeoutMs,
lastHealth: this.checker.getHealth(),
});
}
private async killChild(): Promise<void> {
const child = this.childProcess;
if (!child || child.killed) return;
child.kill("SIGTERM");
await new Promise<void>((resolve) => {
const timeout = setTimeout(() => {
if (!child.killed) child.kill("SIGKILL");
resolve();
}, this.config.stopTimeoutMs);
child.once("exit", () => {
clearTimeout(timeout);
resolve();
});
});
}
private async handleExit(
code: number | null,
signal: NodeJS.Signals | null,
spawnTime: number
): Promise<void> {
this.checker.stop();
this.pid = null;
this.childProcess = null;
if (this.state === "stopping" || this.state === "stopped") return;
const fastCrash = Date.now() - spawnTime < CRASH_FAST_THRESHOLD_MS;
const reason = signal ? `killed by signal ${signal}` : `exited with code ${code ?? "unknown"}`;
const msg = fastCrash ? `Fast crash (${reason})` : reason;
this.lastError = sanitizeErrorMessage(msg);
this.setState("error");
await setToolStatus(this.config.tool, "error", undefined, this.lastError);
}
private setState(state: ServiceState): void {
this.state = state;
this.emit("stateChange", this.getStatus());
}
}
+40
View File
@@ -0,0 +1,40 @@
import { randomBytes } from "node:crypto";
import { encrypt, decrypt } from "@/lib/db/encryption";
import { getServiceRow, updateServiceField } from "@/lib/db/versionManager";
export function generateServiceApiKey(prefix = "nr"): string {
return `${prefix}_${randomBytes(32).toString("base64url")}`;
}
export class ServiceApiKeyDecryptError extends Error {
constructor(tool: string) {
super(
`Stored API key for service '${tool}' could not be decrypted. ` +
`STORAGE_ENCRYPTION_KEY may have changed, the row may be corrupted, ` +
`or it was written on a different machine. Reinstall the service ` +
`or rotate the key via POST /api/services/${tool}/rotate-key.`
);
this.name = "ServiceApiKeyDecryptError";
}
}
export async function getOrCreateApiKey(tool: string): Promise<string> {
const row = await getServiceRow(tool);
if (row?.apiKey) {
const decrypted = decrypt(row.apiKey);
if (decrypted) return decrypted;
// Fail loud: silently regenerating would mint a new key the embedded
// service has never been told, causing every request to 401 with no
// operator-facing signal.
throw new ServiceApiKeyDecryptError(tool);
}
const prefix = tool === "9router" ? "nr" : tool === "mux" ? "mx" : "cp";
const key = generateServiceApiKey(prefix);
await updateServiceField(tool, "apiKey", encrypt(key) ?? key);
return key;
}
export function maskApiKey(plainKey: string): string {
const last4 = plainKey.slice(-4);
return `nr_••••••••${last4}`;
}
+133
View File
@@ -0,0 +1,133 @@
import { getVersionManagerTool } from "@/lib/db/versionManager";
import { markAllUnavailable } from "@/lib/db/serviceModels";
import { registerSupervisor, getSupervisor } from "./registry";
import { ServiceSupervisor } from "./ServiceSupervisor";
import { resolveSpawnArgs as nineRouterSpawnArgs } from "./installers/ninerouter";
import {
resolveSpawnArgs as cliproxySpawnArgs,
CLIPROXY_DEFAULT_PORT,
} from "./installers/cliproxy";
import { resolveSpawnArgs as muxSpawnArgs, MUX_DEFAULT_PORT } from "./installers/mux";
import {
resolveSpawnArgs as bifrostSpawnArgs,
BIFROST_DEFAULT_PORT,
} from "./installers/bifrost";
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);
const BIFROST_PORT = parseInt(process.env.BIFROST_PORT ?? String(BIFROST_DEFAULT_PORT), 10);
type ServiceEntry = {
tool: string;
port: number;
healthPath: string;
healthIntervalMs: number;
stopTimeoutMs: number;
logsBufferBytes: number;
needsApiKey: boolean;
};
const SERVICES: ServiceEntry[] = [
{
tool: "9router",
port: NINEROUTER_PORT,
healthPath: "/api/health",
healthIntervalMs: 2_000,
stopTimeoutMs: 15_000,
logsBufferBytes: 5_242_880,
needsApiKey: true,
},
{
tool: "cliproxy",
port: CLIPROXY_PORT,
healthPath: "/v1/models",
healthIntervalMs: 5_000,
stopTimeoutMs: 15_000,
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,
},
{
tool: "bifrost",
port: BIFROST_PORT,
healthPath: "/v1/models",
healthIntervalMs: 5_000,
stopTimeoutMs: 15_000,
logsBufferBytes: 5_242_880,
needsApiKey: false,
},
];
function buildSpawnArgsFactory(
cfg: ServiceEntry,
apiKey: string
): () => ReturnType<typeof nineRouterSpawnArgs> {
if (cfg.tool === "9router") {
return () => nineRouterSpawnArgs(apiKey, cfg.port);
}
if (cfg.tool === "mux") {
return () => muxSpawnArgs(apiKey, cfg.port);
}
if (cfg.tool === "bifrost") {
return () => bifrostSpawnArgs(cfg.port);
}
return () => cliproxySpawnArgs(cfg.port);
}
export async function bootstrapEmbeddedServices(): Promise<void> {
for (const cfg of SERVICES) {
if (getSupervisor(cfg.tool)) continue;
const row = await getVersionManagerTool(cfg.tool);
if (!row || row.status === "not_installed") continue;
const apiKey = cfg.needsApiKey
? await getOrCreateApiKey(cfg.tool).catch(() => "placeholder")
: "";
const supervisor = new ServiceSupervisor({
tool: cfg.tool,
port: cfg.port,
spawnArgs: buildSpawnArgsFactory(cfg, apiKey),
healthUrl: () => `http://127.0.0.1:${cfg.port}${cfg.healthPath}`,
healthIntervalMs: cfg.healthIntervalMs,
stopTimeoutMs: cfg.stopTimeoutMs,
logsBufferBytes: cfg.logsBufferBytes,
// #6205: embedded services bind a fixed port — probe before spawning so
// an orphaned prior instance yields adopt/clear-error instead of a raw
// EADDRINUSE crash.
probeBeforeSpawn: true,
});
registerSupervisor(supervisor);
const baseUrl = `http://127.0.0.1:${cfg.port}`;
supervisor.on("stateChange", (status: ServiceStatus) => {
if (status.state === "running") {
scheduleServiceModelSync(cfg.tool, baseUrl, apiKey);
} else if (status.state === "stopped" || status.state === "error") {
stopServiceModelSync(cfg.tool);
markAllUnavailable(cfg.tool);
}
});
if (row.autoStart) {
supervisor.start().catch((err: unknown) => {
const msg = err instanceof Error ? err.message : String(err);
console.warn(`[Services] Auto-start failed for ${cfg.tool}: ${msg}`);
});
}
}
}
+19
View File
@@ -0,0 +1,19 @@
/**
* Pure helpers for the embedded-service reverse proxy path handling.
*
* Kept dependency-free so the behavior can be unit-tested without pulling in
* the registry / DB / htmlRewriter that `reverseProxy.ts` imports.
*/
/**
* Map the `[[...path]]` catch-all segments to an upstream request path.
*
* The segment-less panel root (`/embed/`, matched only because the route is an
* OPTIONAL catch-all — #6205) yields an empty segment array, which must map to
* `"/"` so the embedded service serves its index page instead of `/undefined`.
*
* @param pathSegments The catch-all segments, e.g. `["ui", "index.html"]` or `[]`.
*/
export function toUpstreamPath(pathSegments: string[]): string {
return pathSegments.length > 0 ? "/" + pathSegments.join("/") : "/";
}
+285
View File
@@ -0,0 +1,285 @@
/**
* WebSocket reverse proxy for embedded service UIs.
*
* Runs a lightweight HTTP server (port EMBED_WS_PROXY_PORT, default 20131)
* that accepts WebSocket upgrade requests and tunnels them to the matching
* embedded service.
*
* URL pattern: WebSocket connect to host:20131/[name]/[...path]
* [name] → resolved via the services registry (e.g. "9router")
* [...path] → forwarded verbatim to the upstream WS endpoint
*
* Security:
* - Target host is always 127.0.0.1 and port comes from the registry — never
* from user input. No SSRF risk.
* - Server binds to 127.0.0.1 only (loopback) unless EMBED_WS_PROXY_HOST
* is set explicitly. The OmniRoute LOCAL_ONLY rule is enforced at the
* dashboard layer; the proxy itself is loopback-only as defence-in-depth.
* - Max 50 concurrent connections per service. The 51st request receives 503.
* - Idle timeout: 5 minutes without any data → both sockets are destroyed.
* - Hop-by-hop headers cookie/authorization/origin are stripped from the
* upgrade request; Authorization is replaced by Bearer <serviceApiKey>.
*/
import http from "node:http";
import net from "node:net";
import type { IncomingMessage } from "node:http";
import { getSupervisor } from "./registry";
import { getOrCreateApiKey } from "./apiKey";
const DEFAULT_HOST = "127.0.0.1";
const DEFAULT_PORT = 20131;
/** Maximum concurrent WebSocket bridges per service name. */
const MAX_CONNECTIONS_PER_SERVICE = 50;
/** Idle timeout in milliseconds (5 minutes). */
const IDLE_TIMEOUT_MS = 5 * 60 * 1000;
/** Headers to strip from the client upgrade request (case-insensitive). */
const STRIPPED_HEADERS = new Set(["cookie", "authorization", "origin"]);
declare global {
var __omnirouteEmbedWsStarted: boolean | undefined;
}
/**
* Tracks active client sockets per service name.
* Used to enforce MAX_CONNECTIONS_PER_SERVICE.
*/
const activeConnections = new Map<string, Set<net.Socket>>();
/** Regex that matches /<name>/<path> or /<name> */
const PATH_RE = /^\/([^/?#]+)(\/.*)?$/;
function writeError(socket: net.Socket, status: number, message: string): void {
if (!socket.writable || socket.destroyed) return;
const body = Buffer.from(JSON.stringify({ error: message }), "utf8");
const lines = [
`HTTP/1.1 ${status} ${http.STATUS_CODES[status] ?? "Error"}`,
"Connection: close",
"Content-Type: application/json; charset=utf-8",
`Content-Length: ${body.length}`,
"",
"",
];
socket.write(lines.join("\r\n"));
socket.end(body);
}
/**
* Register a client socket into the per-service active set.
* Returns false (and writes 503) if the limit is already reached.
*/
function registerConnection(name: string, socket: net.Socket): boolean {
let set = activeConnections.get(name);
if (!set) {
set = new Set();
activeConnections.set(name, set);
}
if (set.size >= MAX_CONNECTIONS_PER_SERVICE) {
writeError(
socket,
503,
`Service '${name}' connection limit reached (max ${MAX_CONNECTIONS_PER_SERVICE})`
);
return false;
}
set.add(socket);
return true;
}
/** Remove a client socket from the per-service active set. */
function unregisterConnection(name: string, socket: net.Socket): void {
activeConnections.get(name)?.delete(socket);
}
/**
* Build the filtered header list for the upstream upgrade request.
* Strips cookie, authorization, and origin; rewrites host; injects Bearer token.
*/
function buildUpstreamHeaders(rawHeaders: string[], port: number, apiKey: string): string[] {
const lines: string[] = [];
let wroteHost = false;
for (let i = 0; i < rawHeaders.length; i += 2) {
const headerName = rawHeaders[i];
const headerValue = rawHeaders[i + 1] ?? "";
const lower = headerName.toLowerCase();
if (lower === "host") {
lines.push(`Host: 127.0.0.1:${port}`);
wroteHost = true;
} else if (!STRIPPED_HEADERS.has(lower)) {
lines.push(`${headerName}: ${headerValue}`);
}
// cookie / authorization / origin are intentionally dropped here
}
if (!wroteHost) lines.push(`Host: 127.0.0.1:${port}`);
// Always inject the service API key regardless of what the client sent
lines.push(`Authorization: Bearer ${apiKey}`);
return lines;
}
async function proxyUpgrade(req: IncomingMessage, socket: net.Socket, head: Buffer): Promise<void> {
const rawUrl = req.url ?? "/";
const match = PATH_RE.exec(rawUrl.split("?")[0]);
if (!match) {
writeError(socket, 400, "Invalid path");
return;
}
const [, name, rest = "/"] = match;
const supervisor = getSupervisor(name);
if (!supervisor) {
writeError(socket, 404, `Service '${name}' not found`);
return;
}
const { state, port } = supervisor.getStatus();
if (state !== "running") {
writeError(socket, 503, `Service '${name}' is not running (state: ${state})`);
return;
}
// Enforce max concurrent connections per service
if (!registerConnection(name, socket)) {
// writeError already written inside registerConnection
return;
}
// Clean up connection tracking when the client socket closes
socket.once("close", () => unregisterConnection(name, socket));
socket.once("error", () => unregisterConnection(name, socket));
// Fetch the service API key (never cached — key may rotate)
const apiKey = await getOrCreateApiKey(name);
// Rebuild the search string if present
const search = rawUrl.includes("?") ? rawUrl.slice(rawUrl.indexOf("?")) : "";
const upstreamPath = `${rest}${search}`;
const upstream = net.connect(port, "127.0.0.1");
// Idle timeout: reset on any data in either direction; destroy both on expiry
let idleTimer: ReturnType<typeof setTimeout> | null = null;
function resetIdleTimer(): void {
if (idleTimer !== null) clearTimeout(idleTimer);
idleTimer = setTimeout(() => {
socket.destroy();
upstream.destroy();
}, IDLE_TIMEOUT_MS);
}
function clearIdleTimer(): void {
if (idleTimer !== null) {
clearTimeout(idleTimer);
idleTimer = null;
}
}
upstream.once("connect", () => {
const requestLine = `${req.method ?? "GET"} ${upstreamPath} HTTP/${req.httpVersion}`;
const headerLines = buildUpstreamHeaders(req.rawHeaders, port, apiKey);
upstream.write(`${requestLine}\r\n${headerLines.join("\r\n")}\r\n\r\n`);
if (head.length > 0) upstream.write(head);
// Start idle timer once the tunnel is live
resetIdleTimer();
socket.on("data", resetIdleTimer);
upstream.on("data", resetIdleTimer);
socket.pipe(upstream);
upstream.pipe(socket);
});
upstream.on("error", () => {
clearIdleTimer();
writeError(socket, 502, "Upstream connection error");
});
socket.on("error", () => {
clearIdleTimer();
upstream.destroy();
});
socket.on("close", () => {
clearIdleTimer();
upstream.destroy();
});
upstream.on("close", () => {
clearIdleTimer();
socket.destroy();
});
}
/**
* Resolve the bind host for the embed WS proxy.
*
* `EMBED_WS_PROXY_HOST` takes precedence, but we fall back to `LIVE_WS_HOST`
* so a single env var exposes BOTH WebSocket sockets (the Live dashboard server
* on :20129 and this embed proxy on :20131) in Docker / behind a reverse proxy
* or tunnel. Without this fallback the embed proxy stayed bound to 127.0.0.1
* even when the operator set `LIVE_WS_HOST=0.0.0.0`, so the Live view was
* permanently "disconnected" in headless deployments (#5110). Defaults to
* loopback for safety when neither is set.
*/
export function resolveEmbedWsHost(): string {
return process.env.EMBED_WS_PROXY_HOST ?? process.env.LIVE_WS_HOST ?? DEFAULT_HOST;
}
/**
* Start the embed WebSocket proxy server.
* Idempotent — safe to call multiple times.
*/
export function initEmbedWsProxy(): void {
if (globalThis.__omnirouteEmbedWsStarted) return;
const host = resolveEmbedWsHost();
const port = parseInt(process.env.EMBED_WS_PROXY_PORT ?? String(DEFAULT_PORT), 10);
const server = http.createServer((_req, res) => {
res.writeHead(426, "Upgrade Required", { "content-type": "application/json" });
res.end(JSON.stringify({ error: "upgrade_required", message: "Use WebSocket." }));
});
server.on("upgrade", (req: IncomingMessage, socket: net.Socket, head: Buffer) => {
proxyUpgrade(req, socket, head).catch((err: unknown) => {
const msg = err instanceof Error ? err.message : String(err);
writeError(socket, 500, `Internal proxy error: ${msg}`);
});
});
server.on("error", (err: NodeJS.ErrnoException) => {
if (err.code === "EADDRINUSE") {
console.warn(`[EmbedWsProxy] Port ${port} is already in use — embed WS proxy disabled.`);
return;
}
console.warn("[EmbedWsProxy] Failed to start:", err.message);
});
server.listen(port, host, () => {
globalThis.__omnirouteEmbedWsStarted = true;
console.log(`[EmbedWsProxy] Listening on ${host}:${port}`);
});
}
// ─── Exported for testing ────────────────────────────────────────────────────
export {
activeConnections,
registerConnection,
unregisterConnection,
buildUpstreamHeaders,
MAX_CONNECTIONS_PER_SERVICE,
IDLE_TIMEOUT_MS,
};
+79
View File
@@ -0,0 +1,79 @@
/** Periodic health-check poller for embedded services. */
import type { HealthState } from "./types";
const HEALTH_FETCH_TIMEOUT_MS = 5_000;
const FAILURE_THRESHOLD = 3;
export type OnHealthChange = (health: HealthState) => void;
export class HealthChecker {
private timer: ReturnType<typeof setInterval> | null = null;
private consecutiveFailures = 0;
private currentHealth: HealthState = "unknown";
private active = false;
constructor(
private readonly healthUrl: () => string,
private readonly intervalMs: number,
private readonly onChange: OnHealthChange
) {}
start(): void {
if (this.active) return;
this.active = true;
this.consecutiveFailures = 0;
this.currentHealth = "unknown";
this.timer = setInterval(() => void this.poll(), this.intervalMs);
// Don't keep the process alive solely for the health poller.
(this.timer as { unref?: () => void })?.unref?.();
}
stop(): void {
this.active = false;
if (this.timer) {
clearInterval(this.timer);
this.timer = null;
}
this.setHealth("unknown");
}
getHealth(): HealthState {
return this.currentHealth;
}
private async poll(): Promise<void> {
if (!this.active) return;
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), HEALTH_FETCH_TIMEOUT_MS);
try {
const res = await fetch(this.healthUrl(), { signal: controller.signal });
clearTimeout(timeout);
if (res.ok) {
this.consecutiveFailures = 0;
this.setHealth("healthy");
} else {
this.recordFailure();
}
} catch {
clearTimeout(timeout);
this.recordFailure();
}
}
private recordFailure(): void {
this.consecutiveFailures++;
if (this.consecutiveFailures >= FAILURE_THRESHOLD) {
this.setHealth("unhealthy");
}
}
private setHealth(health: HealthState): void {
if (health === this.currentHealth) return;
this.currentHealth = health;
this.onChange(health);
}
}
+185
View File
@@ -0,0 +1,185 @@
/**
* HTML rewriter for the embedded-service reverse proxy.
*
* Rewrites an HTML document so that absolute-path URLs point through the
* OmniRoute proxy prefix instead of directly to the embedded service's port.
*
* What it does:
* - Inserts `<base href="${publicPrefix}/">` as the first child of `<head>`.
* - Rewrites path-absolute URLs (starting with `/` but NOT `//`) in selected
* attributes: <a href>, <link href>, <script src>, <img src/srcset>,
* <form action>, <iframe src>, <source src/srcset>.
*
* What it does NOT do (known v1 limitations):
* - CSS `url(...)` rewriting (too complex, CSS parser not included).
* - JS `window.location` rewriting (client-side navigation may break).
* - Multi-URL `srcset` values (comma-separated) — skipped when a comma
* is detected.
*/
import { parse, serialize } from "parse5";
import type { DefaultTreeAdapterMap } from "parse5";
type Document = DefaultTreeAdapterMap["document"];
type Element = DefaultTreeAdapterMap["element"];
type Node = DefaultTreeAdapterMap["node"];
type Attr = { name: string; value: string; namespace?: string; prefix?: string };
// Matches /foo but not //foo, http://, https://, mailto:, javascript:, #, etc.
const ABS_PATH_RE = /^\/(?!\/)/;
// Schemes that should NOT be rewritten — leave them as-is.
const SKIP_SCHEMES = ["http:", "https:", "mailto:", "javascript:", "data:", "blob:", "ftp:"];
const REWRITABLE_ATTRS: Record<string, string[]> = {
a: ["href"],
link: ["href"],
script: ["src"],
img: ["src", "srcset"],
form: ["action"],
iframe: ["src"],
source: ["src", "srcset"],
};
/**
* Rewrite an HTML string so all path-absolute URLs are prefixed with
* `publicPrefix`, and a `<base href>` is injected into `<head>`.
*
* @param html Raw HTML from the upstream service.
* @param publicPrefix The proxy prefix path (e.g. "/dashboard/providers/services/9router/embed").
* Trailing slash is stripped internally.
*/
export function rewriteHtml(html: string, publicPrefix: string): string {
const prefix = publicPrefix.endsWith("/") ? publicPrefix.slice(0, -1) : publicPrefix;
const doc = parse(html) as Document;
const headEl = findOrCreateHead(doc);
injectBase(headEl, `${prefix}/`);
visitNode(doc, prefix);
return serialize(doc);
}
// ─── private helpers ─────────────────────────────────────────────────────────
/**
* Find the <head> element in the document. parse5 always produces a full tree
* (html → head + body) even for partial HTML, so this should always succeed.
* Falls back to creating a minimal <head> if somehow absent.
*/
function findOrCreateHead(doc: Document): Element {
for (const child of doc.childNodes) {
if (isElement(child) && child.tagName === "html") {
for (const htmlChild of child.childNodes) {
if (isElement(htmlChild) && htmlChild.tagName === "head") {
return htmlChild;
}
}
// <head> missing inside <html> — create one and prepend
const head = makeElement("head");
child.childNodes.unshift(head);
(head as Element & { parentNode: Node }).parentNode = child;
return head;
}
}
// Completely bare fragment — unlikely from parse5, but be safe.
// Find/create <html> too.
const htmlEl = makeElement("html");
const head = makeElement("head");
head.childNodes = [];
(head as Element & { parentNode: Node }).parentNode = htmlEl;
htmlEl.childNodes = [head as Node];
(htmlEl as Element & { parentNode: Node }).parentNode = doc as unknown as Node;
doc.childNodes.push(htmlEl as unknown as Node);
return head;
}
/**
* Prepend `<base href="...">` to `<head>`, but only if one doesn't already
* exist (avoid double-inject on re-proxied pages).
*/
function injectBase(head: Element, baseHref: string): void {
// If a <base> already exists, update its href and exit.
for (const child of head.childNodes) {
if (isElement(child) && child.tagName === "base") {
const hrefAttr = child.attrs.find((a) => a.name === "href");
if (hrefAttr) {
hrefAttr.value = baseHref;
} else {
child.attrs.push({ name: "href", value: baseHref });
}
return;
}
}
// No <base> found — create one and prepend.
const baseEl = makeElement("base");
baseEl.attrs = [{ name: "href", value: baseHref }];
baseEl.childNodes = [];
(baseEl as Element & { parentNode: Node }).parentNode = head;
head.childNodes.unshift(baseEl as unknown as Node);
}
/** Recursively walk the parse5 tree and rewrite matching attrs. */
function visitNode(node: Node, prefix: string): void {
if (isElement(node)) {
const tag = node.tagName.toLowerCase();
const rewritable = REWRITABLE_ATTRS[tag];
if (rewritable) {
for (const attr of node.attrs as Attr[]) {
if (rewritable.includes(attr.name)) {
attr.value = rewriteUrl(attr.value, prefix);
}
}
}
}
const children = (node as { childNodes?: Node[] }).childNodes;
if (children) {
for (const child of children) {
visitNode(child, prefix);
}
}
}
/**
* Rewrite a single URL value:
* - Path-absolute URLs starting with `/` (but not `//`) → prefix + url
* - All other values (relative, external, mailto:, srcset with commas) → unchanged
*/
function rewriteUrl(value: string, prefix: string): string {
const trimmed = value.trim();
if (!trimmed) return value;
// Skip multi-URL srcset (contains comma + space pattern) — too complex for v1
if (trimmed.includes(",")) return value;
// Skip known schemes
for (const scheme of SKIP_SCHEMES) {
if (trimmed.toLowerCase().startsWith(scheme)) return value;
}
// Rewrite path-absolute URLs only
if (ABS_PATH_RE.test(trimmed)) {
return `${prefix}${trimmed}`;
}
return value;
}
function isElement(node: Node): node is Element {
return (node as Element).attrs !== undefined && (node as Element).tagName !== undefined;
}
function makeElement(tag: string): Element {
return {
nodeName: tag,
tagName: tag,
attrs: [],
namespaceURI: "http://www.w3.org/1999/xhtml",
childNodes: [],
parentNode: null as unknown as Node,
} as unknown as Element;
}
+145
View File
@@ -0,0 +1,145 @@
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 BIFROST_PACKAGE = "@maximhq/bifrost";
export const BIFROST_DEFAULT_PORT = 8080;
export const BIFROST_INSTALL_DIR = path.join(DATA_DIR, "services", "bifrost");
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
let latestVersionCache: { value: string; expiresAt: number } | null = null;
const VERSION_CACHE_TTL_MS = 3_600_000;
function getInstalledPkgPath(): string {
return path.join(BIFROST_INSTALL_DIR, "node_modules", "@maximhq", "bifrost", "package.json");
}
function getBinPath(): string {
return path.join(BIFROST_INSTALL_DIR, "node_modules", "@maximhq", "bifrost", "bin.js");
}
function getInstalledVersionSync(): 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 getInstalledVersion(): Promise<string | null> {
return getInstalledVersionSync();
}
export async function getLatestVersion(): Promise<string | null> {
if (latestVersionCache && latestVersionCache.expiresAt > Date.now()) {
return latestVersionCache.value;
}
try {
const { stdout } = await runNpm(["view", BIFROST_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;
}
}
export async function install(version = "latest"): Promise<InstallResult> {
const startMs = Date.now();
// Create install dir + minimal package.json (idempotent)
fs.mkdirSync(BIFROST_INSTALL_DIR, { recursive: true });
const hostPkgPath = path.join(BIFROST_INSTALL_DIR, "package.json");
if (!fs.existsSync(hostPkgPath)) {
fs.writeFileSync(
hostPkgPath,
JSON.stringify(
{ name: "omniroute-bifrost-host", version: "0.0.0", private: true, dependencies: {} },
null,
2
),
"utf8"
);
}
await runNpm(
["install", `${BIFROST_PACKAGE}@${version}`, "--omit=dev", "--no-audit", "--no-fund"],
// `--prefix` via `prefix` (→ npm_config_prefix env) so paths with spaces survive Windows shell
{ cwd: BIFROST_INSTALL_DIR, prefix: BIFROST_INSTALL_DIR }
);
const installedVersion = await getInstalledVersion();
if (!installedVersion) {
throw new InstallError(
"Could not read installed version from node_modules/@maximhq/bifrost/package.json",
"Bifrost instalado mas versão não pôde ser lida.",
500
);
}
await upsertVersionManagerTool({
tool: "bifrost",
installedVersion,
binaryPath: getBinPath(),
status: "stopped",
port: BIFROST_DEFAULT_PORT,
});
// Invalidate cache so next getLatestVersion() re-fetches
latestVersionCache = null;
return {
installedVersion,
installPath: BIFROST_INSTALL_DIR,
durationMs: Date.now() - startMs,
};
}
export async function update(): Promise<InstallResult> {
return install("latest");
}
export function resolveSpawnArgs(port: number): SpawnArgs {
const binPath = getBinPath();
// Pin transport version to the installed npm version for reproducibility (spec §2b)
const transportVersion = getInstalledVersionSync() ?? "latest";
return {
command: process.execPath,
args: [
binPath,
"-port",
String(port),
"-host",
"127.0.0.1",
"-app-dir",
BIFROST_INSTALL_DIR,
"-log-level",
"warn",
],
env: {
...process.env,
BIFROST_TRANSPORT_VERSION: transportVersion,
},
cwd: BIFROST_INSTALL_DIR,
};
}
+116
View File
@@ -0,0 +1,116 @@
/**
* CLIProxyAPI installer adapter for the ServiceSupervisor framework.
*
* Wraps the existing binaryManager/releaseChecker infra (GitHub release download,
* checksum verify, symlink) and exposes the same interface as ninerouter.ts so that
* bootstrap.ts can treat both services uniformly.
*
* Binary location: $DATA_DIR/bin/cliproxyapi (symlink → versioned dir)
* Config: $DATA_DIR/services/cliproxy/config.yaml
* DB row: version_manager WHERE tool = 'cliproxy'
*/
import fs from "node:fs";
import path from "node:path";
import { DATA_DIR } from "@/lib/db/core";
import { upsertVersionManagerTool } from "@/lib/db/versionManager";
import { getLatestRelease } from "@/lib/versionManager/releaseChecker.ts";
import { installVersion, getCurrentBinaryPath } from "@/lib/versionManager/binaryManager.ts";
export const CLIPROXY_DEFAULT_PORT = 8317;
const BIN_DIR = path.join(DATA_DIR, "bin");
const CONFIG_DIR = path.join(DATA_DIR, "services", "cliproxy");
let latestVersionCache: { value: string; expiresAt: number } | null = null;
const VERSION_CACHE_TTL_MS = 3_600_000;
export interface InstallResult {
installedVersion: string;
installPath: string;
durationMs: number;
}
export interface SpawnArgs {
command: string;
args: string[];
env: NodeJS.ProcessEnv;
cwd: string;
}
/** Reads the installed version from the symlink target directory name. */
export async function getInstalledVersion(): Promise<string | null> {
const binaryPath = await getCurrentBinaryPath(DATA_DIR);
if (!binaryPath) return null;
const dirName = path.basename(path.dirname(binaryPath));
const m = dirName.match(/^cliproxyapi-(.+)$/);
return m ? m[1] : null;
}
export async function getLatestVersion(): Promise<string | null> {
if (latestVersionCache && latestVersionCache.expiresAt > Date.now()) {
return latestVersionCache.value;
}
try {
const release = await getLatestRelease();
latestVersionCache = { value: release.version, expiresAt: Date.now() + VERSION_CACHE_TTL_MS };
return release.version;
} catch {
return null;
}
}
/**
* Download and install CLIProxyAPI from GitHub releases.
* Upserts the version_manager row with tool='cliproxy'.
*/
export async function install(version = "latest"): Promise<InstallResult> {
const startMs = Date.now();
const targetVersion = version === "latest" ? (await getLatestRelease()).version : version;
const binaryPath = await installVersion(targetVersion, DATA_DIR);
await upsertVersionManagerTool({
tool: "cliproxy",
installedVersion: targetVersion,
binaryPath,
status: "stopped",
port: CLIPROXY_DEFAULT_PORT,
});
latestVersionCache = null;
return {
installedVersion: targetVersion,
installPath: BIN_DIR,
durationMs: Date.now() - startMs,
};
}
export async function update(): Promise<InstallResult> {
return install("latest");
}
/**
* Build spawn args for ServiceSupervisor.start().
*
* Writes config.yaml synchronously — safe because the destination is a
* DATA_DIR-controlled path (no user input) and the content is static.
* ServiceSupervisor calls spawnArgs() synchronously just before spawn(), so
* async file I/O is not available here.
*/
export function resolveSpawnArgs(port: number): SpawnArgs {
const symlinkPath = path.join(BIN_DIR, "cliproxyapi");
fs.mkdirSync(CONFIG_DIR, { recursive: true });
const configPath = path.join(CONFIG_DIR, "config.yaml");
fs.writeFileSync(configPath, `port: ${port}\nhost: 127.0.0.1\nlog_level: warn\n`, "utf8");
return {
command: symlinkPath,
args: ["-c", configPath],
env: { ...process.env },
cwd: CONFIG_DIR,
};
}
+178
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,
};
}
@@ -0,0 +1,17 @@
/**
* Stub for `src/lib/services/installers/ninerouter.ts` activated by
* `OMNIROUTE_BUILD_PROFILE=minimal`. The 9router install / spawn helpers are
* removed from the built bundle. See SECURITY.md and
* docs/security/SOCKET_DEV_FINDINGS.md.
*/
import { featureDisabledError } from "@/lib/build-profile/featureDisabled";
const FEATURE = "9router-installer";
export async function installNinerouter(): Promise<never> {
throw featureDisabledError(FEATURE);
}
export function resolveSpawnArgs(_apiKey: string, _port: number): never {
throw featureDisabledError(FEATURE);
}
+157
View File
@@ -0,0 +1,157 @@
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 NINEROUTER_PACKAGE = "9router";
export const NINEROUTER_INSTALL_DIR = path.join(DATA_DIR, "services", "9router");
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
let latestVersionCache: { value: string; expiresAt: number } | null = null;
const VERSION_CACHE_TTL_MS = 3_600_000;
function getServerPath(): string {
return path.join(NINEROUTER_INSTALL_DIR, "node_modules", "9router", "app", "server.js");
}
function getInstalledPkgPath(): string {
return path.join(NINEROUTER_INSTALL_DIR, "node_modules", "9router", "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", NINEROUTER_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;
}
}
export async function install(version = "latest"): Promise<InstallResult> {
const startMs = Date.now();
// Create install dir + minimal package.json (idempotent)
fs.mkdirSync(NINEROUTER_INSTALL_DIR, { recursive: true });
const hostPkgPath = path.join(NINEROUTER_INSTALL_DIR, "package.json");
if (!fs.existsSync(hostPkgPath)) {
fs.writeFileSync(
hostPkgPath,
JSON.stringify(
{ name: "omniroute-9router-host", version: "0.0.0", private: true, dependencies: {} },
null,
2
),
"utf8"
);
}
await runNpm(
["install", `${NINEROUTER_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: NINEROUTER_INSTALL_DIR, prefix: NINEROUTER_INSTALL_DIR }
);
const installedVersion = await getInstalledVersion();
if (!installedVersion) {
throw new InstallError(
"Could not read installed version from node_modules/9router/package.json",
"9router instalado mas versão não pôde ser lida.",
500
);
}
await upsertVersionManagerTool({
tool: "9router",
installedVersion,
binaryPath: getServerPath(),
status: "stopped",
});
// Invalidate cache so next getLatestVersion() re-fetches
latestVersionCache = null;
return {
installedVersion,
installPath: NINEROUTER_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(NINEROUTER_INSTALL_DIR, "node_modules");
if (fs.existsSync(nmDir)) {
fs.rmSync(nmDir, { recursive: true, force: true });
}
await upsertVersionManagerTool({
tool: "9router",
status: "not_installed",
installedVersion: null,
binaryPath: null,
});
}
export function resolveSpawnArgs(apiKey: string, port: number): SpawnArgs {
const serverPath = getServerPath();
// Next.js standalone dir ships its own node_modules — include them in NODE_PATH
// so native addons (better-sqlite3) resolve correctly.
const standaloneDir = path.dirname(serverPath);
const bundledNm = path.join(standaloneDir, "node_modules");
const existingNodePath = process.env.NODE_PATH ?? "";
const nodePath = [bundledNm, existingNodePath].filter(Boolean).join(path.delimiter);
return {
command: process.execPath,
args: ["--max-old-space-size=6144", serverPath],
env: {
...process.env,
PORT: String(port),
HOSTNAME: "127.0.0.1",
// API_KEY_SECRET is the HMAC secret 9router uses to generate/validate API keys.
// OmniRoute generates this secret and can derive valid keys from it.
API_KEY_SECRET: apiKey,
DATA_DIR: path.join(NINEROUTER_INSTALL_DIR, "data"),
NODE_ENV: "production",
NODE_PATH: nodePath,
// Embedded mode: skip MITM proxy and cloud tunnel startup
DISABLE_MITM: "true",
DISABLE_TUNNEL: "true",
},
cwd: standaloneDir,
};
}
+167
View File
@@ -0,0 +1,167 @@
/**
* Installer utilities — safe execFile wrapper for npm operations.
*
* Hard rule #13: never string-interpolate runtime values into shell commands.
* All npm invocations use execFile() with an explicit args array, never exec().
*/
import { execFile } from "node:child_process";
import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error";
const DEFAULT_TIMEOUT_MS = 300_000; // 5 min — npm install can be slow
export interface NpmRunResult {
stdout: string;
stderr: string;
}
export class InstallError extends Error {
constructor(
message: string,
public readonly friendly: string,
public readonly httpStatus: number = 500
) {
super(message);
this.name = "InstallError";
}
}
/** Classify raw npm/OS errors into user-friendly messages. */
function classifyError(
err: NodeJS.ErrnoException & { stdout?: string; stderr?: string }
): InstallError {
const raw = sanitizeErrorMessage(err.message);
const stderr = err.stderr ?? "";
if (err.code === "EACCES") {
return new InstallError(
raw,
"Sem permissão para instalar. Verifique as permissões da pasta de dados.",
403
);
}
if (err.code === "ENOENT" && err.message.includes("npm")) {
return new InstallError(
raw,
"Node.js/npm não está disponível no PATH. Instale Node ≥22.22.2.",
500
);
}
if (err.code === "ENOSPC" || stderr.includes("ENOSPC")) {
return new InstallError(raw, "Espaço em disco insuficiente.", 507);
}
if (
err.signal === "SIGTERM" ||
err.code === "ETIMEDOUT" ||
(err as Error & { killed?: boolean }).killed
) {
return new InstallError(raw, "Instalação demorou demais. Tente novamente.", 504);
}
if (
stderr.includes("ENOTFOUND") ||
stderr.includes("network") ||
stderr.includes("ECONNREFUSED") ||
stderr.includes("ERR_INVALID_URL")
) {
return new InstallError(
raw,
"Falha de rede ao instalar. Verifique a conexão e tente novamente.",
503
);
}
return new InstallError(raw, `Falha na instalação: ${raw}`, 500);
}
/**
* Validates a user-supplied service version (npm dist-tag or semver). Constrained
* to letters, digits and `. _ + -`, with a leading alphanumeric, so the value can
* never carry shell metacharacters once `runNpm` runs under a shell on Windows
* (see `buildNpmExecOptions`). Accepts `latest`, `next`, `1.2.3`, `1.2.3-beta.1`,
* `1.2.3+build.5`; rejects `latest && calc`, `$(id)`, spaces, leading `-`, etc.
*/
export const SERVICE_VERSION_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._+-]*$/;
export interface NpmExecOptions {
cwd?: string;
timeout: number;
env: NodeJS.ProcessEnv;
maxBuffer: number;
shell?: boolean;
}
/**
* Builds the `execFile` options for {@link runNpm}.
*
* On Windows, npm is `npm.cmd` (a batch wrapper). Node 24 refuses to `execFile`
* a `.cmd` without a shell (nodejs/node#52554 — manifests as `spawn EINVAL`, see
* issue #5379), so we enable `shell` on win32 only.
*
* Enabling the shell means the shell — not `execFile` — splits the command line,
* so NO runtime value may be interpolated into argv (Hard Rule #13). The install
* prefix (a DATA_DIR path that can legitimately contain spaces, e.g.
* `C:\Users\John Doe\.omniroute\…`) is therefore exported as the
* `npm_config_prefix` environment variable — npm's documented env form of
* `--prefix` — never as an argv entry. With the prefix moved to the environment
* and the version constrained by {@link SERVICE_VERSION_PATTERN}, every remaining
* argv entry is a static, metacharacter-free flag.
*/
export function buildNpmExecOptions(
platform: NodeJS.Platform,
options: { cwd?: string; timeoutMs: number; prefix?: string }
): NpmExecOptions {
const env: NodeJS.ProcessEnv = { ...process.env };
if (options.prefix) {
env.npm_config_prefix = options.prefix;
}
const execOptions: NpmExecOptions = {
cwd: options.cwd,
timeout: options.timeoutMs,
env,
maxBuffer: 10 * 1024 * 1024, // 10 MB for npm output
};
if (platform === "win32") {
execOptions.shell = true;
}
return execOptions;
}
/**
* Runs npm with the given args array. Never uses shell interpolation: argv holds
* only static flags, and any install prefix is passed via `options.prefix`
* (exported as `npm_config_prefix`), not as an argv path. See
* {@link buildNpmExecOptions} for the Windows/Node-24 shell handling.
*/
export function runNpm(
args: string[],
options: { cwd?: string; timeoutMs?: number; prefix?: string } = {}
): Promise<NpmRunResult> {
const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
// On Windows, npm is npm.cmd; on Unix it's npm.
const npmBin = process.platform === "win32" ? "npm.cmd" : "npm";
return new Promise((resolve, reject) => {
execFile(
npmBin,
args,
buildNpmExecOptions(process.platform, {
cwd: options.cwd,
timeoutMs,
prefix: options.prefix,
}),
(err, stdout, stderr) => {
if (err) {
const classified = classifyError(
Object.assign(err, { stdout, stderr }) as NodeJS.ErrnoException & {
stdout: string;
stderr: string;
}
);
reject(classified);
} else {
resolve({ stdout, stderr });
}
}
);
});
}
+105
View File
@@ -0,0 +1,105 @@
/**
* Model sync job for embedded services.
*
* Fetches /v1/models from a running service instance and persists the list
* in the key_value table so the model catalog can expose them without calling
* the service on every request. Also stamps last_sync_at in version_manager.
*
* Schedule: runs once when the service reaches "running" state, then every
* SYNC_INTERVAL_MS. Stops automatically when the service stops.
*/
import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error";
import { getServiceModels, saveServiceModels, type ServiceModel } from "@/lib/db/serviceModels";
import { updateVersionManagerTool } from "@/lib/db/versionManager";
const SYNC_INTERVAL_MS = 5 * 60 * 1000; // 5 minutes
const FETCH_TIMEOUT_MS = 10_000;
const activeTimers = new Map<string, ReturnType<typeof setInterval>>();
/**
* Fetch the model list from a running service and persist it.
* Returns the number of models synced, or -1 on failure.
*/
export async function syncServiceModels(
tool: string,
baseUrl: string,
apiKey: string
): Promise<number> {
try {
const res = await fetch(`${baseUrl}/v1/models`, {
headers: { Authorization: `Bearer ${apiKey}` },
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
});
if (!res.ok) {
console.warn(`[ModelSync:${tool}] /v1/models returned HTTP ${res.status}`);
return -1;
}
const json = await res.json();
const data: unknown = Array.isArray(json?.data) ? json.data : Array.isArray(json) ? json : [];
const models = (data as unknown[])
.filter(
(m): m is ServiceModel =>
typeof m === "object" &&
m !== null &&
typeof (m as Record<string, unknown>).id === "string"
)
.map((m) => ({
...m,
// Prefix the model id with the tool name so downstream routing and the
// executor strip it back off (e.g. "9router/cx/gpt-5-mini").
id: m.id.startsWith(`${tool}/`) ? m.id : `${tool}/${m.id}`,
}));
saveServiceModels(tool, models);
await updateVersionManagerTool(tool, { lastSyncAt: new Date().toISOString() });
console.log(`[ModelSync:${tool}] synced ${models.length} model(s)`);
return models.length;
} catch (err) {
const msg = sanitizeErrorMessage(err instanceof Error ? err.message : String(err));
console.warn(`[ModelSync:${tool}] fetch failed: ${msg}`);
return -1;
}
}
/**
* Start periodic model sync for a service.
* Idempotent — calling again while already running is a no-op.
*/
export function scheduleServiceModelSync(
tool: string,
baseUrl: string,
apiKey: string,
intervalMs = SYNC_INTERVAL_MS
): void {
if (activeTimers.has(tool)) return;
// First sync immediately (non-blocking)
syncServiceModels(tool, baseUrl, apiKey).catch(() => {});
const timer = setInterval(() => {
syncServiceModels(tool, baseUrl, apiKey).catch(() => {});
}, intervalMs);
timer.unref?.();
activeTimers.set(tool, timer);
console.log(`[ModelSync:${tool}] scheduler started (interval ${intervalMs / 1000}s)`);
}
/**
* Stop the periodic sync for a service.
*/
export function stopServiceModelSync(tool: string): void {
const timer = activeTimers.get(tool);
if (!timer) return;
clearInterval(timer);
activeTimers.delete(tool);
console.log(`[ModelSync:${tool}] scheduler stopped`);
}
/** Re-export read path so consumers don't need to import two modules. */
export { getServiceModels };
+104
View File
@@ -0,0 +1,104 @@
/**
* Pre-spawn port/health probe for embedded services (#6205).
*
* Before the supervisor spawns a service child, it probes the service's port
* and health endpoint. This turns two failure modes into graceful outcomes
* instead of a raw `EADDRINUSE` stack trace crashing the child:
*
* - A healthy prior instance is already answering → ADOPT it (skip spawn).
* - The port is held but nothing healthy answers → surface a CLEAR error.
* - The port is free → SPAWN normally.
*
* `decidePreSpawn` is a pure function so the decision logic is unit-testable
* without binding a real port or spawning a process.
*/
import { createConnection } from "node:net";
/** Result of probing the service before spawning. */
export interface PreSpawnProbe {
/** true when the service's healthUrl answered with a 2xx. */
healthy: boolean;
/** true when something is already listening on the service's port. */
portInUse: boolean;
}
/** Outcome of the pre-spawn decision. */
export type PreSpawnDecision =
| { action: "spawn" }
| { action: "adopt" }
| { action: "error"; message: string };
const HEALTH_PROBE_TIMEOUT_MS = 3_000;
const PORT_PROBE_TIMEOUT_MS = 1_000;
/**
* Decide what to do before spawning, given a probe of the port + health.
*
* Pure — no I/O — so it can be exhaustively unit-tested.
*/
export function decidePreSpawn(probe: PreSpawnProbe, port: number): PreSpawnDecision {
// A healthy instance is already serving on the port — adopt it rather than
// spawn a duplicate that would immediately die with EADDRINUSE.
if (probe.healthy) {
return { action: "adopt" };
}
// Port is held but nothing healthy answers: an orphaned or unrelated process
// is squatting on it. Surface a clear, actionable error instead of letting
// the child crash with a raw EADDRINUSE stack.
if (probe.portInUse) {
return {
action: "error",
message:
`Port ${port} is already in use but the service did not respond to a health ` +
`check. An orphaned previous instance or an unrelated process may be holding ` +
`the port — stop it (or free the port) and try starting the service again.`,
};
}
// Port is free and nothing is answering — safe to spawn.
return { action: "spawn" };
}
/** TCP connect check: resolves true when something accepts a connection. */
function isPortInUse(port: number, timeoutMs: number): Promise<boolean> {
return new Promise<boolean>((resolve) => {
const socket = createConnection({ host: "127.0.0.1", port }, () => {
socket.destroy();
resolve(true);
});
socket.setTimeout(timeoutMs);
socket.on("error", () => resolve(false));
socket.on("timeout", () => {
socket.destroy();
resolve(false);
});
});
}
/** Health check: resolves true when healthUrl answers with a 2xx. */
async function isHealthy(healthUrl: string, timeoutMs: number): Promise<boolean> {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), timeoutMs);
try {
const res = await fetch(healthUrl, { signal: controller.signal });
return res.ok;
} catch {
return false;
} finally {
clearTimeout(timeout);
}
}
/**
* Probe the service's port + health endpoint before spawning.
*
* @param healthUrl The service's health endpoint URL.
* @param port The service's registered port.
*/
export async function probeBeforeSpawn(healthUrl: string, port: number): Promise<PreSpawnProbe> {
const [healthy, portInUse] = await Promise.all([
isHealthy(healthUrl, HEALTH_PROBE_TIMEOUT_MS),
isPortInUse(port, PORT_PROBE_TIMEOUT_MS),
]);
return { healthy, portInUse };
}
+42
View File
@@ -0,0 +1,42 @@
/** Singleton registry of ServiceSupervisor instances. */
import type { ServiceSupervisor } from "./ServiceSupervisor";
const supervisors = new Map<string, ServiceSupervisor>();
export function registerSupervisor(supervisor: ServiceSupervisor): void {
supervisors.set(supervisor.getStatus().tool, supervisor);
}
export function getSupervisor(tool: string): ServiceSupervisor | null {
return supervisors.get(tool) ?? null;
}
/** Remove a supervisor by tool name. Intended for use in tests. */
export function unregisterSupervisor(tool: string): void {
supervisors.delete(tool);
}
async function stopAll(): Promise<void> {
// Drive every supervisor stop to completion before the process exits so the
// DB status writes inside ServiceSupervisor.stop() flush. Otherwise the
// event loop drains immediately on SIGTERM and rows are stuck in "running"
// or "starting" until the next boot.
await Promise.allSettled(Array.from(supervisors.values()).map((supervisor) => supervisor.stop()));
}
function handleShutdownSignal(signal: NodeJS.Signals): void {
stopAll()
.catch(() => {
/* never throw out of a signal handler */
})
.finally(() => {
// Re-raise the signal with the default disposition so the process exit
// status reflects the original signal (128 + signal number) rather than
// a synthetic process.exit() code.
process.kill(process.pid, signal);
});
}
process.once("SIGINT", () => handleShutdownSignal("SIGINT"));
process.once("SIGTERM", () => handleShutdownSignal("SIGTERM"));
+180
View File
@@ -0,0 +1,180 @@
/**
* Generic reverse-proxy helper for embedded service UIs.
*
* Forwards HTTP traffic to a locally-running embedded service so its web UI
* can be iframed inside the OmniRoute dashboard without CORS issues.
*
* Security:
* - Target URL is constructed from the service's registered port — never
* from user input — eliminating SSRF risk.
* - Routes that use this helper must be classified LOCAL_ONLY in routeGuard.ts;
* loopback enforcement blocks all non-loopback access before any handler runs.
* - Client cookies and Authorization headers are stripped before forwarding
* to prevent credential leakage between OmniRoute and the embedded service.
* - Upstream set-cookie, x-frame-options, content-security-policy, and
* cross-origin-* headers are stripped from responses so the iframe is not
* broken by the embedded service's own security policies.
* - HTML responses are rewritten (parse5) so path-absolute URLs work through
* the proxy prefix.
*/
import { getSupervisor } from "@/lib/services/registry";
import { getOrCreateApiKey } from "@/lib/services/apiKey";
import { rewriteHtml } from "@/lib/services/htmlRewriter";
import { toUpstreamPath } from "@/lib/services/embedPath";
import { createErrorResponse } from "@/lib/api/errorResponse";
import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error";
// ─── constants ────────────────────────────────────────────────────────────────
/** Standard hop-by-hop headers that must never be forwarded. */
export const HOP_BY_HOP = new Set([
"connection",
"keep-alive",
"proxy-authenticate",
"proxy-authorization",
"te",
"trailers",
"transfer-encoding",
"upgrade",
"host",
]);
/**
* Request headers stripped before forwarding to the embedded service.
* Prevents OmniRoute session cookies and Authorization from leaking upstream.
*/
export const STRIPPED_REQUEST_HEADERS = new Set(["cookie", "authorization"]);
/**
* Response headers stripped before returning to the browser.
*
* - set-cookie: prevents the embedded service from setting cookies in the
* OmniRoute origin, which would conflict with session management.
* - content-security-policy / content-security-policy-report-only: the
* embedded service's CSP is irrelevant inside the OmniRoute iframe.
* - x-frame-options: would block the iframe entirely if set to DENY/SAMEORIGIN
* by the embedded service (OmniRoute controls framing via its own CSP).
* - cross-origin-*: remove COOP/COEP/CORP that could break the framed page.
*/
export const STRIPPED_RESPONSE_HEADERS = new Set([
"set-cookie",
"content-security-policy",
"content-security-policy-report-only",
"x-frame-options",
"cross-origin-embedder-policy",
"cross-origin-opener-policy",
"cross-origin-resource-policy",
]);
export const PROXY_TIMEOUT_MS = 30_000;
// ─── public interface ─────────────────────────────────────────────────────────
export interface ReverseProxyConfig {
/** Service name used for supervisor lookup and API key retrieval. */
name: string;
/** Proxy prefix path (e.g. "/dashboard/providers/services/9router/embed"). */
publicPrefix: string;
/** When true, HTML responses are rewritten via htmlRewriter. Default: true. */
htmlRewrite?: boolean;
// Future: stripCookies, injectHeaders, etc.
}
/**
* Proxy a request through to the locally-running embedded service identified
* by `config.name`.
*
* Resolves the upstream port from the registered supervisor, builds the
* upstream URL from `pathSegments`, forwards safe headers, injects the
* service's own API key, and returns the upstream response with
* security-conflicting headers stripped.
*
* @param request The incoming Next.js route Request.
* @param pathSegments The `[[...path]]` catch-all segments, e.g. `["ui", "index.html"]` or `[]`.
* @param config Proxy configuration (service name + public prefix).
*/
export async function proxyRequest(
request: Request,
pathSegments: string[],
config: ReverseProxyConfig
): Promise<Response> {
const { name, publicPrefix, htmlRewrite = true } = config;
const supervisor = getSupervisor(name);
if (!supervisor) {
return createErrorResponse({ status: 404, message: `Service '${name}' not found.` });
}
const { state, port } = supervisor.getStatus();
if (state !== "running") {
return createErrorResponse({
status: 503,
message: `Service '${name}' is not running (state: ${state}).`,
});
}
const incomingUrl = new URL(request.url);
const upstreamPath = toUpstreamPath(pathSegments);
const upstreamUrl = `http://127.0.0.1:${port}${upstreamPath}${incomingUrl.search}`;
// Build forwarded headers: strip hop-by-hop AND sensitive client headers.
const forwardHeaders = new Headers();
for (const [k, v] of request.headers.entries()) {
const lower = k.toLowerCase();
if (!HOP_BY_HOP.has(lower) && !STRIPPED_REQUEST_HEADERS.has(lower)) {
forwardHeaders.set(k, v);
}
}
forwardHeaders.set("host", `127.0.0.1:${port}`);
// Inject the embedded service's own API key so upstream can authenticate.
const apiKey = await getOrCreateApiKey(name);
forwardHeaders.set("authorization", `Bearer ${apiKey}`);
const hasBody = request.method !== "GET" && request.method !== "HEAD";
try {
const upstream = await fetch(upstreamUrl, {
method: request.method,
headers: forwardHeaders,
body: hasBody ? request.body : undefined,
// @ts-expect-error -- duplex is required by the Fetch spec for streaming
// request bodies but is not yet in the TS DOM lib (Node.js 18+ supports it).
duplex: hasBody ? "half" : undefined,
signal: AbortSignal.timeout(PROXY_TIMEOUT_MS),
});
// Build response headers: strip hop-by-hop and security-conflicting headers.
const responseHeaders = new Headers();
for (const [k, v] of upstream.headers.entries()) {
const lower = k.toLowerCase();
if (!HOP_BY_HOP.has(lower) && !STRIPPED_RESPONSE_HEADERS.has(lower)) {
responseHeaders.set(k, v);
}
}
// Prevent Next.js from caching the proxied response.
responseHeaders.set("cache-control", "no-store");
const contentType = upstream.headers.get("content-type") ?? "";
// HTML responses: buffer, rewrite links, return as string.
if (htmlRewrite && contentType.startsWith("text/html")) {
const html = await upstream.text();
const rewritten = rewriteHtml(html, publicPrefix);
return new Response(rewritten, {
status: upstream.status,
headers: responseHeaders,
});
}
// All other content types: stream through unchanged.
return new Response(upstream.body, {
status: upstream.status,
headers: responseHeaders,
});
} catch (err) {
const msg = sanitizeErrorMessage(err instanceof Error ? err.message : String(err));
return createErrorResponse({ status: 502, message: `Proxy error: ${msg}` });
}
}
+91
View File
@@ -0,0 +1,91 @@
/** In-memory ring buffer for service log lines with optional file flush. */
import fs from "node:fs";
import type { LogLine } from "./types";
const DEFAULT_MAX_BYTES = 5_242_880; // 5 MB
const FLUSH_DEBOUNCE_MS = 60_000; // 1 min
export class RingBuffer {
private entries: LogLine[] = [];
private currentBytes = 0;
private readonly maxBytes: number;
private subscribers: Set<(line: LogLine) => void> = new Set();
private flushTimer: ReturnType<typeof setTimeout> | null = null;
private flushPath: string | null = null;
private flushWarnedOnce = false;
constructor(maxBytes: number = DEFAULT_MAX_BYTES) {
this.maxBytes = maxBytes;
}
setFlushPath(filePath: string): void {
this.flushPath = filePath;
}
push(line: LogLine): void {
const entryBytes = Buffer.byteLength(line.line, "utf8") + 40;
while (this.entries.length > 0 && this.currentBytes + entryBytes > this.maxBytes) {
const evicted = this.entries.shift();
if (evicted) {
this.currentBytes -= Buffer.byteLength(evicted.line, "utf8") + 40;
if (this.currentBytes < 0) this.currentBytes = 0;
}
}
this.entries.push(line);
this.currentBytes += entryBytes;
for (const cb of this.subscribers) {
try {
cb(line);
} catch {
// subscriber errors must not crash the supervisor
}
}
this.scheduleFlush();
}
snapshot(): LogLine[] {
return this.entries.slice();
}
subscribe(cb: (line: LogLine) => void): () => void {
this.subscribers.add(cb);
return () => this.subscribers.delete(cb);
}
private scheduleFlush(): void {
if (!this.flushPath) return;
if (this.flushTimer) return;
this.flushTimer = setTimeout(() => {
this.flushTimer = null;
this.flushToDisk();
}, FLUSH_DEBOUNCE_MS);
}
private flushToDisk(): void {
if (!this.flushPath) return;
try {
const content = this.entries.map((e) => `${e.ts} [${e.stream}] ${e.line}`).join("\n");
fs.writeFileSync(this.flushPath, content, "utf8");
} catch (err: unknown) {
if (!this.flushWarnedOnce) {
this.flushWarnedOnce = true;
const msg = err instanceof Error ? err.message : String(err);
// Non-fatal — log once and stop trying
console.warn(`[RingBuffer] flush to ${this.flushPath} failed: ${msg}`);
}
}
}
dispose(): void {
if (this.flushTimer) {
clearTimeout(this.flushTimer);
this.flushTimer = null;
}
this.subscribers.clear();
}
}
+50
View File
@@ -0,0 +1,50 @@
/** Shared types for the embedded-services layer (ServiceSupervisor, installers, registry). */
export interface ServiceConfig {
tool: string;
port: number;
spawnArgs: () => {
command: string;
args: string[];
env: NodeJS.ProcessEnv;
cwd: string;
};
healthUrl: () => string;
healthIntervalMs: number;
stopTimeoutMs: number;
logsBufferBytes: number;
/**
* When true (#6205), the supervisor probes the port + health endpoint before
* spawning: a healthy prior instance is adopted, a held-but-unhealthy port
* yields a clear error instead of a raw EADDRINUSE stack. Opt-in so the
* default spawn path (and existing supervisor tests) stays byte-identical —
* enabled for services that bind a fixed port (e.g. 9router).
*/
probeBeforeSpawn?: boolean;
}
export type ServiceState =
| "not_installed"
| "stopped"
| "starting"
| "running"
| "stopping"
| "error";
export type HealthState = "healthy" | "unhealthy" | "unknown";
export interface ServiceStatus {
tool: string;
state: ServiceState;
pid: number | null;
port: number;
health: HealthState;
startedAt: string | null;
lastError: string | null;
}
export interface LogLine {
ts: number;
stream: "stdout" | "stderr";
line: string;
}