import fs from "node:fs";
import http from "node:http";
import os from "node:os";
import path from "node:path";
import crypto from "node:crypto";
import { URL } from "node:url";
import { getMemoriesDir, getSkillsDir, getProfile, loadConfig, ownerConfigExists, loadOwnerConfig, loadUsageSnapshot, } from "../config/index.js";
import { getCurrentVersion } from "../utils/version-check.js";
import { logger } from "../logger.js";
import { startTunnel } from "./tunnel.js";
import { getAgentState } from "../agent/state.js";
import { probeLocalTunnelCredential } from "../agent/runtime-credentials.js";
import { renderIndexHtml } from "./ui.js";
import { WebSocketServer } from "ws";
import { generateToken, validateToken } from "./token.js";
import { StreamManager, renderDesktopHtml, renderExpiredHtml } from "./rdp/index.js";
import { renderStatusHtml } from "./mini-app/status.js";
import { renderTasksHtml } from "./mini-app/tasks.js";
const OBS_AUTH_COOKIE = "visionclaw_obs_auth";
function formatUptimeShort(seconds) {
    const h = Math.floor(seconds / 3600);
    const m = Math.floor((seconds % 3600) / 60);
    if (h > 0)
        return `${h}h ${m}m`;
    return `${m}m`;
}
function escHtml(s) {
    return s
        .replaceAll("&", "&amp;")
        .replaceAll("<", "&lt;")
        .replaceAll(">", "&gt;")
        .replaceAll('"', "&quot;")
        .replaceAll("'", "&#39;");
}
function readMemoryFiles() {
    const dir = getMemoriesDir();
    if (!fs.existsSync(dir))
        return [];
    const results = [];
    function walk(dirPath, prefix) {
        let entries;
        try {
            entries = fs.readdirSync(dirPath, { withFileTypes: true });
        }
        catch {
            return;
        }
        for (const entry of entries) {
            const fullPath = path.join(dirPath, entry.name);
            const relPath = prefix ? `${prefix}/${entry.name}` : entry.name;
            if (entry.isDirectory()) {
                walk(fullPath, relPath);
            }
            else {
                try {
                    const stat = fs.statSync(fullPath);
                    const content = fs.readFileSync(fullPath, "utf-8");
                    results.push({
                        path: `/memories/${relPath}`,
                        content,
                        size: stat.size,
                        mtime: stat.mtime.toISOString(),
                    });
                }
                catch {
                    // skip unreadable files
                }
            }
        }
    }
    walk(dir, "");
    return results;
}
function readSkills() {
    const dir = getSkillsDir();
    if (!fs.existsSync(dir))
        return [];
    const results = [];
    let entries;
    try {
        entries = fs.readdirSync(dir, { withFileTypes: true });
    }
    catch {
        return [];
    }
    for (const entry of entries) {
        if (!entry.isDirectory())
            continue;
        const skillMd = path.join(dir, entry.name, "SKILL.md");
        if (!fs.existsSync(skillMd))
            continue;
        try {
            const content = fs.readFileSync(skillMd, "utf-8");
            const fm = /^---[\s\S]*?description:\s*(.+?)[\r\n][\s\S]*?---/.exec(content);
            const bare = /^description:\s*(.+)$/m.exec(content);
            const description = fm ? fm[1].trim() : bare ? bare[1].trim() : "";
            results.push({ name: entry.name, description, content });
        }
        catch {
            // skip unreadable
        }
    }
    return results.sort((a, b) => a.name.localeCompare(b.name));
}
function maskSecret(val) {
    if (!val)
        return "(not set)";
    if (val.length <= 8)
        return "****";
    return val.slice(0, 4) + "…" + val.slice(-4);
}
// TODO(obs-review, security): The OBS gate treats the owner's email as the
// sole shared secret. That is security-through-obscurity because:
//   1. Owner emails are often semi-public (git commits, agent Gmail, etc.).
//   2. There is no rate limiting or failed-attempt logging on `/obs/login`
//      POST, so an attacker who suspects the scheme can cheaply iterate
//      likely addresses.
//   3. The session cookie is a deterministic hash of the email — it can be
//      forged by anyone who learns the email, and cannot be rotated without
//      changing the owner's email.
// Follow-ups to consider: (a) random per-install auth token baked into
// config and HMAC-signed session cookies with an expiry; (b) rate limiting
// + failed-login logging on `/obs/login` POST.
function getObsOwnerEmail() {
    try {
        const ownerEmail = loadOwnerConfig().ownerEmail.trim().toLowerCase();
        return ownerEmail && ownerEmail.length > 0 ? ownerEmail : null;
    }
    catch {
        return null;
    }
}
function parseCookies(header) {
    const cookies = new Map();
    if (!header)
        return cookies;
    for (const part of header.split(";")) {
        const trimmed = part.trim();
        if (!trimmed)
            continue;
        const eq = trimmed.indexOf("=");
        if (eq <= 0)
            continue;
        const key = trimmed.slice(0, eq).trim();
        const value = trimmed.slice(eq + 1).trim();
        if (!key)
            continue;
        cookies.set(key, value);
    }
    return cookies;
}
function renderObsLoginHtml(errorMessage) {
    const errorBlock = errorMessage
        ? `<p style="margin:0 0 16px;color:#b91c1c;">${escHtml(errorMessage)}</p>`
        : "";
    return `<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <title>VisionClaw OBS Access</title>
    <style>
      :root {
        color-scheme: light;
        font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
        background: #f5f1e8;
        color: #1f2937;
      }
      body {
        margin: 0;
        min-height: 100vh;
        display: grid;
        place-items: center;
        background:
          radial-gradient(circle at top, rgba(196, 181, 253, 0.14), transparent 32rem),
          linear-gradient(180deg, #f7f4ed 0%, #efe8dc 100%);
      }
      main {
        width: min(28rem, calc(100vw - 2rem));
        padding: 2rem;
        background: rgba(255, 255, 255, 0.92);
        border: 1px solid rgba(15, 23, 42, 0.08);
        border-radius: 1rem;
        box-shadow: 0 18px 45px rgba(15, 23, 42, 0.10);
      }
      h1 {
        margin: 0 0 0.75rem;
        font-size: 1.5rem;
      }
      p {
        margin: 0 0 1rem;
        line-height: 1.5;
      }
      label {
        display: block;
        margin: 0 0 0.5rem;
        font-weight: 600;
      }
      input {
        width: 100%;
        box-sizing: border-box;
        padding: 0.8rem 0.9rem;
        border: 1px solid #cbd5e1;
        border-radius: 0.75rem;
        font: inherit;
      }
      button {
        margin-top: 1rem;
        width: 100%;
        border: 0;
        border-radius: 0.75rem;
        padding: 0.85rem 1rem;
        font: inherit;
        font-weight: 600;
        color: white;
        background: #0f766e;
        cursor: pointer;
      }
    </style>
  </head>
  <body>
    <main>
      <h1>VisionClaw OBS</h1>
      <p>Enter the owner's email to access the observability console.</p>
      ${errorBlock}
      <form method="POST" action="/obs/login">
        <label for="ownerEmail">Owner email</label>
        <input id="ownerEmail" name="ownerEmail" type="email" autocomplete="email" required>
        <button type="submit">Continue</button>
      </form>
    </main>
  </body>
</html>`;
}
function readConfigInfo() {
    try {
        const config = loadConfig();
        const owner = ownerConfigExists() ? loadOwnerConfig() : null;
        const generalUsage = loadUsageSnapshot("general");
        const codingUsage = loadUsageSnapshot("coding");
        return {
            version: getCurrentVersion(),
            profile: getProfile(),
            agentName: config.agentName,
            model: config.model,
            provider: config.provider,
            gmail: config.gmail,
            gmailPassword: maskSecret(config.gmailPassword),
            phoneNumber: config.phoneNumber ?? "(not set)",
            systemPassword: maskSecret(config.systemPassword),
            anthropicApiKey: maskSecret(config.anthropicApiKey),
            openaiApiKey: maskSecret(config.openaiApiKey),
            azureOpenAIEndpoint: config.azureOpenAIEndpoint ?? "(not set)",
            azureOpenAIApiKey: maskSecret(config.azureOpenAIApiKey),
            azureOpenAIApiVersion: config.azureOpenAIApiVersion ?? "(not set)",
            azureOpenAIDeployment: config.azureOpenAIDeployment ?? "(not set)",
            awsRegion: config.awsRegion ?? "(not set)",
            serpApiKey: maskSecret(config.serpApiKey),
            channels: {
                telegram: config.channels.telegram?.enabled
                    ? { enabled: true, botToken: maskSecret(config.channels.telegram.botToken) }
                    : { enabled: false },
                discord: config.channels.discord?.enabled
                    ? { enabled: true, botToken: maskSecret(config.channels.discord.botToken) }
                    : { enabled: false },
            },
            obs: config.obs,
            skillsDir: config.skillsDir ?? "(default)",
            owner: owner
                ? {
                    ownerName: owner.ownerName,
                    ownerEmail: owner.ownerEmail,
                    heartbeatIntervalMs: owner.heartbeatIntervalMs,
                    dualSessionEnabled: owner.dualSessionEnabled,
                    androidUseEnabled: owner.androidUseEnabled,
                    onboardingComplete: owner.onboardingComplete,
                    onboardedAt: owner.onboardedAt ?? "(n/a)",
                    telegramChatId: owner.telegramChatId ?? "(not set)",
                    allowedDiscordChannelIds: owner.allowedDiscordChannelIds,
                }
                : null,
            generalUsage: generalUsage ?? null,
            codingUsage: codingUsage ?? null,
            mcpServers: (() => {
                try {
                    return getAgentState().mcpServers ?? [];
                }
                catch {
                    return [];
                }
            })(),
        };
    }
    catch {
        return { error: "Failed to load config" };
    }
}
class RingBuffer {
    capacity;
    buf;
    head = 0;
    _count = 0;
    constructor(capacity) {
        this.capacity = capacity;
        this.buf = new Array(capacity);
    }
    push(item) {
        this.buf[this.head] = item;
        this.head = (this.head + 1) % this.capacity;
        if (this._count < this.capacity)
            this._count++;
    }
    toArray() {
        if (this._count === 0)
            return [];
        if (this._count < this.capacity) {
            return this.buf.slice(0, this._count);
        }
        // Buffer is full: oldest is at head, wrap around
        return [
            ...this.buf.slice(this.head),
            ...this.buf.slice(0, this.head),
        ];
    }
}
export function startObsServer(options = {}) {
    const host = options.host ?? "0.0.0.0";
    const port = options.port ?? 0;
    // Ring buffer for snapshot
    const buffer = new RingBuffer(options.bufferSize ?? 1000);
    const sseClients = new Set();
    const unsubscribe = logger.onLog((entry) => {
        // Exclude screenshot entries from the ring buffer — they're too large
        // for in-memory history and the /obs/snapshot endpoint.  They're still
        // streamed live to SSE clients.
        if (entry.category !== "screenshot") {
            buffer.push(entry);
        }
        if (sseClients.size > 0) {
            const payload = `data: ${JSON.stringify(entry)}\n\n`;
            for (const res of sseClients) {
                try {
                    res.write(payload);
                }
                catch {
                    // ignore
                }
            }
        }
    });
    // RDP stream manager (custom H.264 desktop streaming)
    const rdpStreamManager = new StreamManager();
    const server = http.createServer((req, res) => {
        try {
            handleRequest(req, res);
        }
        catch (err) {
            logger.warn(`Unhandled request error: ${err instanceof Error ? err.message : String(err)}`);
            if (!res.headersSent) {
                res.statusCode = 500;
                res.end("Internal Server Error");
            }
        }
    });
    function handleRequest(req, res) {
        try {
            const url = new URL(req.url ?? "/", `http://${req.headers.host ?? "localhost"}`);
            const pathname = url.pathname;
            const cookies = parseCookies(req.headers.cookie);
            const obsOwnerEmail = getObsOwnerEmail();
            const obsOwnerEmailHash = obsOwnerEmail
                ? crypto.createHash("sha256").update(obsOwnerEmail).digest("hex")
                : null;
            const hasObsSession = obsOwnerEmailHash !== null &&
                cookies.get(OBS_AUTH_COOKIE) === obsOwnerEmailHash;
            const requireObsAuth = () => {
                if (hasObsSession)
                    return true;
                res.statusCode = 401;
                res.setHeader("content-type", "text/html; charset=utf-8");
                res.setHeader("cache-control", "no-store");
                res.end(renderObsLoginHtml());
                return false;
            };
            const hasAgentBearerAuth = () => {
                const expected = loadConfig().visionclawServiceApiKey?.trim();
                const header = req.headers.authorization ?? "";
                const token = header.startsWith("Bearer ") ? header.slice("Bearer ".length).trim() : "";
                return !!expected && token === expected;
            };
            if (pathname === "/") {
                res.statusCode = 404;
                res.setHeader("content-type", "text/plain; charset=utf-8");
                res.setHeader("cache-control", "no-store");
                res.end("Not found");
                return;
            }
            // ── Realtime voice Mini App (mounted under `/realtime`) ──────────
            //
            // The realtime backend is a separate module (`src/realtime/server.ts`)
            // that handles its own routes (`/api/session`, `/ws`, ...). We strip
            // the `/realtime` prefix here so the backend sees bare paths, just
            // like the standalone listener it used to run.
            if (options.realtimeBackend && (pathname === "/realtime" || pathname.startsWith("/realtime/"))) {
                const rest = pathname.slice("/realtime".length) || "/";
                // Mutate req.url so the backend sees the stripped path. The OBS
                // server has already parsed `pathname`, so this only affects the
                // backend's view.
                req.url = rest + url.search;
                void options.realtimeBackend.handleRequest(req, res).catch((err) => {
                    logger.warn(`[obs] realtime backend error: ${err instanceof Error ? err.message : String(err)}`);
                    if (!res.headersSent) {
                        res.statusCode = 500;
                        res.setHeader("content-type", "text/plain; charset=utf-8");
                        res.end("Realtime backend error");
                    }
                });
                return;
            }
            if (pathname === "/obs/login" && req.method === "GET") {
                if (hasObsSession) {
                    res.statusCode = 302;
                    res.setHeader("location", "/obs");
                    res.end();
                    return;
                }
                res.statusCode = 200;
                res.setHeader("content-type", "text/html; charset=utf-8");
                res.setHeader("cache-control", "no-store");
                res.end(renderObsLoginHtml());
                return;
            }
            if (pathname === "/obs/login" && req.method === "POST") {
                const LOGIN_BODY_LIMIT = 4096;
                let loginBodySize = 0;
                let loginRejected = false;
                const chunks = [];
                req.on("data", (chunk) => {
                    if (loginRejected)
                        return;
                    loginBodySize += chunk.length;
                    if (loginBodySize > LOGIN_BODY_LIMIT) {
                        loginRejected = true;
                        res.statusCode = 413;
                        res.setHeader("content-type", "text/plain; charset=utf-8");
                        res.end("Request body too large");
                        req.socket.destroy();
                        return;
                    }
                    chunks.push(chunk);
                });
                req.on("end", () => {
                    if (loginRejected)
                        return;
                    const body = Buffer.concat(chunks).toString("utf8");
                    const form = new URLSearchParams(body);
                    const submittedEmail = form.get("ownerEmail")?.trim().toLowerCase() ?? "";
                    if (obsOwnerEmail && submittedEmail === obsOwnerEmail) {
                        res.statusCode = 302;
                        res.setHeader("location", "/obs");
                        res.setHeader("set-cookie", `${OBS_AUTH_COOKIE}=${obsOwnerEmailHash}; Path=/obs; HttpOnly; SameSite=Strict`);
                        res.end();
                        return;
                    }
                    res.statusCode = 401;
                    res.setHeader("content-type", "text/html; charset=utf-8");
                    res.setHeader("cache-control", "no-store");
                    res.end(renderObsLoginHtml("That email does not match the configured owner."));
                });
                return;
            }
            if (pathname === "/obs/logout" && req.method === "POST") {
                res.statusCode = 302;
                res.setHeader("location", "/obs/login");
                res.setHeader("set-cookie", `${OBS_AUTH_COOKIE}=; Path=/obs; HttpOnly; SameSite=Strict; Max-Age=0`);
                res.end();
                return;
            }
            if (pathname === "/obs" || pathname === "/obs/") {
                if (!requireObsAuth())
                    return;
                const html = renderIndexHtml();
                res.statusCode = 200;
                res.setHeader("content-type", "text/html; charset=utf-8");
                res.setHeader("cache-control", "no-store");
                res.end(html);
                return;
            }
            if (pathname === "/obs/health") {
                res.statusCode = 200;
                res.setHeader("content-type", "application/json; charset=utf-8");
                res.setHeader("cache-control", "no-store");
                res.end(JSON.stringify({ ok: true }));
                return;
            }
            if (pathname === "/obs/rdp-session" && req.method === "POST") {
                if (!hasAgentBearerAuth()) {
                    res.statusCode = 401;
                    res.setHeader("content-type", "application/json; charset=utf-8");
                    res.setHeader("cache-control", "no-store");
                    res.end(JSON.stringify({ error: "Unauthorized" }));
                    return;
                }
                const ttlParam = Number(url.searchParams.get("ttlMinutes") ?? "30");
                const ttlMinutes = Number.isFinite(ttlParam) ? Math.max(5, Math.min(480, Math.floor(ttlParam))) : 30;
                const reason = (url.searchParams.get("reason")?.trim() ?? "Requested from admin Remote Access").slice(0, 200);
                const { token, expiresAt } = generateToken(ttlMinutes, reason);
                const baseUrl = (getAgentState().consoleUrl ?? tunnelUrl ?? "").replace(/\/(obs|console)(\/.*)?$/, "").replace(/\/$/, "");
                if (!baseUrl) {
                    res.statusCode = 503;
                    res.setHeader("content-type", "application/json; charset=utf-8");
                    res.setHeader("cache-control", "no-store");
                    res.end(JSON.stringify({ error: "Tunnel URL is unavailable" }));
                    return;
                }
                res.statusCode = 200;
                res.setHeader("content-type", "application/json; charset=utf-8");
                res.setHeader("cache-control", "no-store");
                res.end(JSON.stringify({ url: `${baseUrl}/rdp/${token}`, expiresAt, ttlMinutes }));
                return;
            }
            if (pathname === "/obs/snapshot") {
                if (!requireObsAuth())
                    return;
                res.statusCode = 200;
                res.setHeader("content-type", "application/json; charset=utf-8");
                res.setHeader("cache-control", "no-store");
                res.end(JSON.stringify(buffer.toArray()));
                return;
            }
            if (pathname === "/obs/memory") {
                if (!requireObsAuth())
                    return;
                res.statusCode = 200;
                res.setHeader("content-type", "application/json; charset=utf-8");
                res.setHeader("cache-control", "no-store");
                res.end(JSON.stringify(readMemoryFiles()));
                return;
            }
            if (pathname === "/obs/skills") {
                if (!requireObsAuth())
                    return;
                res.statusCode = 200;
                res.setHeader("content-type", "application/json; charset=utf-8");
                res.setHeader("cache-control", "no-store");
                res.end(JSON.stringify(readSkills()));
                return;
            }
            if (pathname === "/obs/config") {
                if (!requireObsAuth())
                    return;
                res.statusCode = 200;
                res.setHeader("content-type", "application/json; charset=utf-8");
                res.setHeader("cache-control", "no-store");
                res.end(JSON.stringify(readConfigInfo()));
                return;
            }
            if (pathname === "/obs/todos") {
                if (!requireObsAuth())
                    return;
                res.statusCode = 200;
                res.setHeader("content-type", "application/json; charset=utf-8");
                res.setHeader("cache-control", "no-store");
                try {
                    const todos = getAgentState().activityTracker.getTodos();
                    res.end(JSON.stringify(todos));
                }
                catch {
                    res.end(JSON.stringify([]));
                }
                return;
            }
            if (pathname === "/obs/subagents") {
                if (!requireObsAuth())
                    return;
                res.statusCode = 200;
                res.setHeader("content-type", "application/json; charset=utf-8");
                res.setHeader("cache-control", "no-store");
                try {
                    const subagents = getAgentState().activityTracker.getSubagents();
                    res.end(JSON.stringify(subagents));
                }
                catch {
                    res.end(JSON.stringify([]));
                }
                return;
            }
            if (pathname === "/obs/memory-status") {
                // Snapshot of the CLI's auto-memory state: which directory the CLI
                // is using for this project and the most recent recall events.
                // Empty paths / empty recentRecalls is a valid normal response —
                // auto-memory may be disabled by config or simply not have fired
                // yet in this session.
                if (!requireObsAuth())
                    return;
                res.statusCode = 200;
                res.setHeader("content-type", "application/json; charset=utf-8");
                res.setHeader("cache-control", "no-store");
                try {
                    const status = getAgentState().activityTracker.getMemoryStatus();
                    res.end(JSON.stringify(status));
                }
                catch {
                    res.end(JSON.stringify({ paths: {}, recentRecalls: [] }));
                }
                return;
            }
            if (pathname === "/obs/tunnel-url") {
                if (!requireObsAuth())
                    return;
                res.statusCode = 200;
                res.setHeader("content-type", "application/json; charset=utf-8");
                res.setHeader("cache-control", "no-store");
                res.end(JSON.stringify({ url: tunnelUrl }));
                return;
            }
            if (pathname === "/obs/status") {
                if (!requireObsAuth())
                    return;
                res.setHeader("content-type", "text/plain; charset=utf-8");
                res.setHeader("cache-control", "no-store");
                const forceRefresh = url.searchParams.get("refresh") === "1";
                if (!options.statusReport) {
                    res.statusCode = 503;
                    res.end("Status report not available yet (agent still initializing).");
                    return;
                }
                options.statusReport(forceRefresh)
                    .then((report) => {
                    res.statusCode = 200;
                    res.end(report);
                })
                    .catch((err) => {
                    res.statusCode = 500;
                    res.end(`Failed to generate status report: ${err instanceof Error ? err.message : String(err)}`);
                });
                return;
            }
            if (pathname === "/obs/events") {
                if (!requireObsAuth())
                    return;
                res.statusCode = 200;
                res.setHeader("content-type", "text/event-stream; charset=utf-8");
                res.setHeader("cache-control", "no-store");
                res.setHeader("connection", "keep-alive");
                res.setHeader("x-content-type-options", "nosniff");
                res.setHeader("access-control-allow-origin", "*");
                // Send an initial comment to establish stream
                res.write(`: connected\n\n`);
                // Add client
                sseClients.add(res);
                // Heartbeat
                const heartbeatMs = 15000;
                const t = setInterval(() => {
                    try {
                        res.write(`: ping\n\n`);
                    }
                    catch {
                        // ignore
                    }
                }, heartbeatMs);
                req.on("close", () => {
                    clearInterval(t);
                    sseClients.delete(res);
                });
                return;
            }
            // ── Console routes ─────────────────────────────────────────────────
            if (pathname === "/console" || pathname === "/console/") {
                res.statusCode = 501;
                res.setHeader("content-type", "text/plain; charset=utf-8");
                res.setHeader("cache-control", "no-store");
                res.end("Not implemented");
                return;
            }
            // TODO(obs-review, security): `/console/status` and `/console/stop`
            // are still publicly reachable over the tunnel and are NOT behind
            // `requireObsAuth()`. In particular `POST /console/stop` lets any
            // unauthenticated caller request an agent stop, which regresses the
            // "lock down the public surface" goal. Either return 501 like
            // `/console` above, or put both behind `requireObsAuth()` (and
            // widen the auth cookie's Path from `/obs` so it reaches these
            // routes). `/console/status` also leaks `busy` / `stopRequested`
            // and sets `access-control-allow-origin: *`, so any website can
            // probe it cross-origin.
            if (pathname === "/console/status") {
                const status = options.agentStatus
                    ? {
                        busy: options.agentStatus.getBusy(),
                        stopRequested: options.agentStatus.getStopRequested(),
                    }
                    : { busy: false, stopRequested: false };
                res.statusCode = 200;
                res.setHeader("content-type", "application/json; charset=utf-8");
                res.setHeader("cache-control", "no-store");
                res.setHeader("access-control-allow-origin", "*");
                res.end(JSON.stringify(status));
                return;
            }
            if (pathname === "/console/stop" && req.method === "POST") {
                if (options.agentStatus) {
                    options.agentStatus.requestStop();
                    res.statusCode = 200;
                    res.setHeader("content-type", "application/json; charset=utf-8");
                    res.end(JSON.stringify({ ok: true }));
                }
                else {
                    res.statusCode = 503;
                    res.setHeader("content-type", "application/json; charset=utf-8");
                    res.end(JSON.stringify({ ok: false, error: "agent status not available" }));
                }
                return;
            }
            // ── Tunnel control ────────────────────────────────────────────────
            if (pathname === "/obs/tunnel/status") {
                if (!requireObsAuth())
                    return;
                res.statusCode = 200;
                res.setHeader("content-type", "application/json; charset=utf-8");
                res.setHeader("cache-control", "no-store");
                res.end(JSON.stringify({
                    running: tunnelStop !== null,
                    url: tunnelUrl,
                    provider: tunnelProvider ?? null,
                }));
                return;
            }
            if (pathname === "/obs/tunnel/start" && req.method === "POST") {
                if (!requireObsAuth())
                    return;
                res.setHeader("content-type", "application/json; charset=utf-8");
                if (!tunnelProvider) {
                    res.statusCode = 400;
                    res.end(JSON.stringify({ ok: false, error: "no tunnel provider configured" }));
                    return;
                }
                if (!boundPort) {
                    res.statusCode = 503;
                    res.end(JSON.stringify({ ok: false, error: "server not yet bound" }));
                    return;
                }
                doStartTunnel(boundPort)
                    .then((url) => {
                    res.statusCode = 200;
                    res.end(JSON.stringify({ ok: true, url }));
                })
                    .catch((err) => {
                    res.statusCode = 500;
                    res.end(JSON.stringify({ ok: false, error: err instanceof Error ? err.message : String(err) }));
                });
                return;
            }
            if (pathname === "/obs/tunnel/stop" && req.method === "POST") {
                if (!requireObsAuth())
                    return;
                res.setHeader("content-type", "application/json; charset=utf-8");
                if (tunnelStop) {
                    tunnelStop()
                        .then(() => {
                        tunnelStop = null;
                        tunnelUrl = null;
                        try {
                            getAgentState().consoleUrl = undefined;
                        }
                        catch { /* ignore */ }
                        logger.system("Tunnel stopped via API");
                        res.statusCode = 200;
                        res.end(JSON.stringify({ ok: true }));
                    })
                        .catch((err) => {
                        res.statusCode = 500;
                        res.end(JSON.stringify({ ok: false, error: err instanceof Error ? err.message : String(err) }));
                    });
                }
                else {
                    res.statusCode = 200;
                    res.end(JSON.stringify({ ok: true, message: "tunnel was not running" }));
                }
                return;
            }
            // ── Telegram Mini App routes (public — accessed from Telegram WebView) ──
            if (pathname === "/mini/status") {
                res.statusCode = 200;
                res.setHeader("content-type", "text/html; charset=utf-8");
                res.setHeader("cache-control", "no-store");
                res.end(renderStatusHtml());
                return;
            }
            if (pathname === "/mini/tasks") {
                res.statusCode = 200;
                res.setHeader("content-type", "text/html; charset=utf-8");
                res.setHeader("cache-control", "no-store");
                res.end(renderTasksHtml());
                return;
            }
            if (pathname === "/mini/status.json") {
                res.setHeader("content-type", "application/json; charset=utf-8");
                res.setHeader("cache-control", "no-store");
                res.setHeader("access-control-allow-origin", "*");
                void (async () => {
                    try {
                        const state = getAgentState();
                        const sessionMgr = state.sessionManager;
                        const usage = sessionMgr ? sessionMgr.activeSession.getUsageSnapshot() : null;
                        const data = {
                            agentName: options.config?.agentName ?? "VisionClaw",
                            version: getCurrentVersion(),
                            busy: state.busy,
                            uptime: formatUptimeShort(process.uptime()),
                            heartbeat: options.config ? `every ${Math.round(state.ownerConfig.heartbeatIntervalMs / 60_000)}m` : "—",
                            model: options.config?.model ?? "—",
                            provider: "—",
                            runtime: "—",
                            queuedMessages: state.messageQueue.size(),
                            context: usage ? {
                                usedPct: usage.usedPct,
                                usedInputTokens: usage.usedInputTokens,
                                contextWindow: usage.contextWindow,
                            } : null,
                            taskSummary: state.busy ? state.activityTracker.getSummary() : null,
                            services: null,
                            // Activity data for the Mini App
                            todos: state.activityTracker.getTodos(),
                            recentActions: state.activityTracker.getRecentActions().slice(-5),
                            subagents: state.activityTracker.getSubagents(),
                            taskDescription: state.busy ? state.activityTracker.getTaskDescription() : null,
                            elapsedMs: state.busy ? state.activityTracker.getTaskElapsedMs() : null,
                        };
                        try {
                            const { getProviderLabel, getRuntimeLabel } = await import("../agent/model-provider.js");
                            if (options.config) {
                                data.provider = getProviderLabel(options.config.provider);
                                data.runtime = getRuntimeLabel(options.config);
                            }
                        }
                        catch { /* ignore */ }
                        if (options.statusReport) {
                            try {
                                const report = await options.statusReport(false);
                                const serviceLines = report.split("\n");
                                const servicesStart = serviceLines.findIndex((l) => l.includes("Services (checked"));
                                if (servicesStart >= 0) {
                                    const services = [];
                                    for (let i = servicesStart + 1; i < serviceLines.length; i++) {
                                        const line = serviceLines[i];
                                        const match = /^(✅|❌)\s+(.+?):\s+(.+)$/.exec(line);
                                        if (match) {
                                            services.push({
                                                name: match[2],
                                                ok: match[1] === "✅",
                                                detail: match[3],
                                            });
                                        }
                                    }
                                    data.services = services;
                                }
                            }
                            catch { /* ignore — services will be null */ }
                        }
                        res.statusCode = 200;
                        res.end(JSON.stringify(data));
                    }
                    catch (_err) {
                        if (!res.headersSent) {
                            res.statusCode = 500;
                            res.end(JSON.stringify({ error: "Failed to build status data" }));
                        }
                    }
                })();
                return;
            }
            if (pathname === "/mini/tasks.json") {
                res.setHeader("content-type", "application/json; charset=utf-8");
                res.setHeader("cache-control", "no-store");
                res.setHeader("access-control-allow-origin", "*");
                try {
                    const state = getAgentState();
                    const tracker = state.activityTracker;
                    const sessionMgr = state.sessionManager;
                    const data = {
                        busy: state.busy,
                        taskDescription: tracker.getTaskDescription(),
                        elapsedMs: tracker.getTaskElapsedMs(),
                        sessionMode: sessionMgr ? sessionMgr.activeMode : "general",
                        queueSize: state.messageQueue.size(),
                        todos: tracker.getTodos(),
                        recentActions: tracker.getRecentActions().slice(-5),
                        subagents: tracker.getSubagents(),
                    };
                    res.statusCode = 200;
                    res.end(JSON.stringify(data));
                }
                catch (_err) {
                    if (!res.headersSent) {
                        res.statusCode = 500;
                        res.end(JSON.stringify({ error: "Failed to build tasks data" }));
                    }
                }
                return;
            }
            // ── RDP routes (custom H.264 desktop streaming) ──────────────────
            const rdpTokenMatch = /^\/rdp\/([a-f0-9]{64})$/.exec(pathname);
            if (rdpTokenMatch) {
                const tokenStr = rdpTokenMatch[1];
                const entry = validateToken(tokenStr);
                if (!entry) {
                    res.statusCode = 403;
                    res.setHeader("content-type", "text/html; charset=utf-8");
                    res.setHeader("cache-control", "no-store");
                    res.end(renderExpiredHtml());
                    return;
                }
                // Pick ws/wss carefully so the page served over https doesn't try
                // to open an insecure ws:// (mixed-content block). The header can be
                // any casing (spec: ASCII) and may be absent when the request is a
                // direct TLS connection (no proxy) — in that case fall back to the
                // socket's own `encrypted` flag.
                const xfp = req.headers["x-forwarded-proto"];
                const xfpFirst = Array.isArray(xfp) ? xfp[0] : xfp;
                const isTls = xfpFirst?.toLowerCase() === "https" ||
                    ("encrypted" in req.socket && req.socket.encrypted === true);
                const proto = isTls ? "wss" : "ws";
                const wsUrl = `${proto}://${req.headers.host ?? "localhost"}/rdp/stream?token=${tokenStr}`;
                const rdpScreens = rdpStreamManager.getScreenInfo();
                res.statusCode = 200;
                res.setHeader("content-type", "text/html; charset=utf-8");
                res.setHeader("cache-control", "no-store");
                let rdpHostname = "";
                let rdpAgentName = "";
                try {
                    const rdpCfg = loadConfig();
                    rdpHostname = os.hostname();
                    rdpAgentName = rdpCfg.agentName;
                }
                catch {
                    // Config may not exist in test/dev mode — fall back to hostname only
                    try {
                        rdpHostname = os.hostname();
                    }
                    catch { /* ignore */ }
                }
                res.end(renderDesktopHtml(wsUrl, new Date(entry.expiresAt).toISOString(), rdpScreens, { hostname: rdpHostname, agentName: rdpAgentName }));
                return;
            }
            // ── RDP static assets (toolbar icons) ────────────────────────────
            const rdpIconMatch = /^\/rdp\/icons\/([a-z0-9_.]+)$/.exec(pathname);
            if (rdpIconMatch) {
                const iconName = rdpIconMatch[1];
                // Resolve from source tree (dev) or dist tree (prod)
                const iconDir = path.join(new URL(".", import.meta.url).pathname, "rdp", "icons");
                const iconPath = path.join(iconDir, iconName);
                if (fs.existsSync(iconPath) && !iconName.includes("..")) {
                    const ext = path.extname(iconName).toLowerCase();
                    const mimeTypes = {
                        ".png": "image/png",
                        ".svg": "image/svg+xml",
                        ".jpg": "image/jpeg",
                        ".jpeg": "image/jpeg",
                    };
                    res.statusCode = 200;
                    res.setHeader("content-type", mimeTypes[ext] ?? "application/octet-stream");
                    res.setHeader("cache-control", "public, max-age=86400, immutable");
                    fs.createReadStream(iconPath).pipe(res);
                }
                else {
                    res.statusCode = 404;
                    res.setHeader("content-type", "text/plain");
                    res.end("Icon not found");
                }
                return;
            }
            // Basic 404
            res.statusCode = 404;
            res.setHeader("content-type", "text/plain; charset=utf-8");
            res.end(escHtml(`Not found: ${pathname}`));
        }
        catch (err) {
            // Surface the real cause — previously this catch swallowed the error
            // silently, so operators saw only "Internal error" in the browser with
            // no clue what failed server-side.
            const detail = err instanceof Error ? (err.stack ?? err.message) : String(err);
            logger.err(`[obs] request handler failed for ${req.method ?? "?"} ${req.url ?? "?"}: ${detail}`);
            if (!res.headersSent) {
                res.statusCode = 500;
                res.setHeader("content-type", "text/plain; charset=utf-8");
                res.end("Internal error");
            }
            else {
                // Headers already flushed (e.g. during a streaming body) — just
                // close the socket rather than corrupting the response.
                try {
                    res.end();
                }
                catch { /* ignore */ }
            }
        }
    }
    let closed = false;
    server.on("error", (err) => {
        // Don't crash the agent if OBS can't bind.
        const msg = err instanceof Error ? err.message : String(err);
        logger.warn(`Observability server failed to start: ${msg}`);
        // Best-effort cleanup
        if (!closed) {
            closed = true;
            try {
                unsubscribe();
            }
            catch {
                // ignore
            }
            try {
                server.close();
            }
            catch {
                // ignore
            }
        }
    });
    // Handle WebSocket upgrades for VNC and RDP streaming.
    // Tokens are passed as query params (?token=<hex>) and validated here.
    const rdpWss = new WebSocketServer({ noServer: true });
    server.on("upgrade", (req, socket, head) => {
        try {
            const upgradeUrl = new URL(req.url ?? "/", "http://localhost");
            // RDP stream (custom H.264 desktop streaming)
            if (upgradeUrl.pathname === "/rdp/stream") {
                const token = upgradeUrl.searchParams.get("token") ?? "";
                logger.debug(`RDP WS upgrade request (token=${token.slice(0, 8)}...)`);
                const entry = validateToken(token);
                if (!entry) {
                    logger.warn("RDP WS upgrade rejected: invalid or expired token");
                    socket.write("HTTP/1.1 401 Unauthorized\r\nConnection: close\r\n\r\n");
                    socket.destroy();
                    return;
                }
                rdpWss.handleUpgrade(req, socket, head, (ws) => {
                    // One consolidated "client connected" line replaces the previous
                    // "upgrade accepted" + "WebSocket connected" pair.
                    logger.system("[rdp] Client connected");
                    rdpStreamManager.addClient(ws).catch((err) => {
                        logger.err(`RDP client setup failed: ${err instanceof Error ? err.message : String(err)}`);
                        ws.close();
                    });
                });
                return;
            }
            // Reject unknown upgrade paths.
            socket.write("HTTP/1.1 404 Not Found\r\nConnection: close\r\n\r\n");
            socket.destroy();
        }
        catch {
            socket.destroy();
        }
    });
    // Tunnel state — populated asynchronously after the server binds.
    let tunnelUrl = null;
    let tunnelStop = null;
    let boundPort = null;
    const tunnelProvider = options.tunnel;
    // Prefer runtime-credential-delivered tunnel configuration when the active
    // profile has it materialized (written by a previous heartbeat). This
    // avoids the `cert.pem`-based cloudflared tunnel create/route flow and
    // routes cloudflared at the profile-private config.yml + `{UUID}.json`
    // pair instead. Falls back to the legacy options (cert.pem-based) when
    // no runtime credential is present. Re-probed on every start/restart so
    // the first restart after the initial heartbeat-bootstrap picks up the
    // freshly materialized credential without a full agent restart.
    function resolveTunnelOpts() {
        const runtimeTunnel = probeLocalTunnelCredential();
        if (runtimeTunnel) {
            // Canonical obs hostname is always `config.obs.tunnelHostname`
            // (authoritative). The on-disk `config.yml` may legitimately carry
            // additional ingress rules for non-obs hostnames routed to other
            // local ports; we must not infer "our" hostname from yaml order.
            return {
                tunnelName: runtimeTunnel.tunnelName,
                tunnelHostname: options.tunnelHostname,
                tunnelConfigPath: runtimeTunnel.configFile,
                tunnelId: runtimeTunnel.tunnelId,
            };
        }
        return {
            tunnelName: options.tunnelName,
            tunnelHostname: options.tunnelHostname,
            tunnelOriginCert: options.tunnelOriginCert,
            tunnelConfigPath: options.tunnelConfigPath,
        };
    }
    // Serialise every tunnel start/restart through a single chain. The
    // heartbeat manager may kick `restartTunnel` while obs's own initial
    // `doStartTunnel` (triggered from `server.listen`) is still awaiting
    // the Cloudflare edge handshake — without this chain, two concurrent
    // `startTunnel` invocations would race to overwrite `tunnelStop` and
    // leave an orphan cloudflared bound to the stale UUID.
    let tunnelLifecycleChain = Promise.resolve(null);
    async function doStartTunnelInner(actualPort) {
        if (!tunnelProvider)
            return null;
        let url = null;
        try {
            if (tunnelStop) {
                await tunnelStop().catch(() => { });
                tunnelStop = null;
            }
            const currentTunnelOpts = resolveTunnelOpts();
            if (currentTunnelOpts.tunnelId) {
                logger.system(`Tunnel: using runtime credential (tunnelId=${currentTunnelOpts.tunnelId}, config=${currentTunnelOpts.tunnelConfigPath ?? "<none>"})`);
            }
            const result = await startTunnel(actualPort, tunnelProvider, currentTunnelOpts);
            tunnelUrl = result.url;
            tunnelStop = result.stop;
            url = result.url;
        }
        catch (err) {
            logger.warn(`Tunnel failed: ${err instanceof Error ? err.message : String(err)}`);
            tunnelUrl = null;
        }
        try {
            getAgentState().consoleUrl = url ?? undefined;
        }
        catch { /* state not yet initialized */ }
        return url;
    }
    function doStartTunnel(actualPort) {
        // `.catch(() => null)` on the previous link keeps the chain alive
        // even if a prior start rejected; every new request gets its own
        // fresh attempt rather than inheriting the old failure.
        const next = tunnelLifecycleChain
            .catch(() => null)
            .then(() => doStartTunnelInner(actualPort));
        tunnelLifecycleChain = next;
        return next;
    }
    const tunnelUrlPromise = new Promise((resolve) => {
        server.listen(port, host, () => {
            const address = server.address();
            const actualPort = typeof address === "object" && address ? address.port : port;
            boundPort = actualPort;
            logger.system(`Observability: http://${host}:${actualPort}/obs`);
            logger.system(`Console: http://${host}:${actualPort}/console`);
            doStartTunnel(actualPort).then(resolve).catch(() => { resolve(null); });
        });
    });
    const address = server.address();
    const actualPort = typeof address === "object" && address ? address.port : port;
    return {
        port: actualPort,
        tunnelUrl: tunnelUrlPromise,
        restartTunnel: async () => {
            const p = boundPort;
            if (!p || !tunnelProvider)
                return null;
            logger.system("Restarting tunnel...");
            const url = await doStartTunnel(p);
            if (url) {
                logger.system(`Tunnel restarted: ${url}`);
            }
            return url;
        },
        close: async () => {
            closed = true;
            unsubscribe();
            for (const res of sseClients) {
                try {
                    res.end();
                }
                catch {
                    // ignore
                }
            }
            sseClients.clear();
            // Stop RDP streaming processes
            rdpStreamManager.stop();
            if (tunnelStop) {
                await tunnelStop().catch(() => {
                    // ignore tunnel stop errors
                });
            }
            await new Promise((resolve) => {
                server.close(() => { resolve(); });
            });
        },
    };
}
//# sourceMappingURL=server.js.map