From 4d9192b1ea8d100503e6c764329834075051f1e1 Mon Sep 17 00:00:00 2001 From: OpenShell-Community Dev Date: Sun, 15 Mar 2026 17:14:00 +0000 Subject: [PATCH] refactor: fixed policy approver to be more responsive --- .../nemoclaw-ui-extension/extension/index.ts | 321 +++++++++++++--- sandboxes/nemoclaw/policy-proxy.js | 348 ++++++++++++++++++ 2 files changed, 625 insertions(+), 44 deletions(-) diff --git a/sandboxes/nemoclaw/nemoclaw-ui-extension/extension/index.ts b/sandboxes/nemoclaw/nemoclaw-ui-extension/extension/index.ts index f567f22..cad351f 100644 --- a/sandboxes/nemoclaw/nemoclaw-ui-extension/extension/index.ts +++ b/sandboxes/nemoclaw/nemoclaw-ui-extension/extension/index.ts @@ -26,6 +26,46 @@ const STABLE_CONNECTION_WINDOW_MS = 10_000; const STABLE_CONNECTION_TIMEOUT_MS = 45_000; const PAIRING_RELOAD_FLAG = "nemoclaw:pairing-bootstrap-reloaded"; const FORCED_RELOAD_DELAY_MS = 1_000; +const PAIRING_STATUS_POLL_MS = 500; +const PAIRING_REARM_INTERVAL_MS = 4_000; +const OVERLAY_SHOW_DELAY_MS = 400; +const PAIRING_BOOTSTRAPPED_FLAG = "nemoclaw:pairing-bootstrap-complete"; +const POST_READY_SETTLE_MS = 750; +const WARM_START_CONNECTION_WINDOW_MS = 500; +const WARM_START_TIMEOUT_MS = 2_500; +const READINESS_HANDLED = Symbol("pairing-bootstrap-readiness-handled"); + +interface PairingBootstrapState { + status?: string; + approvedCount?: number; + active?: boolean; + lastApprovalDeviceId?: string; + lastError?: string; + sawBrowserPaired?: boolean; +} + +const PAIRING_STATUS_PRIORITY: Record = { + idle: 0, + armed: 1, + pending: 2, + approving: 3, + "approved-pending-settle": 4, + "paired-other-device": 5, + paired: 6, + timeout: 7, + error: 7, +}; + +function isPairingTerminal(state: PairingBootstrapState | null): boolean { + if (!state) return false; + if (state.active) return false; + return state.status === "paired" || state.status === "timeout" || state.status === "error"; +} + +function isPairingRecoveryEligible(state: PairingBootstrapState | null): boolean { + if (!state) return false; + return state.status === "paired"; +} function inject(): boolean { const hasButton = injectButton(); @@ -71,6 +111,7 @@ function setConnectOverlayText(text: string): void { } function revealApp(): void { + markPairingBootstrapped(); document.body.setAttribute("data-nemoclaw-ready", ""); const overlay = document.querySelector(".nemoclaw-connect-overlay"); if (overlay) { @@ -80,7 +121,7 @@ function revealApp(): void { startDenialWatcher(); } -function shouldForcePairingReload(): boolean { +function shouldAllowRecoveryReload(): boolean { try { return sessionStorage.getItem(PAIRING_RELOAD_FLAG) !== "1"; } catch { @@ -88,74 +129,266 @@ function shouldForcePairingReload(): boolean { } } -function markPairingReloadComplete(): void { +function isPairingBootstrapped(): boolean { try { - sessionStorage.setItem(PAIRING_RELOAD_FLAG, "1"); + return sessionStorage.getItem(PAIRING_BOOTSTRAPPED_FLAG) === "1"; + } catch { + return false; + } +} + +function markPairingBootstrapped(): void { + try { + sessionStorage.setItem(PAIRING_BOOTSTRAPPED_FLAG, "1"); } catch { // ignore storage failures } } -function clearPairingReloadFlag(): void { +function markRecoveryReloadUsed(): void { try { - sessionStorage.removeItem(PAIRING_RELOAD_FLAG); + sessionStorage.setItem(PAIRING_RELOAD_FLAG, "1"); } catch { // ignore storage failures } } -function forcePairingReload(reason: string, overlayText: string): void { - console.info(`[NeMoClaw] pairing bootstrap: forcing one-time reload (${reason})`); - markPairingReloadComplete(); - setConnectOverlayText(overlayText); - window.setTimeout(() => window.location.reload(), FORCED_RELOAD_DELAY_MS); +async function fetchPairingBootstrapState(method: "GET" | "POST"): Promise { + try { + const res = await fetch("/api/pairing-bootstrap", { method }); + if (!res.ok) return null; + return (await res.json()) as PairingBootstrapState; + } catch { + return null; + } +} + +function getOverlayTextForPairingState(state: PairingBootstrapState | null): string | null { + switch (state?.status) { + case "armed": + return "Preparing device pairing bootstrap..."; + case "pending": + return "Waiting for device pairing request..."; + case "approving": + return "Approving device pairing..."; + case "approved-pending-settle": + return "Device pairing approved. Waiting for dashboard device to finish pairing..."; + case "paired-other-device": + return "Pairing another device. Waiting for browser dashboard pairing..."; + case "paired": + return "Device paired. Finalizing dashboard..."; + case "approved": + return "Device pairing approved. Waiting for browser dashboard pairing..."; + case "timeout": + return "Pairing bootstrap timed out. Opening dashboard..."; + case "error": + return "Pairing bootstrap hit an error. Opening dashboard..."; + default: + return null; + } } function bootstrap() { console.info("[NeMoClaw] pairing bootstrap: start"); - showConnectOverlay(); - const finalizeConnectedState = async () => { - setConnectOverlayText("Device pairing approved. Finalizing dashboard..."); - console.info("[NeMoClaw] pairing bootstrap: reconnect detected"); - if (shouldForcePairingReload()) { - forcePairingReload("post-reconnect", "Device pairing approved. Reloading dashboard..."); - return; + let pairingPollTimer = 0; + let overlayTimer = 0; + let stopped = false; + let dashboardStable = false; + let latestPairingState: PairingBootstrapState | null = null; + let lastPairingStartAt = 0; + let overlayVisible = false; + let overlayPriority = -1; + + const stopPairingPoll = () => { + stopped = true; + if (pairingPollTimer) window.clearTimeout(pairingPollTimer); + if (overlayTimer) window.clearTimeout(overlayTimer); + }; + + const ensureOverlayVisible = () => { + if (overlayVisible) return; + overlayVisible = true; + showConnectOverlay(); + }; + + const setMonotonicOverlayText = (text: string | null, status?: string) => { + if (!text) return; + const nextPriority = PAIRING_STATUS_PRIORITY[status || ""] ?? overlayPriority; + if (nextPriority < overlayPriority) return; + overlayPriority = nextPriority; + setConnectOverlayText(text); + }; + + const scheduleOverlay = () => { + if (overlayVisible || overlayTimer) return; + overlayTimer = window.setTimeout(() => { + overlayTimer = 0; + ensureOverlayVisible(); + }, OVERLAY_SHOW_DELAY_MS); + }; + + const pollPairingState = async () => { + if (stopped) return null; + const state = await fetchPairingBootstrapState("GET"); + latestPairingState = state; + const text = getOverlayTextForPairingState(state); + setMonotonicOverlayText(text, state?.status); + + if ( + !stopped && + !dashboardStable && + state && + !state.active && + !isPairingTerminal(state) && + Date.now() - lastPairingStartAt >= PAIRING_REARM_INTERVAL_MS + ) { + const rearmed = await fetchPairingBootstrapState("POST"); + if (rearmed) { + latestPairingState = rearmed; + lastPairingStartAt = Date.now(); + const rearmedText = getOverlayTextForPairingState(rearmed); + setMonotonicOverlayText(rearmedText, rearmed.status); + } + } + + pairingPollTimer = window.setTimeout(pollPairingState, PAIRING_STATUS_POLL_MS); + return state; + }; + + const waitForDashboardReadiness = async (timeoutMs: number, overlayText: string) => { + ensureOverlayVisible(); + setConnectOverlayText(overlayText); + await waitForStableConnection(STABLE_CONNECTION_WINDOW_MS, timeoutMs); + }; + + const handlePairingTerminalWithoutStableConnection = async (reason: string) => { + const state = latestPairingState || (await fetchPairingBootstrapState("GET")); + const status = state?.status || "unknown"; + if (isPairingRecoveryEligible(state) && shouldAllowRecoveryReload()) { + console.warn(`[NeMoClaw] pairing bootstrap: ${reason}; pairing=${status}; forcing one recovery reload`); + stopPairingPoll(); + markRecoveryReloadUsed(); + setConnectOverlayText("Pairing succeeded. Recovering dashboard..."); + window.setTimeout(() => window.location.reload(), 750); + return true; } - setConnectOverlayText("Device pairing approved. Verifying dashboard health..."); - try { - console.info("[NeMoClaw] pairing bootstrap: waiting for stable post-reload connection"); - await waitForStableConnection( - STABLE_CONNECTION_WINDOW_MS, - STABLE_CONNECTION_TIMEOUT_MS, - ); - } catch { - console.warn("[NeMoClaw] pairing bootstrap: stable post-reload connection check timed out; delaying reveal"); - await new Promise((resolve) => setTimeout(resolve, POST_PAIRING_SETTLE_DELAY_MS)); + if (isPairingTerminal(state)) { + console.warn(`[NeMoClaw] pairing bootstrap: ${reason}; pairing=${status}; revealing app without further delay`); + stopPairingPoll(); + revealApp(); + return true; } - console.info("[NeMoClaw] pairing bootstrap: reveal app"); - clearPairingReloadFlag(); - revealApp(); + return false; }; - waitForReconnect(INITIAL_CONNECT_TIMEOUT_MS) - .then(finalizeConnectedState) - .catch(async () => { - console.warn("[NeMoClaw] pairing bootstrap: initial reconnect timed out; extending wait"); - if (shouldForcePairingReload()) { - forcePairingReload("initial-timeout", "Pairing is still settling. Reloading dashboard..."); - return; + const runReadinessFlow = () => { + waitForDashboardReadiness( + INITIAL_CONNECT_TIMEOUT_MS, + "Auto-approving device pairing. Hang tight...", + ) + .catch(async () => { + console.warn("[NeMoClaw] pairing bootstrap: initial dashboard readiness check timed out; extending wait"); + if (await handlePairingTerminalWithoutStableConnection("initial readiness timed out")) { + throw READINESS_HANDLED; + } + return waitForDashboardReadiness( + EXTENDED_CONNECT_TIMEOUT_MS, + "Still waiting for device pairing approval...", + ); + }) + .then(async () => { + await new Promise((resolve) => window.setTimeout(resolve, POST_READY_SETTLE_MS)); + const settledState = await fetchPairingBootstrapState("GET"); + if (settledState) latestPairingState = settledState; + + dashboardStable = true; + console.info("[NeMoClaw] pairing bootstrap: reveal app"); + stopPairingPoll(); + setConnectOverlayText("Device pairing approved. Opening dashboard..."); + revealApp(); + }) + .catch(async (err: unknown) => { + if (err === READINESS_HANDLED) return; + if (stopped) return; + if (dashboardStable) return; + if (await handlePairingTerminalWithoutStableConnection("extended readiness timed out")) { + return; + } + const state = latestPairingState || (await fetchPairingBootstrapState("GET")); + const status = state?.status || "unknown"; + console.warn(`[NeMoClaw] pairing bootstrap: readiness timed out; revealing app anyway (status=${status})`); + stopPairingPoll(); + revealApp(); + }); + }; + + void (async () => { + const initialState = await fetchPairingBootstrapState("GET"); + latestPairingState = initialState; + + if (initialState && !initialState.active && isPairingTerminal(initialState)) { + const shouldWarmStart = isPairingBootstrapped() || initialState.status === "paired"; + if (shouldWarmStart) { + try { + await waitForStableConnection(WARM_START_CONNECTION_WINDOW_MS, WARM_START_TIMEOUT_MS); + console.info("[NeMoClaw] pairing bootstrap: warm start succeeded"); + stopPairingPoll(); + revealApp(); + return; + } catch { + // Fall through to normal bootstrap flow. + } } - setConnectOverlayText("Still waiting for device pairing approval..."); + } + + if (initialState === null) { + // Endpoint missing or failed — fall back to reconnect-only flow. + showConnectOverlay(); try { - await waitForReconnect(EXTENDED_CONNECT_TIMEOUT_MS); - await finalizeConnectedState(); + await waitForReconnect(INITIAL_CONNECT_TIMEOUT_MS); + setConnectOverlayText("Device pairing approved. Finalizing dashboard..."); + if (shouldAllowRecoveryReload()) { + markRecoveryReloadUsed(); + setConnectOverlayText("Device pairing approved. Reloading dashboard..."); + window.setTimeout(() => window.location.reload(), FORCED_RELOAD_DELAY_MS); + return; + } + await waitForStableConnection(STABLE_CONNECTION_WINDOW_MS, STABLE_CONNECTION_TIMEOUT_MS); } catch { - console.warn("[NeMoClaw] pairing bootstrap: extended reconnect timed out; revealing app anyway"); - clearPairingReloadFlag(); - revealApp(); + setConnectOverlayText("Still waiting for device pairing approval..."); + try { + await waitForReconnect(EXTENDED_CONNECT_TIMEOUT_MS); + await waitForStableConnection(STABLE_CONNECTION_WINDOW_MS, STABLE_CONNECTION_TIMEOUT_MS); + } catch { + // reveal anyway + } } - }); + revealApp(); + return; + } + + scheduleOverlay(); + const initialText = getOverlayTextForPairingState(initialState); + if (initialText) { + ensureOverlayVisible(); + setMonotonicOverlayText(initialText, initialState?.status); + } + + if (!initialState.active && !isPairingTerminal(initialState)) { + ensureOverlayVisible(); + const started = await fetchPairingBootstrapState("POST"); + if (started) { + latestPairingState = started; + lastPairingStartAt = Date.now(); + const startedText = getOverlayTextForPairingState(started); + setMonotonicOverlayText(startedText, started.status); + } + } + + await pollPairingState(); + runReadinessFlow(); + })(); const keysIngested = ingestKeysFromUrl(); diff --git a/sandboxes/nemoclaw/policy-proxy.js b/sandboxes/nemoclaw/policy-proxy.js index 64efb40..a1d66fb 100644 --- a/sandboxes/nemoclaw/policy-proxy.js +++ b/sandboxes/nemoclaw/policy-proxy.js @@ -15,11 +15,18 @@ const fs = require("fs"); const os = require("os"); const net = require("net"); const crypto = require("crypto"); +const { execFile } = require("child_process"); const POLICY_PATH = process.env.POLICY_PATH || "/etc/openshell/policy.yaml"; const UPSTREAM_PORT = parseInt(process.env.UPSTREAM_PORT || "18788", 10); const LISTEN_PORT = parseInt(process.env.LISTEN_PORT || "18789", 10); const UPSTREAM_HOST = "127.0.0.1"; +const AUTO_PAIR_TIMEOUT_MS = parseInt(process.env.AUTO_PAIR_TIMEOUT_MS || "600000", 10); +const AUTO_PAIR_POLL_MS = parseInt(process.env.AUTO_PAIR_POLL_MS || "500", 10); +const AUTO_PAIR_HEARTBEAT_MS = parseInt(process.env.AUTO_PAIR_HEARTBEAT_MS || "30000", 10); +const AUTO_PAIR_QUIET_POLLS = parseInt(process.env.AUTO_PAIR_QUIET_POLLS || "4", 10); +const AUTO_PAIR_APPROVAL_SETTLE_MS = parseInt(process.env.AUTO_PAIR_APPROVAL_SETTLE_MS || "30000", 10); +const PAIRING_CLI_BIN = process.env.PAIRING_CLI_BIN || "openclaw"; const PROTO_DIR = "/usr/local/lib/nemoclaw-proto"; @@ -38,11 +45,299 @@ const WELL_KNOWN_ENDPOINT = "https://navigator.navigator.svc.cluster.local:8080" let gatewayEndpoint = ""; let sandboxName = ""; +const pairingBootstrap = { + status: "idle", + startedAt: 0, + updatedAt: Date.now(), + approvedCount: 0, + errors: 0, + attempts: 0, + quietPolls: 0, + lastApprovalDeviceId: "", + lastError: "", + sawPending: false, + sawPaired: false, + sawBrowserPaired: false, + active: false, + timer: null, + heartbeatAt: 0, + lastApprovalAt: 0, +}; + function formatRequestLine(req) { const host = req.headers.host || "unknown-host"; return `${req.method || "GET"} ${req.url || "/"} host=${host}`; } +function isNoisyProxyPath(method, requestPath) { + if (!requestPath) return false; + if (requestPath === "/api/pairing-bootstrap") return true; + if (requestPath === "/favicon.ico" || requestPath === "/favicon.svg") return true; + if (requestPath.endsWith(".map")) return true; + if ((method || "GET") === "GET" && requestPath.startsWith("/assets/")) return true; + return false; +} + +function updatePairingState(patch) { + Object.assign(pairingBootstrap, patch, { updatedAt: Date.now() }); +} + +function pairingSnapshot() { + return { + status: pairingBootstrap.status, + startedAt: pairingBootstrap.startedAt || null, + updatedAt: pairingBootstrap.updatedAt, + approvedCount: pairingBootstrap.approvedCount, + errors: pairingBootstrap.errors, + attempts: pairingBootstrap.attempts, + quietPolls: pairingBootstrap.quietPolls, + lastApprovalDeviceId: pairingBootstrap.lastApprovalDeviceId || "", + lastError: pairingBootstrap.lastError || "", + sawPending: pairingBootstrap.sawPending, + sawPaired: pairingBootstrap.sawPaired, + sawBrowserPaired: pairingBootstrap.sawBrowserPaired, + active: pairingBootstrap.active, + lastApprovalAt: pairingBootstrap.lastApprovalAt || null, + }; +} + +function execOpenClaw(args) { + return new Promise((resolve) => { + execFile(PAIRING_CLI_BIN, args, { timeout: 5000, maxBuffer: 1024 * 1024 }, (error, stdout, stderr) => { + resolve({ + ok: !error, + stdout: stdout || "", + stderr: stderr || "", + error: error ? error.message : "", + }); + }); + }); +} + +function parseJsonBody(raw) { + try { + return JSON.parse(raw); + } catch { + return null; + } +} + +function normalizeDeviceList(raw) { + const parsed = parseJsonBody(raw); + if (!parsed || typeof parsed !== "object") { + return { pending: [], paired: [] }; + } + return { + pending: Array.isArray(parsed.pending) ? parsed.pending : [], + paired: Array.isArray(parsed.paired) ? parsed.paired : [], + }; +} + +function approvalSucceeded(raw) { + const parsed = parseJsonBody(raw); + const device = parsed && typeof parsed === "object" ? parsed.device : null; + if (!device || typeof device !== "object") return false; + return Boolean(device.approvedAtMs) || (Array.isArray(device.tokens) && device.tokens.length > 0); +} + +function approvalDeviceId(raw) { + const parsed = parseJsonBody(raw); + const deviceId = parsed && parsed.device && typeof parsed.device.deviceId === "string" + ? parsed.device.deviceId + : ""; + return deviceId ? deviceId.slice(0, 12) : ""; +} + +function approvalRequestId(raw) { + const parsed = parseJsonBody(raw); + return parsed && typeof parsed.requestId === "string" ? parsed.requestId.trim() : ""; +} + +function summarizeDevices(devices) { + const format = (entries) => { + if (!Array.isArray(entries) || entries.length === 0) return "-"; + return entries + .filter((entry) => entry && typeof entry === "object" && entry.deviceId) + .map((entry) => `${entry.clientId || "unknown"}:${String(entry.deviceId).slice(0, 12)}`) + .join(", ") || "-"; + }; + return `pending=${devices.pending.length} [${format(devices.pending)}] paired=${devices.paired.length} [${format(devices.paired)}]`; +} + +function isBrowserDevice(device) { + if (!device || typeof device !== "object") return false; + return device.clientId === "openclaw-control-ui" || device.clientMode === "webchat"; +} + +function finishPairingBootstrap(status, extra = {}) { + if (pairingBootstrap.timer) { + clearTimeout(pairingBootstrap.timer); + } + updatePairingState({ + status, + active: false, + timer: null, + ...extra, + }); + console.log( + `[auto-pair] watcher exiting status=${status} attempts=${pairingBootstrap.attempts} ` + + `approved=${pairingBootstrap.approvedCount} errors=${pairingBootstrap.errors} ` + + `sawPending=${pairingBootstrap.sawPending} sawPaired=${pairingBootstrap.sawPaired}` + ); +} + +async function runPairingBootstrapTick() { + if (!pairingBootstrap.active) return; + + const now = Date.now(); + if (pairingBootstrap.startedAt && now - pairingBootstrap.startedAt >= AUTO_PAIR_TIMEOUT_MS) { + finishPairingBootstrap("timeout", { lastError: "pairing bootstrap timed out" }); + return; + } + + updatePairingState({ attempts: pairingBootstrap.attempts + 1 }); + + const listResult = await execOpenClaw(["devices", "list", "--json"]); + const devices = normalizeDeviceList(listResult.stdout); + const hasPending = devices.pending.length > 0; + const hasPaired = devices.paired.length > 0; + const hasBrowserPaired = devices.paired.some(isBrowserDevice); + + if (!listResult.ok && !listResult.stdout.trim()) { + updatePairingState({ + errors: pairingBootstrap.errors + 1, + status: "error", + lastError: listResult.stderr || listResult.error || "device list failed", + }); + } else { + updatePairingState({ + status: hasPending ? "pending" : hasBrowserPaired ? "paired" : hasPaired ? "paired-other-device" : "armed", + sawPending: pairingBootstrap.sawPending || hasPending, + sawPaired: pairingBootstrap.sawPaired || hasPaired, + sawBrowserPaired: pairingBootstrap.sawBrowserPaired || hasBrowserPaired, + }); + } + + let approvalsThisTick = 0; + let lastApprovedDeviceId = ""; + if (hasPending) { + updatePairingState({ status: "approving" }); + + for (const pending of devices.pending) { + const requestId = pending && typeof pending.requestId === "string" ? pending.requestId.trim() : ""; + if (!requestId) continue; + + const approveResult = await execOpenClaw(["devices", "approve", requestId, "--json"]); + if (approvalSucceeded(approveResult.stdout)) { + approvalsThisTick += 1; + lastApprovedDeviceId = approvalDeviceId(approveResult.stdout) || lastApprovedDeviceId; + console.log( + `[auto-pair] approved request attempts=${pairingBootstrap.attempts} ` + + `request=${approvalRequestId(approveResult.stdout) || requestId} device=${lastApprovedDeviceId || "unknown"}` + ); + continue; + } + + if ((approveResult.stdout || approveResult.stderr).trim()) { + const noisy = !/no pending|no device|not paired|nothing to approve|unknown requestId/i.test( + `${approveResult.stdout}\n${approveResult.stderr}` + ); + if (noisy) { + updatePairingState({ + errors: pairingBootstrap.errors + 1, + lastError: approveResult.stderr || approveResult.stdout || approveResult.error || "approve failed", + }); + console.warn( + `[auto-pair] approve ${requestId} unexpected output attempts=${pairingBootstrap.attempts} ` + + `errors=${pairingBootstrap.errors}: ${pairingBootstrap.lastError}` + ); + } + } + } + } + + if (approvalsThisTick > 0) { + const nextApprovedCount = pairingBootstrap.approvedCount + approvalsThisTick; + updatePairingState({ + approvedCount: nextApprovedCount, + lastApprovalDeviceId: lastApprovedDeviceId || pairingBootstrap.lastApprovalDeviceId, + lastApprovalAt: Date.now(), + lastError: "", + quietPolls: 0, + status: "approved-pending-settle", + sawPending: true, + }); + } else { + const quietPolls = pairingBootstrap.approvedCount > 0 ? pairingBootstrap.quietPolls + 1 : 0; + updatePairingState({ quietPolls }); + + if ( + pairingBootstrap.approvedCount === 0 && + !hasPending && + hasBrowserPaired && + quietPolls >= AUTO_PAIR_QUIET_POLLS + ) { + finishPairingBootstrap("paired", { sawBrowserPaired: true }); + return; + } + + if ( + pairingBootstrap.approvedCount > 0 && + pairingBootstrap.lastApprovalAt > 0 && + Date.now() - pairingBootstrap.lastApprovalAt >= AUTO_PAIR_APPROVAL_SETTLE_MS && + hasBrowserPaired && + quietPolls >= AUTO_PAIR_QUIET_POLLS + ) { + finishPairingBootstrap("paired", { sawBrowserPaired: true }); + return; + } + + } + + if (now - pairingBootstrap.heartbeatAt >= AUTO_PAIR_HEARTBEAT_MS) { + updatePairingState({ heartbeatAt: now }); + console.log( + `[auto-pair] heartbeat status=${pairingBootstrap.status} attempts=${pairingBootstrap.attempts} ` + + `approved=${pairingBootstrap.approvedCount} errors=${pairingBootstrap.errors} ${summarizeDevices(devices)}` + ); + } + + pairingBootstrap.timer = setTimeout(runPairingBootstrapTick, AUTO_PAIR_POLL_MS); +} + +function startPairingBootstrap(reason, force = false) { + if (pairingBootstrap.active) { + return pairingSnapshot(); + } + if ( + !force && + pairingBootstrap.status === "paired" + ) { + return pairingSnapshot(); + } + updatePairingState({ + status: "armed", + startedAt: Date.now(), + approvedCount: 0, + errors: 0, + attempts: 0, + quietPolls: 0, + lastApprovalDeviceId: "", + lastError: "", + sawPending: false, + sawPaired: false, + sawBrowserPaired: false, + active: true, + heartbeatAt: 0, + lastApprovalAt: 0, + }); + console.log(`[auto-pair] watcher starting reason=${reason} timeout=${AUTO_PAIR_TIMEOUT_MS}ms poll=${AUTO_PAIR_POLL_MS}ms`); + runPairingBootstrapTick().catch((error) => { + finishPairingBootstrap("error", { lastError: error.message || String(error) }); + }); + return pairingSnapshot(); +} + // --------------------------------------------------------------------------- // Discovery helpers // --------------------------------------------------------------------------- @@ -650,11 +945,59 @@ function syncAndRespond(yamlBody, res, t0) { }); } +function handlePairingBootstrap(req, res) { + res.setHeader("Access-Control-Allow-Origin", "*"); + res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS"); + res.setHeader("Access-Control-Allow-Headers", "Content-Type"); + + if (req.method === "OPTIONS") { + res.writeHead(204); + res.end(); + return; + } + + if (req.method === "GET") { + sendJson(res, 200, pairingSnapshot()); + return; + } + + if (req.method === "POST") { + const snapshot = startPairingBootstrap("api", true); + sendJson(res, 202, snapshot); + return; + } + + sendJson(res, 405, { error: "method not allowed" }); +} + +function shouldArmPairingFromRequest(req) { + if (!req || !req.url) return false; + if (req.method && req.method !== "GET") return false; + if (req.url.startsWith("/api/")) return false; + if (req.url.startsWith("/assets/")) return false; + if (req.url === "/favicon.ico") return false; + return true; +} + +function sendJson(res, status, body) { + const raw = JSON.stringify(body); + res.writeHead(status, { + "Content-Type": "application/json; charset=utf-8", + "Content-Length": Buffer.byteLength(raw), + }); + res.end(raw); +} + // --------------------------------------------------------------------------- // HTTP server // --------------------------------------------------------------------------- const server = http.createServer((req, res) => { + if (req.url === "/api/pairing-bootstrap") { + handlePairingBootstrap(req, res); + return; + } + if (req.url === "/api/policy") { res.setHeader("Access-Control-Allow-Origin", "*"); res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS"); @@ -679,11 +1022,16 @@ const server = http.createServer((req, res) => { return; } + if (shouldArmPairingFromRequest(req)) { + startPairingBootstrap(`http:${req.url}`); + } + proxyRequest(req, res); }); // WebSocket upgrade — pipe raw TCP to upstream server.on("upgrade", (req, socket, head) => { + startPairingBootstrap(`ws:${req.url || "/"}`); console.log(`[policy-proxy] ws in ${formatRequestLine(req)} -> ${UPSTREAM_HOST}:${UPSTREAM_PORT}`); const upstream = net.createConnection({ host: UPSTREAM_HOST, port: UPSTREAM_PORT }, () => { const reqLine = `${req.method} ${req.url} HTTP/${req.httpVersion}\r\n`;