Skip to content

v1 part 10: the world in a browser tab - #4779

Merged
cagataycali merged 8 commits into
strands-labs:devfrom
cagataycali:v1/part-10-dashboard
Oct 19, 2026
Merged

cagataycali merged 8 commits into
strands-labs:devfrom
cagataycali:v1/part-10-dashboard

Conversation

@cagataycali

@cagataycali cagataycali commented Oct 15, 2026 •

Copy link
Copy Markdown
Member

Part 10 of #4749. Sits directly on dev (#4778 merged): 5 commits, 3 dashboard commits + the two GL fixes (contexts freed on the GL thread before exit; a KeyboardInterrupt on the GL thread reaches the caller).

The world in a browser tab. Dashboard(sim).serve() is a twin of one Simulation, not a process per robot: the
browser loads the same merged MJCF, runs MuJoCo in WebAssembly and gets joint values by name at 30 Hz, so it draws
exactly what the server's world is doing. Each robot gets a card with sliders and an E-STOP; the chat is one
strands agent holding sim.tool and every robot.tool; a robot added at runtime recompiles the twin without a
reload, and a real robot appears through its twin with no dashboard code. It listens on loopback only and refuses
requests from other origins, so a page you happen to have open can't press its buttons, and a real robot needs an
X-Confirm from you before a slider moves it.

With no server to talk to, the same page runs standalone: the browser steps the bundled microduck world in MuJoCo
WebAssembly itself and runs its ONNX skill in onnxruntime-web at 50 Hz, with every microduck skill in a picker and
W A S D to drive it. The pill says server or standalone, and the browser never steps physics while a server is
connected. python -m strands_robots.dashboard.standalone builds the bundle (28 MB, not committed).

The dashboard is a twin of one world: the server, four routes, the MuJoCo WASM twin in the browser, standalone mode

Try it

from strands_robots import Robot, Simulation
from strands_robots.dashboard import Dashboard

arm, g1 = Robot("so101"), Robot("g1", position=[1, 0, 0])
Dashboard(Simulation.default()).serve(port=8090)     # http://127.0.0.1:8090

the server moves the arm, the browser twin follows; the so101 card's E-STOP latches it

standalone: no server, the browser steps the duck and runs its walking skill

Measured

what number how
dashboard tests incl. standalone S1-S5 (headless chromium) 30 passed, 1 skipped (the opt-in live chat); without Playwright the browser tests skip pytest -q tests/dashboard
server act() → the browser twin shows it, n=20 p50 28.8 ms, p95 34.6 ms tests/dashboard/test_latency.py
the chat, a real model turn (opt-in) 2 tool calls (act, stop), shoulder_pan 0.284 STRANDS_ROBOTS_LIVE=1 pytest -s tests/dashboard/test_chat_live.py
standalone, no server, vx=0.3 for 10 s (headless chromium, software GL) at least 1 m forward, at 50 Hz (within 10%), 0 falls tests/dashboard/test_standalone.py S3
Python behind it 988 lines find strands_robots/dashboard -name '*.py' | xargs cat | wc -l
tests on this branch, all extras 560 passed, 20 skipped (GPU, hardware, opt-in live turns) pytest -q tests
the required check's env (hatch run test, no Playwright) 521 passed, 41 skipped hatch run test
the wheel 6.1 MB (the two vendored WASM builds are 21 MB unpacked) uv build --wheel

Why the WASM is in the package

MuJoCo's and onnxruntime's WebAssembly builds ship inside strands_robots/dashboard/static/vendor/, pinned by sha256
in its NOTICE, so the page works on a robot's LAN with no internet and never loads code from a CDN. That makes the
wheel 6.1 MB for everyone; if that matters more later, they can move behind a fetch-on-first-use with the same pins.

@github-advanced-security github-advanced-security AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CodeQL found more than 20 potential problems in the proposed changes. Check the Files changed tab for more details.

Comment thread strands_robots/mesh/broadcast.py Fixed
Comment thread strands_robots/mesh/mesh.py Fixed
Comment thread strands_robots/mesh/mesh.py Fixed
Comment thread strands_robots/mesh/mesh.py Fixed
Comment thread strands_robots/mesh/mesh.py Fixed
Comment thread strands_robots/recording/episode.py Fixed
Comment thread strands_robots/mesh/tool.py Fixed
Comment thread strands_robots/mesh/mesh.py Fixed
Comment thread strands_robots/dashboard/dashboard.py Fixed
Comment thread strands_robots/dashboard/app.py
@cagataycali
cagataycali force-pushed the v1/part-10-dashboard branch 2 times, most recently from 91656a6 to 5de588f Compare October 17, 2026 19:20
@cagataycali

Copy link
Copy Markdown
Member Author

f7aedf4: start()/serve() now name the [dashboard] extra (before: bare No module named 'uvicorn'); each world camera is a card (GET /api/cameras/{name}.png); POST /api/world runs one sim.tool call. MuJoCo renders go through one GL thread, so a server thread can render after the script has.

check command result
hint, 3 entry points pytest tests/dashboard/test_extra_hint.py 3 passed (start/serve red before)
cameras + world verbs + page pytest tests/dashboard/test_cameras.py 6 passed (5 red before)
render from a second live thread pytest tests/test_shared_world.py -k live_thread passed (EGL_BAD_ACCESS before)
full ruff, mypy strands_robots, lint-imports, pytest tests clean, 7 contracts kept, 452 passed / 25 skipped

The dashboard with a camera card showing the so101 and a red cube, rendered by the world

A 320x240 front camera, served as a 23 KB PNG, p50 23.5 ms per request.

@cagataycali
cagataycali force-pushed the v1/part-10-dashboard branch 2 times, most recently from 88cae4f to 0da676f Compare October 17, 2026 23:52
Comment thread strands_robots/robot/robot.py Fixed
Comment thread strands_robots/_gl.py Fixed
Comment thread strands_robots/dashboard/dashboard.py
cagataycali and others added 5 commits October 18, 2026 09:41
Dashboard(sim) serves one page on loopback that draws THE Simulation it is
given: the merged MJCF in MuJoCo WASM, joint values by name over a
websocket, a card per robot with an e-stop that shows the latch, robots
added at runtime, the mesh's devices next to the world's, and a chat over
robot.tool and sim.tool. Every write from the page is the operator gate's
(a real body needs X-Confirm) and cross-site requests are refused.
[dashboard] extra (fastapi, uvicorn, websockets).
…ONNX skills when no server answers

The page that finds no server loads a bundled world (python -m
strands_robots.dashboard.standalone builds it; generated, never committed),
steps it in MuJoCo WASM and runs the microduck's ONNX skills in
onnxruntime-web at 50 Hz, every skill in a picker and W A S D to drive.
It never steps physics while a server is connected. onnxruntime-web is
vendored next to MuJoCo, pinned by sha256 in NOTICE.
…ows each world camera and changes the world with sim.tool's verbs

Without fastapi/uvicorn, Dashboard.start()/serve() raised a bare 'No module named uvicorn' (only .app hinted).
GET /api/cameras, GET /api/cameras/{name}.png (sim.render, lossless PNG) and cameras in /api/scene; a camera card
per world camera on the page; POST /api/world dispatches one sim.tool call, so the page and the model share one
verb set. MuJoCo renders now run on one GL thread: a context stays bound to the thread that made it current, so a
server thread could not render after the script had (EGL_BAD_ACCESS; a hang on macOS).
… so a script that rendered and loaded torch no longer segfaults at shutdown

The GL thread was a ThreadPoolExecutor, which threading._shutdown joins before atexit runs, so renderers still alive at exit were freed by the collector on the main thread: with torch loaded that is SIGSEGV (-11) on macOS (tests/recording test_a_top_level_script_records_two_cameras and test_a_second_run_in_a_new_process_resumes_the_same_directory were red). The GL thread is now a daemon worker that outlives threading._shutdown; every backend registers its renderer closer, _release_gl runs them on that thread at exit and stops it; a scene change closes the old renderers on it too instead of dropping them. Pinned by test_a_script_that_rendered_exits_cleanly_without_closing_its_world (red: -11, green: 0).
…the worker (the next render makes a new one); every module imports alone, as a test
@cagataycali
cagataycali marked this pull request as ready for review October 18, 2026 13:52

@yinsong1986 yinsong1986 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Summary

Adds strands_robots.dashboard: a FastAPI/uvicorn loopback server that serves one page as a twin of a single Simulation (merged MJCF via scene_to_mjcf, 30 Hz state frames over /ws/state, per-robot cards with sliders and E-STOP, world cameras as PNG, POST /api/world over sim.tool's verbs, a strands Agent chat over /ws/chat), a SameOrigin ASGI guard (Host / Origin / Content-Type: application/json on POST), an X-Confirm: yes header as the operator's yes for hardware, and a standalone mode where the browser steps the bundled microduck world in MuJoCo WASM and runs its ONNX skill on onnxruntime-web. The two _gl.py commits move GL work to a daemon worker so contexts are freed on the GL thread at exit and a BaseException on that thread reaches the caller. Vendored MuJoCo / three.js / onnxruntime-web are sha256-pinned in vendor/NOTICE (hashes verified; Apache-2.0 / MIT, compatible). The description matches the diff.

The two inline [MUST FIX] items are both in the hardware-safety path: the page's innerHTML rendering of names an agent can choose (robot id, camera name) lets an injected script forge the X-Confirm header the server trusts, and the state frame reports the twin's latch rather than the hardware robot's, so the panel paints a latched real robot as armed.

What's good

  • asset_file is a dictionary lookup against the paths scene_to_mjcf emitted, not a filesystem join, so /api/assets/{path} cannot traverse.
  • The guard refuses each check alone (Host for DNS rebinding, Origin, Content-Type for cross-site POST) and is pinned per-check in tests/dashboard/test_guard.py; the browser never steps physics while a server is connected.
  • safety.js already builds its rows with textContent, so the fix for the first item is applying the same discipline to renderCards and the chat log.
  • _GLThread is a daemon so the atexit closer can still reach it; close_on_gl_thread_at_exit holds a WeakMethod, so a backend that is garbage-collected early does not keep its renderers alive.
  • changelog.d/4779-*.md present; tests/dashboard runs green here without Playwright (71 passed, 14 skipped across the dashboard + GL + import-alone suites), matching the description.

Verification suggestions

  • Reproduce the first item without a browser: dispatch(WorldDevice(sim), {"rpc": "camera", "camera": {"name": "<b onmouseover=alert(1)>cam</b>"}}) then Dashboard(sim).cameras_json() returns the name raw and bumps sim.revision (so the page re-runs renderCards); same for add_robot with id='<img src=x onerror=...>', whose MJCF compiles and re-parses from string under MuJoCo 3.14.0.
  • Reproduce the second: real = Robot("so101", driver=<fake>, id="real_arm") (twin on), d = Dashboard(real.twin.sim, robots=[real]), d.estop("real_arm_twin") → real.estopped is True but d.frame()["estop"] == {"real_arm_twin": False}.

Comment thread strands_robots/dashboard/static/app.js Outdated
Comment thread strands_robots/dashboard/dashboard.py Outdated
…kup; a hardware card's e-stop state is the real robot's latch

Yin on strands-labs#4779 (app.js:89): robot ids, names, joint names, camera names, policy names and chat tool names were
interpolated into innerHTML and attributes unescaped; an agent chooses some of them (sim.tool add_robot id=,
camera name=), and any script in the page can set X-Confirm itself. One esc() now wraps every server-supplied
string before it reaches innerHTML or an attribute (app.js, safety.js mesh tenant). Pinned in
tests/dashboard/test_names_are_text.py: a camera named '<img id=pwned onerror=…>cam' renders as text, nothing
is injected, no page error (red before: 2 injected elements).

Yin on strands-labs#4779 (dashboard.py:139): frame() read the twin's latch for a hardware robot's card, so a latched real arm
was painted 'armed'. It resolves the card through real_cards() like estop/resume do. Pinned in
test_a_hardware_cards_latch_is_the_real_robots_not_its_twins (red before).

@yinsong1986 yinsong1986 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Summary

Adds strands_robots.dashboard: a FastAPI/uvicorn server bound to loopback that serves one page as a twin of a single Simulation (merged MJCF from scene_to_mjcf, 30 Hz state frames over /ws/state, a card with sliders and E-STOP per robot, world cameras as PNG, POST /api/world over sim.tool's verbs, a strands Agent chat over /ws/chat, POST /api/robots/{id}/run to start a policy), a SameOrigin ASGI guard (Host / Origin / JSON Content-Type on POST), X-Confirm: yes as the operator's yes for hardware, and a standalone mode where the browser steps the bundled microduck world in MuJoCo WASM and runs its ONNX skill on onnxruntime-web. _gl.py moves GL work to a daemon worker so contexts are freed on the GL thread at exit and a BaseException on that thread reaches the caller. The description matches the diff; the two [MUST FIX] items from the review at 26f01cf5 are addressed at this head (every server string goes through esc(), frame() reports the hardware latch for a twin card). One new blocker is inline, in runs.py: the page's policy run on a hardware robot passes the safety chain on the first action only.

What's good

  • The guard holds up to a direct probe: evil Origin, text/plain POST, a foreign Host, Origin: null and an OPTIONS preflight are all 403; /api/assets/{path} is a dictionary lookup, so .. cannot traverse; POST /api/robots refuses a path as a registry name.
  • Vendored MuJoCo / three.js / onnxruntime-web sha256s in static/vendor/NOTICE match the files; every main file carries its own license header (Apache-2.0 / MIT, compatible).
  • _GLThread is a daemon so the atexit closer can still reach it; close_on_gl_thread_at_exit holds a WeakMethod, so a collected backend does not pin its renderers.
  • changelog.d/4779-*.md present; tests runs green here without Playwright (631 passed, 89 skipped with MUJOCO_GL=osmesa where EGL is unavailable), ruff / mypy / lint-imports clean.

Comment thread strands_robots/dashboard/runs.py Outdated
cagataycali and others added 2 commits October 18, 2026 19:51
…ry step; the page's one X-Confirm is the operator's yes for the run

Yin on strands-labs#4779 (runs.py:84): gate(first, confirmed=True) checked the chain on the first action only, then
robot.start() outside from_agent() wrote every later step through robot.act — interlocks, validators, the safe band
and the range refusal were never consulted again, while the same policy through robot.tool run was gated per step.
from_agent(confirmed=) now carries one operator yes as a second contextvar; the loop (and lockstep) write through
gated_write(robot) = confirm_act(confirmed=True) when it is set, so every step still runs estop -> range -> safe ->
interlocks -> validators with the operator step pre-satisfied. runs.start wraps robot.start in
from_agent(confirmed=confirmed); the first-action gate stays as the pre-flight refusal. Pinned in
test_a_page_run_on_hardware_passes_the_safety_chain_on_every_step_not_only_the_first (Yin's reproduction: a
validator fencing |shoulder_pan| > 0.1, a policy 0.05, 0.05, 0.5 — red: the run finished 'done' with 0.5 on the
bus; green: the validator sees every step, the run ends 'error' with the refusal, 0.5 never reaches the bus).
# Conflicts:
#	strands_robots/robot/run/lockstep.py
#	strands_robots/robot/run/loop.py

@yinsong1986 yinsong1986 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Summary

Adds strands_robots.dashboard: a FastAPI/uvicorn server bound to loopback that serves one page as a twin of a single Simulation (merged MJCF from scene_to_mjcf, 30 Hz frames over /ws/state, a card with sliders and E-STOP per robot, world cameras as PNG, POST /api/world over sim.tool's verbs, a strands Agent chat over /ws/chat, POST /api/robots/{id}/run with X-Confirm carried into the loop by from_agent(confirmed=) so every step still passes the chain), a SameOrigin ASGI guard (Host / Origin / Content-Type), and a standalone mode where the vendored MuJoCo + onnxruntime WASM builds step the bundled microduck world in the browser. The two _gl.py changes replace the ThreadPoolExecutor with a daemon _GLThread so renderers can be closed on the GL thread at atexit, and a BaseException on that thread now reaches the caller. Walked the Python and the shipped JS against the prior review threads: the three earlier [MUST FIX] items (DOM escaping, the twin's latch in the frame, per-step gating of a page-started run) are resolved at this head, and I found no new blocker. Asset serving is a dict lookup over the scene's own table (no filesystem join), every write from the page is Origin/Host/Content-Type gated and a real body still needs the operator's yes, and the vendored MuJoCo (Apache-2.0), three.js and onnxruntime-web (MIT) are attributed and sha256-pinned in static/vendor/NOTICE; the two OFL fonts carry their copyright and licence URL in their own name table.

What's good

  • tests/dashboard/test_guard.py exercises each refusal alone (Origin, Content-Type, Host rebinding, cross-site WebSocket) and test_real_gate.py asserts the refusal lands before the bus.
  • The operator yes travels as a ContextVar through the existing copy_context() in loop.start, so tick() needed a one-argument change and lockstep picked it up for free.
  • test_modules_import_alone.py and test_imports.py pin the lazy web-stack import, so the [dashboard] extra stays optional and the dev-branch rule about heavy imports holds.

Verification suggestions

  • The exit-time cleanup is only reachable in CI through osmesa. On a Linux box with EGL (MUJOCO_GL=egl, NVIDIA driver, mujoco 3.16) I ran a script that renders once and exits without closing its world: exit code is 0 on both this head and the base dev commit, but this head prints two Exception ignored in: Renderer.__del__ / GLContext.__del__ tracebacks (EGL_NOT_INITIALIZED from eglDestroyContext) that the base does not. Cause: mujoco.egl registers atexit.register(EGL.eglTerminate, ...) inside the first render, i.e. after on_gl_thread registered _release_gl, so LIFO runs eglTerminate first and the close on the GL thread fails and is swallowed. Cosmetic, not blocking; worth a look since the macOS segfault fix is the stated intent. Repro: python -c "from strands_robots import Robot; a=Robot('so101'); a.sim.camera('eye', size=(64,48), attach=a); print(a.sim.render('eye').shape)" under MUJOCO_GL=egl with DISPLAY unset.
  • With Playwright and the microduck weights cached, pytest -q tests/dashboard/test_standalone.py is the only check that the vendored ORT build actually drives the bundled skill at 50 Hz; the required check skips it.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants