Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
4 changes: 3 additions & 1 deletion docs/v6/reference/runtime.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -152,10 +152,12 @@ DaytonaRuntime(snapshot_name=None, *, image=None, command=None, workdir="/app",
```

- **`snapshot_name`** - Daytona snapshot to boot from (the durable handle).
- **`image`** - Dockerfile/registry ref to build the snapshot once if it's missing. Resources (cpu/memory/gpu) live on the snapshot.
- **`image`** - Dockerfile/registry ref to build the snapshot if it's missing. Daytona records what a snapshot was built from, so when the image content changes (an edited Dockerfile or context file, a repointed registry ref) the snapshot is rebuilt in place under the same name instead of silently reusing the build from before the edit.
- **`workdir`** / **`port`** - guest working directory and in-sandbox serving port.
- **`ssh_host`** / **`ssh_expires_minutes`** - SSH tunnel settings (Daytona exposes services over an SSH local-forward).

Resources (cpu/memory/gpu) are fixed on the snapshot at build time. With `image`, a task's `runtime_config.resources` builds a sized variant under a suffixed name (`my-env-4cpu`); without `image`, an already-built snapshot cannot be resized.

### `HUDRuntime`

```python
Expand Down
52 changes: 51 additions & 1 deletion hud/agents/tests/test_provider_native_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
import shlex
from typing import Any, cast

import asyncssh
import mcp.types as mcp_types
import pytest

Expand Down Expand Up @@ -47,7 +48,22 @@ async def run(
self.commands.append(command)
parts = shlex.split(command)
if len(parts) == 3 and parts[:2] == ["cat", "--"]:
return _Completed(stdout=self._store.get(parts[2], b"").decode())
if parts[2] not in self._store:
if check:
raise asyncssh.ProcessError(
env=None,
command=command,
subsystem=None,
exit_status=1,
exit_signal=None,
returncode=1,
stdout="",
stderr=f"cat: {parts[2]}: No such file or directory",
)
return _Completed(
stderr=f"cat: {parts[2]}: No such file or directory", exit_status=1
)
return _Completed(stdout=self._store[parts[2]].decode())
if len(parts) == 3 and parts[:2] == ["cat", ">"]:
assert input is not None
self._store[parts[2]] = input.encode()
Expand Down Expand Up @@ -520,3 +536,37 @@ async def test_gemini_edit_creates_file_when_old_string_empty() -> None:
await tool.execute({"file_path": "/n.txt", "old_string": "", "new_string": "fresh"})

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."""
ssh = _FakeSSH()
tool = ClaudeTextEditorTool(
spec=ClaudeTextEditorTool.default_spec("claude"), client=cast("SSHClient", ssh)
)

result = await tool.execute({"command": "view", "path": "/nope.txt"})

assert result.isError is True
assert "No such file or directory" in result_text(result)
28 changes: 22 additions & 6 deletions hud/agents/tools/ssh.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,22 @@

from __future__ import annotations

import asyncssh
import mcp.types as mcp_types

from hud.agents.tools.base import AgentTool
from hud.agents.tools.base import AgentTool, tool_err, tool_ok
from hud.capabilities import SSHClient
from hud.types import MCPToolResult


def _remote_error(exc: asyncssh.ProcessError) -> str:
"""What the remote command printed to stderr — a failed file op is an
ordinary tool outcome, so the agent gets the shell's message, not the
exception's repr."""
stderr = exc.stderr.decode("utf-8", "replace") if isinstance(exc.stderr, bytes) else exc.stderr
return (stderr or "").strip() or f"exit {exc.exit_status}"


class SSHTool(AgentTool[SSHClient]):
"""Capability base: tool driven by an ``SSHClient``."""

Expand All @@ -37,19 +46,26 @@ async def bash(self, command: str) -> MCPToolResult:

async def file_read(self, path: str) -> MCPToolResult:
"""Read a text file through SSH exec."""
return tool_ok(await self.client.read_text(path))
try:
return tool_ok(await self.client.read_text(path))
except asyncssh.ProcessError as e:
return tool_err(_remote_error(e))

async def file_write(self, path: str, content: str) -> MCPToolResult:
"""Write a text file through SSH exec."""
await self.client.write_text(path, content)
try:
await self.client.write_text(path, content)
except asyncssh.ProcessError as e:
return tool_err(_remote_error(e))
return tool_ok(f"wrote {len(content)} bytes to {path}")

async def file_list(self, path: str = "/") -> MCPToolResult:
"""List directory entries through SSH exec."""
names = await self.client.listdir(path)
try:
names = await self.client.listdir(path)
except asyncssh.ProcessError as e:
return tool_err(_remote_error(e))
return tool_ok("\n".join(names) if names else "(empty)")


from hud.agents.tools.base import tool_ok # noqa: E402

__all__ = ["SSHTool"]
11 changes: 10 additions & 1 deletion hud/capabilities/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,12 @@
import sys
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from typing import Any, ClassVar, Self
from typing import TYPE_CHECKING, 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 @@ -79,6 +82,7 @@ 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 @@ -90,6 +94,9 @@ def ssh(
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.
"""
normalized = normalize_url(url, default_scheme="ssh", default_port=22)
if shell is None:
Expand All @@ -101,6 +108,8 @@ 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
14 changes: 12 additions & 2 deletions hud/capabilities/ssh.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,10 +56,17 @@ def map_path(self, path: str) -> str:
real filesystem; replicate the chroot: strip the cwd prefix if present,
normalize the remainder against ``/`` (clamping ``..`` at the root,
exactly as a chroot does), and re-anchor under the cwd. Idempotent.

``cwd_aliases`` are the same directory reached through symlinks, and are
stripped like the cwd: re-anchoring one would turn an already correct
absolute path into a nested one that does not exist.
"""
cwd = str(self.capability.params.get("cwd", "")).rstrip("/")
if not cwd:
return path
aliases = [
str(alias).rstrip("/") for alias in self.capability.params.get("cwd_aliases") or []
]
if self._is_windows:
# The workspace publishes cwd via as_posix() (e.g. "C:/work") but
# callers pass native paths ("C:\work\file.txt"); NTFS paths are
Expand All @@ -70,8 +77,11 @@ def map_path(self, path: str) -> str:
elif len(path) >= 2 and path[1] == ":" and path[0].isalpha():
# Drive-absolute outside the workspace: anchor like the chroot.
path = path[2:]
elif path == cwd or path.startswith(cwd + "/"):
path = path[len(cwd) :]
else:
for prefix in (cwd, *aliases):
if prefix and (path == prefix or path.startswith(prefix + "/")):
path = path[len(prefix) :]
break
normalized = posixpath.normpath("/" + path.lstrip("/"))
return cwd if normalized == "/" else cwd + normalized

Expand Down
19 changes: 11 additions & 8 deletions hud/cli/trace.py
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,8 @@ def _load_remote(trace_id: str) -> list[dict[str, Any]]:


def _render_events(events: list[dict[str, Any]]) -> None:
# Payloads render as Text, never as markup: rich would read a literal
# `[len(s) // 2]` in agent output as a style tag and drop it.
turn = 0
for ev in events:
kind = ev.get("kind")
Expand All @@ -175,7 +177,7 @@ def _render_events(events: list[dict[str, Any]]) -> None:

text = ev.get("text")
if text:
console.print(text)
console.print(Text(str(text)))

for tc in ev.get("tool_calls") or []:
name = tc.get("name") or tc.get("function", {}).get("name", "?")
Expand All @@ -184,29 +186,30 @@ def _render_events(events: list[dict[str, Any]]) -> None:
with contextlib.suppress(Exception):
args = json.loads(args)
console.print(
f" [green]→[/green] [bold]{name}[/bold]({_fmt_args(args)})",
highlight=False,
Text.assemble(
" ", ("→", "green"), " ", (str(name), "bold"), f"({_fmt_args(args)})"
)
)

if ev.get("error"):
console.print(f" [red]error: {ev['error']}[/red]")
console.print(Text(f" error: {ev['error']}", style="red"))

elif kind in ("tool_call", "tool_result"):
name = ev.get("tool_name") or ev.get("name") or "?"
result = ev.get("result_text") or ev.get("result") or ""
error = ev.get("error")
if error:
console.print(f" [red]✗ {name}: {error}[/red]")
console.print(Text(f" ✗ {name}: {error}", style="red"))
else:
console.print(f" [dim]{name} →[/dim]")
console.print(Text(f" {name} →", style="dim"))
for line in str(result).splitlines():
console.print(f" {line}")
console.print(Text(f" {line}"))

elif kind == "environment":
msg = ev.get("text") or ev.get("content") or ""
if msg:
console.print(Rule("[yellow]env[/yellow]", style="yellow"))
console.print(msg)
console.print(Text(str(msg)))


def _fmt_args(args: Any) -> str:
Expand Down
33 changes: 33 additions & 0 deletions hud/environment/tests/test_workspace.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import sys
import tempfile
from pathlib import Path
from typing import Any, cast

import asyncssh
import pytest
Expand Down Expand Up @@ -268,3 +269,35 @@ def test_required_isolation_refuses_when_unavailable(monkeypatch, tmp_path) -> N

with pytest.raises(RuntimeError, match="isolation was required"):
ws.Workspace(tmp_path, require_isolation=True)


@pytest.mark.asyncio
async def test_a_symlinked_root_publishes_both_spellings(tmp_path: Path) -> None:
"""A workspace addressed through a symlink (macOS /tmp -> /private/tmp) serves the
real path, so it must publish the caller's spelling too or clients re-anchor it."""
real = tmp_path / "real"
real.mkdir()
link = tmp_path / "link"
link.symlink_to(real, target_is_directory=True)

ws = Workspace(link)
await ws.start()
try:
cap = ws.capability()
assert cap.params["cwd"] == real.as_posix()
assert cap.params["cwd_aliases"] == [link.as_posix()]

client = SSHClient(cap, cast("Any", None))
assert client.map_path(f"{link}/calc.py") == f"{real}/calc.py"
finally:
await ws.stop()


@pytest.mark.asyncio
async def test_a_plain_root_publishes_no_alias(tmp_path: Path) -> None:
ws = Workspace(tmp_path / "root")
await ws.start()
try:
assert "cwd_aliases" not in ws.capability().params
finally:
await ws.stop()
8 changes: 8 additions & 0 deletions hud/environment/workspace.py
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,13 @@ def __init__(
# Only override the default; respect an explicit guest_path.
if self._bwrap is None and guest_path == "/workspace":
self._guest_path = self.root.as_posix()
# The caller's spelling of the same directory when it differs from the real
# path (macOS resolves /tmp to /private/tmp) and sessions run under the real
# one. A client that knows only the real path treats the other spelling as a
# workspace-relative address and re-anchors it somewhere that does not exist.
given = Path(root).absolute().as_posix()
real = self.root.as_posix()
self._cwd_aliases = [given] if given != real and self._guest_path == real else []
# ssh config
self._ssh_host = host
self._ssh_port = port
Expand Down Expand Up @@ -430,6 +437,7 @@ def capability(self, name: str = "shell") -> Capability:
client_key=key_path.read_text() if key_path else None,
client_key_path=key_path,
cwd=self._guest_path,
cwd_aliases=self._cwd_aliases or None,
)

@property
Expand Down
13 changes: 9 additions & 4 deletions hud/eval/run.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
import asyncio
import contextlib
import logging
import traceback
import uuid
from dataclasses import dataclass, field
from typing import TYPE_CHECKING, Any, Self, cast
Expand Down Expand Up @@ -388,13 +389,17 @@ async def _drive() -> None:
driver.add_done_callback(_consume_task_result)
raise
except Exception as exc:
# format_exception_only keeps __notes__ — a provider attaches what
# only it can see there, like the sandbox's env output on a failed
# handshake — where str(exc) would drop them.
detail = "".join(traceback.format_exception_only(exc)).strip()
if run is None:
logger.warning("rollout failed before launch (%s): %s", _phase, exc)
run = Run.failed(f"[{_phase}] {exc}")
logger.warning("rollout failed before launch (%s): %s", _phase, detail)
run = Run.failed(f"[{_phase}] {detail}")
else:
logger.warning("rollout failed mid-run (%s): %s", _phase, exc)
logger.warning("rollout failed mid-run (%s): %s", _phase, detail)
run.trace.status = "error"
run.record(Step(source="system", error=f"[{_phase}] {exc}"))
run.record(Step(source="system", error=f"[{_phase}] {detail}"))
assert run is not None # the body bound it, or the handler synthesized it
run.trace.trace_id = trace_id
run.job_id = job_id
Expand Down
Loading
Loading