Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
41 changes: 41 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
21 changes: 18 additions & 3 deletions entrypoint.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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"
17 changes: 17 additions & 0 deletions nanobot/agent/tools/spawn.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand All @@ -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")
Expand All @@ -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)
Expand Down
100 changes: 100 additions & 0 deletions nanobot/channels/websocket.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand All @@ -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 = ""
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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 "/"
Expand All @@ -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(
Expand Down Expand Up @@ -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.
Expand Down
22 changes: 21 additions & 1 deletion nanobot/cli/commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down
6 changes: 6 additions & 0 deletions nanobot/config/schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -612,13 +616,15 @@ 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
mod = sys.modules[__name__]
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]
Expand Down
Loading
Loading