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..4884900a 100644 --- a/README.md +++ b/README.md @@ -52,6 +52,47 @@ 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. + +**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: + +| 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..3ad2b0db 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"), @@ -33,6 +40,12 @@ 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") @@ -43,6 +56,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..536a94b1 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,60 @@ # Plain HTTP WebUI routes also run through websockets.process_request. _WEBUI_HTTP_OPEN_TIMEOUT_S = 360.0 +# 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." + + +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: + return default + try: + value = int(raw) # int() tolerates surrounding whitespace, rejects "" + 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 +143,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 +210,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 +586,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 self.config.demo: + 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 +619,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 +647,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..4b288199 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(self.config.demo) + # -- Token management --------------------------------------------------- def check_api_token(self, request: WsRequest) -> bool: @@ -305,12 +311,15 @@ def _handle_token_issue(self, connection: Any, request: Any) -> Any: # -- Bootstrap ---------------------------------------------------------- def _handle_bootstrap(self, connection: Any, request: Any) -> Response: + demo = self._demo secret = self.config.token_issue_secret.strip() or self.config.token.strip() - if secret: - if not _issue_route_secret_matches(request.headers, secret): + # Demo mode skips the secret/localhost gate entirely; anonymous visitors + # still get a short-lived WS token minted below. + if not demo: + if secret and 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 secret and 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( @@ -331,6 +340,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, } ) @@ -349,6 +359,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)) @@ -374,6 +390,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/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/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 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,32 @@ export function Sidebar(props: SidebarProps) { onClick={props.onOpenSearch} icon={} /> - } - /> - } - /> - } - /> + {/* Demo mode is chat-only: hide apps / skills / automations. */} + {!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} );