Skip to content
Closed
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
2 changes: 2 additions & 0 deletions companion/src/index.ts
100755 → 100644
Original file line number Diff line number Diff line change
Expand Up @@ -83,8 +83,10 @@ const machineName = (): string => cachedName || "OpenMausBot";
async function refreshMachineName(): Promise<void> {
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 } };
Expand Down
82 changes: 82 additions & 0 deletions scripts/windows/uninstall-service.ps1
Original file line number Diff line number Diff line change
@@ -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 <name> 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
}
178 changes: 178 additions & 0 deletions server/lan-access.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, string> = {};
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<void>((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);
});
});
79 changes: 64 additions & 15 deletions server/tts/index.ts
Original file line number Diff line number Diff line change
@@ -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<elevenlabs.Voice[]> {
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);
Expand All @@ -53,10 +91,21 @@ export async function listVoices(cfg: AppConfig): Promise<elevenlabs.Voice[]> {
/** 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);
}

Expand Down
Loading