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: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,12 @@ A **capability** is a connection the environment exposes; a **harness** attaches

From the [platform UI](https://hud.ai) you can run batches, compare models on the same taskset, and inspect every trace.

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.

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

## Train on rewards
Expand Down
2 changes: 2 additions & 0 deletions hud/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
Grade,
HostedRuntime,
HUDRuntime,
InferenceConnection,
Job,
LocalRuntime,
Run,
Expand Down Expand Up @@ -43,6 +44,7 @@
"Grade",
"HUDRuntime",
"HostedRuntime",
"InferenceConnection",
"Job",
"LocalRuntime",
"Run",
Expand Down
30 changes: 20 additions & 10 deletions hud/agents/claude/sdk/agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@

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

logger = logging.getLogger(__name__)

Expand All @@ -43,8 +43,8 @@
RUN_SCRIPT_PATH = ".hud_run.bat"

_MANAGED_CLAUDE_PATHS = {
"linux-x64": "/media/hud/bin/claude/linux-x64/claude",
"linux-x64-musl": "/media/hud/bin/claude/linux-x64-musl/claude",
"linux-x64": "/usr/local/lib/agents/claude/linux-x64/claude",
"linux-x64-musl": "/usr/local/lib/agents/claude/linux-x64-musl/claude",
}


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

async def _exec(
Expand All @@ -122,6 +123,7 @@ async def _exec(
mcp_servers: dict[str, dict[str, Any]],
prompt: str,
executable: str = "claude",
inference: InferenceConnection | None = None,
) -> None:
mcp_config_path = await self._write_mcp_config(ssh, mcp_servers)
input_text = (
Expand All @@ -145,6 +147,7 @@ async def _exec(
shell=shell,
mcp_config_path=mcp_config_path,
executable=executable,
inference=inference,
)
if shell in WINDOWS_SHELLS:
await ssh.write_text(RUN_SCRIPT_PATH, f"@echo off\r\n{command}\r\n")
Expand Down Expand Up @@ -173,20 +176,26 @@ async def _exec(
except (OSError, asyncssh.Error):
logger.warning("Failed to remove Claude CLI runtime files")

def _build_env_vars(self) -> dict[str, str]:
def _build_env_vars(self, inference: InferenceConnection | 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 = settings.api_key is not None
use_hud_gateway = inference is not None or settings.api_key is not None

if use_hud_gateway:
if not settings.api_key:
if inference is not None:
base_url = inference.base_url
api_key = inference.credential
elif settings.api_key:
base_url = settings.hud_gateway_url
api_key = settings.api_key
else:
raise ValueError("HUD_API_KEY is required for HUD gateway routing")
env["ANTHROPIC_BASE_URL"] = settings.hud_gateway_url
env["ANTHROPIC_API_KEY"] = settings.api_key
env["ANTHROPIC_BASE_URL"] = base_url
env["ANTHROPIC_API_KEY"] = api_key
env["CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS"] = "1"
env["DISABLE_AUTO_COMPACT"] = "1"
if trace_id := get_current_trace_id():
if inference 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 @@ -227,8 +236,9 @@ def _build_cli_command(
shell: str,
mcp_config_path: str | None = None,
executable: str = "claude",
inference: InferenceConnection | None = None,
) -> str:
env_vars = self._build_env_vars()
env_vars = self._build_env_vars(inference)
is_win = shell in WINDOWS_SHELLS
base_args: list[str] = [
executable,
Expand Down
70 changes: 46 additions & 24 deletions hud/agents/codex/agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,13 +25,13 @@

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

logger = logging.getLogger(__name__)

_MANAGED_CODEX_PATHS = {
"linux-x64": "/media/hud/bin/codex/bin/codex",
"linux-x64-musl": "/media/hud/bin/codex/bin/codex",
"linux-x64": "/usr/local/lib/agents/codex/bin/codex",
"linux-x64-musl": "/usr/local/lib/agents/codex/bin/codex",
}


Expand Down Expand Up @@ -208,7 +208,12 @@ def record_tool(self, item: dict[str, Any], started_at: str, ended_at: str) -> N
)


def codex_command(config: CodexCLIConfig, shell: str, executable: str = "codex") -> str:
def codex_command(
config: CodexCLIConfig,
shell: str,
executable: str = "codex",
inference: InferenceConnection | None = None,
) -> str:
env: dict[str, str] = {}
args = [
executable,
Expand All @@ -226,21 +231,29 @@ def codex_command(config: CodexCLIConfig, shell: str, executable: str = "codex")

use_hud_gateway = config.use_hud_gateway
if use_hud_gateway is None:
use_hud_gateway = settings.api_key is not None
use_hud_gateway = inference is not None or settings.api_key is not None
if use_hud_gateway:
if not settings.api_key:
if inference is not None:
base_url = inference.base_url
credential = inference.credential
credential_env = "HUD_RUNTIME_INFERENCE_TOKEN"
elif settings.api_key:
base_url = settings.hud_gateway_url
credential = settings.api_key
credential_env = "HUD_API_KEY"
else:
raise ValueError("HUD_API_KEY is required for HUD gateway routing")
env["HUD_API_KEY"] = settings.api_key
env[credential_env] = credential
overrides = {
"model_provider": "hud",
"model_providers.hud.name": "HUD",
"model_providers.hud.base_url": settings.hud_gateway_url,
"model_providers.hud.env_key": "HUD_API_KEY",
"model_providers.hud.base_url": base_url,
"model_providers.hud.env_key": credential_env,
"model_providers.hud.wire_api": "responses",
}
for key, value in overrides.items():
args.extend(["-c", f"{key}={json.dumps(value)}"])
if trace_id := get_current_trace_id():
if inference is None and (trace_id := get_current_trace_id()):
args.extend(
[
"-c",
Expand All @@ -251,35 +264,42 @@ def codex_command(config: CodexCLIConfig, shell: str, executable: str = "codex")
env["CODEX_API_KEY"] = settings.openai_api_key

args.append("-")
isolate_home = bool(env)
if shell in WINDOWS_SHELLS:
script = ";".join(
[
invocation = (
f"& {powershell_quote(executable)} "
f"{' '.join(powershell_quote(arg) for arg in args[1:])}; "
"$hudExitCode=$LASTEXITCODE"
)
statements = [
*(f"$env:{key}={powershell_quote(value)}" for key, value in env.items()),
invocation,
"exit $hudExitCode",
]
if isolate_home:
statements = [
"$codexHome=Join-Path ([System.IO.Path]::GetTempPath()) "
"('hud-codex-' + [System.Guid]::NewGuid())",
"New-Item -ItemType Directory -Force -Path $codexHome | Out-Null",
"$env:CODEX_HOME=$codexHome",
*(f"$env:{key}={powershell_quote(value)}" for key, value in env.items()),
f"try {{ & {powershell_quote(executable)} "
f"{' '.join(powershell_quote(arg) for arg in args[1:])}; "
"$hudExitCode=$LASTEXITCODE } finally { Remove-Item -Recurse -Force "
"$codexHome }",
f"try {{ {invocation} }} finally {{ Remove-Item -Recurse -Force $codexHome }}",
"exit $hudExitCode",
]
)
return powershell(script)
return powershell(";".join(statements))

command = " ".join(shlex.quote(arg) for arg in args)
env_prefix = " ".join(f"{key}={shlex.quote(value)}" for key, value in env.items())
invocation = f"{env_prefix} {command}" if env_prefix else command
return "; ".join(
[
statements = ['export PATH="$HOME/.local/bin:$PATH"', invocation]
if isolate_home:
statements = [
'codex_home=$(mktemp -d "${TMPDIR:-/tmp}/hud-codex.XXXXXX") || exit 1',
"trap 'rm -rf -- \"$codex_home\"' EXIT",
'export CODEX_HOME="$codex_home"',
'export PATH="$HOME/.local/bin:$PATH"',
invocation,
*statements,
]
)
return "; ".join(statements)


async def run_codex(
Expand All @@ -290,8 +310,9 @@ async def run_codex(
shell: str,
prompt: str,
executable: str = "codex",
inference: InferenceConnection | None = None,
) -> None:
command = codex_command(config, shell, executable)
command = codex_command(config, shell, executable, inference=inference)
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)
Expand Down Expand Up @@ -322,6 +343,7 @@ async def __call__(self, run: Run) -> None:
shell=ssh.capability.params.get("shell", "bash"),
prompt=run.prompt_text,
executable=executable,
inference=run.inference,
)


Expand Down
49 changes: 45 additions & 4 deletions hud/agents/tests/test_claude_cli_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
from hud.agents.types import AgentStep, ClaudeCLIConfig, ToolStep
from hud.capabilities import Capability, 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 @@ -68,6 +69,32 @@ 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",
)

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

assert "ANTHROPIC_BASE_URL=https://inference.hud.so" in gateway
assert "ANTHROPIC_API_KEY=scoped-runtime-token" in gateway
assert "HUD_API_KEY" not in gateway
assert "Trace-Id" not in gateway
for name in (
"ANTHROPIC_MODEL",
"ANTHROPIC_SMALL_FAST_MODEL",
"ANTHROPIC_DEFAULT_SONNET_MODEL",
"ANTHROPIC_DEFAULT_OPUS_MODEL",
"ANTHROPIC_DEFAULT_HAIKU_MODEL",
"CLAUDE_CODE_SUBAGENT_MODEL",
):
assert f"{name}=claude-sonnet-5" in gateway


def test_windows_command_encodes_environment_and_arguments(
monkeypatch: pytest.MonkeyPatch,
) -> None:
Expand Down Expand Up @@ -458,6 +485,7 @@ async def test_manifest_mcp_capability_is_written_for_remote_claude(
ssh = SSHClient(shell, cast("Any", object()))

class Client:
inference = None
manifest = SimpleNamespace(bindings=[shell, mcp])

async def open(self, ref: str) -> SSHClient:
Expand All @@ -471,7 +499,9 @@ async def open(self, ref: str) -> SSHClient:
await agent(
cast(
"Any",
SimpleNamespace(client=Client(), prompt_text="call the tool", runtime_config=None),
SimpleNamespace(
client=Client(), prompt_text="call the tool", runtime_config=None, inference=None
),
)
)

Expand Down Expand Up @@ -499,6 +529,7 @@ async def test_remote_claude_passes_screenshot_encoding_to_computer_mcp(
bridge_active = False

class Client:
inference = None
manifest = SimpleNamespace(bindings=[shell, screen])

async def open(self, ref: str) -> SSHClient:
Expand Down Expand Up @@ -542,7 +573,9 @@ async def execute(*_args: Any, **_kwargs: Any) -> None:
await agent(
cast(
"Any",
SimpleNamespace(client=Client(), prompt_text="use the computer", runtime_config=None),
SimpleNamespace(
client=Client(), prompt_text="use the computer", runtime_config=None, inference=None
),
)
)

Expand Down Expand Up @@ -583,6 +616,7 @@ async def test_remote_claude_preserves_multiple_rfb_bindings(
bridged: list[str] = []

class Client:
inference = None
manifest = SimpleNamespace(bindings=[shell, *screens])

async def open(self, ref: str) -> SSHClient:
Expand Down Expand Up @@ -633,6 +667,7 @@ async def execute(*_args: Any, **kwargs: Any) -> None:
client=Client(),
prompt_text="use both screens",
runtime_config=None,
inference=None,
),
)
)
Expand Down Expand Up @@ -870,6 +905,7 @@ async def test_concurrent_runs_keep_their_ssh_state_isolated(

class Client:
def __init__(self, shell: Capability, ssh: SSHClient) -> None:
self.inference = None
self.manifest = SimpleNamespace(bindings=[shell])
self.ssh = ssh

Expand Down Expand Up @@ -897,9 +933,14 @@ 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)
run_a = SimpleNamespace(
client=Client(shell_a, ssh_a), prompt_text="first", runtime_config=None, inference=None
)
run_b = SimpleNamespace(
client=Client(shell_b, ssh_b), prompt_text="second", runtime_config=None
client=Client(shell_b, ssh_b),
prompt_text="second",
runtime_config=None,
inference=None,
)

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