Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 49 additions & 0 deletions src/app/api/headroom/start/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { getSettings } from "@/lib/db/settings";
import {
DEFAULT_HEADROOM_URL,
isLoopbackHeadroomUrl,
parsePortFromHeadroomUrl,
} from "@/lib/headroom/detect";
import { startHeadroomProxy, HeadroomError } from "@/lib/headroom/process";
import { createErrorResponse } from "@/lib/api/errorResponse";
import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error";

export const dynamic = "force-dynamic";

export async function POST(): Promise<Response> {
try {
const settings = await getSettings();
const url =
typeof settings.headroomUrl === "string" && settings.headroomUrl
? settings.headroomUrl
: DEFAULT_HEADROOM_URL;

// Pair commit 50ed79fe: refuse to spawn against a non-loopback URL.
// External Docker sidecars must be started outside OmniRoute.
if (!isLoopbackHeadroomUrl(url)) {
return createErrorResponse({
status: 400,
message: "External Headroom proxies must be started outside OmniRoute",
type: "invalid_request",
});
}

const port = parsePortFromHeadroomUrl(url) ?? 8787;
const result = await startHeadroomProxy({ port });
return Response.json({ success: true, ...result });
} catch (error) {
if (error instanceof HeadroomError && error.code === "NOT_INSTALLED") {
return createErrorResponse({
status: 400,
message: error.message,
type: "invalid_request",
details: { code: error.code },
});
}
return createErrorResponse({
status: 500,
message: sanitizeErrorMessage(error),
type: "server_error",
});
}
}
26 changes: 26 additions & 0 deletions src/app/api/headroom/status/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { getSettings } from "@/lib/db/settings";
import { DEFAULT_HEADROOM_URL, getHeadroomStatus } from "@/lib/headroom/detect";
import { getManagedPid } from "@/lib/headroom/process";
import { createErrorResponse } from "@/lib/api/errorResponse";
import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error";

export const dynamic = "force-dynamic";

export async function GET(): Promise<Response> {
try {
const settings = await getSettings();
const url =
typeof settings.headroomUrl === "string" && settings.headroomUrl
? settings.headroomUrl
: DEFAULT_HEADROOM_URL;
const status = await getHeadroomStatus(url);
const managedPid = getManagedPid();
return Response.json({ ...status, url, managedPid });
} catch (error) {
return createErrorResponse({
status: 500,
message: sanitizeErrorMessage(error),
type: "server_error",
});
}
}
19 changes: 19 additions & 0 deletions src/app/api/headroom/stop/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { stopHeadroomProxy } from "@/lib/headroom/process";
import { createErrorResponse } from "@/lib/api/errorResponse";
import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error";

export const dynamic = "force-dynamic";

export async function POST(): Promise<Response> {
try {
const result = stopHeadroomProxy();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Since stopHeadroomProxy should be asynchronous to prevent port collision race conditions, we need to await its result here.

Suggested change
const result = stopHeadroomProxy();
const result = await stopHeadroomProxy();

const status = result.stopped ? 200 : 409;
return Response.json(result, { status });
} catch (error) {
return createErrorResponse({
status: 500,
message: sanitizeErrorMessage(error),
type: "server_error",
});
}
}
167 changes: 167 additions & 0 deletions src/lib/headroom/detect.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
/**
* Headroom proxy detection helpers.
*
* Ported from upstream 9router (decolua/9router @ b55cf36d + 50ed79fe).
* Original authors: decolua, Carmelo Campos (@carmelogunsroses), Cursor.
*
* Headroom is the optional third-party token-saver proxy (headroom-ai
* Python CLI). OmniRoute can either:
* 1. Manage a local proxy lifecycle (loopback URL → start/stop from the
* dashboard via `process.ts`).
* 2. Use an external Docker sidecar proxy (non-loopback HEADROOM_URL).
* In this case we only probe /health; start/stop are NOT exposed.
*
* All functions here are pure / side-effect-free where possible so they can
* be unit-tested without spawning processes.
*/

import { execFileSync } from "node:child_process";

const EXTRA_BINS = ["/usr/local/bin", "/opt/homebrew/bin", "/usr/bin", "/bin"];
const EXTENDED_PATH = [...EXTRA_BINS, process.env.PATH || ""].filter(Boolean).join(":");

const PYTHON_CANDIDATES = [
"python3.13",
"python3.12",
"python3.11",
"python3.10",
"python3",
"python",
];
const MIN_VERSION: readonly [number, number] = [3, 10];
const HEADROOM_HEALTH_TIMEOUT_MS = 1500;
const LOOPBACK_HOSTS = new Set(["localhost", "127.0.0.1", "::1", "[::1]", "0.0.0.0"]);

export const DEFAULT_HEADROOM_URL = process.env.HEADROOM_URL || "http://localhost:8787";

export interface HeadroomStatus {
installed: boolean;
path: string | null;
running: boolean;
python: string | null;
localUrl: boolean;
canStart: boolean;
}

export interface BuildHeadroomStatusInput {
url: string;
binaryPath: string | null;
python: string | null;
proxyReachable: boolean;
}

// ──────────────── Pure helpers (unit-testable) ────────────────

export function isLoopbackHeadroomUrl(url: string): boolean {
try {
const parsed = new URL(url);
return LOOPBACK_HOSTS.has(parsed.hostname);
} catch {
return false;
}
}

export function parsePortFromHeadroomUrl(url: string): number | null {
try {
const u = new URL(url);
if (!u.port) return null;
const p = parseInt(u.port, 10);
if (Number.isFinite(p) && p > 0 && p < 65536) return p;
} catch {
// fall through
}
return null;
}

/**
* Assemble the headroom status payload from already-resolved inputs.
* Kept pure so unit tests can exercise every branch without spawning
* processes or hitting the network.
*
* - `running` reflects /health reachability regardless of local CLI presence
* (pair commit 50ed79fe — Docker sidecar support).
* - `canStart` is only true for a loopback URL with the CLI installed; we
* never spawn against a non-loopback URL.
*/
export function buildHeadroomStatus(input: BuildHeadroomStatusInput): HeadroomStatus {
const installed = Boolean(input.binaryPath);
const localUrl = isLoopbackHeadroomUrl(input.url);
return {
installed,
path: input.binaryPath,
running: input.proxyReachable,
python: input.python,
localUrl,
canStart: installed && localUrl,
};
}

// ──────────────── Side-effecting probes ────────────────

export function findHeadroomBinary(): string | null {
try {
// execFileSync (no shell): "which" is invoked directly with "headroom" as an
// arg, so even if some upstream caller passes attacker-controlled input the
// shell metacharacters cannot reach a shell parser.
const out = execFileSync("which", ["headroom"], {
stdio: ["ignore", "pipe", "ignore"],
windowsHide: true,
env: { ...process.env, PATH: EXTENDED_PATH },
})
.toString()
.trim();
return out || null;
} catch {
return null;
}
}
Comment on lines +101 to +117

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The which command is not available by default on Windows environments. Since OmniRoute includes an Electron application that can run on Windows, using which directly will fail to detect the headroom binary even if it is installed. We should check the platform and use where on Windows.

export function findHeadroomBinary(): string | null {
  try {
    const cmd = process.platform === "win32" ? "where" : "which";
    const out = execFileSync(cmd, ["headroom"], {
      stdio: ["ignore", "pipe", "ignore"],
      windowsHide: true,
      env: { ...process.env, PATH: EXTENDED_PATH },
    })
      .toString()
      .trim();
    return out.split(/\r?\n/)[0] || null;
  } catch {
    return null;
  }
}


export function findPython310(): string | null {
for (const candidate of PYTHON_CANDIDATES) {
try {
// candidate is from a fixed allowlist (PYTHON_CANDIDATES) above — no
// user input — but use execFileSync anyway to remove the shell entirely.
const ver = execFileSync(candidate, ["--version"], {
stdio: ["ignore", "pipe", "ignore"],
windowsHide: true,
env: { ...process.env, PATH: EXTENDED_PATH },
})
.toString()
.trim();
const match = ver.match(/(\d+)\.(\d+)/);
if (!match) continue;
const major = parseInt(match[1], 10);
const minor = parseInt(match[2], 10);
if (major > MIN_VERSION[0] || (major === MIN_VERSION[0] && minor >= MIN_VERSION[1])) {
return candidate;
}
} catch {
// try next candidate
}
}
return null;
}

export async function probeProxyRunning(url: string): Promise<boolean> {
if (!url) return false;
const base = String(url).replace(/\/$/, "");
try {
const res = await fetch(`${base}/health`, {
signal: AbortSignal.timeout(HEADROOM_HEALTH_TIMEOUT_MS),
});
return res.ok;
} catch {
return false;
}
}

/**
* Aggregated dashboard status. Composes the three probes above with the
* pure builder so the I/O happens in one place.
*/
export async function getHeadroomStatus(url: string): Promise<HeadroomStatus> {
const binaryPath = findHeadroomBinary();
const python = findPython310();
const proxyReachable = await probeProxyRunning(url);
return buildHeadroomStatus({ url, binaryPath, python, proxyReachable });
}
Loading
Loading