Skip to content

Commit cadd37f

Browse files
committed
merge: resolve PR #481 conflicts with main
2 parents d95202b + c38e675 commit cadd37f

23 files changed

Lines changed: 718 additions & 102 deletions

File tree

docs/v6/reference/runtime.mdx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -153,10 +153,12 @@ DaytonaRuntime(snapshot_name=None, *, image=None, command=None, workdir="/app",
153153
```
154154

155155
- **`snapshot_name`** - Daytona snapshot to boot from (the durable handle).
156-
- **`image`** - Dockerfile/registry ref to build the snapshot once if it's missing. Resources (cpu/memory/gpu) live on the snapshot.
156+
- **`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.
157157
- **`workdir`** / **`port`** - guest working directory and in-sandbox serving port.
158158
- **`ssh_host`** / **`ssh_expires_minutes`** - SSH tunnel settings (Daytona exposes services over an SSH local-forward).
159159

160+
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.
161+
160162
### `HUDRuntime`
161163

162164
```python

hud/agents/tests/test_provider_native_tools.py

Lines changed: 51 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
import shlex
1111
from typing import Any, cast
1212

13+
import asyncssh
1314
import mcp.types as mcp_types
1415
import pytest
1516

@@ -47,7 +48,22 @@ async def run(
4748
self.commands.append(command)
4849
parts = shlex.split(command)
4950
if len(parts) == 3 and parts[:2] == ["cat", "--"]:
50-
return _Completed(stdout=self._store.get(parts[2], b"").decode())
51+
if parts[2] not in self._store:
52+
if check:
53+
raise asyncssh.ProcessError(
54+
env=None,
55+
command=command,
56+
subsystem=None,
57+
exit_status=1,
58+
exit_signal=None,
59+
returncode=1,
60+
stdout="",
61+
stderr=f"cat: {parts[2]}: No such file or directory",
62+
)
63+
return _Completed(
64+
stderr=f"cat: {parts[2]}: No such file or directory", exit_status=1
65+
)
66+
return _Completed(stdout=self._store[parts[2]].decode())
5167
if len(parts) == 3 and parts[:2] == ["cat", ">"]:
5268
assert input is not None
5369
self._store[parts[2]] = input.encode()
@@ -520,3 +536,37 @@ async def test_gemini_edit_creates_file_when_old_string_empty() -> None:
520536
await tool.execute({"file_path": "/n.txt", "old_string": "", "new_string": "fresh"})
521537

522538
assert ssh.files["/n.txt"] == b"fresh"
539+
540+
541+
def test_map_path_leaves_a_symlinked_spelling_of_the_workspace_alone() -> None:
542+
"""A workspace made at /tmp/w is served as /private/tmp/w on macOS; re-anchoring
543+
the caller's spelling instead of stripping it nests the path under itself."""
544+
cap = Capability(
545+
name="shell",
546+
protocol="ssh/2",
547+
url="ssh://localhost:22",
548+
params={"cwd": "/private/tmp/w", "cwd_aliases": ["/tmp/w"]},
549+
)
550+
ssh = SSHClient(cap, cast("Any", None))
551+
552+
assert ssh.map_path("/tmp/w/calc.py") == "/private/tmp/w/calc.py"
553+
assert ssh.map_path("/private/tmp/w/calc.py") == "/private/tmp/w/calc.py"
554+
assert ssh.map_path("/tmp/w") == "/private/tmp/w"
555+
# Workspace-relative addressing still anchors, and an unrelated absolute
556+
# path is still clamped into the workspace like a chroot.
557+
assert ssh.map_path("/REPORT.md") == "/private/tmp/w/REPORT.md"
558+
assert ssh.map_path("/tmp/elsewhere/f.txt") == "/private/tmp/w/tmp/elsewhere/f.txt"
559+
560+
561+
async def test_reading_a_missing_file_is_a_tool_error_not_a_raised_traceback() -> None:
562+
"""Reading before creating is the first thing an editor tool does; that failure
563+
must come back as a tool result carrying the shell's message."""
564+
ssh = _FakeSSH()
565+
tool = ClaudeTextEditorTool(
566+
spec=ClaudeTextEditorTool.default_spec("claude"), client=cast("SSHClient", ssh)
567+
)
568+
569+
result = await tool.execute({"command": "view", "path": "/nope.txt"})
570+
571+
assert result.isError is True
572+
assert "No such file or directory" in result_text(result)

hud/agents/tools/ssh.py

Lines changed: 22 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,13 +7,22 @@
77

88
from __future__ import annotations
99

10+
import asyncssh
1011
import mcp.types as mcp_types
1112

12-
from hud.agents.tools.base import AgentTool
13+
from hud.agents.tools.base import AgentTool, tool_err, tool_ok
1314
from hud.capabilities import SSHClient
1415
from hud.types import MCPToolResult
1516

1617

18+
def _remote_error(exc: asyncssh.ProcessError) -> str:
19+
"""What the remote command printed to stderr — a failed file op is an
20+
ordinary tool outcome, so the agent gets the shell's message, not the
21+
exception's repr."""
22+
stderr = exc.stderr.decode("utf-8", "replace") if isinstance(exc.stderr, bytes) else exc.stderr
23+
return (stderr or "").strip() or f"exit {exc.exit_status}"
24+
25+
1726
class SSHTool(AgentTool[SSHClient]):
1827
"""Capability base: tool driven by an ``SSHClient``."""
1928

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

3847
async def file_read(self, path: str) -> MCPToolResult:
3948
"""Read a text file through SSH exec."""
40-
return tool_ok(await self.client.read_text(path))
49+
try:
50+
return tool_ok(await self.client.read_text(path))
51+
except asyncssh.ProcessError as e:
52+
return tool_err(_remote_error(e))
4153

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

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

5270

53-
from hud.agents.tools.base import tool_ok # noqa: E402
54-
5571
__all__ = ["SSHTool"]

hud/capabilities/base.py

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,12 @@
77
import sys
88
from abc import ABC, abstractmethod
99
from dataclasses import dataclass, field
10-
from typing import Any, ClassVar, Self
10+
from typing import TYPE_CHECKING, Any, ClassVar, Self
1111
from urllib.parse import urlsplit
1212

13+
if TYPE_CHECKING:
14+
from collections.abc import Sequence
15+
1316
#: Matches the scheme prefix of a URL (RFC 3986).
1417
SCHEME_RE: re.Pattern[str] = re.compile(r"^([a-zA-Z][a-zA-Z0-9+\-.]*):")
1518

@@ -79,6 +82,7 @@ def ssh(
7982
client_key_path: str | os.PathLike[str] | None = None,
8083
shell: str | None = None,
8184
cwd: str | None = None,
85+
cwd_aliases: Sequence[str] | None = None,
8286
) -> Capability:
8387
"""``ssh/2`` — SSH daemon with publickey auth.
8488
@@ -90,6 +94,9 @@ def ssh(
9094
from ``sys.platform`` at construction time. Agents read this to
9195
format commands correctly. ``cwd`` is the absolute path sessions
9296
start in (the served workspace); clients anchor file paths to it.
97+
``cwd_aliases`` are other names the same directory answers to (paths
98+
that reach it through symlinks), which clients treat as already
99+
anchored rather than as workspace-relative addresses.
93100
"""
94101
normalized = normalize_url(url, default_scheme="ssh", default_port=22)
95102
if shell is None:
@@ -101,6 +108,8 @@ def ssh(
101108
params["client_key_path"] = os.fspath(client_key_path)
102109
if cwd is not None:
103110
params["cwd"] = cwd
111+
if cwd_aliases:
112+
params["cwd_aliases"] = list(cwd_aliases)
104113
return cls(name=name, protocol="ssh/2", url=normalized, params=params)
105114

106115
@classmethod

hud/capabilities/ssh.py

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -56,10 +56,17 @@ def map_path(self, path: str) -> str:
5656
real filesystem; replicate the chroot: strip the cwd prefix if present,
5757
normalize the remainder against ``/`` (clamping ``..`` at the root,
5858
exactly as a chroot does), and re-anchor under the cwd. Idempotent.
59+
60+
``cwd_aliases`` are the same directory reached through symlinks, and are
61+
stripped like the cwd: re-anchoring one would turn an already correct
62+
absolute path into a nested one that does not exist.
5963
"""
6064
cwd = str(self.capability.params.get("cwd", "")).rstrip("/")
6165
if not cwd:
6266
return path
67+
aliases = [
68+
str(alias).rstrip("/") for alias in self.capability.params.get("cwd_aliases") or []
69+
]
6370
if self._is_windows:
6471
# The workspace publishes cwd via as_posix() (e.g. "C:/work") but
6572
# callers pass native paths ("C:\work\file.txt"); NTFS paths are
@@ -70,8 +77,11 @@ def map_path(self, path: str) -> str:
7077
elif len(path) >= 2 and path[1] == ":" and path[0].isalpha():
7178
# Drive-absolute outside the workspace: anchor like the chroot.
7279
path = path[2:]
73-
elif path == cwd or path.startswith(cwd + "/"):
74-
path = path[len(cwd) :]
80+
else:
81+
for prefix in (cwd, *aliases):
82+
if prefix and (path == prefix or path.startswith(prefix + "/")):
83+
path = path[len(prefix) :]
84+
break
7585
normalized = posixpath.normpath("/" + path.lstrip("/"))
7686
return cwd if normalized == "/" else cwd + normalized
7787

hud/cli/trace.py

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -161,6 +161,8 @@ def _load_remote(trace_id: str) -> list[dict[str, Any]]:
161161

162162

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

176178
text = ev.get("text")
177179
if text:
178-
console.print(text)
180+
console.print(Text(str(text)))
179181

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

191194
if ev.get("error"):
192-
console.print(f" [red]error: {ev['error']}[/red]")
195+
console.print(Text(f" error: {ev['error']}", style="red"))
193196

194197
elif kind in ("tool_call", "tool_result"):
195198
name = ev.get("tool_name") or ev.get("name") or "?"
196199
result = ev.get("result_text") or ev.get("result") or ""
197200
error = ev.get("error")
198201
if error:
199-
console.print(f" [red]{name}: {error}[/red]")
202+
console.print(Text(f" ✗ {name}: {error}", style="red"))
200203
else:
201-
console.print(f" [dim]{name}[/dim]")
204+
console.print(Text(f" {name}", style="dim"))
202205
for line in str(result).splitlines():
203-
console.print(f" {line}")
206+
console.print(Text(f" {line}"))
204207

205208
elif kind == "environment":
206209
msg = ev.get("text") or ev.get("content") or ""
207210
if msg:
208211
console.print(Rule("[yellow]env[/yellow]", style="yellow"))
209-
console.print(msg)
212+
console.print(Text(str(msg)))
210213

211214

212215
def _fmt_args(args: Any) -> str:

hud/environment/tests/test_workspace.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
import sys
77
import tempfile
88
from pathlib import Path
9+
from typing import Any, cast
910

1011
import asyncssh
1112
import pytest
@@ -268,3 +269,35 @@ def test_required_isolation_refuses_when_unavailable(monkeypatch, tmp_path) -> N
268269

269270
with pytest.raises(RuntimeError, match="isolation was required"):
270271
ws.Workspace(tmp_path, require_isolation=True)
272+
273+
274+
@pytest.mark.asyncio
275+
async def test_a_symlinked_root_publishes_both_spellings(tmp_path: Path) -> None:
276+
"""A workspace addressed through a symlink (macOS /tmp -> /private/tmp) serves the
277+
real path, so it must publish the caller's spelling too or clients re-anchor it."""
278+
real = tmp_path / "real"
279+
real.mkdir()
280+
link = tmp_path / "link"
281+
link.symlink_to(real, target_is_directory=True)
282+
283+
ws = Workspace(link)
284+
await ws.start()
285+
try:
286+
cap = ws.capability()
287+
assert cap.params["cwd"] == real.as_posix()
288+
assert cap.params["cwd_aliases"] == [link.as_posix()]
289+
290+
client = SSHClient(cap, cast("Any", None))
291+
assert client.map_path(f"{link}/calc.py") == f"{real}/calc.py"
292+
finally:
293+
await ws.stop()
294+
295+
296+
@pytest.mark.asyncio
297+
async def test_a_plain_root_publishes_no_alias(tmp_path: Path) -> None:
298+
ws = Workspace(tmp_path / "root")
299+
await ws.start()
300+
try:
301+
assert "cwd_aliases" not in ws.capability().params
302+
finally:
303+
await ws.stop()

hud/environment/workspace.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -203,6 +203,13 @@ def __init__(
203203
# Only override the default; respect an explicit guest_path.
204204
if self._bwrap is None and guest_path == "/workspace":
205205
self._guest_path = self.root.as_posix()
206+
# The caller's spelling of the same directory when it differs from the real
207+
# path (macOS resolves /tmp to /private/tmp) and sessions run under the real
208+
# one. A client that knows only the real path treats the other spelling as a
209+
# workspace-relative address and re-anchors it somewhere that does not exist.
210+
given = Path(root).absolute().as_posix()
211+
real = self.root.as_posix()
212+
self._cwd_aliases = [given] if given != real and self._guest_path == real else []
206213
# ssh config
207214
self._ssh_host = host
208215
self._ssh_port = port
@@ -430,6 +437,7 @@ def capability(self, name: str = "shell") -> Capability:
430437
client_key=key_path.read_text() if key_path else None,
431438
client_key_path=key_path,
432439
cwd=self._guest_path,
440+
cwd_aliases=self._cwd_aliases or None,
433441
)
434442

435443
@property

hud/eval/job.py

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -105,18 +105,26 @@ async def trace_enter(
105105
*,
106106
job_id: str | None,
107107
group_id: str | None,
108+
task_slug: str,
108109
model: str | None = None,
109110
) -> None:
110111
"""Report that one rollout started.
111112
112-
``model`` is the model string the agent will sample (when known); the
113-
platform resolves it and attributes the trace immediately on enter.
113+
``task_slug`` identifies the logical task independently of the execution
114+
environment. ``model`` is the model string the agent will sample (when
115+
known); the platform resolves it and attributes the trace immediately on
116+
enter.
114117
"""
115118
if not _reporting_enabled():
116119
return
117120
await _report(
118121
f"/trace/{trace_id}/enter",
119-
{"job_id": job_id, "group_id": group_id, "model": model},
122+
{
123+
"job_id": job_id,
124+
"group_id": group_id,
125+
"task_slug": task_slug,
126+
"model": model,
127+
},
120128
)
121129

122130

0 commit comments

Comments
 (0)