Skip to content
Closed
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
10 changes: 5 additions & 5 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -119,10 +119,10 @@
# =============================================================================
# TERMINAL TOOL CONFIGURATION
# =============================================================================
# Backend type: "local", "singularity", "docker", "modal", or "ssh"
# Backend type: "local", "singularity", "docker", "podman", "modal", or "ssh"
# Terminal backend is configured in ~/.hermes/config.yaml (terminal.backend).
# Use 'hermes setup' or 'hermes config set terminal.backend docker' to change.
# Supported: local, docker, singularity, modal, ssh
# Supported: local, docker, podman, singularity, modal, ssh
#
# Only override here if you need to force a backend without touching config.yaml:
# TERMINAL_ENV=local
Expand All @@ -135,9 +135,9 @@ TERMINAL_MODAL_IMAGE=nikolaik/python-nodejs:python3.11-nodejs20

# Working directory for terminal commands
# For local backend: "." means current directory (resolved automatically)
# For remote backends (ssh/docker/modal/singularity): use an absolute path
# For remote backends (ssh/docker/podman/modal/singularity): use an absolute path
# INSIDE the target environment, or leave unset for the backend's default
# (/root for modal, / for docker, ~ for ssh). Do NOT use a host-local path.
# (/root for modal, / for docker/podman, ~ for ssh). Do NOT use a host-local path.
# Usually managed by config.yaml (terminal.cwd) β€” uncomment to override
# TERMINAL_CWD=.

Expand Down Expand Up @@ -168,7 +168,7 @@ TERMINAL_LIFETIME_SECONDS=300
# SUDO SUPPORT (works with ALL terminal backends)
# =============================================================================
# If set, enables sudo commands by piping password via `sudo -S`.
# Works with: local, docker, singularity, modal, and ssh backends.
# Works with: local, docker, podman, singularity, modal, and ssh backends.
#
# SECURITY WARNING: Password stored in plaintext. Only use on trusted machines.
#
Expand Down
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ hermes-agent/
β”‚ β”œβ”€β”€ code_execution_tool.py # execute_code sandbox
β”‚ β”œβ”€β”€ delegate_tool.py # Subagent delegation
β”‚ β”œβ”€β”€ mcp_tool.py # MCP client (~1050 lines)
β”‚ └── environments/ # Terminal backends (local, docker, ssh, modal, daytona, singularity)
β”‚ └── environments/ # Terminal backends (local, docker, podman, ssh, modal, daytona, singularity)
β”œβ”€β”€ gateway/ # Messaging platform gateway
β”‚ β”œβ”€β”€ run.py # Main loop, slash commands, message dispatch
β”‚ β”œβ”€β”€ session.py # SessionStore β€” conversation persistence
Expand Down
2 changes: 1 addition & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -157,7 +157,7 @@ hermes-agent/
β”‚ β”œβ”€β”€ skill_tools.py # Skill search, load, manage
β”‚ └── environments/ # Terminal execution backends
β”‚ β”œβ”€β”€ base.py # BaseEnvironment ABC
β”‚ β”œβ”€β”€ local.py, docker.py, ssh.py, singularity.py, modal.py, daytona.py
β”‚ β”œβ”€β”€ local.py, docker.py, podman.py, ssh.py, singularity.py, modal.py, daytona.py
β”‚
β”œβ”€β”€ gateway/ # Messaging gateway
β”‚ β”œβ”€β”€ run.py # GatewayRunner β€” platform lifecycle, message routing, cron
Expand Down
2 changes: 1 addition & 1 deletion agent/skill_commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ def build_plan_path(
"""Return the default workspace-relative markdown path for a /plan invocation.

Relative paths are intentional: file tools are task/backend-aware and resolve
them against the active working directory for local, docker, ssh, modal,
them against the active working directory for local, docker, ssh, modal, podman,
daytona, and similar terminal backends. That keeps the plan with the active
workspace instead of the Hermes host's global home directory.
"""
Expand Down
1 change: 1 addition & 0 deletions agent/smart_model_routing.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@
"subagent",
"cron",
"docker",
"podman",
"kubernetes",
}

Expand Down
39 changes: 39 additions & 0 deletions batch_runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -288,13 +288,52 @@ def _process_single_prompt(
except Exception as img_err:
if config.get("verbose"):
print(f" Prompt {prompt_index}: Docker image check failed: {img_err}", flush=True)
elif env_type == "podman":
import subprocess as _sp
try:
cmd = ["podman", "image", "inspect", container_image]
if config.get("podman_rootful"):
cmd = ["sudo"] + cmd

probe = _sp.run(
cmd,
capture_output=True, timeout=10,
)
if probe.returncode != 0:
if config.get("verbose"):
print(f" Prompt {prompt_index}: Pulling podman image {container_image}...", flush=True)

cmd = ["podman", "pull", container_image]
if config.get("podman_rootful"):
cmd = ["sudo"] + cmd

pull = _sp.run(
cmd,
capture_output=True, text=True, timeout=600,
)
if pull.returncode != 0:
return {
"success": False,
"prompt_index": prompt_index,
"error": f"Podman image not available: {container_image}\n{pull.stderr[:500]}",
"trajectory": None,
"tool_stats": {},
"toolsets_used": [],
"metadata": {"batch_num": batch_num, "timestamp": datetime.now().isoformat()},
}
except FileNotFoundError:
pass # Docker CLI not installed β€” skip check (e.g., Modal backend)
except Exception as img_err:
if config.get("verbose"):
print(f" Prompt {prompt_index}: Podman image check failed: {img_err}", flush=True)

from tools.terminal_tool import register_task_env_overrides
overrides = {
"docker_image": container_image,
"modal_image": container_image,
"singularity_image": f"docker://{container_image}",
"daytona_image": container_image,
"podman_image": container_image,
}
if prompt_data.get("cwd"):
overrides["cwd"] = prompt_data["cwd"]
Expand Down
27 changes: 26 additions & 1 deletion cli-config.yaml.example
Original file line number Diff line number Diff line change
Expand Up @@ -211,8 +211,33 @@ terminal:
# daytona_image: "nikolaik/python-nodejs:python3.11-nodejs20"
# container_disk: 10240 # Daytona max is 10GB per sandbox

# -----------------------------------------------------------------------------
# OPTION 7: Podman container
# Commands run in an isolated Podman container
# Great for: reproducible environments, testing, isolation
# -----------------------------------------------------------------------------
# terminal:
# backend: "podman"
# cwd: "/workspace" # Path INSIDE the container (default: /)
# timeout: 180
# lifetime_seconds: 300
# docker_image: "docker.io/nikolaik/python-nodejs:python3.11-nodejs20"
# docker_mount_cwd_to_workspace: true # Explicit opt-in: mount your launch cwd into /workspace
# # Optional: explicitly forward selected env vars into Docker.
# # These values come from your current shell first, then ~/.hermes/.env.
# # Warning: anything forwarded here is visible to commands run in the container.
# docker_forward_env:
# - "GITHUB_TOKEN"
# - "NPM_TOKEN"
# podman_user: pn # default: unset - i.e. whatever is the default user of the container image
# podman_userns: keep-id # default: host
# podman_privileged: true # default: false - i.e. no `--privileged` command line option
# podman_extra_capabilities: [] # default: []
# podman_extra_args: [] # default: []
# podman_rootful: true # default: false - i.e. run Podman as just `podman` instead of `sudo podman`

#
# --- Container resource limits (docker, singularity, modal, daytona -- ignored for local/ssh) ---
# --- Container resource limits (docker, podman, singularity, modal, daytona -- ignored for local/ssh) ---
# These settings apply to all container backends. They control the resources
# allocated to the sandbox and whether its filesystem persists across sessions.
container_cpu: 1 # CPU cores
Expand Down
7 changes: 7 additions & 0 deletions cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -223,12 +223,18 @@ def load_cli_config() -> Dict[str, Any]:
"timeout": 60,
"lifetime_seconds": 300,
"docker_image": "nikolaik/python-nodejs:python3.11-nodejs20",
"podman_image": "docker.io/nikolaik/python-nodejs:python3.11-nodejs20",
"docker_forward_env": [],
"singularity_image": "docker://nikolaik/python-nodejs:python3.11-nodejs20",
"modal_image": "nikolaik/python-nodejs:python3.11-nodejs20",
"daytona_image": "nikolaik/python-nodejs:python3.11-nodejs20",
"docker_volumes": [], # host:container volume mounts for Docker backend
"docker_mount_cwd_to_workspace": False, # explicit opt-in only; default off for sandbox isolation
"podman_userns": "host",
"podman_extra_args": [],
"podman_extra_capabilities": [],
"podman_privileged": False,
"podman_rootful": False,
},
"browser": {
"inactivity_timeout": 120, # Auto-cleanup inactive browser sessions after 2 min
Expand Down Expand Up @@ -417,6 +423,7 @@ def load_cli_config() -> Dict[str, Any]:
"timeout": "TERMINAL_TIMEOUT",
"lifetime_seconds": "TERMINAL_LIFETIME_SECONDS",
"docker_image": "TERMINAL_DOCKER_IMAGE",
"podman_image": "TERMINAL_PODMAN_IMAGE",
"docker_forward_env": "TERMINAL_DOCKER_FORWARD_ENV",
"singularity_image": "TERMINAL_SINGULARITY_IMAGE",
"modal_image": "TERMINAL_MODAL_IMAGE",
Expand Down
4 changes: 2 additions & 2 deletions environments/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ This directory contains the integration layer between **hermes-agent's** tool-ca
- `evaluate_log()` for saving eval results to JSON + samples.jsonl

**HermesAgentBaseEnv** (`hermes_base_env.py`) extends BaseEnv with hermes-agent specifics:
- Sets `os.environ["TERMINAL_ENV"]` to configure the terminal backend (local, docker, modal, daytona, ssh, singularity)
- Sets `os.environ["TERMINAL_ENV"]` to configure the terminal backend (local, docker, podman, modal, daytona, ssh, singularity)
- Resolves hermes-agent toolsets via `_resolve_tools_for_group()` (calls `get_tool_definitions()` which queries `tools/registry.py`)
- Implements `collect_trajectory()` which runs the full agent loop and computes rewards
- Supports two-phase operation (Phase 1: OpenAI server, Phase 2: VLLM ManagedServer)
Expand Down Expand Up @@ -318,7 +318,7 @@ For eval benchmarks, follow the pattern in `terminalbench2_env.py`:
| `distribution` | Probabilistic toolset distribution name | `None` |
| `max_agent_turns` | Max LLM calls per rollout | `30` |
| `agent_temperature` | Sampling temperature | `1.0` |
| `terminal_backend` | `local`, `docker`, `modal`, `daytona`, `ssh`, `singularity` | `local` |
| `terminal_backend` | `local`, `docker`, `podman`, `modal`, `daytona`, `ssh`, `singularity` | `local` |
| `system_prompt` | System message for the agent | `None` |
| `tool_call_parser` | Parser name for Phase 2 | `hermes` |
| `eval_handling` | `STOP_TRAIN`, `LIMIT_TRAIN`, `NONE` | `STOP_TRAIN` |
2 changes: 1 addition & 1 deletion environments/agent_loop.py
Original file line number Diff line number Diff line change
Expand Up @@ -401,7 +401,7 @@ def _tc_to_dict(tc):
tool_elapsed = _time.monotonic() - tool_submit_time
else:
# Run tool calls in a thread pool so backends that
# use asyncio.run() internally (modal, docker, daytona) get
# use asyncio.run() internally (modal, docker, podman, daytona) get
# a clean event loop instead of deadlocking.
loop = asyncio.get_event_loop()
# Capture current tool_name/args for the lambda
Expand Down
2 changes: 1 addition & 1 deletion environments/hermes_base_env.py
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,7 @@ class HermesAgentEnvConfig(BaseEnvConfig):
# --- Terminal backend ---
terminal_backend: str = Field(
default="local",
description="Terminal backend: 'local', 'docker', 'modal', 'daytona', 'ssh', 'singularity'. "
description="Terminal backend: 'local', 'docker', 'podman', 'modal', 'daytona', 'ssh', 'singularity'. "
"Modal or Daytona recommended for production RL (cloud isolation per rollout).",
)
terminal_timeout: int = Field(
Expand Down
4 changes: 2 additions & 2 deletions environments/tool_context.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ async def compute_reward(self, item, result, ctx):
def _run_tool_in_thread(tool_name: str, arguments: Dict[str, Any], task_id: str) -> str:
"""
Run a tool call in a thread pool executor so backends that use asyncio.run()
internally (modal, docker, daytona) get a clean event loop.
internally (modal, docker, podman, daytona) get a clean event loop.

If we're already in an async context, executes handle_function_call() in a
disposable worker thread and blocks for the result.
Expand Down Expand Up @@ -95,7 +95,7 @@ def terminal(self, command: str, timeout: int = 180) -> Dict[str, Any]:
backend = os.getenv("TERMINAL_ENV", "local")
logger.debug("ToolContext.terminal [%s backend] task=%s: %s", backend, self.task_id[:8], command[:100])

# Run via thread helper so modal/docker/daytona backends' asyncio.run() doesn't deadlock
# Run via thread helper so modal/docker/podman/daytona backends' asyncio.run() doesn't deadlock
result = _run_tool_in_thread(
"terminal",
{"command": command, "timeout": timeout},
Expand Down
7 changes: 7 additions & 0 deletions gateway/run.py
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,7 @@ def _ensure_ssl_certs() -> None:
"timeout": "TERMINAL_TIMEOUT",
"lifetime_seconds": "TERMINAL_LIFETIME_SECONDS",
"docker_image": "TERMINAL_DOCKER_IMAGE",
"podman_image": "TERMINAL_PODMAN_IMAGE",
"docker_forward_env": "TERMINAL_DOCKER_FORWARD_ENV",
"singularity_image": "TERMINAL_SINGULARITY_IMAGE",
"modal_image": "TERMINAL_MODAL_IMAGE",
Expand All @@ -126,6 +127,12 @@ def _ensure_ssl_certs() -> None:
"docker_volumes": "TERMINAL_DOCKER_VOLUMES",
"sandbox_dir": "TERMINAL_SANDBOX_DIR",
"persistent_shell": "TERMINAL_PERSISTENT_SHELL",
"podman_user": "TERMINAL_PODMAN_USER",
"podman_userns": "TERMINAL_PODMAN_USERNS",
"podman_privileged": "TERMINAL_PODMAN_PRIVILEGED",
"podman_extra_capabilities": "TERMINAL_PODMAN_EXTRA_CAPABILITIES",
"podman_extra_args": "TERMINAL_PODMAN_EXTRA_ARGS",
"podman_rootful": "TERMINAL_PODMAN_ROOTFUL",
}
for _cfg_key, _env_var in _terminal_env_map.items():
if _cfg_key in _terminal_cfg:
Expand Down
52 changes: 52 additions & 0 deletions hermes_cli/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -297,6 +297,7 @@ def _ensure_hermes_home_managed(home: Path):
# are passed through automatically; this list is for non-skill use cases.
"env_passthrough": [],
"docker_image": "nikolaik/python-nodejs:python3.11-nodejs20",
"podman_image": "docker.io/nikolaik/python-nodejs:python3.11-nodejs20",
"docker_forward_env": [],
# Explicit environment variables to set inside Docker containers.
# Unlike docker_forward_env (which reads values from the host process),
Expand Down Expand Up @@ -324,6 +325,13 @@ def _ensure_hermes_home_managed(home: Path):
# Enabled by default for non-local backends (SSH); local is always opt-in
# via TERMINAL_LOCAL_PERSISTENT env var.
"persistent_shell": True,
# Podman-specific options
"podman_userns": "host", # --userns flag (e.g., "keep-id", "auto")
"podman_user": "", # --user flag (e.g., "1000:1000", "nonroot")
"podman_privileged": False, # --privileged flag
"podman_extra_capabilities": [], # Additional --cap-add values (additive to defaults)
"podman_extra_args": [], # Arbitrary additional podman run flags
"podman_rootful": False, # Run podman commands with sudo (i.e. rootful mode)
},

"browser": {
Expand Down Expand Up @@ -1278,6 +1286,48 @@ def _ensure_hermes_home_managed(home: Path):
"password": True,
"category": "messaging",
},
"TERMINAL_PODMAN_USERNS": {
"description": "Podman user namespace mode (--userns flag)",
"prompt": "Podman User Namespace",
"url": "https://docs.podman.io/en/latest/markdown/podman-run.1.html#userns-mode",
"password": False,
"category": "tool",
},
"TERMINAL_PODMAN_USER": {
"description": "Podman user to run as inside container (--user flag)",
"prompt": "Podman User",
"url": "https://docs.podman.io/en/latest/markdown/podman-run.1.html#user-user",
"password": False,
"category": "tool",
},
"TERMINAL_PODMAN_PRIVILEGED": {
"description": "Run Podman containers in privileged mode (--privileged flag)",
"prompt": "Podman Privileged Mode",
"url": "https://docs.podman.io/en/latest/markdown/podman-run.1.html#privileged",
"password": False,
"category": "tool",
},
"TERMINAL_PODMAN_EXTRA_CAPABILITIES": {
"description": "Additional Linux capabilities to add to Podman containers (--cap-add)",
"prompt": "Podman Extra Capabilities",
"url": "https://docs.podman.io/en/latest/markdown/podman-run.1.html#cap-add-capability",
"password": False,
"category": "tool",
},
"TERMINAL_PODMAN_EXTRA_ARGS": {
"description": "Additional arbitrary arguments for podman run (JSON array)",
"prompt": "Podman Extra Arguments",
"url": "https://docs.podman.io/en/latest/markdown/podman-run.1.html",
"password": False,
"category": "tool",
},
"TERMINAL_PODMAN_ROOTFUL": {
"description": "Run Podman commands with sudo",
"prompt": "Podman Use Sudo",
"url": "",
"password": False,
"category": "tool",
},

# ── Agent settings ──
"MESSAGING_CWD": {
Expand Down Expand Up @@ -2584,6 +2634,8 @@ def show_config():

if terminal.get('backend') == 'docker':
print(f" Docker image: {terminal.get('docker_image', 'nikolaik/python-nodejs:python3.11-nodejs20')}")
elif terminal.get('backend') == 'podman':
print(f" Podman image: {terminal.get('podman_image', 'docker.io/nikolaik/python-nodejs:python3.11-nodejs20')}")
elif terminal.get('backend') == 'singularity':
print(f" Image: {terminal.get('singularity_image', 'docker://nikolaik/python-nodejs:python3.11-nodejs20')}")
elif terminal.get('backend') == 'modal':
Expand Down
14 changes: 14 additions & 0 deletions hermes_cli/doctor.py
Original file line number Diff line number Diff line change
Expand Up @@ -557,6 +557,20 @@ def run_doctor(args):
else:
check_warn("docker not found", "(optional)")

# Podman (optional)
if terminal_env == "podman":
if not shutil.which("podman"):
check_fail("podman not found", "(required for TERMINAL_ENV=podman)")
issues.append("Install Podman or change TERMINAL_ENV")
else:
if shutil.which("podman"):
check_ok("podman", "(optional)")
else:
if _is_termux():
check_info("Podman backend is not available inside Termux (expected on Android)")
else:
check_warn("podman not found", "(optional)")

# SSH (if using ssh backend)
if terminal_env == "ssh":
ssh_host = os.getenv("TERMINAL_SSH_HOST")
Expand Down
Loading
Loading