Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
82ef671
fix(environment): keep bwrap session argv on bubblewrap 0.4 options
jdchawla29 Jul 29, 2026
82472b7
fix(integrations): keep HUD's own state out of the graded filesystem
jdchawla29 Jul 29, 2026
a6ab9ad
fix(integrations): match Harbor's agent-phase environment
jdchawla29 Jul 29, 2026
4c7b3eb
fix(environment): keep one sandbox per rollout, not one per command
jdchawla29 Jul 30, 2026
153152b
fix(environment): stream session output instead of holding it until exit
jdchawla29 Jul 30, 2026
50b2acc
feat(environment): give a session the terminal it asked for
jdchawla29 Jul 30, 2026
bfa5f7c
fix(environment): complete the terminal — controlling tty and resize
jdchawla29 Jul 30, 2026
c2e5110
refactor(environment): make shell_argv honour env and tty unsandboxed…
jdchawla29 Jul 30, 2026
e79a353
fix(environment): keep the harness out of the session's environment a…
jdchawla29 Jul 30, 2026
c5da75d
fix(integrations): let the agent phase not contain the grading paths …
jdchawla29 Jul 30, 2026
51c84a1
fix(environment): map the container's ids into the sandbox
jdchawla29 Jul 30, 2026
5c094e7
fix(integrations): run each phase as the identity declared for it
jdchawla29 Jul 30, 2026
fc8ae82
fix(integrations): move the harness off the root of the task's filesy…
jdchawla29 Jul 30, 2026
b05bd44
fix(integrations): map ids for the verifier that declares no-network
jdchawla29 Jul 30, 2026
812ae12
feat(environment): give the workspace its own network, and a policy o…
jdchawla29 Jul 31, 2026
f076c18
feat(integrations): take every task's network through the workspace's…
jdchawla29 Jul 31, 2026
c6b16b2
fix(environment): land a session in the sandbox's own working directory
jdchawla29 Jul 31, 2026
7b2d4e5
feat(environment): let a bounded workspace reach the services it decl…
jdchawla29 Jul 31, 2026
93659e2
fix(environment): stop the proxy forwarding framing it has already un…
jdchawla29 Jul 31, 2026
6205b08
fix(integrations): hold the verifier to the hosts it declared, not th…
jdchawla29 Jul 31, 2026
00ce091
refactor(integrations): make Harbor use the HUD environment boundary
jdchawla29 Jul 31, 2026
271cabe
feat(runtime): make Docker workspace isolation unconditional
jdchawla29 Jul 31, 2026
e849cbc
refactor(integrations): simplify Harbor adaptation
jdchawla29 Jul 31, 2026
d385996
refactor(integrations): simplify Harbor conversion paths
jdchawla29 Jul 31, 2026
6b6efca
refactor(environment): consolidate workspace execution
jdchawla29 Jul 31, 2026
dd46e60
fix(process): bound completion on leader exit
jdchawla29 Jul 31, 2026
f4f1922
fix(integrations): name the served environment as a literal in adapte…
jdchawla29 Aug 1, 2026
1806fb0
fix(environment): read bwrap's info document to completion
jdchawla29 Aug 1, 2026
c17a6a7
refactor(capabilities): delete the SFTP-chroot path emulation
jdchawla29 Aug 1, 2026
c3b20a8
fix(eval): exclude errored runs from the job mean
jdchawla29 Aug 1, 2026
a85e364
fix(environment): wait on setsid when it must fork
jdchawla29 Aug 1, 2026
de06e66
fix(environment): give an isolated command the image's environment
jdchawla29 Aug 1, 2026
2d6344c
fix(environment): relay only headers the HTTP grammar admits
jdchawla29 Aug 2, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
80 changes: 24 additions & 56 deletions docs/v6/advanced/harbor-convert.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -4,80 +4,48 @@ description: "Load Harbor tasks into the HUD runtime or export HUD tasks to Harb
icon: "ship"
---

Everything that authors tasks - HUD's own `env.py`, platform rows, **Harbor**
task dirs - is a *frontend* that loads into the same primitives (`Environment`,
`Task`, `Taskset`). Integrations are **loaders, not converters**: no codegen
roundtrip to run foreign tasks. Each one implements
[`hud.environment.Integration`](https://github.com/hud-evals/hud-python/blob/main/hud/environment/integration.py)
- `load(ref) -> Taskset` and `environment(ref) -> Environment` - and ships as
the repository's `integrations/<format>`.

Harbor's agent works *inside* its container, so its environment constructor is
only meaningful in there. `adapt()` packages it: one HUD-speaking image per
distinct build context, whose CMD serves `harbor:environment`.
The same rows then run on any container placement.
`adapt()` turns Harbor task directories into the same `Environment`, `Task`, and
`Taskset` primitives used by authored HUD environments. It builds the Harbor
environment first, then wraps that image with the checked-in
`integrations/harbor/Dockerfile`, `env.py`, and `install.sh`. The generated
directory contains task data, not generated source.

## Prerequisites

- A Harbor task directory - each task has `task.toml` + `instruction.md`, and
usually an `environment/` (with a `Dockerfile`) and `tests/`.

## Load Harbor tasks
## Run Harbor tasks

`load(path)` parses a Harbor task dir (or a dataset of them) into a `Taskset`
directly - one row per task dir (`id` = the dir name), sharing one declarative
`Environment` per distinct `environment/` build context:
Build the images and receive a runnable taskset:

```python
from integrations import harbor
from hud.eval import DockerRuntime

assert harbor.detect("./terminal-bench")
taskset = harbor.load("./terminal-bench")

for task in taskset:
print(task.env, task.id, task.columns["difficulty"])
```

Each row carries what the task declared: `[metadata]` as `columns` (difficulty,
category, tags - the platform's filter/leaderboard facets) and
`[environment]` cpu/memory/gpu as `runtime_config`. Time budgets stay off the
row, since they bound the *rollout* rather than the substrate - read one with
`harbor.agent_timeout(task_dir)` and pass it as `rollout_timeout`.

## Run Harbor tasks

Build the images once, then place the rows anywhere:

```python
images = await harbor.adapt("./terminal-bench") # {env name: image ref}
taskset = harbor.load("./terminal-bench", images=images) # rows carry the image
job = await taskset.run(agent, runtime=harbor.docker_runtime())
taskset = await harbor.adapt("./terminal-bench")
job = await taskset.run(agent, runtime=DockerRuntime())
```

`harbor.docker_runtime()` is a `DockerRuntime` that permits nested
namespaces: an adapted image sandboxes *inside itself* to keep the baked
tests and the verdict away from the agent, which plain images neither need
nor should be given.
`DockerRuntime` supplies the inner workspace sandbox needed by adapted images;
the setting is automatic for all HUD Docker environments.

The mapping is a value you hold - nothing is written into the dataset - so
there is no cache to go stale. `adapt(path, push="registry.io/acme")` pushes
instead; `hud deploy` of the generated contexts under `.hud-adapt/` works the
same way, since both run the image's own CMD.
`adapt(path, push="registry.io/acme")` pushes the wrapped images and returns
rows bound to those refs. The readable build inputs are left under
`.hud-adapt/`; `adapt()` owns the two-image build because the wrapper's base is
the Harbor environment it just built.

The image also carries what the task declared about its environment:
`[environment.env]`, `workdir` and `user` become `ENV`, `WORKDIR` and `USER`
directives, so Docker applies them to every process - the agent's shells, the
verifier, the serving process alike.
Each row carries `[metadata]` as `columns` and CPU, memory, and GPU requirements
as `runtime_config`. The image's `tasks.json` carries workdir, environment,
network, and phase-user declarations into the authored HUD environment.

Tasks whose declared behaviour cannot be reproduced faithfully are refused
rather than silently downgraded. Adaptation replaces the container's own boot
process with the serving command, so anything depending on that boot process is
refused: a Dockerfile `ENTRYPOINT`, `healthcheck` (nothing would start the
services it awaits), and `mcp_servers` (nothing would start the servers they
point at) - alongside compose environments, `network_mode = "allowlist"`, a
verifier with its own environment, prebuilt `docker_image` environments,
non-linux `os`, TPUs, multi-step `[[steps]]` tasks, and an agent and verifier
that must run as *different* users (one image, one `USER`).
process with the HUD server, so `healthcheck` and `mcp_servers` declarations are
refused when they depend on that process. Compose environments, separate
verifier environments, non-Linux tasks, TPUs, skills directories, and
multi-step tasks are also refused. Prebuilt images, allowlists, and distinct
agent/verifier users are supported.

## Export HUD tasks to Harbor

Expand Down
2 changes: 1 addition & 1 deletion docs/v6/more/faq.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,7 @@ Evals are a complete use on their own - write tasks, run them across models, rea
</Accordion>

<Accordion title="Can I bring an existing benchmark or tasks?">
Yes. The Harbor integration loads Harbor-format tasks straight into a `Taskset` (`integrations.harbor.load`), no conversion round-trip needed. And a whole benchmark can become one generative task definition. See [Harbor interop](/v6/advanced/harbor-convert).
Yes. `integrations.harbor.adapt()` builds Harbor-format tasks as a runnable HUD `Taskset`. And a whole benchmark can become one generative task definition. See [Harbor interop](/v6/advanced/harbor-convert).
</Accordion>

<Accordion title="Does HUD support robotics / VLA policies?">
Expand Down
4 changes: 2 additions & 2 deletions docs/v6/reference/cli.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -132,8 +132,8 @@ hud sync tasks my-taskset # publish tasks as a named taskset
hud sync env # sync environment metadata
```

External benchmark formats (currently Harbor) load directly into the runtime
as `Taskset`s - no conversion step. See [Harbor interop](/v6/advanced/harbor-convert).
External benchmark formats (currently Harbor) can be adapted into runnable
`Taskset`s. See [Harbor interop](/v6/advanced/harbor-convert).

## Inspect

Expand Down
17 changes: 8 additions & 9 deletions docs/v6/reference/runtime.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -115,21 +115,20 @@ SubprocessRuntime(path, *, env=None, ready_timeout=120.0)
### `DockerRuntime`

```python
DockerRuntime(image=None, *, port=8765, run_args=(), runtime_config=None, nested_sandbox=False)
DockerRuntime(image=None, *, port=8765, run_args=(), runtime_config=None)
```

- **`image`** - image name to run; shorthand for `runtime_config.image`.
- **`port`** - port the image's CMD serves inside the container (the scaffolded `Dockerfile.hud` serves `8765`).
- **`run_args`** - extra `docker run` flags, e.g. `["--gpus", "all"]` or `["-e", "KEY=VAL"]`.
- **`runtime_config`** - a `RuntimeConfig` (image, resources) for finer control.
- **`nested_sandbox`** - the image sandboxes *inside* the container (a `Workspace` using bubblewrap). Off by default.

An image that sandboxes internally needs nested user/mount/proc namespaces,
which Docker's default seccomp profile and masked `/proc` block. Passing
`nested_sandbox=True` starts the container with `--security-opt
seccomp=unconfined --security-opt systempaths=unconfined`; without it such an
environment refuses to serve rather than running unisolated. Images that do
not sandbox internally keep Docker's full containment, so this is opt-in.

`DockerRuntime` always starts with HUD's compact seccomp profile and the system
path configuration required by `Workspace`'s bubblewrap sessions. The profile
is intentionally default-allow for Docker compatibility, allowing the
namespace syscalls needed for the inner workspace sandbox while denying a
small set of unrelated kernel interfaces. It is not Docker's default
deny-by-default profile.

### `ModalRuntime`

Expand Down
11 changes: 2 additions & 9 deletions hud/agents/openai_compatible/tools/filesystem.py
Original file line number Diff line number Diff line change
Expand Up @@ -66,9 +66,6 @@ async def execute(self, arguments: dict[str, Any]) -> MCPToolResult:
path = arguments.get("filePath")
if not isinstance(path, str) or not path:
raise ValueError("filePath is required")
# Map once so the directory predicate and the file read agree on
# the same workspace-anchored path.
path = self.client.map_path(path)
offset = _read_offset(arguments.get("offset"))
limit = _positive_int(arguments.get("limit"), default=DEFAULT_READ_LIMIT, name="limit")
if not (await self.bash(f"test -d {shlex.quote(path)}")).isError:
Expand Down Expand Up @@ -196,9 +193,6 @@ async def execute(self, arguments: dict[str, Any]) -> MCPToolResult:
path = arguments.get("filePath")
if not isinstance(path, str) or not path:
raise ValueError("filePath is required")
# Map once so existence checks, mkdir, and the write all target
# the same workspace-anchored path.
path = self.client.map_path(path)
old = arguments.get("oldString")
new = arguments.get("newString")
if not isinstance(old, str):
Expand Down Expand Up @@ -258,7 +252,6 @@ async def execute(self, arguments: dict[str, Any]) -> MCPToolResult:
path = arguments.get("filePath")
if not isinstance(path, str) or not path:
raise ValueError("filePath is required")
path = self.client.map_path(path)
content = arguments.get("content")
if not isinstance(content, str):
raise ValueError("content is required")
Expand Down Expand Up @@ -294,7 +287,7 @@ async def execute(self, arguments: dict[str, Any]) -> MCPToolResult:
pattern = arguments.get("pattern")
if not isinstance(pattern, str):
raise ValueError("pattern is required")
path = self.client.map_path(str(arguments.get("path") or "."))
path = str(arguments.get("path") or ".")
cmd = f"grep -rn {shlex.quote(pattern)} {shlex.quote(path)}"
include = arguments.get("include")
if isinstance(include, str) and include:
Expand All @@ -318,7 +311,7 @@ async def execute(self, arguments: dict[str, Any]) -> MCPToolResult:
pattern = arguments.get("pattern")
if not isinstance(pattern, str):
raise ValueError("pattern is required")
path = self.client.map_path(str(arguments.get("path") or "."))
path = str(arguments.get("path") or ".")
return await self.bash(f"find {shlex.quote(path)} -name {shlex.quote(pattern)}")


Expand Down
71 changes: 12 additions & 59 deletions hud/agents/tests/test_provider_native_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -319,54 +319,27 @@ async def test_openai_compatible_write_stores_file_via_ssh_exec() -> None:
assert ssh.files["/REPORT.md"] == b"done"


async def test_absolute_paths_anchor_to_the_capability_cwd() -> None:
"""The old SFTP chroot resolved ``/REPORT.md`` against the workspace root;
exec-channel file helpers must keep that contract via the capability cwd."""
ssh = _FakeSSH(cwd="/workspace", files={"/workspace/f.txt": b"inside"})
async def test_paths_reach_the_session_verbatim() -> None:
"""A path means what it says in the session's own namespace: file helpers
and shell commands must never disagree about what a path names, so
nothing is anchored or rewritten on the way through."""
ssh = _FakeSSH(cwd="/app", files={"/app/f.txt": b"inside"})
tool = WriteTool(spec=WriteTool.default_spec("qwen"), client=cast("SSHClient", ssh))

await tool.execute({"filePath": "/REPORT.md", "content": "done"})
await tool.execute({"filePath": "/tmp/probe.txt", "content": "done"})

assert ssh.files["/workspace/REPORT.md"] == b"done"
# Paths already inside the workspace are untouched.
assert await cast("SSHClient", ssh).read_text("/workspace/f.txt") == "inside"


def test_map_path_clamps_traversal_like_a_chroot() -> None:
ssh = cast("SSHClient", _FakeSSH(cwd="/workspace"))
assert ssh.map_path("/workspace/../etc/passwd") == "/workspace/etc/passwd"
assert ssh.map_path("/../etc/passwd") == "/workspace/etc/passwd"
assert ssh.map_path("../../etc/passwd") == "/workspace/etc/passwd"
assert ssh.map_path("a/../b.txt") == "/workspace/b.txt"
assert ssh.map_path("/") == "/workspace"
assert ssh.map_path(".") == "/workspace"


def test_map_path_handles_windows_native_paths() -> None:
"""The workspace publishes cwd via as_posix(); callers pass native
backslash paths, and NTFS compares case-insensitively."""
cap = Capability(
name="shell",
protocol="ssh/2",
url="ssh://localhost:22",
params={"shell": "powershell", "cwd": "C:/work"},
)
ssh = SSHClient(cap, cast("Any", None))
assert ssh.map_path("C:\\work\\file.txt") == "C:/work/file.txt"
assert ssh.map_path("C:\\Work\\sub\\f.txt") == "C:/work/sub/f.txt"
assert ssh.map_path("D:\\other\\f.txt") == "C:/work/other/f.txt"
assert ssh.map_path("\\temp\\f.txt") == "C:/work/temp/f.txt"
assert ssh.map_path("sub\\f.txt") == "C:/work/sub/f.txt"
assert ssh.map_path("C:\\work\\..\\secrets.txt") == "C:/work/secrets.txt"
assert ssh.files["/tmp/probe.txt"] == b"done"
assert "/app/tmp/probe.txt" not in ssh.files
assert await cast("SSHClient", ssh).read_text("/app/f.txt") == "inside"


async def test_read_maps_the_directory_predicate_and_listing_together() -> None:
"""`test -d`, listing, and reads must agree on the anchored path, or
absolute workspace dirs are misclassified as files."""
"""`test -d`, listing, and reads must agree on the same path, or
workspace dirs are misclassified as files."""
ssh = _FakeSSH(cwd="/workspace", files={"/workspace/pkg/mod.py": b"x = 1\n"})
tool = ReadTool(spec=ReadTool.default_spec("qwen"), client=cast("SSHClient", ssh))

result = await tool.execute({"filePath": "/pkg"})
result = await tool.execute({"filePath": "/workspace/pkg"})

text = result_text(result)
assert "<type>directory</type>" in text
Expand Down Expand Up @@ -538,26 +511,6 @@ async def test_gemini_edit_creates_file_when_old_string_empty() -> None:
assert ssh.files["/n.txt"] == b"fresh"


def test_map_path_leaves_a_symlinked_spelling_of_the_workspace_alone() -> None:
"""A workspace made at /tmp/w is served as /private/tmp/w on macOS; re-anchoring
the caller's spelling instead of stripping it nests the path under itself."""
cap = Capability(
name="shell",
protocol="ssh/2",
url="ssh://localhost:22",
params={"cwd": "/private/tmp/w", "cwd_aliases": ["/tmp/w"]},
)
ssh = SSHClient(cap, cast("Any", None))

assert ssh.map_path("/tmp/w/calc.py") == "/private/tmp/w/calc.py"
assert ssh.map_path("/private/tmp/w/calc.py") == "/private/tmp/w/calc.py"
assert ssh.map_path("/tmp/w") == "/private/tmp/w"
# Workspace-relative addressing still anchors, and an unrelated absolute
# path is still clamped into the workspace like a chroot.
assert ssh.map_path("/REPORT.md") == "/private/tmp/w/REPORT.md"
assert ssh.map_path("/tmp/elsewhere/f.txt") == "/private/tmp/w/tmp/elsewhere/f.txt"


async def test_reading_a_missing_file_is_a_tool_error_not_a_raised_traceback() -> None:
"""Reading before creating is the first thing an editor tool does; that failure
must come back as a tool result carrying the shell's message."""
Expand Down
14 changes: 3 additions & 11 deletions hud/capabilities/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,9 @@
import sys
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from typing import TYPE_CHECKING, Any, ClassVar, Self
from typing import Any, ClassVar, Self
from urllib.parse import urlsplit

if TYPE_CHECKING:
from collections.abc import Sequence

#: Matches the scheme prefix of a URL (RFC 3986).
SCHEME_RE: re.Pattern[str] = re.compile(r"^([a-zA-Z][a-zA-Z0-9+\-.]*):")

Expand Down Expand Up @@ -82,7 +79,6 @@ def ssh(
client_key_path: str | os.PathLike[str] | None = None,
shell: str | None = None,
cwd: str | None = None,
cwd_aliases: Sequence[str] | None = None,
) -> Capability:
"""``ssh/2`` — SSH daemon with publickey auth.

Expand All @@ -93,10 +89,8 @@ def ssh(
type (``bash``, ``powershell``, ``cmd``). Defaults to auto-detect
from ``sys.platform`` at construction time. Agents read this to
format commands correctly. ``cwd`` is the absolute path sessions
start in (the served workspace); clients anchor file paths to it.
``cwd_aliases`` are other names the same directory answers to (paths
that reach it through symlinks), which clients treat as already
anchored rather than as workspace-relative addresses.
start in. Paths are the session namespace's own — clients pass them
verbatim, and nothing is anchored or rewritten.
"""
normalized = normalize_url(url, default_scheme="ssh", default_port=22)
if shell is None:
Expand All @@ -108,8 +102,6 @@ def ssh(
params["client_key_path"] = os.fspath(client_key_path)
if cwd is not None:
params["cwd"] = cwd
if cwd_aliases:
params["cwd_aliases"] = list(cwd_aliases)
return cls(name=name, protocol="ssh/2", url=normalized, params=params)

@classmethod
Expand Down
Loading
Loading