diff --git a/webui/src/lib/runtime.ts b/webui/src/lib/runtime.ts index 64dbcb9f..3d327a3a 100644 --- a/webui/src/lib/runtime.ts +++ b/webui/src/lib/runtime.ts @@ -24,11 +24,11 @@ export interface HostRuntimeInfo { } export interface NanobotHostApi { - getRuntimeInfo(): Promise; - restartEngine(): Promise; - pickFolder(): Promise; - openLogs(): Promise; - exportDiagnostics(): Promise; + getRuntimeInfo?(): Promise; + restartEngine?(): Promise; + pickFolder?(): Promise; + openLogs?(): Promise; + exportDiagnostics?(): Promise; openSocket?(url: string): Promise; sendSocket?(id: string, data: string): Promise; closeSocket?(id: string): Promise; @@ -55,6 +55,16 @@ const HOST_WS_CONNECTING = 0; const HOST_WS_OPEN = 1; const HOST_WS_CLOSING = 2; const HOST_WS_CLOSED = 3; +const LOOPBACK_HOST_PORT_PARAM = "nativeHostPort"; +const LOOPBACK_HOST_TOKEN_PARAM = "nativeHostToken"; +const LOOPBACK_HOST_STORAGE_KEY = "nanobot-webui.native-host"; + +interface LoopbackHostConfig { + port: number; + token: string; +} + +let loopbackHostApi: NanobotHostApi | null = null; declare global { interface Window { @@ -64,7 +74,21 @@ declare global { function getHostApi(): NanobotHostApi | null { if (typeof window === "undefined") return null; - return window.nanobotHost ?? null; + return window.nanobotHost ?? loopbackHostApi; +} + +/** + * Install the external native-host bridge advertised in the URL fragment. + * + * Only a loopback port is accepted; callers cannot redirect privileged host + * actions to an arbitrary origin. The short-lived bridge token is removed + * from the URL and retained only for the lifetime of this browser tab. + */ +export function initializeLoopbackRuntimeHost(): boolean { + if (typeof window === "undefined") return false; + const config = consumeLoopbackHostConfig() ?? loadLoopbackHostConfig(); + loopbackHostApi = config ? createLoopbackHostApi(config) : null; + return loopbackHostApi !== null; } export function toRuntimeSurface(surface: string | null | undefined): RuntimeSurface { @@ -88,10 +112,10 @@ export function createRuntimeHost( surface, capabilities: mergedCapabilities, socketFactory: bridge ? createHostWebSocket : undefined, - pickFolder: api ? () => api.pickFolder() : undefined, - restartEngine: api ? () => api.restartEngine() : undefined, - openLogs: api ? () => api.openLogs() : undefined, - exportDiagnostics: api ? () => api.exportDiagnostics() : undefined, + pickFolder: api?.pickFolder?.bind(api), + restartEngine: api?.restartEngine?.bind(api), + openLogs: api?.openLogs?.bind(api), + exportDiagnostics: api?.exportDiagnostics?.bind(api), }; } @@ -136,6 +160,96 @@ function getHostSocketBridge(): HostSocketBridge | null { }; } +function consumeLoopbackHostConfig(): LoopbackHostConfig | null { + const hash = window.location.hash || ""; + const queryStart = hash.indexOf("?"); + if (queryStart < 0) return null; + + const path = hash.slice(0, queryStart) || "#/"; + const params = new URLSearchParams(hash.slice(queryStart + 1)); + const hasBridgeParams = params.has(LOOPBACK_HOST_PORT_PARAM) + || params.has(LOOPBACK_HOST_TOKEN_PARAM); + if (!hasBridgeParams) return null; + + const config = validateLoopbackHostConfig({ + port: Number(params.get(LOOPBACK_HOST_PORT_PARAM)), + token: params.get(LOOPBACK_HOST_TOKEN_PARAM) ?? "", + }); + params.delete(LOOPBACK_HOST_PORT_PARAM); + params.delete(LOOPBACK_HOST_TOKEN_PARAM); + const nextQuery = params.toString(); + const nextHash = `${path}${nextQuery ? `?${nextQuery}` : ""}`; + window.history.replaceState( + null, + "", + `${window.location.pathname}${window.location.search}${nextHash}`, + ); + + try { + if (config) { + window.sessionStorage.setItem(LOOPBACK_HOST_STORAGE_KEY, JSON.stringify(config)); + } else { + window.sessionStorage.removeItem(LOOPBACK_HOST_STORAGE_KEY); + } + } catch { + // The current page can still use the bridge when session storage is unavailable. + } + return config; +} + +function loadLoopbackHostConfig(): LoopbackHostConfig | null { + try { + const raw = window.sessionStorage.getItem(LOOPBACK_HOST_STORAGE_KEY); + if (!raw) return null; + return validateLoopbackHostConfig(JSON.parse(raw) as Partial); + } catch { + return null; + } +} + +function validateLoopbackHostConfig( + value: Partial, +): LoopbackHostConfig | null { + const port = Number(value.port); + const token = typeof value.token === "string" ? value.token : ""; + if (!Number.isInteger(port) || port < 1 || port > 65_535) return null; + if (!/^[A-Za-z0-9_-]{32,128}$/.test(token)) return null; + return { port, token }; +} + +function createLoopbackHostApi(config: LoopbackHostConfig): NanobotHostApi { + return { + async pickFolder(): Promise { + let response: Response; + try { + response = await fetch(`http://127.0.0.1:${config.port}/v1/pick-folder`, { + method: "POST", + cache: "no-store", + credentials: "omit", + referrerPolicy: "no-referrer", + headers: { Authorization: `Bearer ${config.token}` }, + }); + } catch { + throw new Error("Native folder picker is unavailable. Reopen Nanobot and try again."); + } + + const body = await response.json().catch(() => null) as { + error?: unknown; + path?: unknown; + } | null; + if (!response.ok) { + const detail = typeof body?.error === "string" ? body.error : `HTTP ${response.status}`; + throw new Error(`Native folder picker failed: ${detail}`); + } + if (body?.path === null) return null; + if (typeof body?.path !== "string" || !body.path) { + throw new Error("Native folder picker returned an invalid path."); + } + return body.path; + }, + }; +} + class HostWebSocket { binaryType: BinaryType = "blob"; onclose: ((this: WebSocket, ev: CloseEvent) => unknown) | null = null; diff --git a/webui/src/main.tsx b/webui/src/main.tsx index f385ff61..2fc96fd8 100644 --- a/webui/src/main.tsx +++ b/webui/src/main.tsx @@ -3,6 +3,7 @@ import ReactDOM from "react-dom/client"; import App from "./App"; import "./globals.css"; import "./i18n"; +import { initializeLoopbackRuntimeHost } from "./lib/runtime"; // `crypto.randomUUID` is only defined in secure contexts (HTTPS or localhost). // LAN access over plain HTTP leaves it undefined, which crashes components that @@ -23,5 +24,7 @@ if (typeof globalThis.crypto !== "undefined" && !("randomUUID" in globalThis.cry const root = document.getElementById("root"); if (!root) throw new Error("root element missing"); +initializeLoopbackRuntimeHost(); + /* StrictMode disabled: dev double-invokes state updaters; delta accumulation must stay pure — see useNanobotStream. */ ReactDOM.createRoot(root).render(); diff --git a/webui/src/tests/runtime.test.ts b/webui/src/tests/runtime.test.ts index 73198a38..0c9d7052 100644 --- a/webui/src/tests/runtime.test.ts +++ b/webui/src/tests/runtime.test.ts @@ -1,9 +1,17 @@ import { afterEach, describe, expect, it, vi } from "vitest"; -import { getRuntimeHost, isNativeRuntime } from "@/lib/runtime"; +import { + getRuntimeHost, + initializeLoopbackRuntimeHost, + isNativeRuntime, +} from "@/lib/runtime"; afterEach(() => { Reflect.deleteProperty(window, "nanobotHost"); + window.sessionStorage.clear(); + window.history.replaceState(null, "", "/"); + initializeLoopbackRuntimeHost(); + vi.unstubAllGlobals(); }); describe("runtime host facade", () => { @@ -42,4 +50,46 @@ describe("runtime host facade", () => { it("treats server-reported native surface as native for UI labels", () => { expect(isNativeRuntime("native")).toBe(true); }); + + it("installs an authenticated loopback folder picker from the URL fragment", async () => { + const token = "a".repeat(43); + const fetchMock = vi.fn(async () => new Response( + JSON.stringify({ path: "/Users/test/project" }), + { status: 200, headers: { "Content-Type": "application/json" } }, + )); + vi.stubGlobal("fetch", fetchMock); + window.history.replaceState( + null, + "", + `/#/new?bootstrapSecret=secret&nativeHostPort=43123&nativeHostToken=${token}`, + ); + + expect(initializeLoopbackRuntimeHost()).toBe(true); + expect(window.location.hash).toBe("#/new?bootstrapSecret=secret"); + expect(isNativeRuntime()).toBe(true); + await expect(getRuntimeHost().pickFolder?.()).resolves.toBe("/Users/test/project"); + expect(fetchMock).toHaveBeenCalledWith( + "http://127.0.0.1:43123/v1/pick-folder", + expect.objectContaining({ + method: "POST", + headers: { Authorization: `Bearer ${token}` }, + }), + ); + + window.history.replaceState(null, "", "/#/new"); + expect(initializeLoopbackRuntimeHost()).toBe(true); + await expect(getRuntimeHost().pickFolder?.()).resolves.toBe("/Users/test/project"); + }); + + it("rejects invalid loopback bridge bootstrap values", () => { + window.history.replaceState( + null, + "", + "/#/new?nativeHostPort=70000&nativeHostToken=too-short", + ); + + expect(initializeLoopbackRuntimeHost()).toBe(false); + expect(window.location.hash).toBe("#/new"); + expect(getRuntimeHost().pickFolder).toBeUndefined(); + }); });