Skip to content
Draft
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
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -158,9 +158,9 @@ From the [platform UI](https://hud.ai) you can run batches, compare models on th

Hosted Claude Code and Codex harnesses reach platform inference through an
environment-owned, workspace-local endpoint. The endpoint is available only to
`bwrap` workspaces with network isolation; the workspace receives an opaque
per-session key, while platform credentials and trace attribution stay outside
its environment and manifest.
`bwrap` workspaces with network isolation and is bound to the exact CLI process
selected by the harness. Platform credentials stay in the environment-owned
relay rather than the CLI environment, workspace manifest, or child processes.

→ [Run & deploy](https://docs.hud.ai/v6/reference/runtime)

Expand Down
4 changes: 2 additions & 2 deletions hud/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@

# Apply patches to third-party libraries early, before other imports
from . import patches as _patches # noqa: F401
from .capabilities import Connection
from .clients import connect
from .environment import Environment
from .eval import (
Expand All @@ -16,7 +17,6 @@
Grade,
HostedRuntime,
HUDRuntime,
InferenceConnection,
Job,
LocalRuntime,
Run,
Expand All @@ -39,12 +39,12 @@
__all__ = [
"Chat",
"ComposeProject",
"Connection",
"DockerRuntime",
"Environment",
"Grade",
"HUDRuntime",
"HostedRuntime",
"InferenceConnection",
"Job",
"LocalRuntime",
"Run",
Expand Down
32 changes: 18 additions & 14 deletions hud/agents/claude/sdk/agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,8 +33,8 @@
from .events import ClaudeEvents

if TYPE_CHECKING:
from hud.capabilities import SSHClient
from hud.eval.run import InferenceConnection, Run
from hud.capabilities import Connection, SSHClient
from hud.eval.run import Run

logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -111,7 +111,7 @@ async def __call__(self, run: Run) -> None:
mcp_servers=mcp_servers,
prompt=run.prompt_text,
executable=executable,
inference=run.inference,
connection=run.connections.get("inference"),
)

async def _exec(
Expand All @@ -123,7 +123,7 @@ async def _exec(
mcp_servers: dict[str, dict[str, Any]],
prompt: str,
executable: str = "claude",
inference: InferenceConnection | None = None,
connection: Connection | None = None,
) -> None:
mcp_config_path = await self._write_mcp_config(ssh, mcp_servers)
input_text = (
Expand All @@ -147,7 +147,7 @@ async def _exec(
shell=shell,
mcp_config_path=mcp_config_path,
executable=executable,
inference=inference,
connection=connection,
)
if shell in WINDOWS_SHELLS:
await ssh.write_text(RUN_SCRIPT_PATH, f"@echo off\r\n{command}\r\n")
Expand All @@ -162,6 +162,7 @@ async def _exec(
command,
events.consume,
input_text=None if shell in WINDOWS_SHELLS else input_text,
connections=(connection,) if connection is not None else (),
)
logger.info("exit=%s stderr=%d", returncode, len(stderr))
events.finish(returncode=returncode, stderr=stderr)
Expand All @@ -176,16 +177,16 @@ async def _exec(
except (OSError, asyncssh.Error):
logger.warning("Failed to remove Claude CLI runtime files")

def _build_env_vars(self, inference: InferenceConnection | None = None) -> dict[str, str]:
def _build_env_vars(self, connection: Connection | None = None) -> dict[str, str]:
env: dict[str, str] = {}
use_hud_gateway = self.config.use_hud_gateway
if use_hud_gateway is None:
use_hud_gateway = inference is not None or settings.api_key is not None
use_hud_gateway = connection is not None or settings.api_key is not None

if use_hud_gateway:
if inference is not None:
base_url = inference.base_url
api_key = inference.credential
if connection is not None:
base_url = connection.client_url
api_key = "hud-process-bound"
elif settings.api_key:
base_url = settings.hud_gateway_url
api_key = settings.api_key
Expand All @@ -195,7 +196,7 @@ def _build_env_vars(self, inference: InferenceConnection | None = None) -> dict[
env["ANTHROPIC_API_KEY"] = api_key
env["CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS"] = "1"
env["DISABLE_AUTO_COMPACT"] = "1"
if inference is None and (trace_id := get_current_trace_id()):
if connection is None and (trace_id := get_current_trace_id()):
env["ANTHROPIC_CUSTOM_HEADERS"] = f"Trace-Id: {trace_id}"
elif settings.anthropic_api_key:
env["ANTHROPIC_API_KEY"] = settings.anthropic_api_key
Expand Down Expand Up @@ -236,9 +237,9 @@ def _build_cli_command(
shell: str,
mcp_config_path: str | None = None,
executable: str = "claude",
inference: InferenceConnection | None = None,
connection: Connection | None = None,
) -> str:
env_vars = self._build_env_vars(inference)
env_vars = self._build_env_vars(connection)
is_win = shell in WINDOWS_SHELLS
base_args: list[str] = [
executable,
Expand Down Expand Up @@ -272,7 +273,10 @@ def _build_cli_command(
cli_parts = [shlex.quote(a) for a in base_args]
cli_cmd = " ".join(cli_parts)
env_prefix = " ".join(f"{k}={shlex.quote(v)}" for k, v in env_vars.items())
return f'export PATH="$HOME/.local/bin:$PATH"; {env_prefix} {cli_cmd}'
invocation = f"{env_prefix} {cli_cmd}"
if connection is not None:
invocation = f"exec env {env_prefix} {cli_cmd}"
return f'export PATH="$HOME/.local/bin:$PATH"; {invocation}'


__all__ = ["ClaudeCLIAgent"]
7 changes: 4 additions & 3 deletions hud/agents/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,9 @@
import asyncssh

if TYPE_CHECKING:
from collections.abc import Callable
from collections.abc import Callable, Sequence

from hud.capabilities import SSHClient
from hud.capabilities import Connection, SSHClient
from hud.eval.runtime import RuntimeConfig

WINDOWS_SHELLS = ("cmd", "powershell")
Expand Down Expand Up @@ -135,9 +135,10 @@ async def run_jsonl(
consume: Callable[[str], None],
*,
input_text: str | None = None,
connections: Sequence[Connection] = (),
) -> tuple[int, str]:
"""Stream one remote JSONL process and own its cancellation cleanup."""
process = await ssh.create_process(command)
process = await ssh.create_process(command, connections=connections)
stderr_task = asyncio.create_task(process.stderr.read())
try:
if input_text is not None:
Expand Down
41 changes: 27 additions & 14 deletions hud/agents/codex/agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,8 @@
from hud.utils.time import now_iso

if TYPE_CHECKING:
from hud.capabilities import SSHClient
from hud.eval.run import InferenceConnection, Run
from hud.capabilities import Connection, SSHClient
from hud.eval.run import Run

logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -212,7 +212,7 @@ def codex_command(
config: CodexCLIConfig,
shell: str,
executable: str = "codex",
inference: InferenceConnection | None = None,
connection: Connection | None = None,
) -> str:
env: dict[str, str] = {}
args = [
Expand All @@ -231,12 +231,12 @@ def codex_command(

use_hud_gateway = config.use_hud_gateway
if use_hud_gateway is None:
use_hud_gateway = inference is not None or settings.api_key is not None
use_hud_gateway = connection is not None or settings.api_key is not None
if use_hud_gateway:
if inference is not None:
base_url = inference.base_url
credential = inference.credential
credential_env = "HUD_RUNTIME_INFERENCE_TOKEN"
if connection is not None:
base_url = connection.client_url
credential = "hud-process-bound"
credential_env = "HUD_CONNECTION_CREDENTIAL"
elif settings.api_key:
base_url = settings.hud_gateway_url
credential = settings.api_key
Expand All @@ -253,7 +253,7 @@ def codex_command(
}
for key, value in overrides.items():
args.extend(["-c", f"{key}={json.dumps(value)}"])
if inference is None and (trace_id := get_current_trace_id()):
if connection is None and (trace_id := get_current_trace_id()):
args.extend(
[
"-c",
Expand Down Expand Up @@ -292,7 +292,14 @@ def codex_command(
env_prefix = " ".join(f"{key}={shlex.quote(value)}" for key, value in env.items())
invocation = f"{env_prefix} {command}" if env_prefix else command
statements = ['export PATH="$HOME/.local/bin:$PATH"', invocation]
if isolate_home:
if connection is not None:
statements = [
'codex_home=$(mktemp -d "${TMPDIR:-/tmp}/hud-codex.XXXXXX") || exit 1',
'export CODEX_HOME="$codex_home"',
'export PATH="$HOME/.local/bin:$PATH"',
f"exec env {env_prefix} {command}",
]
elif isolate_home:
statements = [
'codex_home=$(mktemp -d "${TMPDIR:-/tmp}/hud-codex.XXXXXX") || exit 1',
"trap 'rm -rf -- \"$codex_home\"' EXIT",
Expand All @@ -310,12 +317,18 @@ async def run_codex(
shell: str,
prompt: str,
executable: str = "codex",
inference: InferenceConnection | None = None,
connection: Connection | None = None,
) -> None:
command = codex_command(config, shell, executable, inference=inference)
command = codex_command(config, shell, executable, connection=connection)
logger.info("SSH exec codex CLI (%d chars)", len(command))
events = CodexEvents(run, model=config.model, started_at=now_iso())
returncode, stderr = await run_jsonl(ssh, command, events.consume, input_text=prompt)
returncode, stderr = await run_jsonl(
ssh,
command,
events.consume,
input_text=prompt,
connections=(connection,) if connection is not None else (),
)
logger.info("exit=%s stderr=%d", returncode, len(stderr))
events.finish(returncode=returncode, stderr=stderr)

Expand Down Expand Up @@ -343,7 +356,7 @@ async def __call__(self, run: Run) -> None:
shell=ssh.capability.params.get("shell", "bash"),
prompt=run.prompt_text,
executable=executable,
inference=run.inference,
connection=run.connections.get("inference"),
)


Expand Down
31 changes: 17 additions & 14 deletions hud/agents/tests/test_claude_cli_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,9 +28,8 @@
from hud.agents.tests.cli_fakes import FakeProcess as _FakeStreamProcess
from hud.agents.tests.cli_fakes import fake_run as _fake_run
from hud.agents.types import AgentStep, ClaudeCLIConfig, ToolStep
from hud.capabilities import Capability, SSHClient
from hud.capabilities import Capability, Connection, SSHClient
from hud.capabilities.rfb import WebPScreenshotEncoding
from hud.eval import InferenceConnection
from hud.settings import settings
from hud.telemetry.context import set_trace_context
from hud.types import MCPToolResult
Expand Down Expand Up @@ -69,21 +68,25 @@ def test_command_follows_explicit_gateway_routing(monkeypatch: pytest.MonkeyPatc
assert "ANTHROPIC_MODEL=claude-sonnet-5" in provider


def test_command_prefers_rollout_inference_connection() -> None:
inference = InferenceConnection(
base_url="https://inference.hud.so",
credential="scoped-runtime-token",
def test_command_uses_process_bound_connection_without_its_credential() -> None:
connection = Connection(
name="inference",
capability="ssh",
url="https://inference.hud.so",
headers={"Authorization": "Bearer scoped-runtime-token"},
)

gateway = ClaudeCLIAgent(ClaudeCLIConfig(use_hud_gateway=True))._build_cli_command(
shell="bash",
inference=inference,
connection=connection,
)

assert "ANTHROPIC_BASE_URL=https://inference.hud.so" in gateway
assert "ANTHROPIC_API_KEY=scoped-runtime-token" in gateway
assert f"ANTHROPIC_BASE_URL={connection.client_url}" in gateway
assert "ANTHROPIC_API_KEY=hud-process-bound" in gateway
assert "scoped-runtime-token" not in gateway
assert "HUD_API_KEY" not in gateway
assert "Trace-Id" not in gateway
assert "exec env" in gateway
for name in (
"ANTHROPIC_MODEL",
"ANTHROPIC_SMALL_FAST_MODEL",
Expand Down Expand Up @@ -500,7 +503,7 @@ async def open(self, ref: str) -> SSHClient:
cast(
"Any",
SimpleNamespace(
client=Client(), prompt_text="call the tool", runtime_config=None, inference=None
client=Client(), prompt_text="call the tool", runtime_config=None, connections={}
),
)
)
Expand Down Expand Up @@ -574,7 +577,7 @@ async def execute(*_args: Any, **_kwargs: Any) -> None:
cast(
"Any",
SimpleNamespace(
client=Client(), prompt_text="use the computer", runtime_config=None, inference=None
client=Client(), prompt_text="use the computer", runtime_config=None, connections={}
),
)
)
Expand Down Expand Up @@ -667,7 +670,7 @@ async def execute(*_args: Any, **kwargs: Any) -> None:
client=Client(),
prompt_text="use both screens",
runtime_config=None,
inference=None,
connections={},
),
)
)
Expand Down Expand Up @@ -934,13 +937,13 @@ async def execute(
agent = ClaudeCLIAgent()
monkeypatch.setattr(agent, "_exec", execute)
run_a = SimpleNamespace(
client=Client(shell_a, ssh_a), prompt_text="first", runtime_config=None, inference=None
client=Client(shell_a, ssh_a), prompt_text="first", runtime_config=None, connections={}
)
run_b = SimpleNamespace(
client=Client(shell_b, ssh_b),
prompt_text="second",
runtime_config=None,
inference=None,
connections={},
)

first = asyncio.create_task(agent(cast("Any", run_a)))
Expand Down
Loading