fix(docker): bind a Windows workspace when /workspace is already claimed

A volume that already owns /workspace skipped the configured working
directory, so tools treated that host path as unmounted. Bind it at a
second mount, or point tools at the volume that already has it, for any
drive path.
This commit is contained in:
brooklyn!
2026-09-24 08:02:48 -05:00
parent 50873d2154
commit 1a72042115
8 changed files with 370 additions and 30 deletions

View File

@@ -21,9 +21,11 @@ import uuid
from pathlib import Path
from typing import Optional
from tools.environments.base import BaseEnvironment, EnvironmentConnectionError
from tools.environments.base import BaseEnvironment, EnvironmentConnectionError, _SHELL_ENV_NAME_RE
from tools.terminal_tool_config import (
_host_path_key, _is_windows_drive_path, cwd_follows_host_mount,
)
from tools.environments.base_output import _popen_bash
from tools.environments.base_session_env import _SHELL_ENV_NAME_RE
from tools.environments.docker_egress import (
_EGRESS_LABEL_KEY, _critical_egress_env_names, _egress_enforce_on_docker, _egress_proxy_args_for_docker,
_egress_reuse_fingerprint, check_docker_env_collisions, check_extra_args_collisions,
@@ -511,6 +513,57 @@ def _host_user_args(run_as_host_user: bool) -> list[str]:
return []
_VOLUME_SPEC_RE = re.compile(r"^(?P<host>.+):(?P<container>/[^:]+)(?::[^:]*)?$")
# Second mount when a user volume already owns /workspace. Not a username.
_HOST_CWD_FALLBACK_MOUNTS = ("/host-cwd", "/host-cwd-2", "/host-cwd-3")
def _split_volume_spec(spec: str) -> tuple[str, str] | None:
"""``host:container[:mode]`` → ``(host, container)``. Drive-letter hosts keep their colon."""
if not isinstance(spec, str):
return None
match = _VOLUME_SPEC_RE.match(spec.strip())
if not match:
return None
return match.group("host"), match.group("container")
def _container_mount_taken(volume_args: list[str], mount: str) -> bool:
target = mount.rstrip("/") or "/"
for arg in volume_args:
parsed = _split_volume_spec(arg)
if parsed and (parsed[1].rstrip("/") or "/") == target:
return True
return False
def _existing_host_mount(volume_args: list[str], host_cwd_abs: str) -> str | None:
"""Container path if a user volume already bind-mounts this host directory."""
want = _host_path_key(host_cwd_abs)
if not want:
return None
for arg in volume_args:
parsed = _split_volume_spec(arg)
if parsed and _host_path_key(parsed[0]) == want:
return parsed[1]
return None
def _free_host_cwd_mount(volume_args: list[str]) -> str:
for candidate in _HOST_CWD_FALLBACK_MOUNTS:
if not _container_mount_taken(volume_args, candidate):
return candidate
return _HOST_CWD_FALLBACK_MOUNTS[-1]
def _abs_host_cwd(host_cwd: str) -> str:
"""Absolute host path. A Windows drive path is not prefixed with the POSIX process cwd."""
expanded = os.path.expanduser(host_cwd)
if _is_windows_drive_path(expanded) and os.name != "nt":
return expanded
return os.path.abspath(expanded)
class DockerEnvironment(BaseEnvironment):
"""Hardened Docker container execution (caps dropped, no-new-privileges, PID limits,
size-limited tmpfs). The container is the security boundary — its filesystem stays
@@ -570,6 +623,13 @@ class DockerEnvironment(BaseEnvironment):
resource_args = self._resource_args(image, cpu, memory, disk, network, shm_size, extra_args)
volume_args, writable_args = self._mount_args(volumes, host_cwd, auto_mount_cwd, task_id)
mount = getattr(self, "host_cwd_mount", None)
if mount and cwd_follows_host_mount(cwd, mount):
logger.info(
"Container cwd follows configured host workspace at %s (requested %s)",
mount, cwd)
cwd = mount
self.cwd = mount
volume_args.extend(_readonly_skill_mount_args())
egress_label, egress_volume_args, egress_host_args, env_args, validated_extra = (
self._egress_and_env_args(extra_args))
@@ -697,7 +757,15 @@ class DockerEnvironment(BaseEnvironment):
def _mount_args(self, volumes, host_cwd, auto_mount_cwd, task_id) -> tuple[list[str], list[str]]:
"""``(volume_args, writable_args)`` for user volumes, host cwd and /workspace,/root.
Persistent mode bind-mounts from TERMINAL_SANDBOX_DIR (default ~/.hermes/sandboxes/)."""
Persistent mode bind-mounts from TERMINAL_SANDBOX_DIR (default ~/.hermes/sandboxes/).
A configured host working directory is bound even when another volume already
claims ``/workspace``: at ``/workspace`` when that path is free, otherwise at
a second mount. ``host_cwd`` / ``host_cwd_mount`` tell tools which container
path is that directory. A Windows drive path is bound whenever it exists on
the host — it can never be a path inside the Linux container, and the check
is the drive shape, not a username.
"""
volume_args: list[str] = []
for vol in (volumes or []):
if not isinstance(vol, str):
@@ -712,17 +780,33 @@ class DockerEnvironment(BaseEnvironment):
volume_args.extend(["-v", vol])
workspace_explicitly_mounted = any(":/workspace" in v for v in volume_args)
host_cwd_abs = os.path.abspath(os.path.expanduser(host_cwd)) if host_cwd else ""
bind_host_cwd = (
auto_mount_cwd and bool(host_cwd_abs) and os.path.isdir(host_cwd_abs)
and not workspace_explicitly_mounted)
if auto_mount_cwd and host_cwd and not os.path.isdir(host_cwd_abs):
host_cwd_abs = _abs_host_cwd(host_cwd) if host_cwd else ""
windows_cwd = _is_windows_drive_path(host_cwd or "") or _is_windows_drive_path(host_cwd_abs)
host_dir_exists = bool(host_cwd_abs) and os.path.isdir(host_cwd_abs)
should_bind = host_dir_exists and (auto_mount_cwd or windows_cwd)
if (auto_mount_cwd or windows_cwd) and host_cwd and not host_dir_exists:
logger.debug("Skipping docker cwd mount: host_cwd is not a valid directory: %s", host_cwd)
# The host directory actually bound at /workspace, if any. Readers that
# only hold the env instance (cwd remapping on live envs) use it to
# recognize a session workspace registered as a raw host path.
self.host_cwd = host_cwd_abs if bind_host_cwd else None
mount_workspace = not bind_host_cwd and not workspace_explicitly_mounted
existing_mount = _existing_host_mount(volume_args, host_cwd_abs) if should_bind else None
if existing_mount:
# Already bind-mounted (often the volume that claimed /workspace). Point
# tools at that container path instead of adding a second -v.
self.host_cwd = host_cwd_abs
self.host_cwd_mount = existing_mount
bind_target = None
elif should_bind:
bind_target = (
"/workspace" if not workspace_explicitly_mounted
else _free_host_cwd_mount(volume_args))
self.host_cwd = host_cwd_abs
self.host_cwd_mount = bind_target
else:
self.host_cwd = None
self.host_cwd_mount = None
bind_target = None
bind_at_workspace = bind_target == "/workspace"
mount_workspace = not bind_at_workspace and not workspace_explicitly_mounted
writable_args: list[str] = []
if self._persistent:
@@ -741,10 +825,10 @@ class DockerEnvironment(BaseEnvironment):
writable_args += ["--tmpfs", "/workspace:rw,exec,size=10g"] if mount_workspace else []
writable_args += ["--tmpfs", "/home:rw,exec,size=1g", "--tmpfs", "/root:rw,exec,size=1g"]
if bind_host_cwd:
logger.info("Mounting configured host cwd to /workspace: %s", host_cwd_abs)
volume_args = ["-v", f"{host_cwd_abs}:/workspace", *volume_args]
elif workspace_explicitly_mounted:
if bind_target:
logger.info("Mounting configured host cwd to %s: %s", bind_target, host_cwd_abs)
volume_args = ["-v", f"{host_cwd_abs}:{bind_target}", *volume_args]
elif workspace_explicitly_mounted and not existing_mount:
logger.debug("Skipping docker cwd mount: /workspace already mounted by user config")
return volume_args, writable_args

View File

@@ -468,7 +468,15 @@ class ShellFileOperations(LintMixin, SearchMixin, FileOperations):
def _expand_path(self, path: str) -> str:
"""Expand ``~`` / ``~user`` via the backend's shell (its HOME, not the
host's). Must run BEFORE shell escaping — ~ doesn't expand in quotes."""
host's). A host path under the configured workspace mount is rewritten
to that container path first, so a Windows drive path is readable
inside Docker. Must run BEFORE shell escaping — ~ doesn't expand in quotes."""
from tools.terminal_tool_config import translate_mounted_host_path
host_root = getattr(self.env, "host_cwd", None)
container_root = getattr(self.env, "host_cwd_mount", None) or "/workspace"
translated = translate_mounted_host_path(path, host_root or "", container_root)
if translated:
return translated
if not path or not path.startswith('~'):
return path
result = self._exec("echo $HOME")

View File

@@ -234,6 +234,14 @@ async def _resolve_container_fallback(
f"'{p}' is not reachable inside the sandbox and no active sandbox "
f"session is available to read it",
src=src, origin="container")
from tools.terminal_tool_config import translate_mounted_host_path
translated = translate_mounted_host_path(
str(p),
getattr(env, "host_cwd", None) or "",
getattr(env, "host_cwd_mount", None) or "/workspace",
)
if translated:
p = Path(translated)
# Bound the read INSIDE the sandbox: head -c caps at ingest-limit+1 (+1 distinguishes "at the
# cap" from "over") so /dev/zero can't stream unbounded base64 into host memory. The input
# redirect avoids argv (leading-dash paths); tr -d instead of GNU-only base64 -w0 (BusyBox).

View File

@@ -44,9 +44,9 @@ from tools.terminal_tool_lifecycle import (
_evict_environment_for_task, cleanup_all_environments, ensure_task_env,
)
from tools.terminal_tool_config import (
_is_container_backend, _is_host_cwd, _is_mounted_host_cwd, _is_unusable_container_cwd, _parse_env_var,
coerce_ssh_remote_cwd,
_plugin_env_flag, _quiet, _safe_getcwd, _tenv, _tenv_bool,
_is_container_backend, _is_host_cwd, _is_mounted_host_cwd, _is_unusable_container_cwd,
_is_windows_drive_path, _parse_env_var, _plugin_env_flag, _quiet, _safe_getcwd, _tenv, _tenv_bool,
coerce_ssh_remote_cwd, translate_mounted_host_path,
)
from tools.terminal_tool_backends import (
_REQUIREMENT_CHECKERS, _VERCEL_SANDBOX_DEFAULT_CWD, _check_plugin_requirements,
@@ -285,8 +285,8 @@ def _sanitize_cwd_for_live_env(env: Any, new_cwd: str) -> Optional[str]:
file operations with an unrelated ``cd:`` error. Prefix-shaped host paths
are already rejected on the creation paths. This write classifies the
directory mounted at ``/workspace`` as unusable before that prefix
heuristic, then remaps the match to ``/workspace`` instead of storing the
host path. Non-container backends apply the override
heuristic, then remaps the match (or a child of it) to its container mount
instead of storing the host path. Non-container backends apply the override
verbatim (ACP project-root switching must keep working).
"""
env_type = getattr(env, "env_type", None)
@@ -299,8 +299,14 @@ def _sanitize_cwd_for_live_env(env: Any, new_cwd: str) -> Optional[str]:
# every later file-tools exec would `cd` to it (exit 126).
if not _is_unusable_container_cwd(new_cwd, mounted_host=mounted):
return new_cwd
if _is_mounted_host_cwd(new_cwd, mounted):
return "/workspace"
if mounted:
# The bind may sit at a fallback mount when /workspace is claimed.
container_mount = getattr(env, "host_cwd_mount", None) or "/workspace"
if _is_mounted_host_cwd(new_cwd, mounted):
return container_mount
translated = translate_mounted_host_path(new_cwd, mounted, container_mount)
if translated:
return translated
return None
@@ -555,7 +561,7 @@ def _lookup_active_env(effective_task_id: str, task_id: Optional[str]):
def _resolve_task_host_cwd(config: Dict[str, Any], task_id: Optional[str]) -> Optional[str]:
"""Host directory to bind-mount at ``/workspace`` for *task_id*'s container.
"""Host directory to bind into *task_id*'s container.
Single owner of the cwd-mount policy for every creation site. Shared-
container mode: the ``TERMINAL_CWD``-derived ``config["host_cwd"]``.
@@ -565,8 +571,27 @@ def _resolve_task_host_cwd(config: Dict[str, Any], task_id: Optional[str]) -> Op
fresh session's mount from it would leak the previous session's directory.
Overrides tagged ``cwd_source: "process"`` are refused for the same reason;
``cwd_source: "session"`` or untagged (ACP/RL) overrides mount.
A Windows drive path is still a mount source when the cwd-to-/workspace
flag is off: it cannot exist inside the Linux container. The container
path may be ``/workspace`` or a second mount when that path is taken.
"""
if config.get("env_type") != "docker" or not config.get("docker_mount_cwd_to_workspace"):
if config.get("env_type") != "docker":
return None
if not config.get("docker_mount_cwd_to_workspace"):
# POSIX homes stay behind the opt-in flag (isolation). A Windows drive
# workspace cannot exist in the container, so it is still a mount source.
host = config.get("host_cwd")
if isinstance(host, str) and _is_windows_drive_path(host):
return host
overrides = resolve_task_overrides(task_id) if task_id else {}
candidate = overrides.get("cwd") if isinstance(overrides, dict) else None
if (
isinstance(overrides, dict)
and overrides.get("cwd_source") != "process"
and isinstance(candidate, str)
and _is_windows_drive_path(candidate)
):
return candidate
return None
# Top-level CLI parent ("default") is a single-session process — legacy behavior.
if not _docker_session_isolation_enabled() or _resolve_container_task_id(task_id) == "default":
@@ -660,6 +685,21 @@ def _resolve_config_cwd(env_type: str, mount_docker_cwd: bool) -> tuple:
):
host_cwd = candidate
cwd = "/workspace"
elif env_type == "docker" and _is_windows_drive_path(cwd):
# A Windows workspace cannot exist inside the Linux container. Bind it
# even when docker_mount_cwd_to_workspace is off; the env retargets cwd
# to the mount (which may not be /workspace).
candidate = os.path.expanduser(cwd)
if os.name == "nt":
candidate = os.path.abspath(candidate)
if os.path.isdir(candidate):
host_cwd = candidate
cwd = candidate
else:
logger.info("Ignoring TERMINAL_CWD=%r for %s backend "
"(host/relative path won't work in sandbox). Using %r instead.",
cwd, env_type, default_cwd)
cwd = default_cwd
elif _is_container_backend(env_type) and cwd and _is_unusable_container_cwd(cwd) and cwd != default_cwd:
logger.info("Ignoring TERMINAL_CWD=%r for %s backend "
"(host/relative path won't work in sandbox). Using %r instead.",
@@ -827,6 +867,47 @@ def _resolve_notification_flag_conflict(*, notify_on_complete: bool, watch_patte
return watch_patterns, ""
def _rewrite_via_env_mount(path: str, env) -> str | None:
"""Container path for *path* when *env* bind-mounted that host directory."""
if env is None or not path:
return None
host = getattr(env, "host_cwd", None)
mount = getattr(env, "host_cwd_mount", None)
if not isinstance(host, str) or not host or not mount:
return None
return translate_mounted_host_path(path, host, mount)
def _mount_envs(env):
if env is not None:
return [env]
return list(_active_environments.values())
def _container_visible_cwd(path: str, env_type: str | None, env=None) -> str:
if not path or not _is_container_backend(env_type or ""):
return path
for item in _mount_envs(env):
translated = _rewrite_via_env_mount(path, item)
if translated:
return translated
return path
def _container_visible_default(default_cwd: str, env_type: str | None, env=None) -> str:
"""Point a planner ``/workspace`` assumption at the mount that actually holds the host cwd."""
if not _is_container_backend(env_type or ""):
return default_cwd
for item in _mount_envs(env):
mount = getattr(item, "host_cwd_mount", None)
host = getattr(item, "host_cwd", None)
if not mount or not host or mount == default_cwd:
continue
if default_cwd == "/workspace" or _is_unusable_container_cwd(default_cwd):
return mount
return default_cwd
def _resolve_command_cwd(
*,
workdir: Optional[str],
@@ -834,6 +915,7 @@ def _resolve_command_cwd(
session_key: Optional[str] = None,
env_type: Optional[str] = None,
mounted_host: Optional[str] = None,
env=None,
) -> str:
"""cwd for a command: explicit ``workdir`` > the session's own cwd record >
``default_cwd``.
@@ -852,11 +934,14 @@ def _resolve_command_cwd(
Same guard class as the env-creation sanitizers (#50636, #54447); this is the per-command sibling site.
"""
if workdir:
return coerce_ssh_remote_cwd(workdir, env_type)
return coerce_ssh_remote_cwd(_container_visible_cwd(workdir, env_type, env), env_type)
recorded = get_session_cwd(session_key)
if recorded and _is_container_backend(env_type) and _is_unusable_container_cwd(
recorded, mounted_host=mounted_host
):
visible = _container_visible_cwd(recorded, env_type, env)
if visible != recorded:
return visible
if _is_mounted_host_cwd(recorded, mounted_host):
logger.info(
"Remapping recorded session cwd %r for %s backend "
@@ -869,8 +954,8 @@ def _resolve_command_cwd(
"(host/relative path won't work in sandbox). Using %r instead.",
recorded, env_type, default_cwd,
)
return default_cwd
return coerce_ssh_remote_cwd(recorded or default_cwd, env_type)
return _container_visible_default(default_cwd, env_type, env)
return recorded or coerce_ssh_remote_cwd(_container_visible_default(default_cwd, env_type, env), env_type)
def _error_json(error: str, *, exit_code: int = -1, status: Optional[str] = None, **extra) -> str:
@@ -1168,6 +1253,7 @@ def _run_foreground(
command_cwd = _resolve_command_cwd(
workdir=workdir, default_cwd=plan.cwd, session_key=session_key, env_type=env_type,
mounted_host=getattr(env, "host_cwd", None) or plan.host_cwd,
env=env,
)
# bounded_capture: model-facing output keeps a head/tail window
# while streaming so a verbose command can't OOM the gateway;

View File

@@ -162,6 +162,7 @@ def spawn_background_process(
effective_cwd = _resolve_command_cwd(
workdir=workdir, default_cwd=cwd, session_key=session_key, env_type=env_type,
mounted_host=mounted_host if mounted_host is not None else getattr(env, "host_cwd", None),
env=env,
)
try:
proc_session = _spawn(

View File

@@ -62,6 +62,59 @@ _WINDOWS_DRIVE_RE = re.compile(r"^[A-Za-z]:[\\/]")
def _is_host_cwd(path: str) -> bool:
return path.startswith(_HOST_CWD_PREFIXES) or bool(_WINDOWS_DRIVE_RE.match(path))
def _is_windows_drive_path(path: str) -> bool:
"""True for any ``D:\\...`` / ``e:/...`` path. Not a username check."""
return bool(path) and bool(_WINDOWS_DRIVE_RE.match(path))
def _host_path_key(path: str) -> str:
"""Compare host paths without caring about slash style or drive-letter case.
A trailing slash is not significant, except we never collapse a drive root
(``C:/``) into a bare ``C:`` that would prefix-match every path on that drive.
"""
text = (path or "").replace("\\", "/")
if len(text) >= 2 and text[1] == ":":
text = text[0].lower() + text[1:]
if len(text) > 3:
text = text.rstrip("/")
return text
def translate_mounted_host_path(path: str, host_root: str, container_root: str) -> str | None:
"""Map *path* onto *container_root* when it is *host_root* or a child of it.
Returns None when *path* is not under that host directory. Slash style and
drive-letter case do not matter; a sibling directory (``proj`` vs ``proj-other``)
is not a child.
"""
if not path or not host_root or not container_root:
return None
key = _host_path_key(path)
root = _host_path_key(host_root)
if not key or not root:
return None
mount = container_root.rstrip("/") or "/"
if key == root:
return mount
prefix = root if root.endswith("/") else root + "/"
if not key.startswith(prefix):
return None
return f"{mount}/{key[len(prefix):]}"
def cwd_follows_host_mount(cwd: str, mount: str) -> bool:
"""True when *cwd* was the host workspace (or the assumed ``/workspace`` view of it).
An explicit in-container path other than that assumption is left alone.
"""
if not mount or not cwd or cwd == mount:
return False
if _is_unusable_container_cwd(cwd):
return True
return cwd == "/workspace" and mount != "/workspace"
_CONTAINER_BACKENDS = frozenset({"docker", "singularity", "modal", "daytona", "vercel_sandbox"})
_BUILTIN_BACKENDS = _CONTAINER_BACKENDS | {"local", "ssh", "managed_modal"}

View File

@@ -229,6 +229,7 @@ def gateway_lifecycle_block(
guard_cwd = _resolve_command_cwd(
workdir=workdir, default_cwd=guard_cwd_base, session_key=session_key, env_type=env_type,
mounted_host=getattr(env, "host_cwd", None),
env=env,
)
unsafe, refusal = scan_gateway_lifecycle(
command,