Skip to content

Commit 02dbf69

Browse files
bai-uipathclaude
andauthored
fix(docker): expand ~ and $VAR in extra_mounts destinations (#128)
* fix(docker): expand ~ and $VAR in extra_mounts destinations The source side of an extra_mounts spec is normalized with expandvars(expanduser(...)) so authors can write portable specs; the destination was only checked for a leading "/" and never expanded. That asymmetry makes one common mount impossible to write portably. env_passthrough forwards HOME with the HOST value on purpose, so any container-side path that must line up with $HOME -- $HOME/.uipath for the uip CLI's saved login state, for instance -- has a different literal value on every host. The only way to express it was to hardcode one host's home directory, which then mounts to the wrong place everywhere else. A login state the CLI cannot see fails tasks as a capability problem rather than a config one, so the misconfiguration is close to invisible: it cost 26% of the rows in an ad-hoc Maestro run before it was spotted. Expand the destination the same way, before the absolute-path check, so `~/.uipath:$HOME/.uipath:rw` resolves. Two details worth keeping: - expandvars leaves an unset variable verbatim, so a typo'd name still fails the absolute-path check. The message now shows the raw and the expanded form, otherwise it reads as a puzzle. - the framework-owned-mount check runs on the expanded destination, since a variable could itself expand to /work or / and the raw form would sail past the gate. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * docs(docker): trim the extra_mounts destination comments to scope The added docstring and test docstrings justified destination expansion with `$HOME`/`.uipath`, which is not a case this serves: skills experiments mount login state at a literal destination. Restate it against the one real consumer, a container path that has to match a host-valued var. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(docker): reject expansions that inject a ':' into a mount path Two problems. `ruff format` wanted the destination error on one line, which failed the Quality Gate and the Windows Smoke Test. More importantly, a variable whose value carries a ':' added fields to the spec rebuilt at the bottom of the validator. `SNEAKY=/mnt/x:rw` in `$real:$SNEAKY:ro` produced `/real:/mnt/x:rw:ro`, moving the destination and widening a declared read-only mount. Guard both sides after expansion, excluding the Windows drive prefix whose colon is legitimate and already split off. Two tests cover it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent f6d99ff commit 02dbf69

2 files changed

Lines changed: 55 additions & 8 deletions

File tree

‎src/coder_eval/isolation/docker_runner.py‎

Lines changed: 20 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -290,11 +290,14 @@ def _validate_extra_mount(spec: str) -> str:
290290
291291
Defends against typos that would silently expose the host fs to the
292292
container, and against mount specs that shadow framework-owned mounts.
293-
Normalizes the source side by expanding ``~`` and ``$VAR`` so authors
294-
can write portable specs. Returns the (possibly rewritten) spec to
295-
feed back into argv.
293+
Normalizes BOTH sides by expanding ``~`` and ``$VAR`` so authors can
294+
write portable specs. Returns the (possibly rewritten) spec to feed
295+
back into argv.
296296
297297
Notes:
298+
- Destinations are expanded too. A container path that has to match a
299+
host-valued var (``$SKILLS_REPO_PATH``) would otherwise have to be
300+
hardcoded per machine.
298301
- Mode is REQUIRED. Forgetting ``:ro`` is the single most common way
299302
to accidentally hand the container RW access to a host directory,
300303
so we make the author write it explicitly.
@@ -312,26 +315,35 @@ def _validate_extra_mount(spec: str) -> str:
312315
parts = body.split(":")
313316
if len(parts) < 2 or len(parts) > 3:
314317
raise ValueError(f"Invalid extra_mounts entry {spec!r}: expected `src:dst[:ro|rw]`.")
315-
src, dst = head + parts[0], parts[1]
318+
src, raw_dst = head + parts[0], parts[1]
316319
# Default to read-only when mode is omitted. Mounting host paths RW
317320
# by default is the wrong sandbox stance: the few RW use-cases are
318321
# better stated explicitly than implied by silence.
319322
mode = parts[2] if len(parts) == 3 else "ro"
320323
if not src:
321324
raise ValueError(f"Invalid extra_mounts entry {spec!r}: empty source path.")
322-
if not dst:
325+
if not raw_dst:
323326
raise ValueError(f"Invalid extra_mounts entry {spec!r}: empty destination path.")
327+
# Expanded before the absolute-path check: that is the point.
328+
expanded_src = os.path.expandvars(os.path.expanduser(src))
329+
dst = os.path.expandvars(os.path.expanduser(raw_dst))
330+
# A variable whose value carries a ':' would add fields to the spec rebuilt
331+
# at the bottom, silently moving the destination or widening the mode.
332+
# The drive prefix is excluded: its colon is legitimate and already split off.
333+
if ":" in dst or ":" in expanded_src[len(head) :]:
334+
raise ValueError(f"Invalid extra_mounts entry {spec!r}: expansion introduced a ':' into a path.")
324335
if not dst.startswith("/"):
325-
raise ValueError(f"Invalid extra_mounts entry {spec!r}: destination must be an absolute path.")
336+
# expandvars leaves an unset var verbatim, so typos land here.
337+
detail = f"{raw_dst!r}" if dst == raw_dst else f"{raw_dst!r} (expanded to {dst!r})"
338+
raise ValueError(f"Invalid extra_mounts entry {spec!r}: destination must be an absolute path, got {detail}.")
326339
if mode not in ("ro", "rw"):
327340
raise ValueError(f"Invalid extra_mounts entry {spec!r}: mode must be 'ro' or 'rw'.")
328-
# Expand ~ and $VAR in the source so authors can write portable specs.
329-
expanded_src = os.path.expandvars(os.path.expanduser(src))
330341
if not Path(expanded_src).exists():
331342
raise ValueError(f"Invalid extra_mounts entry {spec!r}: source path does not exist on host.")
332343
# Reject destinations that shadow framework-owned mounts inside the
333344
# container. ``/work`` substrings are caught too -- /work/foo would
334345
# land underneath our staging dir and shadow the input/output tree.
346+
# Expanded form: a var could itself expand to a reserved path.
335347
dst_norm = dst.rstrip("/") or "/"
336348
if dst_norm in _RESERVED_MOUNT_DESTS or dst_norm.startswith(CONTAINER_WORK_DIR + "/"):
337349
raise ValueError(

‎tests/test_docker_runner_mounts.py‎

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,41 @@ def test_var_expansion_in_source(self, real_dir, monkeypatch):
108108
result = _validate_extra_mount("$MYDIR:/mnt/x")
109109
assert result.startswith(real_dir + ":")
110110

111+
def test_var_expansion_in_destination(self, real_dir, monkeypatch):
112+
"""``$VAR`` in the destination expands too."""
113+
monkeypatch.setenv("HOME", "/home/someuser")
114+
result = _validate_extra_mount(f"{real_dir}:$HOME/.uipath:rw")
115+
assert result == f"{real_dir}:/home/someuser/.uipath:rw"
116+
117+
def test_home_expansion_in_destination(self, real_dir, monkeypatch):
118+
"""``~`` in the destination expands the same way the source does."""
119+
monkeypatch.setenv("HOME", "/home/someuser")
120+
result = _validate_extra_mount(f"{real_dir}:~/.uipath:ro")
121+
assert result == f"{real_dir}:/home/someuser/.uipath:ro"
122+
123+
def test_unset_var_destination_rejected_with_both_forms(self, real_dir):
124+
"""An unset var is left verbatim, so it must fail loudly."""
125+
with pytest.raises(ValueError, match="destination must be an absolute path"):
126+
_validate_extra_mount(f"{real_dir}:$NO_SUCH_VAR_HERE/x:ro")
127+
128+
def test_destination_var_expanding_to_reserved_is_rejected(self, real_dir, monkeypatch):
129+
"""The shadow check runs on the expanded destination."""
130+
monkeypatch.setenv("SNEAKY", "/work")
131+
with pytest.raises(ValueError, match="shadows a framework-owned mount"):
132+
_validate_extra_mount(f"{real_dir}:$SNEAKY:ro")
133+
134+
def test_destination_var_carrying_a_colon_is_rejected(self, real_dir, monkeypatch):
135+
"""A ':' in an expanded value would add fields to the rebuilt spec."""
136+
monkeypatch.setenv("SNEAKY", "/mnt/x:rw")
137+
with pytest.raises(ValueError, match="introduced a ':'"):
138+
_validate_extra_mount(f"{real_dir}:$SNEAKY:ro")
139+
140+
def test_source_var_carrying_a_colon_is_rejected(self, monkeypatch):
141+
"""Same guard on the source side, which is rebuilt the same way."""
142+
monkeypatch.setenv("SNEAKY", "/mnt/x:rw")
143+
with pytest.raises(ValueError, match="introduced a ':'"):
144+
_validate_extra_mount("$SNEAKY:/mnt/y:ro")
145+
111146
def test_malformed_no_colon(self):
112147
with pytest.raises(ValueError, match="expected `src:dst"):
113148
_validate_extra_mount("just-one-token")

0 commit comments

Comments
 (0)