diff --git a/gateway/cwd_placeholder.py b/gateway/cwd_placeholder.py new file mode 100644 index 00000000000..f102595e337 --- /dev/null +++ b/gateway/cwd_placeholder.py @@ -0,0 +1,49 @@ +"""Resolve gateway ``terminal.cwd`` placeholder values to ``TERMINAL_CWD``. + +When ``terminal.cwd`` is unset or a placeholder (``.``, ``auto``, ``cwd``), +the gateway must not blindly map host ``Path.home()`` into container backends. +Docker with workspace mounting still needs an explicit host path signal +(``MESSAGING_CWD`` or an absolute config path) for ``terminal_tool`` to map +``/host/project`` → ``/workspace``. +""" + +from __future__ import annotations + +CWD_PLACEHOLDERS = frozenset({".", "auto", "cwd"}) + + +def _truthy_env(value: str | None) -> bool: + return (value or "").strip().lower() in {"true", "1", "yes"} + + +def resolve_placeholder_terminal_cwd( + *, + configured_cwd: str, + terminal_backend: str, + messaging_cwd: str | None, + docker_mount_cwd_to_workspace: bool, + home_fallback: str, +) -> str | None: + """Return the ``TERMINAL_CWD`` value to set, or ``None`` to leave it unset. + + Cases: + - **local** + placeholder → ``MESSAGING_CWD`` or ``home_fallback`` + - **docker** + placeholder + mount on + host ``MESSAGING_CWD`` → host path + (for ``terminal_tool`` ``/workspace`` mapping) + - **docker** + placeholder + mount off → ``None`` (sandbox default) + - other non-local backends + placeholder → ``None`` + """ + if configured_cwd and configured_cwd not in CWD_PLACEHOLDERS: + return configured_cwd + + backend = (terminal_backend or "local").strip().lower() + if backend == "local": + messaging = (messaging_cwd or "").strip() + return messaging or home_fallback + + if backend == "docker" and docker_mount_cwd_to_workspace: + messaging = (messaging_cwd or "").strip() + if messaging and messaging not in CWD_PLACEHOLDERS: + return messaging + + return None diff --git a/gateway/run.py b/gateway/run.py index 7736474e591..b08650d08ac 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -1660,16 +1660,27 @@ os.environ["HERMES_EXEC_ASK"] = "1" # Set terminal working directory for messaging platforms. # config.yaml terminal.cwd is the canonical source (bridged to TERMINAL_CWD -# by the config bridge above). When it's unset or a placeholder, default -# to home directory. MESSAGING_CWD is accepted as a backward-compat -# fallback (deprecated — the warning above tells users to migrate). +# by the config bridge above). Placeholder values are resolved per-backend — +# see gateway/cwd_placeholder.py for the three-case contract (local vs docker +# mount-off vs docker mount-on). MESSAGING_CWD is a backward-compat fallback. +from gateway.cwd_placeholder import CWD_PLACEHOLDERS, resolve_placeholder_terminal_cwd + _configured_cwd = os.environ.get("TERMINAL_CWD", "") -if not _configured_cwd or _configured_cwd in {".", "auto", "cwd"}: - if os.environ.get("TERMINAL_ENV", "").strip().lower() == "ssh": +if not _configured_cwd or _configured_cwd in CWD_PLACEHOLDERS: + _resolved_cwd = resolve_placeholder_terminal_cwd( + configured_cwd=_configured_cwd, + terminal_backend=os.environ.get("TERMINAL_ENV", ""), + messaging_cwd=os.getenv("MESSAGING_CWD"), + docker_mount_cwd_to_workspace=os.getenv( + "TERMINAL_DOCKER_MOUNT_CWD_TO_WORKSPACE", "false" + ).lower() + in {"true", "1", "yes"}, + home_fallback=str(Path.home()), + ) + if _resolved_cwd is None: os.environ.pop("TERMINAL_CWD", None) else: - _fallback = os.getenv("MESSAGING_CWD") or str(Path.home()) - os.environ["TERMINAL_CWD"] = _fallback + os.environ["TERMINAL_CWD"] = _resolved_cwd from gateway.config import ( ChannelOverride,