Repository navigation
v1 part 10: the world in a browser tab - #4779
Conversation
There was a problem hiding this comment.
CodeQL found more than 20 potential problems in the proposed changes. Check the Files changed tab for more details.
cdca23d to
eef8a5d
Compare
eef8a5d to
aa7f75c
Compare
aa7f75c to
e3eff39
Compare
91656a6 to
5de588f
Compare
|
f7aedf4:
A 320x240 |
88cae4f to
0da676f
Compare
87d2964 to
6c2e4c1
Compare
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
6c2e4c1 to
26f01cf
Compare
yinsong1986
left a comment
There was a problem hiding this comment.
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_fileis a dictionary lookup against the pathsscene_to_mjcfemitted, 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.jsalready builds its rows withtextContent, so the fix for the first item is applying the same discipline torenderCardsand the chat log._GLThreadis a daemon so the atexit closer can still reach it;close_on_gl_thread_at_exitholds aWeakMethod, so a backend that is garbage-collected early does not keep its renderers alive.changelog.d/4779-*.mdpresent;tests/dashboardruns 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>"}})thenDashboard(sim).cameras_json()returns the name raw and bumpssim.revision(so the page re-runsrenderCards); same foradd_robotwithid='<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 Truebutd.frame()["estop"] == {"real_arm_twin": False}.
…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
left a comment
There was a problem hiding this comment.
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/plainPOST, a foreignHost,Origin: nulland an OPTIONS preflight are all 403;/api/assets/{path}is a dictionary lookup, so..cannot traverse;POST /api/robotsrefuses a path as a registry name. - Vendored MuJoCo / three.js / onnxruntime-web sha256s in
static/vendor/NOTICEmatch the files; every main file carries its own license header (Apache-2.0 / MIT, compatible). _GLThreadis a daemon so the atexit closer can still reach it;close_on_gl_thread_at_exitholds aWeakMethod, so a collected backend does not pin its renderers.changelog.d/4779-*.mdpresent;testsruns green here without Playwright (631 passed, 89 skipped withMUJOCO_GL=osmesawhere EGL is unavailable), ruff / mypy / lint-imports clean.
…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
left a comment
There was a problem hiding this comment.
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.pyexercises each refusal alone (Origin, Content-Type, Host rebinding, cross-site WebSocket) andtest_real_gate.pyasserts the refusal lands before the bus.- The operator yes travels as a
ContextVarthrough the existingcopy_context()inloop.start, sotick()needed a one-argument change andlocksteppicked it up for free. test_modules_import_alone.pyandtest_imports.pypin 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 basedevcommit, but this head prints twoException ignored in: Renderer.__del__/GLContext.__del__tracebacks (EGL_NOT_INITIALIZEDfromeglDestroyContext) that the base does not. Cause:mujoco.eglregistersatexit.register(EGL.eglTerminate, ...)inside the first render, i.e. afteron_gl_threadregistered_release_gl, so LIFO runseglTerminatefirst 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)"underMUJOCO_GL=eglwithDISPLAYunset. - With Playwright and the microduck weights cached,
pytest -q tests/dashboard/test_standalone.pyis the only check that the vendored ORT build actually drives the bundled skill at 50 Hz; the required check skips it.

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 oneSimulation, not a process per robot: thebrowser 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.tooland everyrobot.tool; a robot added at runtime recompiles the twin without areload, 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-Confirmfrom 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
serverorstandalone, and the browser never steps physics while a server isconnected.
python -m strands_robots.dashboard.standalonebuilds the bundle (28 MB, not committed).Try it
Measured
pytest -q tests/dashboardact()→ the browser twin shows it, n=20tests/dashboard/test_latency.pyact,stop), shoulder_pan 0.284STRANDS_ROBOTS_LIVE=1 pytest -s tests/dashboard/test_chat_live.pytests/dashboard/test_standalone.pyS3find strands_robots/dashboard -name '*.py' | xargs cat | wc -lpytest -q testshatch run test, no Playwright)hatch run testuv build --wheelWhy the WASM is in the package
MuJoCo's and onnxruntime's WebAssembly builds ship inside
strands_robots/dashboard/static/vendor/, pinned by sha256in its
NOTICE, so the page works on a robot's LAN with no internet and never loads code from a CDN. That makes thewheel 6.1 MB for everyone; if that matters more later, they can move behind a fetch-on-first-use with the same pins.