Plugin terminal backends are no longer stuck in /root: default_cwd and supports_streams


You wire a plugin terminal backend into Hermes — Cua Spaces, a QEMU VM, or your company’s internal execution service. The plugin loads, terminal.backend points at it, and the first command lands in /root even though the project lives at /home/acme/project. You ask the agent to cd there; the next command is back in /root. Strangely, read_file still resolves the project path. Try putting Bot Desktop inside that sandbox and it refuses, without a clear reason.

None of that was your plugin’s fault. Three hardcoded assumptions in the core produced it, and PR #142225, merged on October 20, 2026, turned them into options a provider can declare. Here is the mechanism, the API, and the two pitfalls worth designing around.

The three hardcoded assumptions

One: the start directory was /root or nothing. A _DEFAULT_CWD_BY_BACKEND table covered ssh and vercel_sandbox only. Every other backend fell back to /root, and a plugin had no way to say where it should start.

Two: the container guard threw sandbox home paths away. Each command’s working directory comes from the session’s recorded cwd, else the config default (_resolve_command_cwd) — and execute() prefers that value over the environment’s own env.cwd. So commands kept going to /root, and when you cd-ed into /home/acme/project, the guard classified /home/... as a host path and dropped it on the next command. The file tools kept the path, which is why commands and file tools ended up on different planets.

Three: stream-capable backends were a fixed tuple. exec_prefix() in tools/environments/streams.py recognized the Docker, SSH and Singularity environment classes, and tools/bot_desktop/placement.py hardcoded _STREAM_BACKENDS = ("docker", "ssh", "singularity"). A plugin could only join by editing the core and adding its own name.

Attribute one: default_cwd

A provider adds one read-only property. Return an absolute in-sandbox path, or "~" to mean “each sandbox’s own home”:

from agent.terminal_env_provider import TerminalEnvironmentProvider


class AcmeBoxProvider(TerminalEnvironmentProvider):
    name = "acmebox"
    is_container = True

    @property
    def default_cwd(self):
        return "/home/acme"        # known home: an absolute path is enough

For sandboxes whose home is only known after they connect — the normal case for a hosted-space product — return "~":

    @property
    def default_cwd(self):
        return "~"

Hermes never expands that tilde on the host. It passes cwd="~" to create_environment() verbatim, and your environment resolves it to the home it actually got:

class AcmeBoxEnvironment(BaseEnvironment):
    def __init__(self, *, cwd, timeout, sandbox):
        if cwd == "~":
            cwd = sandbox.home()      # e.g. /home/acme
        super().__init__(cwd=cwd, timeout=timeout)

Hermes then stamps that resolved path on the environment object, so any cd under the sandbox home survives into later commands and the file tools anchor relative paths in the same place — the split between command cwd and file-tool cwd disappears.

Three details matter in practice. default_cwd is used when terminal.cwd is unset, or when the container guard discards a host-looking terminal.cwd. It is read on every command, so keep it cheap — no network calls. Relative paths are ignored, because Hermes would anchor them on the host.

Attribute two: supports_streams and exec_prefix()

The screen, computer_use and the browser follow the terminal into a sandbox by spawning long-lived processes there with stdin and stdout left open. Docker, SSH and Singularity have always done this. A plugin backend can now opt in:

  1. Set supports_streams = True on the provider. bot_desktop.placement: auto then treats your backend as a valid placement target instead of refusing every plugin backend.
  2. Give the environment an exec_prefix(*, user=None, interactive=True) method. It returns a local argv that runs whatever follows it inside the sandbox — the shape of docker exec -i <container> — or None when it can’t right now.
class AcmeBoxEnvironment:
    def exec_prefix(self, *, user=None, interactive=True):
        return ["acmebox", "exec", "--stdin", self.sandbox_id, "--"]

Three conventions: Hermes appends bash -c <script> as separate words after your argv, so the client must pass them through unchanged and the sandbox needs bash; return a list, never a single command string; user names the account to run as, so ignore it if your backend cannot switch users.

The image side has requirements too: Xvnc, Xfce, Chromium and cua-driver must be in the sandbox — nousresearch/hermes-sandbox:desktop ships them. Bot Desktop and the browser keep scratch files under the environment’s get_temp_dir().

Both attributes are optional and default off. A plugin that sets neither behaves exactly as it did before.

Two pitfalls

Isolation mode eats both features. Both exec_prefix() and the ~ home resolution read the environment object itself, so they only work for plugins loaded in-process. Under plugins.isolation: host the environment reaches Hermes as an opaque placeholder: Bot Desktop refuses a supports_streams backend with a message naming the setting, and default_cwd = "~" stays a literal tilde. If your backend is meant to host the screen, do not run that plugin isolated.

Do not hand it host paths. default_cwd accepts an in-sandbox absolute path or "~". Passing a host path like /Users/you/project does not raise — it is treated as unusable and falls back. Likewise, the guard only keeps a cd that lands under the home you declared, so a sandbox home that merely looks like /home/... still needs default_cwd set.

Can you use it today?

The change is on main (merged October 20, 2026) but not in any release: the latest published version is v0.22.2, which predates this interface. Upgrade when the next patch lands, or track main. For the wider picture, read the terminal backend plugin guide; plugin install, enable and isolation commands are in the hermes plugins reference; and plugin lifecycle work is covered in the plugin lifecycle hooks post.

The short version

A plugin terminal backend can now answer two questions itself: where commands start (default_cwd, either an absolute path or "~"), and whether the screen may live inside it (supports_streams plus exec_prefix()). Both are opt-in with unchanged defaults — and the one thing to plan around is isolation mode, where both degrade to a placeholder.