From 28af13a5177b7610d1c636a69eef0a8ababfadc5 Mon Sep 17 00:00:00 2001 From: Ho1yShif Date: Tue, 7 Jul 2026 18:53:23 -0700 Subject: [PATCH 1/4] feat(render): add locked-down DEMO mode for a public no-auth demo DEMO=true runs a hosted, unauthenticated, chat-only nanobot (what powers the template's public demo); forks default to full token auth. Each browser still gets its own isolated session, and public usage is bounded by env-configurable rate + per-session caps. - entrypoint.sh selects render-demo-config.json when DEMO=true (owns the --config flag; render.yaml passes only `gateway`). - render-demo-config.json: chat + web only. Shell, filesystem, subagent, MCP, cron, self-modify, image-gen all off; restrictToWorkspace + no local service access + cheaper default model. - WebSocketConfig.demo: relaxes the 0.0.0.0-requires-token validator; the /webui/bootstrap gate is skipped in demo and mints anonymous short-lived WS tokens; response carries demo:true. - New tools.subagent.enable flag (the one dangerous tool lacking a config gate); cron_service withheld from the demo agent so the cron tool never registers. - Per-connection rate limit + per-session message cap in the WS connection loop, configured via DEMO_RATE_LIMIT_PER_MINUTE / DEMO_MAX_MESSAGES_PER_SESSION (0 disables); friendly reply when a cap trips. - WebUI reads demo flag: hides settings/apps/skills/automations, forces the chat view, shows a demo banner. - Web fetch is already SSRF-guarded in core (loopback/link-local/metadata/RFC1918 blocked, redirects re-validated), so the one live tool can't pivot internally. - README/.env.example/render.yaml document DEMO, the lockdown, and the limits (incl. how to change or disable them on a fork). Adds tests for the demo bootstrap bypass, validator, rate/session caps, and tool-lockdown; non-demo auth path is regression-covered. Co-Authored-By: Claude Opus 4.8 (1M context) --- .env.example | 14 ++ README.md | 39 +++++ entrypoint.sh | 21 ++- nanobot/agent/tools/spawn.py | 11 ++ nanobot/channels/websocket.py | 99 ++++++++++++ nanobot/cli/commands.py | 22 ++- nanobot/config/schema.py | 6 + nanobot/webui/ws_http.py | 8 +- render-demo-config.json | 38 +++++ render.yaml | 22 ++- tests/channels/test_websocket_demo.py | 211 +++++++++++++++++++++++++ tests/tools/test_tool_loader.py | 77 +++++++++ webui/src/App.tsx | 24 +++ webui/src/components/Sidebar.tsx | 70 ++++---- webui/src/lib/types.ts | 2 + webui/src/providers/ClientProvider.tsx | 6 +- 16 files changed, 633 insertions(+), 37 deletions(-) create mode 100644 render-demo-config.json create mode 100644 tests/channels/test_websocket_demo.py diff --git a/.env.example b/.env.example index 68fc233a..928eaeb8 100644 --- a/.env.example +++ b/.env.example @@ -16,3 +16,17 @@ NANOBOT_WEB_TOKEN=replace-with-a-long-random-string # Port the gateway's WebSocket/WebUI channel binds; Render routes public traffic # here. Matches channels.websocket.port in render-config.json. PORT=8765 + +# --- DEMO mode (optional) --------------------------------------------------- +# Set DEMO=true to run the hosted, unauthenticated, locked-down demo: chat-only +# (no shell/file/subagent/MCP/cron/image-gen; web search/fetch stays on), served +# from render-demo-config.json. Leave false/unset to keep full auth via +# NANOBOT_WEB_TOKEN. Not a secret. See README.md → "DEMO mode". +DEMO=false + +# Demo abuse controls — only apply when DEMO=true. Set to 0 to disable a limit. +# Messages per minute, per WebSocket connection (default 10). +DEMO_RATE_LIMIT_PER_MINUTE=10 +# Total messages per browser session (default 30). Each browser gets its own +# isolated session. +DEMO_MAX_MESSAGES_PER_SESSION=30 diff --git a/README.md b/README.md index d12908cd..9b79c953 100644 --- a/README.md +++ b/README.md @@ -52,6 +52,45 @@ A deployed nanobot is a capable agent: anyone who gets past `NANOBOT_WEB_TOKEN` - This template ships hardened defaults (`restrictToWorkspace`, self-modification writes off) and runs as a non-root user in an isolated container — but the token is still your primary defense. - Rotate the token (and your API key) if you suspect it leaked. +## DEMO mode + +DEMO mode lets you host a **public, no-auth demo** of nanobot that anyone can chat with immediately — no `NANOBOT_WEB_TOKEN` prompt. It's what powers the hosted demo of this template. + +**Forks default to full auth.** `DEMO` is `false`/unset unless you deliberately turn it on, so forking this template gives you the normal token-gated agent. Only set `DEMO=true` if you actually want an open, locked-down demo. + +**How it works.** When `DEMO=true`, [`entrypoint.sh`](./entrypoint.sh) starts the gateway with [`render-demo-config.json`](./render-demo-config.json) instead of `render-config.json`. That config is locked down and the WebSocket channel runs in `demo` mode: the WebUI bootstrap skips the auth gate and mints short-lived anonymous tokens, and each browser still gets its own **isolated** session (`websocket:{uuid}`), so visitors never see each other's chats. + +**The lockdown (what the demo agent can and can't do).** Chat + web search/fetch only. Everything else is off: + +| Capability | Demo | Why | +|---|---|---| +| Web search / fetch | ✅ on | The one useful tool; SSRF-guarded (see below). | +| Shell / exec | ❌ off | Would expose env vars incl. your `ANTHROPIC_API_KEY`. | +| Filesystem (read/write/edit/find) | ❌ off | Would let it read files/config. | +| Subagents (`spawn`) | ❌ off | New `tools.subagent.enable` flag, off in demo. | +| Cron / scheduled tasks | ❌ off | The agent is chat-only, no background work. | +| MCP servers | ❌ off | No external tool servers. | +| Self-modification (`my`) | ❌ off | No runtime config changes. | +| Image generation | ❌ off | Cost control. | + +The demo also sets `restrictToWorkspace: true`, `webuiAllowLocalServiceAccess: false`, and a cheaper default model (`anthropic/claude-haiku-4-5`) for cost defense-in-depth. The WebUI hides the settings / apps / automations / skills UI and shows a "Demo mode" banner. + +**Abuse / cost limits.** Two env vars bound public usage (they apply **only** when `DEMO=true`): + +| Variable | Default | Meaning | +|---|---|---| +| `DEMO_RATE_LIMIT_PER_MINUTE` | `10` | Max messages per minute, per WebSocket connection. | +| `DEMO_MAX_MESSAGES_PER_SESSION` | `30` | Max total messages per browser session. | + +When a cap trips, the agent replies "Demo limit reached — deploy your own nanobot to keep chatting." and stops the turn. + +**Changing or disabling the limits on your fork.** Set the env vars in `render.yaml` (or the Render dashboard → Environment). Raise them for a busier demo, or set either to **`0`** to disable that limit entirely (unlimited). They have no effect unless `DEMO=true`. + +**Security caveats.** +- Web fetch is SSRF-guarded in nanobot core: requests to loopback, link-local (incl. cloud metadata `169.254.169.254`), and RFC1918 private ranges are blocked, and every redirect hop is re-validated (`nanobot/security/network.py`). The demo adds no `ssrf_whitelist`, so nothing is exempted. +- With shell and filesystem tools off, the agent has no way to read the process environment or on-disk config, so the resolved `ANTHROPIC_API_KEY` (in-memory only) is unreachable. +- A demo is still a public LLM on your API key. Keep the limits sane, watch spend in the Anthropic console, and prefer the cheaper default model. + ## Configuration & docs Edit [`render-config.json`](./render-config.json) to change the model, provider, enabled tools, or channels, then redeploy. To use a different provider (OpenAI, etc.), swap the `providers` block and the model, and add the matching key to `render.yaml` as a `sync: false` env var. diff --git a/entrypoint.sh b/entrypoint.sh index ef2c2bf1..d206340b 100755 --- a/entrypoint.sh +++ b/entrypoint.sh @@ -5,13 +5,28 @@ # start is diagnosable in the platform's logs instead of a silent exit. echo "[entrypoint] starting as $(id)" +# Pick the gateway config. DEMO=true/1 runs the locked-down, unauthenticated +# hosted demo (chat-only, rate/session capped); anything else keeps full auth. +# render.yaml passes only `gateway` as the command, so the entrypoint owns the +# --config selection here. +CONFIG="/app/render-config.json" +case "$DEMO" in + true|TRUE|True|1) + CONFIG="/app/render-demo-config.json" + echo "[entrypoint] DEMO mode enabled — using $CONFIG" + ;; + *) + echo "[entrypoint] normal mode — using $CONFIG" + ;; +esac + dir="$HOME/.nanobot" mkdir -p "$dir" || echo "[entrypoint] warning: mkdir $dir failed" if [ "$(id -u)" != "0" ]; then # Already non-root (platform forced a user). Can't chown; just run. echo "[entrypoint] not root — running app as $(id -un)" - exec nanobot "$@" + exec nanobot "$@" --config "$CONFIG" fi # Running as root: make the mounted disk writable by the app user. @@ -22,8 +37,8 @@ chown -R nanobot:nanobot "$dir" || echo "[entrypoint] warning: chown $dir failed # still write the root-owned disk. if setpriv --reuid=nanobot --regid=nanobot --init-groups true 2>/dev/null; then echo "[entrypoint] dropping privileges to nanobot via setpriv" - exec setpriv --reuid=nanobot --regid=nanobot --init-groups nanobot "$@" + exec setpriv --reuid=nanobot --regid=nanobot --init-groups nanobot "$@" --config "$CONFIG" fi echo "[entrypoint] setpriv privilege-drop not permitted — running app as root" -exec nanobot "$@" +exec nanobot "$@" --config "$CONFIG" diff --git a/nanobot/agent/tools/spawn.py b/nanobot/agent/tools/spawn.py index 420afc04..2e9a4617 100644 --- a/nanobot/agent/tools/spawn.py +++ b/nanobot/agent/tools/spawn.py @@ -8,12 +8,19 @@ from nanobot.agent.tools.base import Tool, tool_parameters from nanobot.agent.tools.context import ContextAware, RequestContext from nanobot.agent.tools.schema import NumberSchema, StringSchema, tool_parameters_schema +from nanobot.config_base import Base from nanobot.security.workspace_access import current_workspace_scope if TYPE_CHECKING: from nanobot.agent.subagent import SubagentManager +class SubagentToolConfig(Base): + """Subagent (spawn) tool configuration.""" + + enable: bool = True # allow spawning background subagents + + @tool_parameters( tool_parameters_schema( task=StringSchema("The task for the subagent to complete"), @@ -43,6 +50,10 @@ def __init__(self, manager: "SubagentManager"): default=None, ) + @classmethod + def enabled(cls, ctx: Any) -> bool: + return bool(ctx.config.subagent.enable) and ctx.subagent_manager is not None + @classmethod def create(cls, ctx: Any) -> Tool: return cls(manager=ctx.subagent_manager) diff --git a/nanobot/channels/websocket.py b/nanobot/channels/websocket.py index 024ad5f4..a997d46a 100644 --- a/nanobot/channels/websocket.py +++ b/nanobot/channels/websocket.py @@ -5,9 +5,12 @@ import asyncio import hmac import json +import os import re import ssl +import time import uuid +from collections import deque from collections.abc import Callable from contextlib import suppress from pathlib import Path @@ -62,6 +65,59 @@ # Plain HTTP WebUI routes also run through websockets.process_request. _WEBUI_HTTP_OPEN_TIMEOUT_S = 360.0 +# Demo abuse controls. Defaults apply only in demo mode; a value <= 0 (or an +# unparseable/unset env var falling back below) disables that dimension. +_DEMO_RATE_LIMIT_PER_MINUTE_DEFAULT = 10 +_DEMO_MAX_MESSAGES_PER_SESSION_DEFAULT = 30 +_DEMO_LIMIT_MESSAGE = "Demo limit reached — deploy your own nanobot to keep chatting." + + +def _demo_env_int(name: str, default: int) -> int: + """Read a non-negative int env var; fall back to *default* if unset/invalid.""" + raw = os.environ.get(name) + if raw is None or not raw.strip(): + return default + try: + value = int(raw.strip()) + except ValueError: + return default + return value if value >= 0 else default + + +class _DemoLimiter: + """Per-connection token-bucket rate limit + per-session message cap. + + Each demo WebSocket connection owns its own session, so the per-session + cap is enforced per connection. A limit <= 0 disables that dimension. + """ + + def __init__( + self, + rate_per_minute: int, + max_per_session: int, + *, + now: Callable[[], float] = time.monotonic, + ) -> None: + self._rate = max(0, rate_per_minute) + self._max = max(0, max_per_session) + self._now = now + self._events: deque[float] = deque() + self._count = 0 + + def check(self) -> bool: + """Return True if a message is allowed (and consume one); False if capped.""" + if self._max and self._count >= self._max: + return False + if self._rate: + now = self._now() + while self._events and now - self._events[0] >= 60.0: + self._events.popleft() + if len(self._events) >= self._rate: + return False + self._events.append(now) + self._count += 1 + return True + class WebSocketConfig(Base): """WebSocket server channel configuration. @@ -86,6 +142,12 @@ class WebSocketConfig(Base): enabled: bool = True host: str = "127.0.0.1" port: int = 8765 + # Demo mode: hosted, unauthenticated, locked-down chat. When True the HTTP + # bootstrap skips the secret/localhost gate (it still mints a short-lived WS + # token) and 0.0.0.0 binding is allowed without a static token. Forks leave + # this False and keep full auth. Pair with a locked-down tools config and + # the DEMO_* rate/session caps. + demo: bool = False unix_socket_path: str = "" path: str = "/" token: str = "" @@ -147,6 +209,10 @@ def token_issue_path_differs_from_ws_path(self) -> Self: def wildcard_host_requires_auth(self) -> Self: if self.host not in ("0.0.0.0", "::"): return self + if self.demo: + # Demo mode intentionally serves unauthenticated chat; the bootstrap + # mints anonymous short-lived tokens and the toolset is locked down. + return self if self.token.strip() or self.token_issue_secret.strip(): return self raise ValueError( @@ -519,6 +585,26 @@ async def runner() -> None: self._server_task = asyncio.create_task(runner()) await self._server_task + def _new_demo_limiter(self) -> _DemoLimiter | None: + """Build a per-connection demo limiter, or None when not in demo mode.""" + if not getattr(self.config, "demo", False): + return None + return _DemoLimiter( + _demo_env_int( + "DEMO_RATE_LIMIT_PER_MINUTE", _DEMO_RATE_LIMIT_PER_MINUTE_DEFAULT + ), + _demo_env_int( + "DEMO_MAX_MESSAGES_PER_SESSION", _DEMO_MAX_MESSAGES_PER_SESSION_DEFAULT + ), + ) + + async def _send_demo_limit_reply(self, connection: Any, chat_id: str) -> None: + """Deliver the friendly demo-limit assistant message and end the turn.""" + await self._send_event( + connection, "message", chat_id=chat_id, text=_DEMO_LIMIT_MESSAGE + ) + await self._send_event(connection, "turn_end", chat_id=chat_id, latency_ms=0) + async def _connection_loop(self, connection: Any) -> None: request = connection.request path_part = request.path if request else "/" @@ -532,6 +618,7 @@ async def _connection_loop(self, connection: Any) -> None: client_id = client_id[:128] default_chat_id = str(uuid.uuid4()) + limiter = self._new_demo_limiter() try: await connection.send( @@ -559,12 +646,24 @@ async def _connection_loop(self, connection: Any) -> None: envelope = _parse_envelope(raw) if envelope is not None: + if ( + limiter is not None + and envelope.get("type") == "message" + and not limiter.check() + ): + cid = envelope.get("chat_id") + target = cid if _is_valid_chat_id(cid) else default_chat_id + await self._send_demo_limit_reply(connection, target) + continue await self._dispatch_envelope(connection, client_id, envelope) continue content = _parse_inbound_payload(raw) if content is None: continue + if limiter is not None and not limiter.check(): + await self._send_demo_limit_reply(connection, default_chat_id) + continue # WebSocket already authenticates at handshake time (token), # so pairing is not applicable. Treat as non-DM to avoid # sending pairing codes to an already-authenticated client. diff --git a/nanobot/cli/commands.py b/nanobot/cli/commands.py index 4c6c3d9a..9ca7139c 100644 --- a/nanobot/cli/commands.py +++ b/nanobot/cli/commands.py @@ -867,6 +867,19 @@ def _provider_setup_error(config: Config) -> str | None: return None +def _websocket_demo_enabled(config: Config) -> bool: + """True when the WebSocket channel is configured for unauthenticated demo mode.""" + from nanobot.channels.websocket import WebSocketConfig + + current = getattr(config.channels, "websocket", None) + if not current: + return False + try: + return bool(WebSocketConfig.model_validate(current).demo) + except Exception: + return False + + def _webui_config_dict(config: Config) -> dict[str, Any]: """Return the current WebSocket config as a mutable alias-key dictionary.""" from nanobot.channels.websocket import WebSocketConfig @@ -1315,13 +1328,20 @@ def _run_gateway( cron = CronService(cron_store_path) trigger_store = LocalTriggerStore(config.workspace_path) + # Demo mode is chat-only: withhold cron_service from the agent so the cron + # tool never registers (CronTool.enabled checks ctx.cron_service is not None). + # The service itself keeps running for existing machinery; a fresh demo + # workspace has no jobs and the agent has no tool to create any. + ws_demo = _websocket_demo_enabled(config) + agent_cron_service = None if ws_demo else cron + # Create agent with cron service agent = AgentLoop.from_config( config, bus, provider=provider_snapshot.provider, model=provider_snapshot.model, context_window_tokens=provider_snapshot.context_window_tokens, - cron_service=cron, + cron_service=agent_cron_service, session_manager=session_manager, image_generation_provider_configs=image_gen_provider_configs(config), provider_snapshot_loader=load_provider_snapshot, diff --git a/nanobot/config/schema.py b/nanobot/config/schema.py index 290edcdd..9d4cf0c9 100644 --- a/nanobot/config/schema.py +++ b/nanobot/config/schema.py @@ -16,6 +16,7 @@ from nanobot.agent.tools.image_generation import ImageGenerationToolConfig from nanobot.agent.tools.self import MyToolConfig from nanobot.agent.tools.shell import ExecToolConfig + from nanobot.agent.tools.spawn import SubagentToolConfig from nanobot.agent.tools.web import WebToolsConfig @@ -367,6 +368,9 @@ class ToolsConfig(Base): image_generation: ImageGenerationToolConfig = Field( default_factory=lambda: _lazy_default("nanobot.agent.tools.image_generation", "ImageGenerationToolConfig"), ) + subagent: SubagentToolConfig = Field( + default_factory=lambda: _lazy_default("nanobot.agent.tools.spawn", "SubagentToolConfig"), + ) restrict_to_workspace: bool = False # policy intent: keep tool access inside workspace when possible webui_allow_local_service_access: bool = Field( default=True, @@ -612,6 +616,7 @@ def _resolve_tool_config_refs() -> None: from nanobot.agent.tools.image_generation import ImageGenerationToolConfig from nanobot.agent.tools.self import MyToolConfig from nanobot.agent.tools.shell import ExecToolConfig + from nanobot.agent.tools.spawn import SubagentToolConfig from nanobot.agent.tools.web import WebFetchConfig, WebSearchConfig, WebToolsConfig # Re-export into this module's namespace @@ -619,6 +624,7 @@ def _resolve_tool_config_refs() -> None: mod.ExecToolConfig = ExecToolConfig # type: ignore[attr-defined] mod.FileToolsConfig = FileToolsConfig # type: ignore[attr-defined] mod.CliAppsToolConfig = CliAppsToolConfig # type: ignore[attr-defined] + mod.SubagentToolConfig = SubagentToolConfig # type: ignore[attr-defined] mod.WebToolsConfig = WebToolsConfig # type: ignore[attr-defined] mod.WebSearchConfig = WebSearchConfig # type: ignore[attr-defined] mod.WebFetchConfig = WebFetchConfig # type: ignore[attr-defined] diff --git a/nanobot/webui/ws_http.py b/nanobot/webui/ws_http.py index 24b3b6ce..c6f763cb 100644 --- a/nanobot/webui/ws_http.py +++ b/nanobot/webui/ws_http.py @@ -305,8 +305,13 @@ def _handle_token_issue(self, connection: Any, request: Any) -> Any: # -- Bootstrap ---------------------------------------------------------- def _handle_bootstrap(self, connection: Any, request: Any) -> Response: + demo = bool(getattr(self.config, "demo", False)) secret = self.config.token_issue_secret.strip() or self.config.token.strip() - if secret: + if demo: + # Demo mode: skip the secret/localhost gate entirely. Anonymous + # visitors still get a short-lived WS token minted below. + pass + elif secret: if not _issue_route_secret_matches(request.headers, secret): return _http_error(401, "Unauthorized") elif not _is_localhost(connection): @@ -331,6 +336,7 @@ def _handle_bootstrap(self, connection: Any, request: Any) -> Response: "model_name": _resolve_bootstrap_model_name(self.runtime_model_name), "runtime_surface": self._runtime_surface, "runtime_capabilities": self._capabilities, + "demo": demo, } ) diff --git a/render-demo-config.json b/render-demo-config.json new file mode 100644 index 00000000..644387aa --- /dev/null +++ b/render-demo-config.json @@ -0,0 +1,38 @@ +{ + "agents": { + "defaults": { + "model": "anthropic/claude-haiku-4-5", + "provider": "auto" + } + }, + "providers": { + "anthropic": { + "apiKey": "${ANTHROPIC_API_KEY}" + } + }, + "gateway": { + "host": "127.0.0.1", + "port": 18790 + }, + "channels": { + "websocket": { + "enabled": true, + "host": "0.0.0.0", + "port": 8765, + "demo": true, + "websocketRequiresToken": true + } + }, + "tools": { + "restrictToWorkspace": true, + "webuiAllowRemotePackageInstall": false, + "webuiAllowLocalServiceAccess": false, + "web": { "enable": true }, + "exec": { "enable": false }, + "file": { "enable": false }, + "my": { "enable": false, "allowSet": false }, + "image_generation": { "enabled": false }, + "subagent": { "enable": false }, + "mcp_servers": {} + } +} diff --git a/render.yaml b/render.yaml index 70abbc2d..97288239 100644 --- a/render.yaml +++ b/render.yaml @@ -10,9 +10,11 @@ services: dockerContext: . # Render's Docker Command REPLACES the Dockerfile ENTRYPOINT (it is not # appended to it), so invoke the entrypoint explicitly. The entrypoint - # prepares the mounted disk, drops to the non-root user when permitted, then - # execs `nanobot gateway --config /app/render-config.json`. - dockerCommand: /usr/local/bin/entrypoint.sh gateway --config /app/render-config.json + # prepares the mounted disk, drops to the non-root user when permitted, and + # picks the config: `--config /app/render-config.json` normally, or + # `--config /app/render-demo-config.json` when DEMO is true. It appends the + # --config flag itself, so pass only `gateway` here. + dockerCommand: /usr/local/bin/entrypoint.sh gateway plan: starter healthCheckPath: / envVars: @@ -28,6 +30,20 @@ services: # in render-config.json. - key: PORT value: 8765 + # DEMO mode (optional, default off). Set to "true" to run the hosted, + # unauthenticated, locked-down demo (chat-only: no shell/file/subagent/ + # MCP/cron/image-gen; web search/fetch stays on). Forks should leave this + # false/unset to keep full auth via NANOBOT_WEB_TOKEN. Not a secret. + - key: DEMO + value: "false" + # Demo abuse controls (only apply when DEMO=true). Plain, non-secret + # values. Set to 0 to disable a limit. See README → "DEMO mode". + # Messages per minute, per WebSocket connection. + - key: DEMO_RATE_LIMIT_PER_MINUTE + value: 10 + # Total messages per browser session (each browser gets its own session). + - key: DEMO_MAX_MESSAGES_PER_SESSION + value: 30 # Persist sessions / memory across deploys. Config lives in /app (code dir), # so this mount does not shadow it. disk: diff --git a/tests/channels/test_websocket_demo.py b/tests/channels/test_websocket_demo.py new file mode 100644 index 00000000..4c4d0aa5 --- /dev/null +++ b/tests/channels/test_websocket_demo.py @@ -0,0 +1,211 @@ +"""Tests for DEMO mode: config validator, bootstrap bypass, and abuse caps.""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any +from unittest.mock import AsyncMock, MagicMock + +import pytest +from pydantic import ValidationError + +from nanobot.channels.websocket import ( + _DEMO_LIMIT_MESSAGE, + WebSocketChannel, + WebSocketConfig, + _demo_env_int, + _DemoLimiter, +) +from nanobot.webui.gateway_services import build_gateway_services + +# --- Config validator ------------------------------------------------------- + + +def test_wildcard_host_allowed_when_demo_without_token(): + cfg = WebSocketConfig.model_validate( + {"host": "0.0.0.0", "demo": True, "websocketRequiresToken": True} + ) + assert cfg.demo is True + assert cfg.token == "" + + +def test_wildcard_host_rejected_when_not_demo_without_token(): + with pytest.raises(ValidationError): + WebSocketConfig.model_validate({"host": "0.0.0.0"}) + + +def test_demo_defaults_false(): + assert WebSocketConfig().demo is False + + +# --- Env parsing ------------------------------------------------------------ + + +def test_demo_env_int_unset_returns_default(monkeypatch): + monkeypatch.delenv("X_DEMO_TEST", raising=False) + assert _demo_env_int("X_DEMO_TEST", 10) == 10 + + +def test_demo_env_int_parses_value(monkeypatch): + monkeypatch.setenv("X_DEMO_TEST", "3") + assert _demo_env_int("X_DEMO_TEST", 10) == 3 + + +def test_demo_env_int_zero_disables(monkeypatch): + monkeypatch.setenv("X_DEMO_TEST", "0") + assert _demo_env_int("X_DEMO_TEST", 10) == 0 + + +def test_demo_env_int_invalid_falls_back(monkeypatch): + monkeypatch.setenv("X_DEMO_TEST", "abc") + assert _demo_env_int("X_DEMO_TEST", 10) == 10 + + +# --- Rate limiter + per-session cap ----------------------------------------- + + +def test_limiter_rate_limit_trips_within_window(): + t = [0.0] + lim = _DemoLimiter(2, 0, now=lambda: t[0]) + assert lim.check() is True + assert lim.check() is True + assert lim.check() is False # third within the same minute is blocked + t[0] = 61.0 # window slides forward + assert lim.check() is True + + +def test_limiter_session_cap_trips(): + lim = _DemoLimiter(0, 3, now=lambda: 0.0) + assert [lim.check() for _ in range(4)] == [True, True, True, False] + + +def test_limiter_unlimited_when_zero(): + lim = _DemoLimiter(0, 0, now=lambda: 0.0) + assert all(lim.check() for _ in range(100)) + + +def test_limit_message_is_friendly(): + assert "deploy your own nanobot" in _DEMO_LIMIT_MESSAGE.lower() + + +# --- Bootstrap demo bypass -------------------------------------------------- + + +class _FakeConn: + def __init__(self, remote: tuple[str, int] | None): + self.remote_address = remote + + +class _FakeRequest: + def __init__(self, headers: dict[str, str] | None = None): + self.headers = headers or {} + + +def _services(cfg: dict[str, Any], tmp_path: Path): + bus = MagicMock() + bus.publish_inbound = AsyncMock() + return build_gateway_services( + config=WebSocketConfig.model_validate(cfg), + bus=bus, + session_manager=None, + static_dist_path=None, + workspace_path=tmp_path, + default_restrict_to_workspace=False, + runtime_model_name=lambda: "anthropic/claude-haiku-4-5", + runtime_surface="browser", + runtime_capabilities_overrides=None, + cron_service=None, + local_trigger_store=None, + cron_pending_job_ids=None, + local_trigger_pending_ids=None, + ) + + +def _handler(cfg: dict[str, Any], tmp_path: Path): + return _services(cfg, tmp_path).http + + +def _channel(cfg: dict[str, Any], tmp_path: Path) -> WebSocketChannel: + validated = WebSocketConfig.model_validate(cfg) + bus = MagicMock() + bus.publish_inbound = AsyncMock() + services = _services(cfg, tmp_path) + return WebSocketChannel(validated, bus, gateway=services) + + +def test_bootstrap_demo_bypass_remote_no_secret(tmp_path): + handler = _handler( + {"host": "0.0.0.0", "demo": True, "websocketRequiresToken": True}, + tmp_path, + ) + conn = _FakeConn(("203.0.113.9", 5555)) # non-localhost + resp = handler._handle_bootstrap(conn, _FakeRequest()) + assert resp.status_code == 200 + body = json.loads(resp.body) + assert body["demo"] is True + assert body["token"] + + +def test_bootstrap_non_demo_remote_no_secret_is_forbidden(tmp_path): + handler = _handler( + {"host": "127.0.0.1", "websocketRequiresToken": False}, + tmp_path, + ) + conn = _FakeConn(("203.0.113.9", 5555)) # non-localhost, no secret set + resp = handler._handle_bootstrap(conn, _FakeRequest()) + assert resp.status_code == 403 + + +def test_bootstrap_non_demo_with_secret_requires_header(tmp_path): + handler = _handler( + {"host": "0.0.0.0", "token": "s3cr3t-token", "websocketRequiresToken": True}, + tmp_path, + ) + conn = _FakeConn(("203.0.113.9", 5555)) + # No auth header supplied → unauthorized. + resp = handler._handle_bootstrap(conn, _FakeRequest()) + assert resp.status_code == 401 + # Correct secret → 200 and demo is False. + ok = handler._handle_bootstrap( + conn, _FakeRequest({"X-Nanobot-Auth": "s3cr3t-token"}) + ) + assert ok.status_code == 200 + assert json.loads(ok.body)["demo"] is False + + +# --- Channel limiter wiring + friendly stop --------------------------------- + + +def test_new_demo_limiter_none_when_not_demo(tmp_path): + ch = _channel({"host": "127.0.0.1", "websocketRequiresToken": False}, tmp_path) + assert ch._new_demo_limiter() is None + + +def test_new_demo_limiter_built_when_demo(tmp_path, monkeypatch): + monkeypatch.setenv("DEMO_RATE_LIMIT_PER_MINUTE", "5") + monkeypatch.setenv("DEMO_MAX_MESSAGES_PER_SESSION", "7") + ch = _channel( + {"host": "0.0.0.0", "demo": True, "websocketRequiresToken": True}, tmp_path + ) + lim = ch._new_demo_limiter() + assert isinstance(lim, _DemoLimiter) + assert lim._rate == 5 + assert lim._max == 7 + + +async def test_send_demo_limit_reply_emits_message_and_turn_end(tmp_path): + ch = _channel( + {"host": "0.0.0.0", "demo": True, "websocketRequiresToken": True}, tmp_path + ) + sent: list[dict[str, Any]] = [] + + class _Conn: + async def send(self, raw: str) -> None: + sent.append(json.loads(raw)) + + await ch._send_demo_limit_reply(_Conn(), "chat-123") + assert sent[0]["event"] == "message" + assert sent[0]["text"] == _DEMO_LIMIT_MESSAGE + assert sent[0]["chat_id"] == "chat-123" + assert sent[1]["event"] == "turn_end" diff --git a/tests/tools/test_tool_loader.py b/tests/tools/test_tool_loader.py index c7ad9d69..d23cc637 100644 --- a/tests/tools/test_tool_loader.py +++ b/tests/tools/test_tool_loader.py @@ -192,6 +192,40 @@ def test_spawn_tool_create(): assert isinstance(tool, SpawnTool) +def test_spawn_tool_enabled_gated_by_config_and_manager(): + from nanobot.agent.tools.spawn import SpawnTool + from nanobot.config.schema import ToolsConfig + + # Enabled: flag on (default) + manager present. + ctx = ToolContext( + config=ToolsConfig(), + workspace="/tmp", + subagent_manager=MagicMock(), + ) + assert SpawnTool.enabled(ctx) is True + + # Disabled by config flag even when a manager exists. + ctx_off = ToolContext( + config=ToolsConfig.model_validate({"subagent": {"enable": False}}), + workspace="/tmp", + subagent_manager=MagicMock(), + ) + assert SpawnTool.enabled(ctx_off) is False + + # Disabled when no manager is wired, regardless of the flag. + ctx_no_mgr = ToolContext( + config=ToolsConfig(), + workspace="/tmp", + subagent_manager=None, + ) + assert SpawnTool.enabled(ctx_no_mgr) is False + + +def test_subagent_config_default_enable_true(): + from nanobot.config.schema import ToolsConfig + assert ToolsConfig().subagent.enable is True + + def test_cron_tool_enabled_without_service(): from nanobot.agent.tools.cron import CronTool mock_config = MagicMock() @@ -398,3 +432,46 @@ def test_loader_registers_same_tools_as_old_hardcoded(): } actual = set(registered) assert expected <= actual, f"Missing tools: {expected - actual}" + + +# --- DEMO config lockdown (security hard requirement) --- + + +def test_demo_config_registers_no_sensitive_tools(tmp_path, monkeypatch): + """The shipped render-demo-config.json must not register exec/file/subagent/ + MCP (or cron/self/image-gen) tools — only chat + web. This proves the demo + agent cannot reach shell, filesystem, or spawn/MCP surfaces.""" + from nanobot.agent.tools.registry import ToolRegistry + from nanobot.config.loader import load_config + + monkeypatch.setenv("ANTHROPIC_API_KEY", "test-key") + demo_config_path = Path(__file__).resolve().parents[2] / "render-demo-config.json" + config = load_config(demo_config_path) + + # Pass a subagent_manager to prove the *config flag* (not a missing manager) + # disables spawn. cron_service is None to match the demo gateway wiring. + ctx = ToolContext( + config=config.tools, + workspace=str(tmp_path), + bus=MagicMock(), + subagent_manager=MagicMock(), + cron_service=None, + timezone="UTC", + ) + registry = ToolRegistry() + registered = set(ToolLoader().load(ctx, registry)) + + # Sensitive surfaces are absent. + forbidden = { + "exec", "write_stdin", "list_exec_sessions", + "read_file", "write_file", "edit_file", "list_dir", "find_files", "grep", + "spawn", "cron", "my", + } + assert not (forbidden & registered), ( + f"demo config unexpectedly registered: {forbidden & registered}" + ) + # No MCP tools (mcp_servers is empty). + assert not any(name.startswith("mcp_") for name in registered) + # Chat + web remain available. + assert "web_search" in registered + assert "web_fetch" in registered diff --git a/webui/src/App.tsx b/webui/src/App.tsx index 13beb114..2959c29c 100644 --- a/webui/src/App.tsx +++ b/webui/src/App.tsx @@ -62,6 +62,7 @@ type BootState = tokenExpiresAt: number; modelName: string | null; runtimeSurface: RuntimeSurface; + demo: boolean; }; const SIDEBAR_STORAGE_KEY = "nanobot-webui.sidebar"; @@ -406,6 +407,7 @@ export default function App() { tokenExpiresAt: bootstrapTokenExpiresAt(boot.expires_in), modelName: boot.model_name ?? null, runtimeSurface, + demo: boot.demo === true, }); } catch (e) { if (cancelled) return; @@ -511,9 +513,11 @@ export default function App() { client={state.client} token={state.token} modelName={state.modelName} + demo={state.demo} > void; onLogout: () => void; onNativeEngineRestart: () => Promise; @@ -1162,6 +1168,14 @@ function Shell({ return () => window.removeEventListener("keydown", handleKeyDown); }, [onNewChat, onOpenSessionSearch]); + // Demo mode is chat-only: never render settings/apps/automations/skills + // views, even via a stale URL hash or deep link. + useEffect(() => { + if (demo && view !== "chat") { + navigate({ view: "chat", activeKey, settingsSection: "overview" }); + } + }, [demo, view, activeKey, navigate]); + const onSelectSearchResult = useCallback( (key: string) => { setSessionSearchOpen(false); @@ -1439,6 +1453,16 @@ function Shell({ showHostChrome && "host-window-shell", )} > + {demo ? ( +
+ + {t("app.demoBanner", { + defaultValue: + "Demo mode — chat only. Deploy your own nanobot for full features.", + })} + +
+ ) : null} {showHostChrome ? ( (null); const collapsed = Boolean(props.collapsed); @@ -154,27 +156,33 @@ export function Sidebar(props: SidebarProps) { onClick={props.onOpenSearch} icon={} /> - } - /> - } - /> - } - /> + {!demo && ( + } + /> + )} + {!demo && ( + } + /> + )} + {!demo && ( + } + /> + )} {props.archivedCount ? ( - } - /> + {demo ? ( + + {t("sidebar.demoBadge", { defaultValue: "Demo mode" })} + + ) : ( + } + /> + )} diff --git a/webui/src/lib/types.ts b/webui/src/lib/types.ts index 35ba111c..c4b139c5 100644 --- a/webui/src/lib/types.ts +++ b/webui/src/lib/types.ts @@ -302,6 +302,8 @@ export interface BootstrapResponse { model_name?: string | null; runtime_surface?: RuntimeSurface; runtime_capabilities?: RuntimeCapabilities; + /** Hosted, unauthenticated, locked-down demo mode. Hides settings/dangerous UI. */ + demo?: boolean; } export type RuntimeSurface = "browser" | "native"; diff --git a/webui/src/providers/ClientProvider.tsx b/webui/src/providers/ClientProvider.tsx index e97ab4cf..1346db1a 100644 --- a/webui/src/providers/ClientProvider.tsx +++ b/webui/src/providers/ClientProvider.tsx @@ -6,6 +6,8 @@ interface ClientContextValue { client: NanobotClient; token: string; modelName: string | null; + /** Hosted, unauthenticated, locked-down demo mode. */ + demo: boolean; } const ClientContext = createContext(null); @@ -14,15 +16,17 @@ export function ClientProvider({ client, token, modelName = null, + demo = false, children, }: { client: NanobotClient; token: string; modelName?: string | null; + demo?: boolean; children: ReactNode; }) { return ( - + {children} ); From 3b604e331978b3193401ce944002dce861d3d681 Mon Sep 17 00:00:00 2001 From: Ho1yShif Date: Tue, 7 Jul 2026 19:52:06 -0700 Subject: [PATCH 2/4] refactor(render): tidy DEMO-mode code for readability - ws_http bootstrap: invert the demo check so the auth gate is simply skipped instead of an empty `pass` branch. - Sidebar: collapse three repeated `{!demo && ...}` guards into one fragment; move the useClient import into the provider import group. - websocket: clarify the demo-limit env-var comment (0 disables; unset/negative/invalid falls back to the default). No behavior change; 54 demo tests pass, ruff clean, frontend build green. Co-Authored-By: Claude Opus 4.8 (1M context) --- nanobot/channels/websocket.py | 5 ++-- nanobot/webui/ws_http.py | 17 +++++------ webui/src/components/Sidebar.tsx | 51 ++++++++++++++++---------------- 3 files changed, 36 insertions(+), 37 deletions(-) diff --git a/nanobot/channels/websocket.py b/nanobot/channels/websocket.py index a997d46a..a1cc0de0 100644 --- a/nanobot/channels/websocket.py +++ b/nanobot/channels/websocket.py @@ -65,8 +65,9 @@ # Plain HTTP WebUI routes also run through websockets.process_request. _WEBUI_HTTP_OPEN_TIMEOUT_S = 360.0 -# Demo abuse controls. Defaults apply only in demo mode; a value <= 0 (or an -# unparseable/unset env var falling back below) disables that dimension. +# Demo abuse controls, applied only in demo mode. Set an env var to 0 to +# disable that dimension; an unset, negative, or unparseable value falls back +# to the default below. _DEMO_RATE_LIMIT_PER_MINUTE_DEFAULT = 10 _DEMO_MAX_MESSAGES_PER_SESSION_DEFAULT = 30 _DEMO_LIMIT_MESSAGE = "Demo limit reached — deploy your own nanobot to keep chatting." diff --git a/nanobot/webui/ws_http.py b/nanobot/webui/ws_http.py index c6f763cb..84cce7b7 100644 --- a/nanobot/webui/ws_http.py +++ b/nanobot/webui/ws_http.py @@ -307,15 +307,14 @@ def _handle_token_issue(self, connection: Any, request: Any) -> Any: def _handle_bootstrap(self, connection: Any, request: Any) -> Response: demo = bool(getattr(self.config, "demo", False)) secret = self.config.token_issue_secret.strip() or self.config.token.strip() - if demo: - # Demo mode: skip the secret/localhost gate entirely. Anonymous - # visitors still get a short-lived WS token minted below. - pass - elif secret: - if not _issue_route_secret_matches(request.headers, secret): - return _http_error(401, "Unauthorized") - elif not _is_localhost(connection): - return _http_error(403, "bootstrap is localhost-only") + # Demo mode skips the secret/localhost gate entirely; anonymous visitors + # still get a short-lived WS token minted below. + if not demo: + if secret: + if not _issue_route_secret_matches(request.headers, secret): + return _http_error(401, "Unauthorized") + elif not _is_localhost(connection): + return _http_error(403, "bootstrap is localhost-only") if not self.tokens.can_issue(include_api_token=True): return _http_response( diff --git a/webui/src/components/Sidebar.tsx b/webui/src/components/Sidebar.tsx index ab2e2070..1cc753c6 100644 --- a/webui/src/components/Sidebar.tsx +++ b/webui/src/components/Sidebar.tsx @@ -13,7 +13,6 @@ import { useTranslation } from "react-i18next"; import { ChatList } from "@/components/ChatList"; import { ConnectionBadge } from "@/components/ConnectionBadge"; -import { useClient } from "@/providers/ClientProvider"; import { Button } from "@/components/ui/button"; import { Separator } from "@/components/ui/separator"; import type { @@ -21,6 +20,7 @@ import type { SidebarViewState, } from "@/lib/types"; import { cn } from "@/lib/utils"; +import { useClient } from "@/providers/ClientProvider"; interface SidebarProps { sessions: ChatSummary[]; @@ -156,32 +156,31 @@ export function Sidebar(props: SidebarProps) { onClick={props.onOpenSearch} icon={} /> + {/* Demo mode is chat-only: hide apps / skills / automations. */} {!demo && ( - } - /> - )} - {!demo && ( - } - /> - )} - {!demo && ( - } - /> + <> + } + /> + } + /> + } + /> + )} {props.archivedCount ? ( Date: Tue, 7 Jul 2026 20:12:31 -0700 Subject: [PATCH 3/4] =?UTF-8?q?fix(render):=20isolate=20demo=20sessions=20?= =?UTF-8?q?=E2=80=94=20close=20the=20cross-visitor=20history=20leak?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Demo mode mints an API token for every anonymous visitor, so the token is not an identity. But the WebUI session endpoints guarded only on that token and then returned the *global* session store, so any demo visitor (e.g. a fresh incognito tab) could list, open, and delete every other visitor's chat via GET /api/sessions and /api/sessions/{key}/*. Fix, enforced server-side (authoritative — the client can't be trusted): - GET /api/sessions returns an empty list in demo mode. - All per-session routes (messages, webui-thread, file-preview, automations, delete) return 403 in demo mode. Each connection still gets its own fresh, unguessable websocket:{uuid} chat and can chat normally; there is simply no cross-session HTTP access. Non-demo mode is unchanged (single token-gated owner still sees all sessions), verified by the existing bearer-token route test. Note: no per-connection WS attach guard was added. The client re-attaches every known chat_id on reconnect, so a per-connection ownership check would break a visitor's own chat after any network blip, and demo has no durable identity to distinguish owner from attacker. The boundary is instead: unguessable uuid4 ids + an empty session list (no discovery) + 403 on every per-session read, which closes the exfiltration path. Adds test_demo_mode_does_not_expose_other_sessions and updates the README DEMO-mode isolation description. Co-Authored-By: Claude Opus 4.8 (1M context) --- README.md | 4 ++- nanobot/webui/ws_http.py | 16 ++++++++++ tests/channels/test_websocket_http_routes.py | 33 ++++++++++++++++++++ 3 files changed, 52 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 9b79c953..4884900a 100644 --- a/README.md +++ b/README.md @@ -58,7 +58,9 @@ DEMO mode lets you host a **public, no-auth demo** of nanobot that anyone can ch **Forks default to full auth.** `DEMO` is `false`/unset unless you deliberately turn it on, so forking this template gives you the normal token-gated agent. Only set `DEMO=true` if you actually want an open, locked-down demo. -**How it works.** When `DEMO=true`, [`entrypoint.sh`](./entrypoint.sh) starts the gateway with [`render-demo-config.json`](./render-demo-config.json) instead of `render-config.json`. That config is locked down and the WebSocket channel runs in `demo` mode: the WebUI bootstrap skips the auth gate and mints short-lived anonymous tokens, and each browser still gets its own **isolated** session (`websocket:{uuid}`), so visitors never see each other's chats. +**How it works.** When `DEMO=true`, [`entrypoint.sh`](./entrypoint.sh) starts the gateway with [`render-demo-config.json`](./render-demo-config.json) instead of `render-config.json`. That config is locked down and the WebSocket channel runs in `demo` mode: the WebUI bootstrap skips the auth gate and mints short-lived anonymous tokens. + +**Session isolation.** Because demo mode is unauthenticated, the anonymous token identifies no one — so the server treats it as **no** identity for session access. Each connection gets its own fresh chat (`websocket:{uuid}`, an unguessable id), and the session-browsing API is closed off: `GET /api/sessions` returns an empty list and every per-session read/delete route (`/messages`, `/webui-thread`, `/file-preview`, `/automations`, `/delete`) returns `403`. Visitors can chat in their own session but cannot list, open, or delete anyone else's — demo history is ephemeral and per-connection, never browsable. **The lockdown (what the demo agent can and can't do).** Chat + web search/fetch only. Everything else is off: diff --git a/nanobot/webui/ws_http.py b/nanobot/webui/ws_http.py index 84cce7b7..d49db62c 100644 --- a/nanobot/webui/ws_http.py +++ b/nanobot/webui/ws_http.py @@ -195,6 +195,12 @@ def __init__( def workspace_controls_available(self, connection: Any) -> bool: return self._runtime_surface == "native" or _is_localhost(connection) + @property + def _demo(self) -> bool: + """Unauthenticated demo mode: the API token identifies no one, so + cross-session reads must be refused (see _dispatch_session_routes).""" + return bool(getattr(self.config, "demo", False)) + # -- Token management --------------------------------------------------- def check_api_token(self, request: WsRequest) -> bool: @@ -354,6 +360,12 @@ def _bootstrap_ws_url(self, request: Any) -> str: # -- Session routes ----------------------------------------------------- async def _dispatch_session_routes(self, request: WsRequest, got: str) -> Response | None: + if self._demo and re.match(r"^/api/sessions/[^/]+/", got): + # Demo visitors share an anonymous token, so per-session reads and + # deletes would leak across visitors. Each connection only reaches + # its own live chat over the WebSocket; no cross-session HTTP access. + return _http_error(403, "not available in demo mode") + m = re.match(r"^/api/sessions/([^/]+)/messages$", got) if m: return self._handle_session_messages(request, m.group(1)) @@ -379,6 +391,10 @@ async def _dispatch_session_routes(self, request: WsRequest, got: str) -> Respon async def _handle_sessions_list(self, request: WsRequest) -> Response: if not self.check_api_token(request): return _http_error(401, "Unauthorized") + if self._demo: + # No per-visitor identity in demo mode: never expose the global + # session store as a browsable list. + return _http_json_response({"sessions": []}) if self.session_manager is None: return _http_error(503, "session manager unavailable") payload = await asyncio.to_thread(self._sessions_list_payload) diff --git a/tests/channels/test_websocket_http_routes.py b/tests/channels/test_websocket_http_routes.py index 2d5f42d1..d8f37595 100644 --- a/tests/channels/test_websocket_http_routes.py +++ b/tests/channels/test_websocket_http_routes.py @@ -212,6 +212,39 @@ async def test_bootstrap_returns_token_for_localhost( await server_task +@pytest.mark.asyncio +async def test_demo_mode_does_not_expose_other_sessions( + bus: MagicMock, tmp_path: Path +) -> None: + # Demo mode mints an API token for every anonymous visitor, so the token is + # not an identity. The session store must not leak across visitors: the list + # is empty and per-session reads/deletes are refused. + sm = _seed_session(tmp_path, key="websocket:abc") + port = _free_port() + base = f"http://127.0.0.1:{port}" + channel = _ch(bus, session_manager=sm, port=port, demo=True, host="0.0.0.0") + server_task = asyncio.create_task(channel.start()) + await asyncio.sleep(0.3) + try: + boot = await _http_get(f"{base}/webui/bootstrap") + assert boot.status_code == 200 + assert boot.json()["demo"] is True + auth = {"Authorization": f"Bearer {boot.json()['token']}"} + + listing = await _http_get(f"{base}/api/sessions", headers=auth) + assert listing.status_code == 200 + assert listing.json()["sessions"] == [] + + for suffix in ("messages", "webui-thread", "file-preview", "automations"): + resp = await _http_get( + f"{base}/api/sessions/websocket%3Aabc/{suffix}", headers=auth + ) + assert resp.status_code == 403, suffix + finally: + await channel.stop() + await server_task + + @pytest.mark.asyncio async def test_sessions_routes_require_bearer_token( bus: MagicMock, tmp_path: Path From 4919a20f1770ce83b8370dd4c5c23ea087aa7fcf Mon Sep 17 00:00:00 2001 From: Ho1yShif Date: Tue, 7 Jul 2026 20:21:07 -0700 Subject: [PATCH 4/4] refactor(render): tidy DEMO-mode accessors and tool config Follow-up readability pass on the DEMO-mode code: - ws_http: use the _demo property in _handle_bootstrap instead of re-deriving it inline, and flatten the nested secret/localhost guard. - ws_http/websocket: read the declared config.demo field directly rather than defensive getattr. - websocket: drop redundant whitespace handling in _demo_env_int (int() already trims and rejects empty strings). - spawn: declare config_key/config_cls on SpawnTool to match the contract every other config-bearing tool follows. No behavior change. ruff clean; 104 tests pass. Co-Authored-By: Claude Opus 4.8 (1M context) --- nanobot/agent/tools/spawn.py | 6 ++++++ nanobot/channels/websocket.py | 6 +++--- nanobot/webui/ws_http.py | 11 +++++------ 3 files changed, 14 insertions(+), 9 deletions(-) diff --git a/nanobot/agent/tools/spawn.py b/nanobot/agent/tools/spawn.py index 2e9a4617..3ad2b0db 100644 --- a/nanobot/agent/tools/spawn.py +++ b/nanobot/agent/tools/spawn.py @@ -40,6 +40,12 @@ class SubagentToolConfig(Base): class SpawnTool(Tool, ContextAware): """Tool to spawn a subagent for background task execution.""" + config_key = "subagent" + + @classmethod + def config_cls(cls): + return SubagentToolConfig + def __init__(self, manager: "SubagentManager"): self._manager = manager self._origin_channel: ContextVar[str] = ContextVar("spawn_origin_channel", default="cli") diff --git a/nanobot/channels/websocket.py b/nanobot/channels/websocket.py index a1cc0de0..536a94b1 100644 --- a/nanobot/channels/websocket.py +++ b/nanobot/channels/websocket.py @@ -76,10 +76,10 @@ def _demo_env_int(name: str, default: int) -> int: """Read a non-negative int env var; fall back to *default* if unset/invalid.""" raw = os.environ.get(name) - if raw is None or not raw.strip(): + if raw is None: return default try: - value = int(raw.strip()) + value = int(raw) # int() tolerates surrounding whitespace, rejects "" except ValueError: return default return value if value >= 0 else default @@ -588,7 +588,7 @@ async def runner() -> None: def _new_demo_limiter(self) -> _DemoLimiter | None: """Build a per-connection demo limiter, or None when not in demo mode.""" - if not getattr(self.config, "demo", False): + if not self.config.demo: return None return _DemoLimiter( _demo_env_int( diff --git a/nanobot/webui/ws_http.py b/nanobot/webui/ws_http.py index d49db62c..4b288199 100644 --- a/nanobot/webui/ws_http.py +++ b/nanobot/webui/ws_http.py @@ -199,7 +199,7 @@ def workspace_controls_available(self, connection: Any) -> bool: def _demo(self) -> bool: """Unauthenticated demo mode: the API token identifies no one, so cross-session reads must be refused (see _dispatch_session_routes).""" - return bool(getattr(self.config, "demo", False)) + return bool(self.config.demo) # -- Token management --------------------------------------------------- @@ -311,15 +311,14 @@ def _handle_token_issue(self, connection: Any, request: Any) -> Any: # -- Bootstrap ---------------------------------------------------------- def _handle_bootstrap(self, connection: Any, request: Any) -> Response: - demo = bool(getattr(self.config, "demo", False)) + demo = self._demo secret = self.config.token_issue_secret.strip() or self.config.token.strip() # Demo mode skips the secret/localhost gate entirely; anonymous visitors # still get a short-lived WS token minted below. if not demo: - if secret: - if not _issue_route_secret_matches(request.headers, secret): - return _http_error(401, "Unauthorized") - elif not _is_localhost(connection): + if secret and not _issue_route_secret_matches(request.headers, secret): + return _http_error(401, "Unauthorized") + if not secret and not _is_localhost(connection): return _http_error(403, "bootstrap is localhost-only") if not self.tokens.can_issue(include_api_token=True):