diff --git a/companion/src/index.ts b/companion/src/index.ts old mode 100755 new mode 100644 index 06f513287..34e8fc0c2 --- a/companion/src/index.ts +++ b/companion/src/index.ts @@ -83,8 +83,10 @@ const machineName = (): string => cachedName || "OpenMausBot"; async function refreshMachineName(): Promise { if (cachedName) return; // an explicit override is not ours to second-guess try { + const token = process.env.OMB_AUTH_TOKEN?.trim(); const res = await fetch(`http://127.0.0.1:${HARNESS_PORT}/api/config`, { signal: AbortSignal.timeout(3000), + headers: token ? { authorization: `Bearer ${token}` } : undefined, }); if (!res.ok) return; const config = (await res.json()) as { profile?: { name?: string } }; diff --git a/scripts/windows/uninstall-service.ps1 b/scripts/windows/uninstall-service.ps1 new file mode 100644 index 000000000..49e7db0bd --- /dev/null +++ b/scripts/windows/uninstall-service.ps1 @@ -0,0 +1,82 @@ +# OpenMausBot Windows Service Uninstallation Script +# This script removes the OpenMausBot Windows service +# +# Prerequisites: +# - PowerShell 5.1+ (run as Administrator) +# +# Usage: +# .\uninstall-service.ps1 [-ServiceName "OpenMausBot"] + +param( + [string]$ServiceName = "OpenMausBot", + [switch]$Help +) + +if ($Help) { + Write-Host @" +OpenMausBot Windows Service Uninstallation + +Usage: .\uninstall-service.ps1 [OPTIONS] + +Options: + -ServiceName Service name to uninstall (default: OpenMausBot) + -Help Show this help message + +Example: + .\uninstall-service.ps1 +"@ + exit 0 +} + +# Check if running as Administrator +$isAdmin = ([Security.Principal.WindowsPrincipal] [Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator) +if (-not $isAdmin) { + Write-Error "This script must be run as Administrator. Right-click PowerShell and select 'Run as Administrator'." + exit 1 +} + +# Check if NSSM is available +$nssmExe = "$env:TEMP\nssm\nssm.exe" +if (-not (Test-Path $nssmExe)) { + Write-Error "NSSM not found at $nssmExe. The service may have been installed differently." + Write-Host "Trying to remove service using sc.exe..." -ForegroundColor Yellow + sc.exe delete $ServiceName + exit $LASTEXITCODE +} + +# Check if service exists +$service = Get-Service -Name $ServiceName -ErrorAction SilentlyContinue +if (-not $service) { + Write-Host "Service '$ServiceName' does not exist." -ForegroundColor Yellow + exit 0 +} + +Write-Host "Uninstalling service '$ServiceName'..." -ForegroundColor Cyan + +# Stop the service if running +if ($service.Status -eq 'Running') { + Write-Host "Stopping service..." -ForegroundColor Yellow + & $nssmExe stop $ServiceName + Start-Sleep -Seconds 2 +} + +# Remove the service +& $nssmExe remove $ServiceName confirm + +if ($LASTEXITCODE -eq 0) { + Write-Host "✓ Service uninstalled successfully!" -ForegroundColor Green + + # Ask about log cleanup + $logDir = "$env:PROGRAMDATA\OpenMausBot\logs" + if (Test-Path $logDir) { + Write-Host "`nLog files are still present at: $logDir" -ForegroundColor Cyan + $cleanup = Read-Host "Do you want to delete the log files? (Y/N)" + if ($cleanup -eq 'Y' -or $cleanup -eq 'y') { + Remove-Item $logDir -Recurse -Force + Write-Host "✓ Log files deleted" -ForegroundColor Green + } + } +} else { + Write-Error "Failed to uninstall service. Exit code: $LASTEXITCODE" + exit $LASTEXITCODE +} diff --git a/server/lan-access.test.ts b/server/lan-access.test.ts new file mode 100644 index 000000000..0a2ff6085 --- /dev/null +++ b/server/lan-access.test.ts @@ -0,0 +1,178 @@ +// LAN access authentication test: boots the harness server with OMB_AUTH_TOKEN +// and verifies that public API endpoints require authentication. +import { spawn, type ChildProcess } from "node:child_process"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; + +const SERVER_DIR = dirname(fileURLToPath(import.meta.url)); +const ROOT = join(SERVER_DIR, ".."); +const PORT = 18900 + Math.floor(Math.random() * 10_000); +const BASE = `http://127.0.0.1:${PORT}`; +const AUTH_TOKEN = "test-token-" + Math.random().toString(36); +const CORS_ORIGIN = "*"; + +let child: ChildProcess; +let home: string; +let stderr = ""; + +const api = async ( + method: string, + path: string, + opts?: { body?: unknown; auth?: boolean | string }, +): Promise<{ status: number; body: any }> => { + const headers: Record = {}; + if (opts?.body) headers["content-type"] = "application/json"; + if (opts?.auth !== false) { + const token = typeof opts?.auth === "string" ? opts.auth : AUTH_TOKEN; + headers["authorization"] = `Bearer ${token}`; + } + const res = await fetch(`${BASE}${path}`, { + method, + headers, + body: opts?.body ? JSON.stringify(opts.body) : undefined, + }); + return { status: res.status, body: await res.json().catch(() => null) }; +}; + +beforeAll(async () => { + home = mkdtempSync(join(tmpdir(), "omb-lan-test-")); + // minimal fleet + mkdirSync(join(home, ".openmausbot"), { recursive: true }); + writeFileSync( + join(home, ".openmausbot", "config.json"), + JSON.stringify({ instances: { ghost: { driver: "not-a-real-driver", displayName: "Ghost" } } }), + ); + + child = spawn(process.execPath, ["--experimental-strip-types", join(SERVER_DIR, "index.ts")], { + cwd: ROOT, + env: { + ...(process.env.PATH ? { PATH: process.env.PATH } : {}), + ...(process.env.SystemRoot ? { SystemRoot: process.env.SystemRoot } : {}), + HOME: home, + USERPROFILE: home, + OMB_PORT: String(PORT), + OMB_HOST: "127.0.0.1", + OMB_AUTH_TOKEN: AUTH_TOKEN, + OMB_CORS_ORIGIN: CORS_ORIGIN, + }, + stdio: ["ignore", "pipe", "pipe"], + }); + child.stderr!.on("data", (c) => (stderr += c)); + + const deadline = Date.now() + 20_000; + for (;;) { + try { + const res = await fetch(`${BASE}/api/health`); + if (res.ok) break; + } catch { + /* not up yet */ + } + if (Date.now() > deadline) throw new Error(`server never came up. stderr:\n${stderr}`); + if (child.exitCode !== null) throw new Error(`server exited ${child.exitCode}. stderr:\n${stderr}`); + await new Promise((r) => setTimeout(r, 150)); + } +}, 30_000); + +afterAll(async () => { + child?.kill("SIGTERM"); + await new Promise((resolve) => { + if (!child || child.exitCode !== null) return resolve(); + child.on("close", () => resolve()); + setTimeout(() => (child.kill("SIGKILL"), resolve()), 5_000).unref?.(); + }); + rmSync(home, { recursive: true, force: true }); +}); + +describe("LAN access authentication", () => { + it("allows /api/health without authentication", async () => { + const res = await api("GET", "/api/health", { auth: false }); + expect(res.status).toBe(200); + expect(res.body.app).toBe("openmausbot"); + }); + + it("blocks /api/instances without authentication", async () => { + const res = await api("GET", "/api/instances", { auth: false }); + expect(res.status).toBe(401); + expect(res.body.error).toContain("unauthorized"); + }); + + it("allows /api/instances with valid token", async () => { + const res = await api("GET", "/api/instances"); + expect(res.status).toBe(200); + expect(Array.isArray(res.body.instances)).toBe(true); + }); + + it("blocks /api/instances with invalid token", async () => { + const res = await api("GET", "/api/instances", { auth: "wrong-token" }); + expect(res.status).toBe(401); + expect(res.body.error).toContain("unauthorized"); + }); + + it("blocks /api/bots without authentication", async () => { + const res = await api("GET", "/api/bots", { auth: false }); + expect(res.status).toBe(401); + }); + + it("allows /api/bots with valid token", async () => { + const res = await api("GET", "/api/bots"); + expect(res.status).toBe(200); + expect(Array.isArray(res.body.bots)).toBe(true); + }); + + it("blocks POST /api/bots without authentication", async () => { + const res = await api("POST", "/api/bots", { body: {}, auth: false }); + expect(res.status).toBe(401); + }); + + it("allows POST /api/bots with valid token", async () => { + const res = await api("POST", "/api/bots", { body: {} }); + expect(res.status).toBe(201); + expect(res.body.bot).toBeDefined(); + }); + + it("blocks /api/config without authentication", async () => { + const res = await api("GET", "/api/config", { auth: false }); + expect(res.status).toBe(401); + }); + + it("allows /api/config with valid token", async () => { + const res = await api("GET", "/api/config"); + expect(res.status).toBe(200); + }); + + it("includes CORS headers in responses", async () => { + const res = await fetch(`${BASE}/api/health`); + expect(res.headers.get("access-control-allow-origin")).toBe(CORS_ORIGIN); + expect(res.headers.get("access-control-allow-methods")).toContain("PATCH"); + expect(res.headers.get("access-control-allow-headers")).toContain("Authorization"); + expect(res.headers.get("access-control-allow-headers")).toContain("Last-Event-ID"); + expect(res.headers.get("access-control-allow-credentials")).toBeNull(); + }); + + it("accepts the token as ?access_token= for EventSource", async () => { + const denied = await fetch(`${BASE}/api/events`); + expect(denied.status).toBe(401); + denied.body?.cancel(); + const ac = new AbortController(); + const allowed = await fetch(`${BASE}/api/events?access_token=${encodeURIComponent(AUTH_TOKEN)}`, { + signal: ac.signal, + }); + expect(allowed.status).toBe(200); + ac.abort(); + }); + + it("handles OPTIONS preflight requests", async () => { + const res = await fetch(`${BASE}/api/instances`, { + method: "OPTIONS", + headers: { + "access-control-request-method": "GET", + "access-control-request-headers": "authorization", + }, + }); + expect(res.status).toBe(204); + expect(res.headers.get("access-control-allow-origin")).toBe(CORS_ORIGIN); + }); +}); diff --git a/server/tts/index.ts b/server/tts/index.ts index 5a1298c8c..aacf42804 100644 --- a/server/tts/index.ts +++ b/server/tts/index.ts @@ -1,50 +1,88 @@ -// Voice, wired to config. The ElevenLabs API lives in elevenlabs.ts; this -// file is only the part that reads ~/.openmausbot/config.json and decides -// whether there is a voice at all. +// Voice, wired to config. Routes to either ElevenLabs or an OpenAI-compatible +// provider based on config.provider. Defaults to ElevenLabs for backward +// compatibility with existing configs that have no provider field. import type { AppConfig } from "../config.ts"; import * as elevenlabs from "./elevenlabs.ts"; +import * as openaiCompatible from "./openai-compatible.ts"; export class NoVoiceConfigured extends Error { // a plain field rather than a constructor parameter property: the harness // runs under `node --experimental-strip-types`, which is strip-ONLY, so a // parameter property is rejected at load time even though it typechecks - readonly reason: "key" | "voice"; - - constructor(reason: "key" | "voice") { - super( - reason === "key" - ? "Add an ElevenLabs key in App Settings to turn on voice." - : "Pick a voice in App Settings.", - ); + readonly reason: "key" | "voice" | "baseUrl"; + + constructor(reason: "key" | "voice" | "baseUrl", provider?: string) { + const providerName = provider === "openai-compatible" ? "OpenAI-compatible" : "ElevenLabs"; + let msg: string; + if (reason === "key") { + msg = provider === "openai-compatible" + ? "Add an API key in App Settings if your server requires one, or leave it empty for local servers." + : `Add an ${providerName} key in App Settings to turn on voice.`; + } else if (reason === "baseUrl") { + msg = "Add a base URL in App Settings for your OpenAI-compatible server."; + } else { + msg = "Pick a voice in App Settings."; + } + super(msg); this.reason = reason; } } +function getProvider(cfg: AppConfig): "elevenlabs" | "openai-compatible" { + // Default to elevenlabs for backward compatibility with existing configs + return cfg.tts?.provider ?? "elevenlabs"; +} + export function voiceConfigured(cfg: AppConfig): boolean { + const provider = getProvider(cfg); + if (provider === "openai-compatible") { + // OpenAI-compatible needs baseUrl and voice; key is optional + return Boolean(cfg.tts?.baseUrl && cfg.tts?.voice); + } + // ElevenLabs needs key and voice return Boolean(cfg.tts?.key && cfg.tts?.voice); } /** A per-bot voice is a complete choice too; it should not be blocked just * because the app-wide fallback has not been selected yet. */ export function voiceReady(cfg: AppConfig, voiceId?: string): boolean { + const provider = getProvider(cfg); + if (provider === "openai-compatible") { + return Boolean(cfg.tts?.baseUrl && (voiceId || cfg.tts?.voice)); + } return Boolean(cfg.tts?.key && (voiceId || cfg.tts?.voice)); } /** What the settings panel needs. Never includes the key — same write-only * rule as every other credential. */ export function describeVoice(cfg: AppConfig) { + const provider = getProvider(cfg); return { - configured: Boolean(cfg.tts?.key), + provider, + configured: provider === "openai-compatible" ? Boolean(cfg.tts?.baseUrl) : Boolean(cfg.tts?.key), ready: voiceConfigured(cfg), voice: cfg.tts?.voice ?? "", + baseUrl: cfg.tts?.baseUrl ?? "", }; } -export function verifyKey(key: string) { +export function verifyKey(key: string, provider: "elevenlabs" | "openai-compatible", baseUrl?: string) { + if (provider === "openai-compatible") { + if (!baseUrl) { + return Promise.resolve({ ok: false, message: "Base URL is required for OpenAI-compatible servers." } as const); + } + return openaiCompatible.verifyKey(baseUrl, key || undefined); + } return elevenlabs.verifyKey(key); } export async function listVoices(cfg: AppConfig): Promise { + const provider = getProvider(cfg); + if (provider === "openai-compatible") { + const baseUrl = cfg.tts?.baseUrl; + if (!baseUrl) return []; + return openaiCompatible.listVoices(baseUrl, cfg.tts?.key); + } const key = cfg.tts?.key; if (!key) return []; return elevenlabs.listVoices(key); @@ -53,10 +91,21 @@ export async function listVoices(cfg: AppConfig): Promise { /** Synthesize one utterance. Throws NoVoiceConfigured when there is nothing * to speak with, which the route turns into a 409 the client can explain. */ export function speak(cfg: AppConfig, text: string, voiceId?: string) { + const provider = getProvider(cfg); + + if (provider === "openai-compatible") { + const baseUrl = cfg.tts?.baseUrl; + if (!baseUrl) throw new NoVoiceConfigured("baseUrl", provider); + const voice = voiceId || cfg.tts?.voice; + if (!voice) throw new NoVoiceConfigured("voice", provider); + return openaiCompatible.synthesize(text, voice, baseUrl, cfg.tts?.key); + } + + // ElevenLabs: check key first, then voice (matches the original behavior) const key = cfg.tts?.key; - if (!key) throw new NoVoiceConfigured("key"); + if (!key) throw new NoVoiceConfigured("key", provider); const voice = voiceId || cfg.tts?.voice; - if (!voice) throw new NoVoiceConfigured("voice"); + if (!voice) throw new NoVoiceConfigured("voice", provider); return elevenlabs.synthesize(text, voice, key); } diff --git a/server/tts/openai-compatible.ts b/server/tts/openai-compatible.ts new file mode 100644 index 000000000..d02cc7e81 --- /dev/null +++ b/server/tts/openai-compatible.ts @@ -0,0 +1,127 @@ +// OpenAI-compatible TTS — supports Kokoro-FastAPI, LiteLLM, and any server +// that implements the OpenAI /v1/audio/speech API. The key is optional +// (local unauthenticated servers like Kokoro need none). + +export interface Voice { + id: string; + label: string; + description?: string; +} + +export interface Audio { + bytes: Uint8Array; + mime: string; +} + +export type VerifyResult = { ok: true } | { ok: false; message: string }; + +async function safeJson(res: Response): Promise { + try { + return await res.json(); + } catch { + return null; + } +} + +function message(status: number, what: string, body: any): string { + const theirs = + (typeof body?.error?.message === "string" && body.error.message.trim()) || + (typeof body?.detail === "string" && body.detail.trim()) || + (typeof body?.message === "string" && body.message.trim()) || + ""; + if (status === 401 || status === 403) { + return "The OpenAI-compatible server rejected that key. Check the key and try again."; + } + if (status === 429) return theirs || "The server is rate-limiting this request — wait a moment and try again."; + return theirs ? `${what} failed: ${theirs}` : `${what} failed (${status})`; +} + +function authHeader(key?: string): Record { + return key ? { authorization: `Bearer ${key}` } : {}; +} + +/** Verify the server is reachable and the key (if provided) works. We check + * against a cheap speech request rather than a models or voices endpoint that + * may not exist on all OpenAI-compatible servers. */ +export async function verifyKey(baseUrl: string, key?: string): Promise { + try { + const url = `${baseUrl.replace(/\/$/, "")}/audio/speech`; + const res = await fetch(url, { + method: "POST", + headers: { "content-type": "application/json", ...authHeader(key) }, + body: JSON.stringify({ model: "tts-1", input: "test", voice: "alloy" }), + signal: AbortSignal.timeout(20_000), + }); + // 200 = success, 400 = server understood but rejected params (still valid), others are real failures + if (res.ok || res.status === 400) return { ok: true }; + return { ok: false, message: message(res.status, "checking that server", await safeJson(res)) }; + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + if (msg.includes("aborted")) { + return { ok: false, message: "Couldn't reach the server — check the URL and your connection." }; + } + return { ok: false, message: `Couldn't reach the server: ${msg}` }; + } +} + +/** Try to list voices from the server. Many OpenAI-compatible servers don't + * expose a voices endpoint, so we return a fallback list of common Kokoro + * voices when the endpoint is missing or fails. */ +export async function listVoices(baseUrl: string, key?: string): Promise { + try { + const url = `${baseUrl.replace(/\/$/, "")}/voices`; + const res = await fetch(url, { + headers: authHeader(key), + signal: AbortSignal.timeout(20_000), + }); + if (!res.ok) return fallbackVoices(); + const body = await safeJson(res); + // Try both OpenAI shape ({ voices: [...] }) and direct array shape + const list = Array.isArray(body) ? body : body?.voices ?? []; + if (!Array.isArray(list) || list.length === 0) return fallbackVoices(); + return list + .map((v: any): Voice => ({ + id: String(v.voice_id ?? v.id ?? ""), + label: String(v.name ?? v.label ?? v.id ?? "Voice"), + description: v.description || v.labels?.description || undefined, + })) + .filter((v: Voice) => v.id); + } catch { + return fallbackVoices(); + } +} + +/** Fallback voices for servers that don't expose a /voices endpoint. Based on + * common Kokoro-FastAPI voices, but generic enough for any OpenAI-compatible + * server. Users can also type a custom voice ID. */ +function fallbackVoices(): Voice[] { + return [ + { id: "af_heart", label: "Heart (af)" }, + { id: "am_adam", label: "Adam (am)" }, + { id: "am_michael", label: "Michael (am)" }, + { id: "af_bella", label: "Bella (af)" }, + { id: "af_sarah", label: "Sarah (af)" }, + { id: "af_nicole", label: "Nicole (af)" }, + { id: "bf_emma", label: "Emma (bf)" }, + { id: "bf_isabella", label: "Isabella (bf)" }, + { id: "bm_george", label: "George (bm)" }, + { id: "bm_lewis", label: "Lewis (bm)" }, + ]; +} + +export async function synthesize( + text: string, + voiceId: string, + baseUrl: string, + key?: string, +): Promise