feat(render): locked-down DEMO mode for a public no-auth demo#5
Merged
Conversation
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) <noreply@anthropic.com>
- 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) <noreply@anthropic.com>
…leak
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Adds a
DEMO=truemode so the template's hosted demo works with no auth (anyone can chat immediately), while anyone who forks the template keeps the fully authenticated default. The demo agent is chat-only and locked down and cannot reach sensitive info; public usage is bounded by env-configurable rate + per-session caps.Design spec:
docs/superpowers/specs/2026-07-07-demo-mode-design.md(gitignored, local).What it does
entrypoint.shrunsrender-demo-config.jsonwhenDEMO=true, elserender-config.json.render.yamlnow passes onlygateway(entrypoint owns--config).DEMOdefaultsfalse.restrictToWorkspace,webuiAllowLocalServiceAccess:false, and a cheaper default model.WebSocketConfig.demorelaxes the0.0.0.0-requires-token validator;/webui/bootstrapskips its secret/localhost gate in demo and mints short-lived anonymous WS tokens (existing token path unchanged); response carriesdemo:true. Each browser still gets its own isolatedwebsocket:{uuid}session.tools.subagent.enableflag — the one dangerous tool that lacked a config gate — off in demo. Cron is disabled by withholdingcron_servicefrom the demo agent.DEMO_RATE_LIMIT_PER_MINUTE(10) /DEMO_MAX_MESSAGES_PER_SESSION(30);0disables. Friendly reply on trip.demo, hides settings/apps/skills/automations, forces chat view, shows a demo banner.Security (no sensitive-info access)
ANTHROPIC_API_KEYlives in-memory only) or on-disk config.nanobot/security/network.py): loopback, link-local incl. cloud metadata169.254.169.254, and RFC1918 are blocked on every request and redirect hop. Demo adds nossrf_whitelist.Verification
ruff checkclean on all touched files.tests/channels/test_websocket_demo.py,tests/tools/test_tool_loader.py— 54 passed): demo bootstrap bypass returns 200 +demo:truewithout a secret; validator allows0.0.0.0in demo; rate + per-session caps trip the friendly stop; demo config registers no exec/file/subagent/mcp tools; non-demo still requires the token (regression).tests/channels tests/config tests/webuirun: no new failures — the 46 failures are all pre-existing onmain(feishu/plugins, confirmed by running them onmain).bun run buildsucceeds.render.yamlvalidates againsthttps://render.com/schema/render.yaml.json(fixed a booleanDEMOvalue → quoted string).🤖 Generated with Claude Code