From bf1a20bf3cc96acf6e63b0bf2cd0d47c91965122 Mon Sep 17 00:00:00 2001 From: Johan Vaz <252314190+Jo2234@users.noreply.github.com> Date: Sun, 4 Oct 2026 16:40:53 +0800 Subject: [PATCH] Protect local web APIs with launch tokens and strict request validation --- README.md | 4 +- desktop/electron/renderer.html | 6 +- docs/DEMO.md | 2 +- docs/GAP_AND_DESIGN.md | 19 +- docs/ONBOARDING_HOME_DESIGN.md | 2 + docs/notes.md | 7 + fluent_ai/web.py | 309 +++++++++++++--- package.json | 2 +- scripts/portfolio_demo.py | 2 +- scripts/record_portfolio_demo.cjs | 3 +- tests/test_renderer_web_token.js | 81 +++++ tests/test_web.py | 570 +++++++++++++++++++++++------- 12 files changed, 827 insertions(+), 180 deletions(-) create mode 100644 tests/test_renderer_web_token.js diff --git a/README.md b/README.md index 5dde5e3..311ff19 100644 --- a/README.md +++ b/README.md @@ -120,7 +120,9 @@ Video context uses `OPENAI_VISION_MODEL`, defaulting to `gpt-4.1-mini` for faste python -m fluent_ai.web --port 7860 ``` -Open `http://127.0.0.1:7860`. The UI exposes Lesson Mode, Conversation Mode, video on/off, visible object context, live profile state, and agent logs. +Open `http://127.0.0.1:7860` (or `http://localhost:7860`). The UI exposes Lesson Mode, Conversation Mode, video on/off, visible object context, live profile state, and agent logs. + +The browser server is local-only and rejects other websites. See [local web security](docs/GAP_AND_DESIGN.md#local-web-security) for the token and request rules that scripts must follow. ## Desktop app diff --git a/desktop/electron/renderer.html b/desktop/electron/renderer.html index 24901e6..494589f 100644 --- a/desktop/electron/renderer.html +++ b/desktop/electron/renderer.html @@ -2460,10 +2460,14 @@

Text fallback

} if (!window.fluentAI) { + // Browser mode only: fluent_ai.web injects a per-launch token into the + // served page. Electron uses preload IPC and never takes this path. + const apiToken = document.querySelector('meta[name="fluentai-api-token"]')?.content || ""; + async function bridge(command, payload, options = {}) { const response = await fetchWithTimeout(`/api/bridge/${command}`, { method: "POST", - headers: {"Content-Type": "application/json"}, + headers: {"Content-Type": "application/json", "X-FluentAI-Token": apiToken}, body: JSON.stringify(payload || {}) }, { timeoutMs: 20000, retries: options.idempotent ? 1 : 0 }); return response.json(); diff --git a/docs/DEMO.md b/docs/DEMO.md index 48f7284..9fca320 100644 --- a/docs/DEMO.md +++ b/docs/DEMO.md @@ -28,7 +28,7 @@ Open `http://127.0.0.1:7860`, use a separate learner profile, complete placement ## Offline development harness -`scripts/portfolio_demo.py` and `scripts/record_portfolio_demo.cjs` remain available as explicit mock-provider development tools. They write the older labelled workflow capture under ignored `cache/offline-recording/`, keeping it separate from the published live API assets. Tests continue to mock providers so CI requires no credentials or paid requests. +`scripts/portfolio_demo.py` and `scripts/record_portfolio_demo.cjs` remain available as explicit mock-provider development tools. The harness uses `FluentAIServer`. The recorder copies the page's per-launch token into its direct API check. They write the older labelled workflow capture under ignored `cache/offline-recording/`, keeping it separate from the published live API assets. Tests continue to mock providers so CI requires no credentials or paid requests. ## Validation diff --git a/docs/GAP_AND_DESIGN.md b/docs/GAP_AND_DESIGN.md index 7f16a03..ac5f5b2 100644 --- a/docs/GAP_AND_DESIGN.md +++ b/docs/GAP_AND_DESIGN.md @@ -8,12 +8,29 @@ | `fluent_ai/agent.py`, `conversation.py` | Topic selection, quizzes, scoring, review scheduling, and conversation progress | | `fluent_ai/openai_provider.py` | OpenAI lesson generation, grading, tutor responses, Realtime, vision, and phrase audio | | `fluent_ai/desktop_bridge.py` | Shared commands for interactive sessions, onboarding, memory controls, and checkpoints | -| `fluent_ai/web.py` | Local HTTP server exposing the bridge and shared renderer | +| `fluent_ai/web.py` | Loopback HTTP server exposing the bridge and shared renderer behind Host/Origin/token checks | | `desktop/electron/` | Electron main/preload processes and shared browser/desktop renderer | | `fluent_ai/state.py` | Versioned local JSON state, migrations, transactions, and reset boundaries | Electron invokes a Python bridge process per command in development and a bundled bridge executable when packaged. The web UI uses HTTP bridge endpoints. The CLI uses the same core lesson, conversation, provider, and state modules. The obsolete Tk launcher has been retired. +## Local web security + +`fluent_ai.web` protects learner memory and paid provider routes from other websites, including no-cors/form CSRF and DNS rebinding: + +- Binds only to `127.0.0.1`, `localhost`, or `::1` (`--host` rejects other values). Every request needs exactly one `Host` equal to `127.0.0.1`, `localhost`, or `[::1]` with the listening port; other names get 421. +- Each server launch creates a random token. `GET /` embeds it in a `fluentai-api-token` meta tag, served `no-store` and unframeable. Every `/api/*` GET and POST must send it as `X-FluentAI-Token`. Tokens never appear in URLs, logs, state, or the static renderer. +- A sent `Origin` must exactly match the app origin. A sent `Sec-Fetch-Site` must be `same-origin` or `none`. There are no CORS grants, and OPTIONS returns 405. +- POST bodies must be one JSON object with `Content-Type: application/json` (optional `charset=utf-8`), a single ASCII `Content-Length`, and no `Transfer-Encoding`. They are capped at 64,000 bytes and 64 nesting levels. Send `{}` when there are no options. +- Rejections return `{"ok": false, "error", "error_code"}` with a 4xx status. They happen before any state read, provider construction, or bridge dispatch. + +Migration notes: + +- **Local scripts:** fetch `GET /` with a loopback Host, read the meta token, and send it on each request. Scripts may omit `Origin`, but they still need Host and token. +- **After a server restart:** pages holding an old token get 403 `invalid_token` until reloaded. +- **Embedding code:** construct `FluentAIServer`; plain `ThreadingHTTPServer` has no token and fails closed. +- **Electron and CLI:** unaffected. Electron uses preload IPC, and the CLI bridge reads stdin JSON. + ## State schema v2 `data/progress.json` is the development default. Packaged desktop storage is described in [packaging](PACKAGING_DESIGN.md). The top-level structure is: diff --git a/docs/ONBOARDING_HOME_DESIGN.md b/docs/ONBOARDING_HOME_DESIGN.md index 4c9899f..e1d0b51 100644 --- a/docs/ONBOARDING_HOME_DESIGN.md +++ b/docs/ONBOARDING_HOME_DESIGN.md @@ -23,6 +23,8 @@ The schema's `privacy.local_only` describes local learner-memory storage. The ap Raw microphone recordings and raw camera video are not persisted as learner memory. Transcripts, corrections, session checkpoints, and camera summaries can contain personal context. Generated phrase audio is cached under `cache/tts` beside the state file and removed by delete-all. Camera sampling skips unchanged frames before inference, with explicit capture and a bounded refresh; fake test feeds must not become claimed real-world objects. +The browser server only answers same-origin loopback requests that carry the per-launch app token. Other websites cannot read or delete memory through it (see [local web security](GAP_AND_DESIGN.md#local-web-security)). Any local process that can load the page can still obtain the token, just as it could read `data/` directly. + The privacy fields document intent and supported controls; do not treat them as a general consent engine or a guarantee about provider retention. Keep API keys out of progress, exports, and logs. Electron key storage and precedence are described in [packaging](PACKAGING_DESIGN.md). ## Validation limits diff --git a/docs/notes.md b/docs/notes.md index 007c290..38cd43b 100644 --- a/docs/notes.md +++ b/docs/notes.md @@ -21,6 +21,13 @@ Current behavior is documented in [architecture](GAP_AND_DESIGN.md), [curriculum - The PyInstaller bridge passed its outside-checkout status/missing-key/SDK-import smokes. Electron packaging succeeded using the installed lockfile-matching Electron distribution; ad-hoc signing and strict deep signature verification passed. The host required Command Line Tools via a per-command `DEVELOPER_DIR` because its selected Xcode loader was broken. The default Electron download was blocked by sandbox DNS; no source/build-config workaround was committed. - No paid API calls or physical-camera/clean-account GUI acceptance were performed. +## Local web request security (2026-10-04) + +- Decision: the browser server is loopback-only. It validates Host/Origin/fetch metadata and requires a per-launch token for every API request, plus strict JSON bodies. This closes foreign-page `text/plain` POSTs to delete-all, provider, and other bridge routes, and DNS rebinding. Contract and migration: [architecture](GAP_AND_DESIGN.md#local-web-security). +- Validation: socket-level tests cover every bridge command and the legacy lesson/conversation routes. They exercise foreign/null/cross-port/wrong-scheme Origin, cross-site fetch metadata, missing/wrong/stale/duplicate tokens, rebinding/invalid/duplicate Host, unsupported media types, bad/oversize/5,000-digit/superscript lengths, chunked bodies, malformed/non-object/NaN/deep JSON, and OPTIONS/PUT/DELETE/PATCH. Spies show rejected requests never reach state, providers, or dispatch, and files stay byte-identical. Unauthenticated malformed lengths keep their 403/421 status. +- Authorized browser-shaped and originless requests persist disposable state. VM tests check that both the shared and fallback browser renderers send the token, and that Electron IPC makes no HTTP calls. 145 Python tests, JS checks, Ruff, and mocked smoke passed. +- Limits: no real-browser, Electron window, camera, or paid-provider run was part of this validation. Intentional breaks are non-loopback `--host`, tokenless scripts, empty or non-object POST bodies, and plain `ThreadingHTTPServer` embedding. + ## Provenance The full construction log and original five design proposals remain available in [the pre-cleanup Git snapshot](https://github.com/Jo2234/FluentAI/tree/d923a6a49243ed19cbad3c3470e165157f2c34b0). In that snapshot, `notes.md` records dated work and validation, and `docs/` contains the original north-star, schema, onboarding, curriculum, and packaging proposals. Their work-package assignments, future-tense descriptions, and old source line numbers are historical, not current implementation instructions. Git history retains authorship and the evolution of those decisions. diff --git a/fluent_ai/web.py b/fluent_ai/web.py index 7d1ba4b..c4e6ef7 100644 --- a/fluent_ai/web.py +++ b/fluent_ai/web.py @@ -1,8 +1,12 @@ from __future__ import annotations import argparse +import hmac +import html import json -from dataclasses import asdict +import re +import secrets +import socket from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from pathlib import Path from typing import Any @@ -17,6 +21,53 @@ MAX_JSON_BYTES = 64_000 +MAX_JSON_DEPTH = 64 +# The browser UI is served only to loopback clients. These names are matched +# exactly (with the listening port), so attacker DNS names that rebind to +# 127.0.0.1 are rejected by Host even when Origin matches that Host. +LOOPBACK_HOSTS = ("127.0.0.1", "localhost", "[::1]") +LOOPBACK_BIND_HOSTS = ("127.0.0.1", "localhost", "::1") +TOKEN_HEADER = "X-FluentAI-Token" +TOKEN_META = "fluentai-api-token" +BRIDGE_PREFIX = "/api/bridge/" +COMMAND_RE = re.compile(r"[a-z_]{1,64}") +SAME_ORIGIN_FETCH_SITES = ("same-origin", "none") +NO_STORE_HEADERS = ( + ("Cache-Control", "no-store"), + ("X-Content-Type-Options", "nosniff"), + ("Referrer-Policy", "no-referrer"), +) +# Pages carrying the API token must not be framed by another site (clickjacking). +HTML_HEADERS = (("X-Frame-Options", "DENY"), ("Content-Security-Policy", "frame-ancestors 'none'")) + + +class RequestRejected(Exception): + def __init__(self, status: int, code: str, message: str) -> None: + super().__init__(message) + self.status = status + self.code = code + self.message = message + + +class FluentAIServer(ThreadingHTTPServer): + """Loopback HTTP server with a fresh API token for each launch.""" + + daemon_threads = True + + def __init__(self, server_address: tuple[str, int], handler_class: type[BaseHTTPRequestHandler]) -> None: + if server_address[0] not in LOOPBACK_BIND_HOSTS: + raise ValueError(f"FluentAI web only binds to loopback hosts: {', '.join(LOOPBACK_BIND_HOSTS)}") + if server_address[0] == "::1": + self.address_family = socket.AF_INET6 + super().__init__(server_address, handler_class) + self.api_token = secrets.token_urlsafe(32) + port = self.server_address[1] + authorities = {f"{host}:{port}" for host in LOOPBACK_HOSTS} + if port == 80: + authorities.update(LOOPBACK_HOSTS) + self.allowed_authorities = frozenset(authorities) + self.allowed_origins = frozenset(f"http://{authority}" for authority in authorities) + HTML = """ @@ -184,21 +235,23 @@ const objectEl = document.getElementById("object"); const preview = document.getElementById("videoPreview"); + const apiToken = document.querySelector('meta[name="fluentai-api-token"]')?.content || ""; + async function postJSON(url, body) { output.textContent = "Running agents..."; const response = await fetch(url, { method: "POST", - headers: {"Content-Type": "application/json"}, + headers: {"Content-Type": "application/json", "X-FluentAI-Token": apiToken}, body: JSON.stringify(body || {}) }); const data = await response.json(); - output.textContent = data.text || JSON.stringify(data, null, 2); + output.textContent = data.text || data.error || JSON.stringify(data, null, 2); } async function loadStatus() { - const response = await fetch("/api/status"); + const response = await fetch("/api/status", { headers: {"X-FluentAI-Token": apiToken} }); const data = await response.json(); - statusEl.textContent = data.status; + statusEl.textContent = data.status || data.error; } function updateVideoPreview() { @@ -234,39 +287,61 @@ class FluentAIHandler(BaseHTTPRequestHandler): state_path = DEFAULT_PROGRESS_PATH language = "Spanish" renderer_path = Path(__file__).resolve().parent.parent / "desktop" / "electron" / "renderer.html" - + timeout = 30 + + # Every request is checked in this order before any learner state is read, + # any provider is constructed, or any bridge command runs: + # 1. Host is an exact loopback authority on the listening port. + # 2. API requests: Origin (if sent) is that same loopback origin, fetch + # metadata (if sent) is same-origin, and X-FluentAI-Token matches. + # 3. POST bodies are application/json, bounded, UTF-8 JSON objects. def do_GET(self) -> None: - if self.path == "/": - self._send_text(self._renderer_html(), "text/html") + try: + path = self._checked_path() + if path == "/": + self._send_html(self._with_token(self._renderer_html())) + return + if path not in ("/api/status", "/api/progress"): + raise RequestRejected(404, "not_found", "Not found.") + self._check_api_caller() + except RequestRejected as exc: + self._send_rejection(exc) return - if self.path == "/api/status": + if path == "/api/status": provider = OpenAIProvider() state = load_state(self.state_path, self.language) profile = profile_for(state, provider) status = f"{provider.status()} Level {profile['level']}; weak topics: {', '.join(profile['weak_topics'])}." self._send_json({"status": status}) return - if self.path == "/api/progress": - self._send_json(load_state(self.state_path, self.language)) - return - self.send_error(404) + self._send_json(load_state(self.state_path, self.language)) def do_POST(self) -> None: - body = self._read_json() - if self.path.startswith("/api/bridge/"): - command = self.path.rsplit("/", 1)[-1] + self._body_consumed = False + try: + path = self._checked_path() + self._check_api_caller() + command = self._route_post(path) + body = self._read_json() + except RequestRejected as exc: + self._send_rejection(exc) + return + if command is not None: self._send_json(self._run_bridge_command(command, body)) return - if self.path == "/api/lesson": + if path == "/api/lesson": self._send_json({"text": run_lesson_cycle(self.state_path, self.language)}) return - if self.path == "/api/conversation": - turns = _bounded_int(body.get("turns", 4), 2, 8, 4) - video_on = body.get("video", "off") == "on" - video_object = str(body.get("object") or "").strip() or None - self._send_json({"text": run_conversation_cycle(self.state_path, self.language, turns, video_on, video_object)}) - return - self.send_error(404) + turns = _bounded_int(body.get("turns", 4), 2, 8, 4) + video_on = body.get("video", "off") == "on" + video_object = str(body.get("object") or "").strip() or None + self._send_json({"text": run_conversation_cycle(self.state_path, self.language, turns, video_on, video_object)}) + + def do_OPTIONS(self) -> None: + # No CORS: preflights get no Access-Control-* grant. + self._send_rejection(RequestRejected(405, "method_not_allowed", "Method not allowed.")) + + do_PUT = do_PATCH = do_DELETE = do_OPTIONS def log_message(self, format: str, *args: Any) -> None: return @@ -277,6 +352,56 @@ def _renderer_html(self) -> str: except OSError: return HTML + def _with_token(self, page: str) -> str: + meta = f'' + if "" in page: + return page.replace("", f"\n {meta}", 1) + return meta + page + + def _server_token(self) -> str: + token = getattr(self.server, "api_token", None) + if not isinstance(token, str) or not token: + # Plain ThreadingHTTPServer instances have no token; fail closed. + raise RequestRejected(500, "server_misconfigured", "Server has no API token; start it with FluentAIServer.") + return token + + def _single_header(self, name: str) -> str | None: + values = self.headers.get_all(name) or [] + if len(values) > 1: + raise RequestRejected(400, "duplicate_header", f"Duplicate {name} header.") + return values[0] if values else None + + def _checked_path(self) -> str: + allowed = getattr(self.server, "allowed_authorities", frozenset()) + host = self._single_header("Host") + if host is None or host.strip().lower() not in allowed: + raise RequestRejected(421, "invalid_host", "Requests must address this app on a loopback host and port.") + if not self.path.startswith("/") or self.path.startswith("//"): + raise RequestRejected(400, "invalid_path", "Invalid request path.") + return self.path.split("?", 1)[0] + + def _check_api_caller(self) -> None: + origin = self._single_header("Origin") + if origin is not None and origin.strip().lower() not in getattr(self.server, "allowed_origins", frozenset()): + raise RequestRejected(403, "invalid_origin", "Cross-origin requests are not allowed.") + fetch_site = self._single_header("Sec-Fetch-Site") + if fetch_site is not None and fetch_site.strip().lower() not in SAME_ORIGIN_FETCH_SITES: + raise RequestRejected(403, "invalid_origin", "Cross-site requests are not allowed.") + expected = self._server_token() + supplied = self._single_header(TOKEN_HEADER) or "" + if not hmac.compare_digest(supplied.encode("utf-8"), expected.encode("utf-8")): + raise RequestRejected(403, "invalid_token", "Missing or stale app token. Reload the FluentAI page.") + + def _route_post(self, path: str) -> str | None: + if path.startswith(BRIDGE_PREFIX): + command = path[len(BRIDGE_PREFIX):] + if not COMMAND_RE.fullmatch(command) or command not in BRIDGE_COMMANDS: + raise RequestRejected(404, "unknown_command", f"Unknown command: {command[:64]}") + return command + if path in ("/api/lesson", "/api/conversation"): + return None + raise RequestRejected(404, "not_found", "Not found.") + def _run_bridge_command(self, command: str, body: dict[str, Any]) -> dict[str, Any]: handler = BRIDGE_COMMANDS.get(command) if handler is None: @@ -290,33 +415,124 @@ def _run_bridge_command(self, command: str, body: dict[str, Any]) -> dict[str, A return {"ok": False, "error": f"{exc.__class__.__name__}: {exc}"} def _read_json(self) -> dict[str, Any]: - length = _bounded_int(self.headers.get("content-length", "0"), 0, MAX_JSON_BYTES, 0) - if length <= 0: - return {} - raw = self.rfile.read(length).decode("utf-8", errors="replace") + content_type = self._single_header("Content-Type") + if not _is_json_media_type(content_type): + raise RequestRejected(415, "unsupported_media_type", "Requests must use Content-Type: application/json.") + if self._single_header("Transfer-Encoding") is not None: + raise RequestRejected(400, "unsupported_transfer_encoding", "Transfer-Encoding is not supported; send Content-Length.") + raw_length = self._single_header("Content-Length") + if raw_length is None: + raise RequestRejected(411, "length_required", "Content-Length is required.") + length = _parse_content_length(raw_length) + if length is None: + raise RequestRejected(400, "invalid_content_length", "Invalid Content-Length.") + if length > MAX_JSON_BYTES: + raise RequestRejected(413, "body_too_large", f"Request body exceeds {MAX_JSON_BYTES} bytes.") + if length == 0: + raise RequestRejected(400, "empty_body", "Request body must be a JSON object; send {} for no options.") + raw = self.rfile.read(length) + self._body_consumed = True + if len(raw) != length: + raise RequestRejected(400, "incomplete_body", "Request body ended before Content-Length.") try: - value = json.loads(raw) - except json.JSONDecodeError: - return {} - return value if isinstance(value, dict) else {} - - def _send_json(self, payload: dict[str, Any]) -> None: - data = json.dumps(payload).encode("utf-8") - self.send_response(200) - self.send_header("Content-Type", "application/json") - self.send_header("Content-Length", str(len(data))) - self.end_headers() - self.wfile.write(data) + value = json.loads(raw.decode("utf-8"), parse_constant=_reject_json_constant) + except (UnicodeDecodeError, ValueError, RecursionError): + raise RequestRejected(400, "invalid_json", "Request body must be valid UTF-8 JSON.") from None + if not isinstance(value, dict): + raise RequestRejected(400, "invalid_json", "Request body must be a JSON object.") + if _json_depth(value) > MAX_JSON_DEPTH: + raise RequestRejected(400, "invalid_json", f"JSON nesting exceeds {MAX_JSON_DEPTH} levels.") + return value + + def _send_rejection(self, exc: RequestRejected) -> None: + # Unread request bodies must not be parsed as a follow-up request. + self.close_connection = True + self._discard_small_body() + self._send_json({"ok": False, "error": exc.message, "error_code": exc.code}, status=exc.status) + + def _discard_small_body(self) -> None: + # Drain (never parse) a bounded body so closing the socket does not + # reset the connection before the client reads the JSON error. + # Malformed or oversized lengths are not drained; the connection closes + # and the original rejection status is still sent. + if self.command != "POST" or getattr(self, "_body_consumed", False) or "Transfer-Encoding" in self.headers: + return + lengths = self.headers.get_all("Content-Length") or [] + length = _parse_content_length(lengths[0]) if len(lengths) == 1 else None + if length is None or length > MAX_JSON_BYTES: + return + try: + self.rfile.read(length) + except OSError: + pass + + def _send_json(self, payload: dict[str, Any], status: int = 200) -> None: + self._send_bytes(json.dumps(payload).encode("utf-8"), "application/json", status) + + def _send_html(self, payload: str) -> None: + self._send_bytes(payload.encode("utf-8"), "text/html; charset=utf-8", 200, HTML_HEADERS) - def _send_text(self, payload: str, content_type: str) -> None: - data = payload.encode("utf-8") - self.send_response(200) + def _send_bytes(self, data: bytes, content_type: str, status: int, extra: tuple[tuple[str, str], ...] = ()) -> None: + self.send_response(status) self.send_header("Content-Type", content_type) self.send_header("Content-Length", str(len(data))) + for name, value in NO_STORE_HEADERS + extra: + self.send_header(name, value) + if self.close_connection: + self.send_header("Connection", "close") self.end_headers() self.wfile.write(data) +def _is_json_media_type(value: str | None) -> bool: + if value is None: + return False + media_type, *params = (part.strip() for part in value.split(";")) + if media_type.lower() != "application/json": + return False + for param in params: + name, _, param_value = param.partition("=") + if name.strip().lower() != "charset" or param_value.strip().strip('"').lower() not in ("utf-8", "utf8"): + return False + return True + + +def _reject_json_constant(value: str) -> Any: + raise ValueError(f"Unsupported JSON constant: {value}") + + +def _parse_content_length(value: str) -> int | None: + """ASCII decimal Content-Length; None if malformed, MAX_JSON_BYTES + 1 if too large. + + Never converts unbounded digit strings to int (Python caps int() digits). + """ + digits = value.strip(" \t") + if not digits or not digits.isascii() or not digits.isdigit(): + return None + digits = digits.lstrip("0") or "0" + if len(digits) > len(str(MAX_JSON_BYTES)): + return MAX_JSON_BYTES + 1 + return min(int(digits), MAX_JSON_BYTES + 1) + + +def _json_depth(value: Any) -> int: + deepest = 0 + stack = [(value, 1)] + while stack: + item, depth = stack.pop() + if isinstance(item, dict): + children = item.values() + elif isinstance(item, list): + children = item + else: + continue + deepest = max(deepest, depth) + if deepest > MAX_JSON_DEPTH: + return deepest + stack.extend((child, depth + 1) for child in children) + return deepest + + def _bounded_int(value: Any, low: int, high: int, default: int) -> int: try: parsed = int(value) @@ -418,14 +634,15 @@ def run_conversation_cycle(state_path: Path, language: str, turns: int, video_on def run_server(host: str, port: int, state_path: Path, language: str) -> None: FluentAIHandler.state_path = state_path FluentAIHandler.language = language - server = ThreadingHTTPServer((host, port), FluentAIHandler) - print(f"FluentAI web app running at http://{host}:{port}") + server = FluentAIServer((host, port), FluentAIHandler) + url_host = "[::1]" if host == "::1" else host + print(f"FluentAI web app running at http://{url_host}:{server.server_address[1]}") server.serve_forever() def build_parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser(description="Run the FluentAI local web app.") - parser.add_argument("--host", default="127.0.0.1") + parser.add_argument("--host", default="127.0.0.1", choices=LOOPBACK_BIND_HOSTS, help="Loopback bind address.") parser.add_argument("--port", type=int, default=7860) parser.add_argument("--state-path", type=Path, default=DEFAULT_PROGRESS_PATH) parser.add_argument("--language", default="Spanish") diff --git a/package.json b/package.json index 8077f74..4a24866 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,7 @@ "build:app": "npm run package:mac", "app": "npm run package:mac && npm run sign:mac && open dist/mac-arm64/FluentAI.app", "test:python": "python -m unittest discover -s tests -q", - "test:js": "node --check desktop/electron/main.js && node --check desktop/electron/preload.js && node --check docs/mockups/duolingo-inspired/render-screenshots.js && node tests/test_main_redaction.js && node tests/test_renderer_sessions.js", + "test:js": "node --check desktop/electron/main.js && node --check desktop/electron/preload.js && node --check docs/mockups/duolingo-inspired/render-screenshots.js && node tests/test_main_redaction.js && node tests/test_renderer_sessions.js && node tests/test_renderer_web_token.js", "smoke": "python scripts/smoke_demo.py", "check": "npm run test:js && npm run test:python && npm run smoke", "package:bridge": "./scripts/build_bridge.sh", diff --git a/scripts/portfolio_demo.py b/scripts/portfolio_demo.py index 9671ae9..fff4459 100644 --- a/scripts/portfolio_demo.py +++ b/scripts/portfolio_demo.py @@ -70,7 +70,7 @@ def main(): DemoHandler.state_path = path DemoHandler.language = "Spanish" with patch.object(web, "OpenAIProvider", DemoProvider), patch.object(desktop_bridge, "OpenAIProvider", DemoProvider): - with web.ThreadingHTTPServer(("127.0.0.1", args.port), DemoHandler) as server: + with web.FluentAIServer(("127.0.0.1", args.port), DemoHandler) as server: print(f"Mocked portfolio recording: http://127.0.0.1:{args.port}", flush=True) try: server.serve_forever() diff --git a/scripts/record_portfolio_demo.cjs b/scripts/record_portfolio_demo.cjs index e85dc0f..00edbf3 100644 --- a/scripts/record_portfolio_demo.cjs +++ b/scripts/record_portfolio_demo.cjs @@ -90,7 +90,8 @@ const { chromium } = require('playwright'); await page.reload(); await page.locator('#homePane').waitFor({ state: 'visible' }); await chapter('Reloaded · Progress persists · Voice/model quality not shown'); - const saved = await page.request.post(`${url}/api/bridge/status`, { data: {} }); + const token = await page.locator('meta[name="fluentai-api-token"]').getAttribute('content'); + const saved = await page.request.post(`${url}/api/bridge/status`, { data: {}, headers: { 'X-FluentAI-Token': token } }); const status = await saved.json(); assert(status.profile.xp >= result.profile.xp); assert(status.profile.review_count > 0); diff --git a/tests/test_renderer_web_token.js b/tests/test_renderer_web_token.js new file mode 100644 index 0000000..8116d87 --- /dev/null +++ b/tests/test_renderer_web_token.js @@ -0,0 +1,81 @@ +'use strict'; +// Browser-mode bridges must send the per-launch token; Electron IPC must not use HTTP. +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const vm = require('node:vm'); +const path = require('node:path'); + +const root = path.join(__dirname, '..'); +const renderer = fs.readFileSync(path.join(root, 'desktop/electron/renderer.html'), 'utf8'); +const rendererScript = renderer.match(/