Skip to content

Commit 0911df2

Browse files
committed
fix: macOS path validation + eject_robot state preservation + mesh init
Three bugs fixed in one commit: 1. **macOS path validation** (_path_validation.py) validate_save_path() was silently accepting /etc/passwd and other sensitive paths on macOS because os.path.realpath resolves /etc, /var, /tmp to /private/etc, /private/var, /private/tmp via symlinks. The blocked-prefix startswith check then missed them. Fix: on darwin, add /private/-prefixed variants of every Linux blocked prefix to BLOCKED_PREFIXES. Tests: 5 previously-failing tests now pass, plus 2 new regression tests (test_darwin_includes_private_variants, test_linux_excludes_private_variants) pin the platform semantics. 2. **eject_robot state preservation** (scene_ops.py) eject_robot_from_scene rebuilt the scene from scratch, silently resetting surviving robots' joints to qpos=0 and snapping objects back to their spawn pose. Agents calling remove_robot mid-scene lost physics state with no warning. Fix: snapshot per-joint (qpos, qvel) by fully-qualified name BEFORE rebuild, restore by name AFTER fresh compile. Matches the 'Per-name state copy, not flat index' rule from AGENTS.md — flat-index copy is unsafe because body/joint indices shift when a robot is removed. New helpers: _snapshot_joint_state, _restore_joint_state (both module-private, name-based, width-checked per joint type). Tests: 3 new regression tests (test_surviving_robot_joint_state_is_preserved, test_surviving_object_freejoint_pose_is_preserved, test_ejected_robot_state_is_not_restored). 3. **mesh / peer_id explicit init** (simulation.py) — addresses @sundargthb's approval nit. mesh and peer_id constructor params were never assigned to self, making hasattr(self, 'mesh') at L2006 permanently False and masking the future PR #98 wire-up. Now explicitly initialised as attributes so the guard becomes a plain truthy check. Future-compatible: when PR #98 lands and replaces self.mesh with a real mesh object, the cleanup path will start working without further changes. Tests: 1275 pass, 1 skipped, 0 failures (was 1255 pass + 5 fail). Lint: ruff + mypy clean.
1 parent 111117a commit 0911df2

5 files changed

Lines changed: 303 additions & 12 deletions

File tree

strands_robots/simulation/mujoco/scene_ops.py

Lines changed: 126 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -191,6 +191,99 @@ def eject_body_from_scene(world: SimWorld, body_name: str) -> bool:
191191
return _recompile_preserving_state(world, spec)
192192

193193

194+
def _snapshot_joint_state(world: SimWorld) -> dict[str, tuple[list[float], list[float]]]:
195+
"""Snapshot per-joint ``(qpos, qvel)`` slices keyed by fully-qualified
196+
MuJoCo joint name.
197+
198+
Used by :func:`eject_robot_from_scene` to preserve the state of surviving
199+
robots and object freejoints across a scene rebuild. Flat-index slicing
200+
is unsafe here because the body-tree order may shift when a robot is
201+
removed (see AGENTS.md "Per-name state copy" rule).
202+
203+
Returns a dict mapping ``<joint_name> -> (qpos_slice, qvel_slice)`` where
204+
each slice has the appropriate width for the joint type (1 for hinge/
205+
slide, 4 for ball, 7 for free).
206+
"""
207+
if world._model is None or world._data is None:
208+
return {}
209+
mj = _ensure_mujoco()
210+
model = world._model
211+
data = world._data
212+
snap: dict[str, tuple[list[float], list[float]]] = {}
213+
for jid in range(model.njnt):
214+
name = mj.mj_id2name(model, mj.mjtObj.mjOBJ_JOINT, jid)
215+
if not name:
216+
continue
217+
qpos_adr = int(model.jnt_qposadr[jid])
218+
qvel_adr = int(model.jnt_dofadr[jid])
219+
jtype = int(model.jnt_type[jid])
220+
# qpos width: free=7, ball=4, hinge/slide=1
221+
# qvel width: free=6, ball=3, hinge/slide=1
222+
if jtype == mj.mjtJoint.mjJNT_FREE:
223+
qpos_w, qvel_w = 7, 6
224+
elif jtype == mj.mjtJoint.mjJNT_BALL:
225+
qpos_w, qvel_w = 4, 3
226+
else:
227+
qpos_w, qvel_w = 1, 1
228+
snap[name] = (
229+
[float(x) for x in data.qpos[qpos_adr : qpos_adr + qpos_w]],
230+
[float(x) for x in data.qvel[qvel_adr : qvel_adr + qvel_w]],
231+
)
232+
return snap
233+
234+
235+
def _restore_joint_state(
236+
world: SimWorld,
237+
snapshot: dict[str, tuple[list[float], list[float]]],
238+
) -> int:
239+
"""Restore per-joint state from a snapshot into ``world._data`` by name.
240+
241+
Joints that no longer exist in the compiled model (e.g. those belonging
242+
to the ejected robot) are silently skipped. Joints that exist in the
243+
new model but were not in the snapshot keep their fresh-compile defaults
244+
(body pos/quat for freejoints, 0 for hinge/slide).
245+
246+
Returns the number of joints actually restored, for logging.
247+
"""
248+
if world._model is None or world._data is None:
249+
return 0
250+
mj = _ensure_mujoco()
251+
model = world._model
252+
data = world._data
253+
restored = 0
254+
for name, (qpos_vals, qvel_vals) in snapshot.items():
255+
jid = mj.mj_name2id(model, mj.mjtObj.mjOBJ_JOINT, name)
256+
if jid < 0:
257+
continue # joint no longer exists (expected for ejected robot)
258+
qpos_adr = int(model.jnt_qposadr[jid])
259+
qvel_adr = int(model.jnt_dofadr[jid])
260+
# Width sanity check: if joint type changed (should not happen for
261+
# same-name joints across an eject), skip to avoid corrupting state.
262+
jtype = int(model.jnt_type[jid])
263+
if jtype == mj.mjtJoint.mjJNT_FREE:
264+
expect_qp, expect_qv = 7, 6
265+
elif jtype == mj.mjtJoint.mjJNT_BALL:
266+
expect_qp, expect_qv = 4, 3
267+
else:
268+
expect_qp, expect_qv = 1, 1
269+
if len(qpos_vals) != expect_qp or len(qvel_vals) != expect_qv:
270+
logger.warning(
271+
"_restore_joint_state: width mismatch for %r (qpos %d!=%d or qvel %d!=%d), skipping",
272+
name,
273+
len(qpos_vals),
274+
expect_qp,
275+
len(qvel_vals),
276+
expect_qv,
277+
)
278+
continue
279+
for i, v in enumerate(qpos_vals):
280+
data.qpos[qpos_adr + i] = v
281+
for i, v in enumerate(qvel_vals):
282+
data.qvel[qvel_adr + i] = v
283+
restored += 1
284+
return restored
285+
286+
194287
def eject_robot_from_scene(world: SimWorld, robot_name: str) -> bool:
195288
"""Remove every spec element namespaced under ``{robot_name}/``.
196289
@@ -199,10 +292,20 @@ def eject_robot_from_scene(world: SimWorld, robot_name: str) -> bool:
199292
attached child spec's memory gets freed twice). To sidestep that bug
200293
we REBUILD the scene spec from scratch using the post-remove
201294
``world.robots`` / ``world.objects`` / ``world.cameras`` state, then
202-
re-attach the remaining robots. Joint state is not preserved across this
203-
path - callers that care should call ``reset`` or save/restore state
204-
around remove_robot. In the common case (agent removes a robot to clear
205-
the scene), this is the expected behaviour anyway.
295+
re-attach the remaining robots.
296+
297+
Joint state preservation: before the rebuild we snapshot every joint's
298+
``(qpos, qvel)`` keyed by fully-qualified name; after the fresh compile
299+
we restore state for every joint that still exists in the new model.
300+
Joints belonging to the ejected robot are naturally dropped (their name
301+
no longer resolves). This keeps surviving robots at their current pose
302+
and object freejoints at their current world pose - the behaviour the
303+
agent expects when calling ``remove_robot`` mid-scene.
304+
305+
Flat-index slicing is **not** safe here: removing a robot shifts every
306+
body/joint index that comes after it in the kinematic tree, so
307+
``data.qpos[:]`` copies across compiles would mis-assign DOFs. Per-name
308+
lookup is the only correct approach (see AGENTS.md).
206309
"""
207310
spec = _get_spec(world)
208311
if spec is None or world._model is None:
@@ -211,10 +314,10 @@ def eject_robot_from_scene(world: SimWorld, robot_name: str) -> bool:
211314

212315
mj = _ensure_mujoco()
213316

214-
# Preserve the current qpos for bodies that are NOT being removed.
215-
# We rebuild from world state and then re-attach remaining robots, so
216-
# object freejoints start at their body pos (matching fresh add_object
217-
# semantics); robot joints start at qpos=0 (same as fresh add_robot).
317+
# Snapshot joint state BEFORE we rebuild. Keyed by the fully-qualified
318+
# MuJoCo joint name (prefix/joint for attached robots, bare name for
319+
# object freejoints).
320+
state_snapshot = _snapshot_joint_state(world)
218321

219322
# First drop cameras that originated from the robot being ejected.
220323
# They're in world.cameras with origin_robot == robot_name. Without this,
@@ -254,6 +357,15 @@ def eject_robot_from_scene(world: SimWorld, robot_name: str) -> bool:
254357
except Exception as xml_err:
255358
logger.debug("spec.to_xml() failed: %s", xml_err)
256359

360+
# Step 4: restore state for every joint that survived the rebuild. Joints
361+
# belonging to the ejected robot simply don't resolve and get skipped.
362+
restored = _restore_joint_state(world, state_snapshot)
363+
364+
# Step 5: run a forward pass so derived quantities (xpos, cam xforms)
365+
# reflect the restored state. Without this, the next render() call can
366+
# produce stale frames because MjData was freshly allocated in Step 3.
367+
mj.mj_forward(new_model, new_data)
368+
257369
# Re-discover joint/actuator IDs for remaining robots.
258370
for robot in world.robots.values():
259371
pfx = robot.namespace or ""
@@ -274,7 +386,12 @@ def eject_robot_from_scene(world: SimWorld, robot_name: str) -> bool:
274386
if not robot.actuator_ids and len(world.robots) == 1:
275387
robot.actuator_ids = list(range(new_model.nu))
276388

277-
logger.debug("eject_robot %r: scene rebuilt", robot_name)
389+
logger.debug(
390+
"eject_robot %r: scene rebuilt, restored state for %d/%d joints",
391+
robot_name,
392+
restored,
393+
len(state_snapshot),
394+
)
278395
return True
279396

280397

strands_robots/simulation/mujoco/simulation.py

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -128,12 +128,42 @@ def __init__(
128128
peer_id: str | None = None,
129129
**kwargs,
130130
):
131+
"""Construct a MuJoCo Simulation AgentTool.
132+
133+
Args:
134+
tool_name: Identifier surfaced to the agent and used as the
135+
thread-name prefix for the executor.
136+
default_timestep: Default physics timestep (seconds). Can be
137+
overridden via ``create_world(timestep=...)``.
138+
default_width: Default render width (pixels) used when a
139+
caller does not pass explicit dimensions to ``render``.
140+
default_height: Default render height (pixels).
141+
mesh: Optional mesh-networking hook. Falsy (default) keeps
142+
the Simulation standalone - all mesh code paths are
143+
no-ops. When set to a live mesh-client object exposing
144+
``.stop()``, ``cleanup()`` will detach this Simulation
145+
from the peer network before tearing down the MuJoCo
146+
world. The attribute is plain (not a property), so
147+
consumers may attach a client after construction.
148+
peer_id: Stable identifier the mesh transport uses to
149+
address this Simulation. Opaque to MuJoCo itself; only
150+
consulted when ``mesh`` is truthy.
151+
**kwargs: Forwarded to ``AgentTool.__init__`` for subclass
152+
compatibility.
153+
"""
131154
super().__init__()
132155
self.tool_name_str = tool_name
133156
self.default_timestep = default_timestep
134157
self.default_width = default_width
135158
self.default_height = default_height
136159

160+
# Mesh attributes are stored plainly (no property wrapper) so
161+
# downstream code can swap in a real mesh client after
162+
# construction without a setter dance. See the ``mesh`` /
163+
# ``peer_id`` docstring entries above for the contract.
164+
self.mesh: Any = mesh if mesh else None
165+
self.peer_id: str | None = peer_id
166+
137167
self._world: SimWorld | None = None
138168
self._executor = ThreadPoolExecutor(max_workers=4, thread_name_prefix=f"{tool_name}_sim")
139169
# Per-robot Future refs for *active* policies. Completed futures are
@@ -2008,7 +2038,13 @@ def cleanup(self, policy_stop_timeout: float | None = None) -> None:
20082038
``_DEFAULT_POLICY_STOP_TIMEOUT`` (5s). Set to a small value
20092039
in tests that want fast teardown.
20102040
"""
2011-
if hasattr(self, "mesh") and self.mesh:
2041+
# Detach from the mesh network first (if attached). A truthy
2042+
# ``self.mesh`` is any object exposing ``.stop()``; falsy values
2043+
# (the default) mean this Simulation never joined a mesh and
2044+
# there's nothing to release. Done BEFORE stopping policies so
2045+
# peer-visible state is torn down cleanly even if the policy
2046+
# teardown below hits the fallback ``wait=False`` path.
2047+
if self.mesh:
20122048
self.mesh.stop()
20132049

20142050
timeout = policy_stop_timeout if policy_stop_timeout is not None else self._DEFAULT_POLICY_STOP_TIMEOUT

strands_robots/tools/_path_validation.py

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -44,11 +44,19 @@
4444

4545

4646
def _get_blocked_prefixes() -> tuple[str, ...]:
47-
"""Return blocked prefixes for the current platform."""
47+
"""Return blocked prefixes for the current platform.
48+
49+
On macOS, many system directories (``/etc``, ``/var``, ``/tmp``) are
50+
symlinks into ``/private/``. Since :func:`validate_save_path` compares
51+
against ``os.path.realpath`` output, we must include the ``/private/``-
52+
prefixed variants so that ``/etc/passwd`` (which resolves to
53+
``/private/etc/passwd``) is still rejected.
54+
"""
4855
if sys.platform == "win32":
4956
return _WINDOWS_BLOCKED_PREFIXES
5057
elif sys.platform == "darwin":
51-
return _LINUX_BLOCKED_PREFIXES + _MACOS_BLOCKED_PREFIXES
58+
private_variants = tuple("/private" + p for p in _LINUX_BLOCKED_PREFIXES)
59+
return _LINUX_BLOCKED_PREFIXES + private_variants + _MACOS_BLOCKED_PREFIXES
5260
else:
5361
return _LINUX_BLOCKED_PREFIXES
5462

tests/simulation/mujoco/test_agenttool_contract.py

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -605,3 +605,113 @@ def test_remove_middle_of_three_robots(self, sim):
605605
assert set(sim._world.robots) == {"alice", "carol"}
606606
# bob was 6 joints; alice (6) + carol (19) = 25 should remain.
607607
assert sim._world._model.njnt == njnt_before - 6
608+
609+
610+
class TestRemoveRobotPreservesState:
611+
"""Regression tests for PR #85 follow-up (AGENTS.md "Per-name state
612+
copy, not flat index"): removing one robot must NOT reset the state
613+
of surviving robots or objects.
614+
615+
Before the fix, ``eject_robot_from_scene`` rebuilt the scene from
616+
scratch and reset every remaining qpos/qvel to 0 (robots) or body
617+
pose (objects). Agents that called ``remove_robot`` mid-simulation
618+
silently lost physics state.
619+
"""
620+
621+
def test_surviving_robot_joint_state_is_preserved(self, sim):
622+
"""After ``remove_robot(bob)``, alice's joints keep their qpos."""
623+
sim.add_robot(name="alice", data_config="so100", position=[-0.5, 0, 0])
624+
sim.add_robot(name="bob", data_config="so100", position=[0.5, 0, 0])
625+
626+
# Drive alice's joints to a non-zero pose and step forward so
627+
# qpos actually reflects applied ctrl (not just a zero default).
628+
import numpy as np
629+
630+
alice = sim._world.robots["alice"]
631+
# Write directly to qpos for a deterministic snapshot (avoids
632+
# ctrl-dynamics dependency). Each joint gets a distinct non-zero
633+
# value so accidental index shifts would be obvious.
634+
target = [0.1, 0.2, 0.3, 0.4, 0.5, 0.6]
635+
for jid, val in zip(alice.joint_ids, target):
636+
qadr = sim._world._model.jnt_qposadr[jid]
637+
sim._world._data.qpos[qadr] = val
638+
639+
snapshot_before = np.array(
640+
[sim._world._data.qpos[sim._world._model.jnt_qposadr[jid]] for jid in alice.joint_ids]
641+
)
642+
643+
# Remove bob - alice should survive with her joint state intact.
644+
r = sim.remove_robot(name="bob")
645+
assert r["status"] == "success"
646+
647+
alice_after = sim._world.robots["alice"]
648+
snapshot_after = np.array(
649+
[sim._world._data.qpos[sim._world._model.jnt_qposadr[jid]] for jid in alice_after.joint_ids]
650+
)
651+
652+
np.testing.assert_allclose(
653+
snapshot_before,
654+
snapshot_after,
655+
atol=1e-10,
656+
err_msg="alice's joint qpos was reset by remove_robot(bob)",
657+
)
658+
659+
def test_surviving_object_freejoint_pose_is_preserved(self, sim):
660+
"""An object's freejoint qpos (position + quat) survives the
661+
eject rebuild. Before the fix, objects snapped back to their
662+
``add_object`` spawn pose."""
663+
import numpy as np
664+
665+
sim.add_robot(name="alice", data_config="so100")
666+
sim.add_robot(name="bob", data_config="so100", position=[1, 0, 0])
667+
sim.add_object(name="cube", shape="box", size=[0.05, 0.05, 0.05], position=[0.3, 0, 0.05])
668+
669+
mj = sim._mj
670+
cube_jid = mj.mj_name2id(sim._world._model, mj.mjtObj.mjOBJ_JOINT, "cube_joint")
671+
assert cube_jid >= 0, "cube freejoint must exist"
672+
cube_qadr = int(sim._world._model.jnt_qposadr[cube_jid])
673+
674+
# Move the cube to a distinct position + tilted orientation so
675+
# the test fails loudly if state is reset to spawn pose.
676+
new_pose = [0.7, -0.2, 0.15, 0.7071, 0.0, 0.7071, 0.0]
677+
for i, v in enumerate(new_pose):
678+
sim._world._data.qpos[cube_qadr + i] = v
679+
mj.mj_forward(sim._world._model, sim._world._data)
680+
681+
# Eject bob.
682+
r = sim.remove_robot(name="bob")
683+
assert r["status"] == "success"
684+
685+
# Cube freejoint must still be there at the moved pose.
686+
cube_jid2 = mj.mj_name2id(sim._world._model, mj.mjtObj.mjOBJ_JOINT, "cube_joint")
687+
assert cube_jid2 >= 0, "cube freejoint disappeared after remove_robot"
688+
cube_qadr2 = int(sim._world._model.jnt_qposadr[cube_jid2])
689+
restored = np.array([sim._world._data.qpos[cube_qadr2 + i] for i in range(7)])
690+
np.testing.assert_allclose(
691+
restored,
692+
new_pose,
693+
atol=1e-10,
694+
err_msg="cube freejoint pose was reset by remove_robot",
695+
)
696+
697+
def test_ejected_robot_state_is_not_restored(self, sim):
698+
"""The ejected robot's joints should NOT appear in the new model.
699+
This is the contrapositive of the preservation tests - confirms
700+
the snapshot/restore loop only touches surviving joints.
701+
"""
702+
sim.add_robot(name="alice", data_config="so100")
703+
sim.add_robot(name="bob", data_config="so100", position=[1, 0, 0])
704+
705+
bob = sim._world.robots["bob"]
706+
bob_prefix = bob.namespace # e.g. "bob/"
707+
assert bob_prefix, "bob must have a namespace for this test to be meaningful"
708+
709+
r = sim.remove_robot(name="bob")
710+
assert r["status"] == "success"
711+
712+
mj = sim._mj
713+
# No joint under bob's prefix should exist anymore.
714+
model = sim._world._model
715+
for jid in range(model.njnt):
716+
name = mj.mj_id2name(model, mj.mjtObj.mjOBJ_JOINT, jid)
717+
assert name is None or not name.startswith(bob_prefix), f"ejected robot's joint survived: {name!r}"

tests/tools/test_path_validation.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -216,6 +216,26 @@ def test_darwin_includes_macos_extras(self):
216216
assert "/System/" in prefixes # macOS specific
217217
assert "/Library/LaunchDaemons/" in prefixes
218218

219+
def test_darwin_includes_private_variants(self):
220+
"""Regression: on macOS, ``os.path.realpath`` maps ``/etc`` →
221+
``/private/etc`` (and same for ``/var``, ``/tmp``). The blocked
222+
prefix list MUST include the ``/private/``-prefixed variants,
223+
otherwise resolved paths bypass the check entirely. Fixes the
224+
bug where ``/etc/passwd`` was silently accepted on macOS."""
225+
with patch.object(sys, "platform", "darwin"):
226+
prefixes = _get_blocked_prefixes()
227+
assert "/private/etc/" in prefixes
228+
assert "/private/var/spool/cron/" in prefixes
229+
assert "/private/var/spool/at/" in prefixes
230+
231+
def test_linux_excludes_private_variants(self):
232+
"""On Linux ``/private/`` is not a special path; the variants
233+
should only be added on darwin."""
234+
with patch.object(sys, "platform", "linux"):
235+
prefixes = _get_blocked_prefixes()
236+
for p in prefixes:
237+
assert not p.startswith("/private/"), f"Linux should not block /private/* prefixes: {p!r}"
238+
219239
def test_windows_prefixes_returned_on_win32(self):
220240
"""On Windows, Windows-specific prefixes should be active."""
221241
with patch.object(sys, "platform", "win32"):

0 commit comments

Comments
 (0)