diff --git a/agent/HERMES.md b/agent/HERMES.md new file mode 100644 index 0000000..a402a93 --- /dev/null +++ b/agent/HERMES.md @@ -0,0 +1,53 @@ +# Bux context for Hermes + +You are Hermes running inside Browser Use Box. + +## Runtime + +- Default workspace: `/home/bux`. +- Persistent user state lives under `/home/bux`. +- The Bux repo is usually at `/opt/bux/repo`. +- User-private context belongs in `/opt/bux/repo/private/` or Hermes' own + private memory, not in repo-tracked files. + +## Browser + +Use the existing Browser Use Cloud browser. Do not install local Chrome, +Chromium, Playwright browsers, or other desktop browser runtimes. + +Before browser work: + +```bash +source /home/bux/.claude/browser.env +``` + +Then use `browser-harness-js`: + +```bash +browser-harness-js 'await session.connect({wsUrl: process.env.BU_CDP_WS}); await session.Page.navigate({url: "https://example.com"})' +``` + +The profile is persistent. Cookies and logins should survive between turns. + +## Telegram + +Telegram lanes are separate user-facing sessions. The environment may include: + +- `TG_CHAT_ID` +- `TG_THREAD_ID` +- `TG_USER_ID` +- `TG_USERNAME` +- `TG_FROM_NAME` +- `TG_OWNER_ID` +- `TG_IS_OWNER` + +For background work that should report back to the same lane, use `tg-send`. + +## Behavior + +- Be action-first and concise. +- If blocked by login, 2FA, CAPTCHA, or a human-only browser step, explain the + blocker and share the Browser Use live URL if available. +- Prefer the existing browser harness over raw HTTP for websites with user + state. +- Keep the box tidy. Avoid unnecessary global installs. diff --git a/agent/bootstrap.sh b/agent/bootstrap.sh index 122fd28..18815ea 100755 --- a/agent/bootstrap.sh +++ b/agent/bootstrap.sh @@ -109,6 +109,10 @@ chmod 0644 "$CODEX_CONFIG" # agent/ after a box has already been provisioned never get linked into # /usr/local/bin without a re-bootstrap. Re-assert here on every update so # the symlinks track agent/ as new helpers ship. Idempotent (ln -sfn). +HERMES_WAS_ENABLED=0 +if [ -x /usr/local/bin/bux-hermes ] || [ -f /home/bux/.hermes/SOUL.md ]; then + HERMES_WAS_ENABLED=1 +fi ln -sfn "$REPO_DIR/agent/tg-send" /usr/local/bin/tg-send ln -sfn "$REPO_DIR/agent/tg-buttons" /usr/local/bin/tg-buttons ln -sfn "$REPO_DIR/agent/tg-schedule" /usr/local/bin/tg-schedule @@ -118,6 +122,15 @@ ln -sfn /usr/local/bin/tg-schedule /usr/local/bin/schedule ln -sfn "$REPO_DIR/agent/agency-report" /usr/local/bin/agency-report ln -sfn "$REPO_DIR/agent/bux-restart" /usr/local/bin/bux-restart ln -sfn "$REPO_DIR/agent/bux-miniapp-tunnel" /usr/local/bin/bux-miniapp-tunnel +ln -sfn "$REPO_DIR/agent/bux-hermes" /usr/local/bin/bux-hermes + +# --- Hermes support (optional third lane agent) --------------------------- +# Keep existing Hermes-enabled boxes current, but do not make every production +# box opt into Hermes just because this helper exists in the repo. +if [ "${WITH_HERMES:-0}" = "1" ] || [ "$HERMES_WAS_ENABLED" = "1" ]; then + /bin/bash "$AGENT_DIR/install-hermes" \ + || echo "bootstrap: hermes install/update failed (non-fatal)" >&2 +fi # --- system prompt + CLAUDE.md/AGENTS.md symlinks -------------------------- # The one source of truth is /home/bux/system-prompt.md (copied from the diff --git a/agent/box_agent.py b/agent/box_agent.py index 29b7260..8645318 100644 --- a/agent/box_agent.py +++ b/agent/box_agent.py @@ -36,6 +36,7 @@ ENV_PATH = Path('/etc/bux/env') HEARTBEAT_INTERVAL = 30 TG_ENV = Path('/etc/bux/tg.env') +VALID_DEFAULT_AGENTS = {'claude', 'codex', 'hermes'} # Where the OSS repo is cloned by install.sh at bake time. /opt/bux/agent is # a symlink to /opt/bux/repo/agent so systemd units' ExecStart=/opt/bux/agent @@ -917,11 +918,17 @@ async def _handle(self, raw: str | bytes) -> None: owner_tg_user_id: int | None = int(raw_owner) if raw_owner else None except (TypeError, ValueError): owner_tg_user_id = None + raw_default_agent = msg.get('bux_default_agent') or msg.get('default_agent') + default_agent = str(raw_default_agent or '').strip().lower() + if default_agent and default_agent not in VALID_DEFAULT_AGENTS: + LOG.warning('ignoring invalid tg_install default_agent=%r', raw_default_agent) + default_agent = '' await self._tg_install( msg.get('bot_token', ''), msg.get('setup_token', ''), msg.get('bot_username', ''), owner_tg_user_id=owner_tg_user_id, + default_agent=default_agent or None, ) elif cmd == 'update': # Pull latest agent code from the OSS repo and restart services. @@ -1400,6 +1407,7 @@ async def _tg_install( bot_username: str, *, owner_tg_user_id: int | None = None, + default_agent: str | None = None, ) -> None: if not bot_token: await self._send( @@ -1420,6 +1428,8 @@ async def _tg_install( # pre-dates this writer; we only need user_id for auth purposes # (Telegram stamps `from.id` server-side, can't be forged). lines.append(f'TG_OWNER_ID={int(owner_tg_user_id)}') + if default_agent: + lines.append(f'BUX_DEFAULT_AGENT={default_agent}') TG_ENV.write_text('\n'.join(lines) + '\n', encoding='utf-8') # Mode 0o600, owner bux:bux (we run as bux). Both readers can still # get the token: the bux-telegram-bot.service runs as User=root diff --git a/agent/bux-hermes b/agent/bux-hermes new file mode 100755 index 0000000..766575d --- /dev/null +++ b/agent/bux-hermes @@ -0,0 +1,237 @@ +#!/usr/bin/env bash +# Stable Bux wrapper around the real Hermes binary. +# +# Telegram and tests call this wrapper instead of calling `hermes` directly so +# Bux can provide the same workspace, browser, and routing environment every +# time while preserving Hermes' own auth/subscription files under /home/bux. +set -euo pipefail + +BUX_HOME="${BUX_HOME:-/home/bux}" +BUX_USER="${BUX_USER:-bux}" +HERMES_HOME="${HERMES_HOME:-$BUX_HOME/.hermes}" +export HOME="$BUX_HOME" +export USER="$BUX_USER" +export HERMES_HOME +export PATH="$BUX_HOME/.local/bin:$BUX_HOME/.npm-global/bin:/usr/local/bin:/usr/bin:/bin:${PATH:-}" +export BUX_HERMES_SOUL="${BUX_HERMES_SOUL:-$HERMES_HOME/SOUL.md}" + +BROWSER_ENV="${BROWSER_ENV:-$BUX_HOME/.claude/browser.env}" +if [ -r "$BROWSER_ENV" ]; then + set -a + # shellcheck disable=SC1090 + . "$BROWSER_ENV" + set +a +fi +HERMES_ENV="${HERMES_ENV:-$HERMES_HOME/env}" +if [ -r "$HERMES_ENV" ]; then + set -a + # shellcheck disable=SC1090 + . "$HERMES_ENV" + set +a +fi + +if [ "${1:-}" = "--bux-env-check" ]; then + printf 'BUX_HOME=%s\n' "$BUX_HOME" + printf 'BUX_HERMES_SOUL=%s\n' "$BUX_HERMES_SOUL" + printf 'BU_CDP_WS=%s\n' "${BU_CDP_WS:-}" + printf 'BU_BROWSER_ID=%s\n' "${BU_BROWSER_ID:-}" + printf 'BU_PROFILE_ID=%s\n' "${BU_PROFILE_ID:-}" + exit 0 +fi + +find_hermes() { + if [ -n "${HERMES_BIN:-}" ]; then + if [ -x "$HERMES_BIN" ]; then + printf '%s\n' "$HERMES_BIN" + return 0 + fi + printf 'bux-hermes: HERMES_BIN is not executable: %s\n' "$HERMES_BIN" >&2 + return 1 + fi + for candidate in \ + "$BUX_HOME/.local/bin/hermes" \ + "$BUX_HOME/.npm-global/bin/hermes" \ + /usr/local/bin/hermes \ + /usr/bin/hermes + do + if [ -x "$candidate" ]; then + printf '%s\n' "$candidate" + return 0 + fi + done + if command -v hermes >/dev/null 2>&1; then + command -v hermes + return 0 + fi + return 1 +} + +if ! hermes_bin="$(find_hermes)"; then + cat >&2 <<'EOF' +bux-hermes: Hermes is not installed or not executable. + +Install or authenticate Hermes as the bux user, then retry. For a terminal +login flow from Telegram, use: + + /terminal hermes login +EOF + exit 127 +fi + +hermes_python() { + local first + first="$(head -n 1 "$hermes_bin" 2>/dev/null || true)" + if [[ "$first" == "#!"*python* ]]; then + first="${first#\#!}" + if [ -x "$first" ]; then + printf '%s\n' "$first" + return 0 + fi + fi + command -v python3 +} + +current_provider() { + "$(hermes_python)" <<'PY' 2>/dev/null || true +from pathlib import Path +import os +import yaml + +path = Path(os.environ.get("HERMES_HOME", str(Path.home() / ".hermes"))) / "config.yaml" +try: + data = yaml.safe_load(path.read_text(encoding="utf-8")) or {} +except Exception: + data = {} +model = data.get("model") if isinstance(data, dict) else {} +provider = model.get("provider") if isinstance(model, dict) else "" +print(str(provider or "").strip()) +PY +} + +configure_codex() { + local model="${1:-${HERMES_CODEX_MODEL:-gpt-5.5}}" + HERMES_CODEX_MODEL="$model" "$(hermes_python)" <<'PY' +from __future__ import annotations + +import os +import sys + +from hermes_cli.auth import ( + DEFAULT_CODEX_BASE_URL, + AuthError, + _import_codex_cli_tokens, + _save_codex_tokens, + _update_config_for_provider, + resolve_codex_runtime_credentials, +) + +base_url = os.environ.get("HERMES_CODEX_BASE_URL", "").strip().rstrip("/") or DEFAULT_CODEX_BASE_URL +source = "" +try: + creds = resolve_codex_runtime_credentials() + base_url = str(creds.get("base_url") or base_url).strip().rstrip("/") or base_url + source = "existing Hermes Codex credentials" +except Exception: + tokens = _import_codex_cli_tokens() + if not tokens: + print( + "No usable Codex CLI credentials found. Run /codex login first, " + "or use /terminal hermes model for a separate Hermes login.", + file=sys.stderr, + ) + raise SystemExit(3) + _save_codex_tokens(tokens) + source = "imported Codex CLI credentials" + +_update_config_for_provider("openai-codex", base_url) +print(source) +PY + "$hermes_bin" config set model.default "$model" >/dev/null + printf 'Hermes configured for OpenAI Codex (%s).\n' "$model" +} + +configure_claude() { + local model="${1:-${HERMES_CLAUDE_MODEL:-claude-sonnet-4-6}}" + "$(hermes_python)" <<'PY' +from __future__ import annotations + +import sys + +from agent.anthropic_adapter import read_claude_code_credentials, _resolve_claude_code_token_from_credentials +from hermes_cli.config import use_anthropic_claude_code_credentials + +creds = read_claude_code_credentials() +if not _resolve_claude_code_token_from_credentials(creds): + print( + "No usable Claude Code credentials found. Run /claude login first, " + "or use /terminal hermes model for a separate Hermes login.", + file=sys.stderr, + ) + raise SystemExit(3) + +use_anthropic_claude_code_credentials() +print("using Claude Code credentials") +PY + "$hermes_bin" config set model.provider anthropic >/dev/null + "$hermes_bin" config set model.base_url https://api.anthropic.com >/dev/null + "$hermes_bin" config set model.default "$model" >/dev/null + printf 'Hermes configured for Claude Code (%s).\n' "$model" +} + +configure_auto() { + local quiet=0 + if [ "${1:-}" = "--quiet" ]; then + quiet=1 + fi + local provider + provider="$(current_provider)" + if [ -n "$provider" ]; then + if [ "$quiet" = 0 ]; then + printf 'Hermes already configured for %s.\n' "$provider" + fi + return 0 + fi + if configure_codex; then + return 0 + fi + if configure_claude; then + return 0 + fi + return 3 +} + +cmd="${1:-}" +case "$cmd" in + run) + shift + if [ -n "${HERMES_RUN_COMMAND:-}" ]; then + export HERMES_PROMPT="$*" + exec bash -lc "$HERMES_RUN_COMMAND" + fi + exec "$hermes_bin" --oneshot "$*" + ;; + status) + if "$hermes_bin" status >/dev/null 2>&1; then + exec "$hermes_bin" status + fi + exec "$hermes_bin" --version + ;; + configure-codex|setup-codex) + shift + configure_codex "$@" + ;; + configure-claude|setup-claude) + shift + configure_claude "$@" + ;; + configure-auto|setup-auto) + shift + configure_auto "$@" + ;; + "") + exec "$hermes_bin" --help + ;; + *) + exec "$hermes_bin" "$@" + ;; +esac diff --git a/agent/install-hermes b/agent/install-hermes new file mode 100755 index 0000000..f1fd643 --- /dev/null +++ b/agent/install-hermes @@ -0,0 +1,143 @@ +#!/usr/bin/env bash +# Idempotently add Bux integration around Hermes without touching Hermes auth. +set -euo pipefail + +WITH_HERMES="${WITH_HERMES:-1}" +if [ "$WITH_HERMES" = "0" ]; then + echo "install-hermes: skipped (WITH_HERMES=0)" + exit 0 +fi + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_DIR="${BUX_REPO_DIR:-$(cd "$SCRIPT_DIR/.." && pwd)}" +BUX_HOME="${BUX_HOME:-/home/bux}" +BUX_USER="${BUX_USER:-bux}" +BUX_GROUP="${BUX_GROUP:-bux}" +BUX_BIN_DIR="${BUX_BIN_DIR:-/usr/local/bin}" +HERMES_HOME="${HERMES_HOME:-$BUX_HOME/.hermes}" +HERMES_SOUL="${HERMES_SOUL:-$HERMES_HOME/SOUL.md}" +HERMES_INSTALL_MODE="${HERMES_INSTALL_MODE:-detect}" +export HERMES_HOME + +say() { printf 'install-hermes: %s\n' "$*"; } +warn() { printf 'install-hermes: WARN %s\n' "$*" >&2; } + +user_exists() { + id -u "$BUX_USER" >/dev/null 2>&1 +} + +chown_bux_if_possible() { + if user_exists; then + chown "$BUX_USER:$BUX_GROUP" "$@" 2>/dev/null || true + fi +} + +run_as_bux() { + if user_exists && [ "$(id -u)" -eq 0 ]; then + sudo -iu "$BUX_USER" bash -lc "$*" + else + bash -lc "$*" + fi +} + +find_hermes() { + if [ -n "${HERMES_BIN:-}" ] && [ -x "$HERMES_BIN" ]; then + printf '%s\n' "$HERMES_BIN" + return 0 + fi + for candidate in \ + "$BUX_HOME/.local/bin/hermes" \ + "$BUX_HOME/.npm-global/bin/hermes" \ + /usr/local/bin/hermes \ + /usr/bin/hermes + do + if [ -x "$candidate" ]; then + printf '%s\n' "$candidate" + return 0 + fi + done + return 1 +} + +install -d -m 0755 "$HERMES_HOME" +chown_bux_if_possible "$HERMES_HOME" + +if hermes_bin="$(find_hermes)"; then + say "found Hermes at $hermes_bin" +elif [ -n "${HERMES_INSTALL_CMD:-}" ]; then + say "running HERMES_INSTALL_CMD as $BUX_USER" + run_as_bux "$HERMES_INSTALL_CMD" || warn "HERMES_INSTALL_CMD failed" +elif [ "$HERMES_INSTALL_MODE" = "skip" ] || [ "$HERMES_INSTALL_MODE" = "detect" ]; then + warn "Hermes binary not found; leaving wrapper installed so /hermes reports a clear error" +else + warn "unknown HERMES_INSTALL_MODE=$HERMES_INSTALL_MODE; not installing Hermes" +fi + +if [ -d "$BUX_BIN_DIR" ] || mkdir -p "$BUX_BIN_DIR" 2>/dev/null; then + if ln -sfn "$REPO_DIR/agent/bux-hermes" "$BUX_BIN_DIR/bux-hermes" 2>/dev/null; then + say "linked $BUX_BIN_DIR/bux-hermes" + else + warn "could not link $BUX_BIN_DIR/bux-hermes" + fi +else + warn "could not create $BUX_BIN_DIR" +fi + +python3 - "$REPO_DIR/agent/HERMES.md" "$REPO_DIR/private/hermes/soul.local.md" "$HERMES_SOUL" <<'PY' +from __future__ import annotations + +import re +import shutil +import sys +from pathlib import Path + +source = Path(sys.argv[1]) +local = Path(sys.argv[2]) +dest = Path(sys.argv[3]) + +managed = source.read_text(encoding="utf-8").rstrip() + "\n" +local_text = local.read_text(encoding="utf-8").rstrip() + "\n" if local.exists() else "" + +block = ( + "\n" + + managed + + "\n\n" + + "\n" + + local_text + + "\n" +) + +dest.parent.mkdir(parents=True, exist_ok=True) +existing = dest.read_text(encoding="utf-8") if dest.exists() else "" +if existing and not (dest.with_suffix(dest.suffix + ".pre-bux")).exists(): + shutil.copy2(dest, dest.with_suffix(dest.suffix + ".pre-bux")) + +pattern = re.compile( + r"(?s).*\n?" +) +if pattern.search(existing): + out = pattern.sub(block, existing) +elif existing.strip(): + out = existing.rstrip() + "\n\n" + block +else: + out = block + +dest.write_text(out, encoding="utf-8") +PY + +chmod 0644 "$HERMES_SOUL" +chown_bux_if_possible "$HERMES_SOUL" +say "updated $HERMES_SOUL" + +if hermes_bin="$(find_hermes)"; then + printf 'HERMES_BIN=%s\n' "$hermes_bin" > "$HERMES_HOME/env" + chmod 0644 "$HERMES_HOME/env" + chown_bux_if_possible "$HERMES_HOME/env" + if user_exists && [ "$(id -u)" -eq 0 ]; then + sudo -u "$BUX_USER" -H env HERMES_HOME="$HERMES_HOME" "$hermes_bin" --version >/dev/null 2>&1 || true + sudo -u "$BUX_USER" -H env HERMES_HOME="$HERMES_HOME" "$REPO_DIR/agent/bux-hermes" configure-auto --quiet >/dev/null 2>&1 || true + else + HERMES_HOME="$HERMES_HOME" "$hermes_bin" --version >/dev/null 2>&1 || true + HERMES_HOME="$HERMES_HOME" "$REPO_DIR/agent/bux-hermes" configure-auto --quiet >/dev/null 2>&1 || true + fi +fi diff --git a/agent/telegram_bot.py b/agent/telegram_bot.py index 46b04d7..89fe80e 100644 --- a/agent/telegram_bot.py +++ b/agent/telegram_bot.py @@ -1,11 +1,11 @@ """Telegram bot — forum-aware, multi-agent, per-lane sessions. -Each (chat_id, message_thread_id) pair is its own lane: per-lane claude/codex -session id, per-lane FIFO. All lanes share `/home/bux` as the working dir — +Each (chat_id, message_thread_id) pair is its own lane: per-lane claude/codex/ +hermes session id, per-lane FIFO. All lanes share `/home/bux` as the working dir — per-lane isolation is purely at the *agent session* layer (different session UUIDs → different transcripts), not the filesystem. Within a lane messages serialize so the same session UUID is never written by two procs at once. -Across lanes, no concurrency cap: spin up as many parallel claude/codex +Across lanes, no concurrency cap: spin up as many parallel claude/codex/hermes turns as the box can carry. Auth: deeplink-based one-shot setup token. First chat to redeem `/start ` @@ -18,7 +18,7 @@ State (on disk): /etc/bux/tg-allowed.txt — newline-separated allowed chat_ids (mode 640 root:bux) - /etc/bux/tg-state.json — {offset, agents: {lane_slug: 'claude'|'codex'}, + /etc/bux/tg-state.json — {offset, agents: {lane_slug: 'claude'|'codex'|'hermes'}, owners: {chat_id: {user_id,name,username,bound_at}}} /etc/bux/tg-queue.json — {lane_slug: [job, …]} pending FIFO /home/bux/.bux/sessions/ — per-lane claude/codex session UUID @@ -30,7 +30,7 @@ other-chat messages drop silently. 3. Once allowed, each message is keyed to a lane (chat_id, thread_id) and enqueued. A worker drains that lane, dispatching each job to the lane's - bound agent (claude default; `/codex` flips it). + bound agent (claude default; `/codex` or `/hermes` flips it). 4. Stream-json events from claude come back as one editable TG message bubble in the lane's topic, with a per-turn random "thinking" emoji in the placeholder and a 💔 reaction only on failure. @@ -253,7 +253,8 @@ def random_thinking_reaction() -> str: # Recognised agents per lane. Values double as PATH binary names. AGENT_CLAUDE = "claude" AGENT_CODEX = "codex" -AGENTS = (AGENT_CLAUDE, AGENT_CODEX) +AGENT_HERMES = "hermes" +AGENTS = (AGENT_CLAUDE, AGENT_CODEX, AGENT_HERMES) DEFAULT_AGENT = AGENT_CLAUDE CODEX_REASONING_EFFORTS = ("low", "medium", "high", "xhigh") CODEX_MODEL_CHOICES = ( @@ -284,6 +285,7 @@ def random_thinking_reaction() -> str: ("compact", "summarize this topic's session to free up context"), ("claude", "switch/login/logout Claude"), ("codex", "switch/login/logout Codex"), + ("hermes", "switch to Hermes"), ("fast", "switch this topic's Codex lane to fast mode"), ("model", "show/set this topic's Codex model"), ("usage", "show latest Codex usage / rate-limit diagnostic"), @@ -640,6 +642,19 @@ def _cloud_terminal_reply_markup(url: str) -> dict: } +def _configured_default_agent() -> str | None: + raw = ( + os.environ.get("BUX_DEFAULT_AGENT", "").strip().lower() + or _read_kv(TG_ENV).get("BUX_DEFAULT_AGENT", "").strip().lower() + ) + if not raw: + return None + if raw in AGENTS: + return raw + LOG.warning("ignoring invalid BUX_DEFAULT_AGENT=%r", raw) + return None + + def _write_tg_env_value(key: str, value: str) -> None: lines = TG_ENV.read_text().splitlines() if TG_ENV.exists() else [] prefix = f"{key}=" @@ -1444,16 +1459,19 @@ def _is_agent_authed(agent: str) -> bool: def _agent_for(key: LaneKey, state: dict) -> str: - """Resolve which agent handles this lane. /claude or /codex sets it. + """Resolve which agent handles this lane. /claude, /codex, /hermes set it. - For unbound lanes (no explicit /claude or /codex), prefer the CLI - that's actually signed in. If both are signed in, keep claude as - the historical default. If neither is, also fall back to claude — - the user will see the auth-error path and can switch with /codex. + For unbound lanes, an explicit BUX_DEFAULT_AGENT wins first. Without that, + prefer the CLI that's actually signed in. If both are signed in, keep claude + as the historical default. If neither is, also fall back to claude — the + user will see the auth-error path and can switch with /codex or /hermes. """ bound = (state.get("agents") or {}).get(_lane_slug(key)) if bound in AGENTS: return bound + configured = _configured_default_agent() + if configured: + return configured if _is_agent_authed(AGENT_CLAUDE): return AGENT_CLAUDE if _is_agent_authed(AGENT_CODEX): @@ -4635,7 +4653,7 @@ def run_task( # If at least one is signed in, dispatch normally — the auth- # error fallback below still catches mid-session 401s for an # expired credential. - if not _login_status_cached("claude") and not _login_status_cached("codex"): + if agent != AGENT_HERMES and not _login_status_cached("claude") and not _login_status_cached("codex"): owner = _owner_for(chat_id, self.state) or {} sender_id = (sender or {}).get("user_id") if not owner or not sender_id or _is_owner({"user_id": sender_id}, owner): @@ -4684,6 +4702,8 @@ def run_task( try: if agent == AGENT_CODEX: self._run_codex(key, prompt, reply_to, sender=sender, thinking_emoji=thinking_emoji) + elif agent == AGENT_HERMES: + self._run_hermes(key, prompt, reply_to, sender=sender, thinking_emoji=thinking_emoji) else: self._run_claude(key, prompt, reply_to, sender=sender, thinking_emoji=thinking_emoji) except Exception as e: @@ -5485,6 +5505,169 @@ def _cmd_usage( reply_markup=_codex_rate_limit_markup(_codex_settings_for(key, self.state)), ) + def _run_hermes( + self, + key: LaneKey, + prompt: str, + reply_to: int | None, + sender: dict | None = None, + thinking_emoji: str | None = None, + ) -> None: + """Stream a Hermes turn into the lane's TG topic. + + Hermes' exact CLI event shape is intentionally hidden behind + /usr/local/bin/bux-hermes. Until Hermes exposes a stable JSONL + contract here, treat stdout lines as user-visible text and surface + stderr when the turn produces no visible output. + """ + chat_id, thread_id = key + env = self._build_env(key, AGENT_HERMES, sender=sender) + prompt = _prefix_sender(prompt, sender, _owner_for(chat_id, self.state)) + + hermes_wrapper = self._which_hermes() + if not hermes_wrapper: + self.react(chat_id, reply_to, EMOJI_ERROR) + self.send( + chat_id, + "Hermes is not configured on this box yet. Run " + "`WITH_HERMES=1 sudo /opt/bux/repo/agent/bootstrap.sh`, or use " + "`/terminal hermes login` after installing Hermes as the `bux` user.", + reply_to=reply_to, + thread_id=thread_id, + markdown=True, + ) + return + + cmd = ["sudo", "-u", "bux", "-H"] + [f"{k}={v}" for k, v in env.items() if v] + cmd += [hermes_wrapper, "run", prompt] + + import tempfile + + stderr_buf = tempfile.TemporaryFile(mode="w+", encoding="utf-8") + try: + proc = subprocess.Popen( + cmd, + stdin=subprocess.DEVNULL, + stdout=subprocess.PIPE, + stderr=stderr_buf, + text=True, + cwd=str(WORKSPACE), + start_new_session=True, + ) + except Exception as e: + try: + stderr_buf.close() + except Exception: + pass + self.react(chat_id, reply_to, EMOJI_ERROR) + self.send( + chat_id, + f"Failed to spawn Hermes: {e}", + reply_to=reply_to, + thread_id=thread_id, + ) + return + + slug = _lane_slug(key) + with _inflight_lock: + _inflight_procs[slug] = proc + + stream_msg = StreamingMessage(self, chat_id, reply_to, thread_id, thinking_emoji=thinking_emoji) + stream_msg.start() + started_at = time.time() + any_text = False + assert proc.stdout is not None + try: + try: + for line in proc.stdout: + text = self._hermes_visible_text(line) + if text: + stream_msg.append(text) + any_text = True + except Exception: + LOG.exception("Hermes stdout loop failed") + duration_ms = int((time.time() - started_at) * 1000) + stream_msg.finalize(duration_ms=duration_ms) + + try: + proc.wait(timeout=5) + except subprocess.TimeoutExpired: + try: + _kill_inflight_proc(slug, proc, "drain-timeout") + proc.wait(timeout=1) + except Exception: + pass + except Exception: + pass + + if proc.returncode in (-9, -15): + if _inflight_cancel_reasons.get(slug) not in {"user-cancel", "drain-timeout", "followup-steer"}: + self.react(chat_id, reply_to, EMOJI_ERROR) + self.send( + chat_id, + "Cancelled.", + reply_to=reply_to, + thread_id=thread_id, + ) + return + + if not any_text: + err = "" + try: + stderr_buf.seek(0) + err = stderr_buf.read().strip() + except Exception: + pass + self.react(chat_id, reply_to, EMOJI_ERROR) + self.send( + chat_id, + err or "(Hermes returned no output)", + reply_to=reply_to, + thread_id=thread_id, + ) + elif proc.returncode not in (0, None): + self.react(chat_id, reply_to, EMOJI_ERROR) + finally: + with _inflight_lock: + if _inflight_procs.get(slug) is proc: + _inflight_procs.pop(slug, None) + _inflight_cancel_reasons.pop(slug, None) + try: + stderr_buf.close() + except Exception: + pass + + @staticmethod + def _hermes_visible_text(line: str) -> str: + raw = line.strip() + if not raw: + return "" + try: + ev = json.loads(raw) + except Exception: + return raw + if not isinstance(ev, dict): + return raw + for key in ("text", "message", "content", "output"): + val = ev.get(key) + if isinstance(val, str) and val.strip(): + return val.strip() + return "" + + @staticmethod + def _which_hermes() -> str | None: + env_path = os.environ.get("BUX_HERMES_WRAPPER", "").strip() + if env_path and os.path.isfile(env_path) and os.access(env_path, os.X_OK): + return env_path + for p in ( + "/usr/local/bin/bux-hermes", + "/usr/bin/bux-hermes", + "/opt/bux/repo/agent/bux-hermes", + ): + if os.path.isfile(p) and os.access(p, os.X_OK): + return p + return None + @staticmethod def _which_codex() -> str | None: for p in ( @@ -6274,6 +6457,10 @@ def handle(self, msg: dict) -> None: "/claude login — sign in Claude through a terminal flow\n" "/claude logout — sign out Claude\n" "/goal — continuous goal-mode, copilot by default (I suggest, you accept). Append 'autopilot' / 'full autonomy' / 'no approvals' for full autonomy.\n" + "/hermes — switch this topic to Hermes\n" + "/hermes codex — use existing Codex login for Hermes\n" + "/hermes claude — use existing Claude Code login for Hermes\n" + "/hermes status — show Hermes wrapper status\n" "/agency — open the Mini App\n" "/miniapp — open the Mini App\n" "/live — live-view URL of the active browser\n" @@ -6573,6 +6760,33 @@ def handle(self, msg: dict) -> None: return self._cmd_agent(key, chat_id, mid, thread_id, AGENT_CLAUDE) return + if cmd == "/hermes": + action = arg.strip().lower() + if action == "status": + self._cmd_hermes_status(chat_id, mid, thread_id) + return + if action in ("auto", "setup"): + self._cmd_hermes_provider(key, chat_id, mid, thread_id, sender, owner, "auto") + return + if action in ("codex", "openai", "openai-codex"): + self._cmd_hermes_provider(key, chat_id, mid, thread_id, sender, owner, "codex") + return + if action in ("claude", "claude-code", "anthropic"): + self._cmd_hermes_provider(key, chat_id, mid, thread_id, sender, owner, "claude") + return + if action in ("login", "auth"): + _set_agent_for(key, AGENT_HERMES, self.state) + self.send( + chat_id, + "Hermes login is local to this box. Use `/terminal hermes login` " + "or `/terminal bux-hermes login` to complete it as the `bux` user.", + reply_to=mid, + thread_id=thread_id, + markdown=True, + ) + return + self._cmd_agent(key, chat_id, mid, thread_id, AGENT_HERMES) + return if cmd == "/version": self._cmd_version(chat_id, mid, thread_id) return @@ -6906,7 +7120,7 @@ def _cmd_agent( if not arg: self.send( chat_id, - f"This topic is using `{current}`.\n\nUse `/claude` or `/codex` " + f"This topic is using `{current}`.\n\nUse `/claude`, `/codex`, or `/hermes` " "to switch.", reply_to=reply_to, thread_id=thread_id, @@ -7535,6 +7749,126 @@ def _send_login_picker( reply_markup=_login_picker_reply_markup(), ) + def _cmd_hermes_status( + self, + chat_id: int, + reply_to: int | None, + thread_id: int, + ) -> None: + wrapper = self._which_hermes() + if not wrapper: + self.send( + chat_id, + "Hermes wrapper is not installed. Run " + "`WITH_HERMES=1 sudo /opt/bux/repo/agent/bootstrap.sh`.", + reply_to=reply_to, + thread_id=thread_id, + markdown=True, + ) + return + try: + r = subprocess.run( + ["sudo", "-u", "bux", "-H", wrapper, "status"], + capture_output=True, + text=True, + timeout=15, + cwd=str(WORKSPACE), + ) + out = ((r.stdout or "") + (r.stderr or "")).strip() + if not out: + out = f"Hermes wrapper present at `{wrapper}` (rc={r.returncode})." + self.send( + chat_id, + out, + reply_to=reply_to, + thread_id=thread_id, + markdown=True, + ) + except Exception as e: + self.send( + chat_id, + f"Hermes status failed: {e}", + reply_to=reply_to, + thread_id=thread_id, + ) + + def _cmd_hermes_provider( + self, + key: LaneKey, + chat_id: int, + reply_to: int | None, + thread_id: int, + sender: dict, + owner: dict | None, + provider: str, + ) -> None: + if not _is_owner(sender, owner): + self.send( + chat_id, + "❌ Hermes provider setup is owner-only.", + reply_to=reply_to, + thread_id=thread_id, + markdown=True, + ) + return + wrapper = self._which_hermes() + if not wrapper: + self.send( + chat_id, + "Hermes wrapper is not installed. Run " + "`WITH_HERMES=1 sudo /opt/bux/repo/agent/bootstrap.sh`.", + reply_to=reply_to, + thread_id=thread_id, + markdown=True, + ) + return + + actions = { + "auto": ("configure-auto", "an existing coding-agent login"), + "codex": ("configure-codex", "OpenAI Codex"), + "claude": ("configure-claude", "Claude Code"), + } + action, label = actions.get(provider, actions["auto"]) + try: + r = subprocess.run( + ["sudo", "-u", "bux", "-H", wrapper, action], + capture_output=True, + text=True, + timeout=45, + cwd=str(WORKSPACE), + ) + except Exception as e: + self.send( + chat_id, + f"Hermes {label} setup failed: {e}", + reply_to=reply_to, + thread_id=thread_id, + ) + return + + out = ((r.stdout or "") + (r.stderr or "")).strip() + if r.returncode != 0: + self.send( + chat_id, + f"Hermes {label} setup failed.\n\n{out}", + reply_to=reply_to, + thread_id=thread_id, + markdown=True, + ) + return + + _set_agent_for(key, AGENT_HERMES, self.state) + message = f"Hermes is now using {label}." + if out: + message += f"\n\n{out}" + self.send( + chat_id, + message, + reply_to=reply_to, + thread_id=thread_id, + markdown=True, + ) + def _cmd_claude_login( self, chat_id: int, diff --git a/agent/test_telegram_bot.py b/agent/test_telegram_bot.py index b2e1d6e..dc6897d 100644 --- a/agent/test_telegram_bot.py +++ b/agent/test_telegram_bot.py @@ -6,6 +6,7 @@ import tempfile import threading import time +import types import unittest from pathlib import Path from unittest import mock @@ -1230,6 +1231,131 @@ def finish_goal_start() -> None: enqueue.assert_not_called() +class HermesRoutingTest(unittest.TestCase): + def test_bux_default_agent_routes_unbound_lane_to_hermes(self) -> None: + state = {"offset": 0, "agents": {}, "codex_settings": {}, "owners": {}} + with ( + mock.patch.dict(os.environ, {"BUX_DEFAULT_AGENT": "hermes"}, clear=False), + mock.patch.object(telegram_bot, "_is_agent_authed", return_value=False), + ): + self.assertEqual(telegram_bot._agent_for((100, 25), state), "hermes") + + def test_explicit_lane_binding_overrides_bux_default_agent(self) -> None: + state = { + "offset": 0, + "agents": {"100_25": "codex"}, + "codex_settings": {}, + "owners": {}, + } + with mock.patch.dict(os.environ, {"BUX_DEFAULT_AGENT": "hermes"}, clear=False): + self.assertEqual(telegram_bot._agent_for((100, 25), state), "codex") + + def test_bux_default_agent_can_be_read_from_tg_env(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + tg_env = Path(tmp) / "tg.env" + tg_env.write_text("BUX_DEFAULT_AGENT=hermes\n", encoding="utf-8") + state = {"offset": 0, "agents": {}, "codex_settings": {}, "owners": {}} + with ( + mock.patch.object(telegram_bot, "TG_ENV", tg_env), + mock.patch.dict(os.environ, {}, clear=True), + mock.patch.object(telegram_bot, "_is_agent_authed", return_value=False), + ): + self.assertEqual(telegram_bot._agent_for((100, 25), state), "hermes") + + def test_hermes_lane_bypasses_claude_codex_login_gate(self) -> None: + bot = telegram_bot.Bot.__new__(telegram_bot.Bot) + bot.state = { + "offset": 0, + "agents": {"100_25": "hermes"}, + "codex_settings": {}, + "owners": {}, + } + called: list[str] = [] + bot._send_login_picker = lambda *_args, **_kwargs: called.append("picker") # type: ignore[method-assign] + bot._run_hermes = lambda *_args, **_kwargs: called.append("hermes") # type: ignore[method-assign] + fake_agency_db = types.SimpleNamespace( + conn=lambda: object(), + pop_refine_context_for_thread=lambda _db, _thread_id: None, + ) + + with ( + mock.patch.dict(sys.modules, {"agency_db": fake_agency_db}), + mock.patch.object(telegram_bot, "_login_status_cached", return_value=False), + ): + bot.run_task((100, 25), "hello", 55, sender={"user_id": "1"}) + + self.assertEqual(called, ["hermes"]) + + def test_missing_hermes_wrapper_reports_clear_error(self) -> None: + bot = telegram_bot.Bot.__new__(telegram_bot.Bot) + bot.state = {"offset": 0, "agents": {}, "codex_settings": {}, "owners": {}} + sent: list[str] = [] + bot.send = lambda _chat, text, **_kwargs: sent.append(text) # type: ignore[method-assign] + bot.react = lambda *_args, **_kwargs: None # type: ignore[method-assign] + + with mock.patch.object(telegram_bot.Bot, "_which_hermes", return_value=None): + bot._run_hermes((100, 25), "hello", 55, sender={"user_id": "1"}) + + self.assertIn("Hermes is not configured", sent[0]) + + def test_hermes_visible_text_accepts_plain_and_json_lines(self) -> None: + self.assertEqual(telegram_bot.Bot._hermes_visible_text("hello\n"), "hello") + self.assertEqual( + telegram_bot.Bot._hermes_visible_text('{"text": "hello json"}\n'), + "hello json", + ) + + def test_hermes_codex_provider_setup_switches_lane(self) -> None: + bot = telegram_bot.Bot.__new__(telegram_bot.Bot) + bot.state = {"offset": 0, "agents": {}, "codex_settings": {}, "owners": {}} + sent: list[str] = [] + bot.send = lambda _chat, text, **_kwargs: sent.append(text) # type: ignore[method-assign] + + completed = types.SimpleNamespace( + returncode=0, + stdout="Hermes configured for OpenAI Codex (gpt-5.5).\n", + stderr="", + ) + with ( + mock.patch.object(telegram_bot.Bot, "_which_hermes", return_value="/usr/local/bin/bux-hermes"), + mock.patch.object(telegram_bot.subprocess, "run", return_value=completed) as run, + mock.patch.object(telegram_bot, "save_state"), + ): + bot._cmd_hermes_provider( + (100, 25), + 100, + 55, + 25, + sender={"user_id": "1"}, + owner={"user_id": "1"}, + provider="codex", + ) + + self.assertEqual(bot.state["agents"]["100_25"], "hermes") + self.assertEqual(run.call_args.args[0][-1], "configure-codex") + self.assertIn("OpenAI Codex", sent[0]) + + def test_hermes_provider_setup_is_owner_only(self) -> None: + bot = telegram_bot.Bot.__new__(telegram_bot.Bot) + bot.state = {"offset": 0, "agents": {}, "codex_settings": {}, "owners": {}} + sent: list[str] = [] + bot.send = lambda _chat, text, **_kwargs: sent.append(text) # type: ignore[method-assign] + + with mock.patch.object(telegram_bot.subprocess, "run") as run: + bot._cmd_hermes_provider( + (100, 25), + 100, + 55, + 25, + sender={"user_id": "2"}, + owner={"user_id": "1"}, + provider="codex", + ) + + run.assert_not_called() + self.assertIn("owner-only", sent[0]) + + class MiniAppLaunchTest(unittest.TestCase): def test_public_url_can_be_read_from_tg_env_when_process_env_is_stale(self) -> None: with tempfile.TemporaryDirectory() as tmp: diff --git a/docs/hermes-bux-implementation-plan.md b/docs/hermes-bux-implementation-plan.md new file mode 100644 index 0000000..ffd8831 --- /dev/null +++ b/docs/hermes-bux-implementation-plan.md @@ -0,0 +1,518 @@ +# Hermes on Bux Implementation Plan + +## Objective + +Add Hermes at the Bux layer first, before BuxFather or cloud provisioning selects +it by default. + +Success means a normal Browser Use Box can: + +1. Install or discover a Hermes runtime as the `bux` user. +2. Preserve the user's existing Hermes subscription and local credentials. +3. Add Bux operating context to Hermes without overwriting user-specific Hermes + context. +4. Run Hermes from Telegram as a third lane agent beside Claude and Codex. +5. Use the existing Browser Use Box browser, workspace, Telegram routing, and + helper scripts. +6. Leave cloud/BuxFather as a later thin selector of the default agent. + +## Design Principles + +- Bux owns installation and runtime. Cloud should not know how Hermes works. +- Hermes auth is local. Do not move Hermes credentials into cloud or `/etc/bux`. +- The installer must be idempotent and conservative. Existing Hermes state wins. +- The Telegram bot should call a Bux wrapper, not the raw Hermes binary. +- Bux context should be versioned in the repo, while personal context stays + gitignored. +- BuxFather should only pass `default_agent=hermes` after Bux can run Hermes + reliably on its own. + +## Current Bux Hierarchy + +### First Install + +`install.sh` sets up a fresh machine: + +- system packages +- Node, Claude Code, Codex, browser harness, ttyd +- `/home/bux/CLAUDE.md` +- `/home/bux/AGENTS.md -> /home/bux/CLAUDE.md` +- helper scripts in `/usr/local/bin` +- systemd units +- optional `/etc/bux/tg.env` + +Hermes first-install work belongs here only if it is needed on brand-new boxes. + +### Update / Self-Heal + +`agent/bootstrap.sh` runs on existing boxes after `/update` and boot-time pulls. +It re-links helpers, re-applies systemd units, refreshes browser harness, and +self-heals missing Codex installs. + +Hermes update-time work belongs here too. Any new Hermes helper, context file, +or systemd unit must be re-asserted here so older boxes catch up. + +### Runtime Agent Router + +`agent/telegram_bot.py` maps each Telegram lane to an agent. Today: + +- `claude` +- `codex` + +Hermes should become a third lane agent here: + +- `hermes` + +### Cloud Control Agent + +`agent/box_agent.py` receives cloud WebSocket commands. It should stay mostly +out of Hermes until cloud needs to set the default Telegram agent during +`tg_install`. + +## Target Files + +Add these files in `~/Projects/bux`: + +```text +agent/HERMES.md +agent/install-hermes +agent/bux-hermes +docs/hermes-bux-implementation-plan.md +``` + +Later, once runtime is proven: + +```text +agent/telegram_bot.py +install.sh +agent/bootstrap.sh +agent/box_agent.py +agent/test_telegram_bot.py +``` + +Optional only if Hermes needs a daemon: + +```text +agent/bux-hermes.service +``` + +## Runtime Shape + +The Telegram bot should run: + +```text +telegram_bot.py -> /usr/local/bin/bux-hermes -> hermes +``` + +`bux-hermes` is the stable Bux-owned boundary. It should: + +- run as user `bux` +- set `HOME=/home/bux` +- set the Bux PATH: + `/home/bux/.local/bin:/home/bux/.npm-global/bin:/usr/local/bin:/usr/bin:/bin` +- source `/home/bux/.claude/browser.env` if present +- keep `TG_CHAT_ID`, `TG_THREAD_ID`, `TG_USER_ID`, `TG_USERNAME`, + `TG_FROM_NAME`, `TG_OWNER_ID`, and `TG_IS_OWNER` from the bot environment +- set `BUX_HERMES_SOUL=/home/bux/.hermes/SOUL.md` +- execute the real Hermes binary + +The raw Hermes binary path should be configurable: + +```text +HERMES_BIN=/home/bux/.local/bin/hermes +``` + +If `HERMES_BIN` is unset, the wrapper should search: + +```text +/home/bux/.local/bin/hermes +/home/bux/.npm-global/bin/hermes +/usr/local/bin/hermes +/usr/bin/hermes +``` + +This lets Bux support either a uv/pipx install, npm install, or a pre-existing +Hermes binary without baking in one package manager too early. + +## Hermes Context Model + +The user mentioned adding the Bux thing to Hermes' `SOUL.md`. Do that, but make +it safe. + +Use three layers: + +```text +agent/HERMES.md + Public Bux doctrine for Hermes. Tracked in git. + +private/hermes/soul.local.md + Optional per-box local overlay. Gitignored. + +/home/bux/.hermes/SOUL.md + Generated final file consumed by Hermes. +``` + +`agent/install-hermes` should generate `/home/bux/.hermes/SOUL.md` with managed +markers: + +```text + +contents of agent/HERMES.md + + + +contents of private/hermes/soul.local.md, if present + +``` + +Rules: + +- Never delete text outside the managed blocks. +- If `/home/bux/.hermes/SOUL.md` already exists without Bux markers, preserve it + by appending the Bux managed block below the existing content. +- Make a one-time backup before first modification: + `/home/bux/.hermes/SOUL.md.pre-bux`. +- If Hermes supports includes or multiple context files, prefer includes over + rewriting `SOUL.md`. Until that is confirmed, use the marker strategy. + +`agent/HERMES.md` should be short and Bux-specific: + +- You are Hermes running inside Browser Use Box. +- Default workspace is `/home/bux`. +- Use Browser Use Cloud through `browser-harness-js`. +- Source `/home/bux/.claude/browser.env` before browser work. +- Use `tg-send` for background replies. +- Respect Telegram lane context via `TG_CHAT_ID` and `TG_THREAD_ID`. +- Do not install local Chrome or Playwright browsers. +- Put user-private, durable context in `private/` or Hermes' own memory, not in + repo-tracked files. + +Do not copy the full `agent/CLAUDE.md` into Hermes' soul. Hermes needs a focused +adapter document, not all Claude-specific commands and assumptions. + +## Installer Contract + +Create `agent/install-hermes`. + +Inputs: + +```text +WITH_HERMES=1 # default once stable; during rollout it can default to 0 +HERMES_BIN=... # optional explicit binary path +HERMES_INSTALL_CMD=... # optional operator-provided install command +HERMES_INSTALL_MODE=detect # detect, npm, uv, skip +``` + +Behavior: + +1. Exit early if `WITH_HERMES=0`. +2. Ensure `/home/bux/.hermes` exists and is owned by `bux:bux`. +3. If a Hermes binary already exists, do not reinstall it. +4. If no binary exists: + - if `HERMES_INSTALL_CMD` is set, run it as `bux` + - else if `HERMES_INSTALL_MODE` is known, run that installer as `bux` + - else warn and continue +5. Generate or update `/home/bux/.hermes/SOUL.md`. +6. Symlink `agent/bux-hermes` to `/usr/local/bin/bux-hermes`. +7. Run a non-destructive status check if possible: + `bux-hermes --version` or `hermes --version`. +8. Never run `hermes login` automatically. + +Subscription/auth rule: + +Existing Hermes subscription state under `/home/bux` must be preserved. The +installer may detect and report auth state, but it must not rotate, delete, or +replace credentials. + +## First Install Wiring + +In `install.sh`: + +1. Add optional env documentation near `WITH_ZTK`: + + ```text + WITH_HERMES - install/configure Hermes support + HERMES_BIN - optional existing Hermes binary path + HERMES_INSTALL_CMD - optional custom install command, run as bux + ``` + +2. Set a rollout default: + + ```bash + WITH_HERMES="${WITH_HERMES:-0}" + ``` + + Start with `0` until the Hermes package/install source is confirmed. Flip to + `1` after staging proves stable. + +3. After the Codex install block, call: + + ```bash + if [ "$WITH_HERMES" = "1" ]; then + /bin/bash "$REPO_DIR/agent/install-hermes" || warn 'hermes install failed' + fi + ``` + +Do not fail the whole Bux install if Hermes fails during the first rollout. +Claude and the browser must still come up. + +## Bootstrap Wiring + +In `agent/bootstrap.sh`: + +1. Re-link the wrapper on every update: + + ```bash + ln -sfn "$REPO_DIR/agent/bux-hermes" /usr/local/bin/bux-hermes + ``` + +2. Re-run the Hermes installer when enabled: + + ```bash + if [ "${WITH_HERMES:-0}" = "1" ] || [ -x /usr/local/bin/bux-hermes ]; then + /bin/bash "$AGENT_DIR/install-hermes" || \ + echo "bootstrap: hermes install/update failed (non-fatal)" >&2 + fi + ``` + +The second condition matters: once a box has Hermes enabled, `/update` should +keep its wrapper and Bux soul current even if the environment variable is not +present on a later bootstrap. + +## Telegram Integration + +In `agent/telegram_bot.py`: + +1. Add constants: + + ```python + AGENT_HERMES = "hermes" + AGENTS = (AGENT_CLAUDE, AGENT_CODEX, AGENT_HERMES) + ``` + +2. Add `/hermes` to `BOT_COMMANDS`. + +3. Add command handling next to `/claude` and `/codex`: + + - `/hermes`: switch current lane to Hermes + - `/hermes status`: run `bux-hermes --version` and auth/status check if + Hermes supports it + - `/hermes login`: only if Hermes has a headless or terminal-safe login + +4. Dispatch: + + ```python + if agent == AGENT_CODEX: + self._run_codex(...) + elif agent == AGENT_HERMES: + self._run_hermes(...) + else: + self._run_claude(...) + ``` + +5. Implement `_run_hermes(...)` using the existing `StreamingMessage` pattern. + +Minimum first implementation: + +- call `/usr/local/bin/bux-hermes` +- pass the prompt as a final argument or stdin, depending on Hermes CLI shape +- use `cwd=/home/bux` +- pass the same `_build_env(...)` output used for Claude and Codex +- stream stdout into Telegram +- capture stderr to a temp file +- surface install/auth errors clearly + +If Hermes has no JSON streaming mode, plain stdout streaming is acceptable for +the first pass. Add structured event parsing later. + +## Default Agent Config + +Use `BUX_DEFAULT_AGENT`, not `TG_DEFAULT_AGENT`. + +Reason: + +- The default is a Bux runtime preference. +- Telegram is only one input surface. +- The same setting could later apply to miniapp or shell-created lanes. + +Storage: + +```text +/etc/bux/tg.env +BUX_DEFAULT_AGENT=hermes +``` + +`telegram_bot.py` should read it from the existing systemd environment or from +`/etc/bux/tg.env` via `_read_kv(TG_ENV)`. + +Resolution: + +1. Explicit lane binding in `/etc/bux/tg-state.json` wins. +2. `BUX_DEFAULT_AGENT` wins for unbound lanes if valid. +3. Auth/install-aware fallback: + - if default is Hermes but `bux-hermes` is missing, show a Hermes install + error + - do not silently run Claude for a lane explicitly defaulted to Hermes +4. If no default is set, keep current behavior: prefer authed Claude, then + authed Codex, then Claude. + +This lets BuxFather later pass `BUX_DEFAULT_AGENT=hermes` during `tg_install` +without changing how Hermes itself is installed. + +## Cloud/BuxFather Boundary Later + +Only after the Bux-level runtime works: + +1. Cloud adds `agent_kind` to the BuxFather pending `/newbox` payload. +2. Managed-bot flow passes `default_agent="hermes"` to `install_telegram(...)`. +3. `install_telegram(...)` includes `default_agent` or `bux_default_agent` in + the WebSocket `tg_install` payload. +4. `box_agent.py` writes `BUX_DEFAULT_AGENT=hermes` into `/etc/bux/tg.env`. + +Cloud should not install Hermes, store Hermes credentials, or track Hermes auth +state in phase one. + +## Phased Work Plan + +### Phase 1: Bux Hermes Installer And Context + +Files: + +- `agent/HERMES.md` +- `agent/install-hermes` +- `agent/bux-hermes` +- `install.sh` +- `agent/bootstrap.sh` + +Done when: + +- running `WITH_HERMES=1 sudo ./install.sh` creates or preserves + `/home/bux/.hermes/SOUL.md` +- existing `SOUL.md` content survives +- Bux-managed block updates on rerun +- `/usr/local/bin/bux-hermes` exists +- no Hermes login is attempted automatically + +### Phase 2: Local Wrapper Smoke Test + +Files: + +- `agent/bux-hermes` +- maybe `agent/install-hermes` + +Done when: + +- `sudo -iu bux bux-hermes --version` works when Hermes is installed +- wrapper exits with a useful error when Hermes is missing +- wrapper sees `BU_CDP_WS` after browser keeper writes browser env +- wrapper runs from `/home/bux` + +### Phase 3: Telegram Hermes Lane + +Files: + +- `agent/telegram_bot.py` +- `agent/test_telegram_bot.py` + +Done when: + +- `/hermes` switches only the current lane +- normal prompts in that lane call `_run_hermes(...)` +- missing Hermes binary produces a clear Telegram message +- Claude and Codex behavior is unchanged + +### Phase 4: Bux Default Agent + +Files: + +- `agent/telegram_bot.py` +- `agent/box_agent.py` later + +Done when: + +- `BUX_DEFAULT_AGENT=hermes` routes new unbound lanes to Hermes +- explicit `/claude` and `/codex` still override the default +- invalid default is ignored with a log warning or visible status + +### Phase 5: Cloud Selector + +Files in `~/Projects/cloud`: + +- `backend/common/services/buxfather/nonce.py` +- `backend/common/services/buxfather/flows/newbox.py` +- `backend/common/services/buxfather/flows/managed_bot.py` +- `backend/app/endpoints/api/v3/boxes/services.py` + +Files in `~/Projects/bux`: + +- `agent/box_agent.py` + +Done when: + +- BuxFather can create a normal Box whose child bot starts with + `BUX_DEFAULT_AGENT=hermes` +- cloud never touches Hermes credentials +- old boxes without default-agent support still fall back safely + +## Test Plan + +Shell/script tests: + +- `bash -n agent/install-hermes` +- `bash -n agent/bux-hermes` +- `bash -n install.sh` +- `bash -n agent/bootstrap.sh` + +Installer behavior tests, preferably with a temp HOME or fixture: + +- no existing `SOUL.md` +- existing unmarked `SOUL.md` +- existing marked `SOUL.md` +- `private/hermes/soul.local.md` present +- missing Hermes binary +- explicit `HERMES_BIN` + +Telegram tests: + +- `AGENTS` includes Hermes +- `/agent hermes` or `/hermes` binds only current lane +- missing wrapper error path +- `BUX_DEFAULT_AGENT=hermes` controls unbound lane +- explicit lane binding overrides `BUX_DEFAULT_AGENT` + +Manual staging: + +1. Provision or use a staging Box. +2. Install Hermes manually as `bux` using the real subscription flow. +3. Run `WITH_HERMES=1 sudo ./install.sh` or `sudo agent/bootstrap.sh`. +4. Confirm subscription state still works. +5. Confirm `/home/bux/.hermes/SOUL.md` contains Bux managed context. +6. Confirm `source ~/.claude/browser.env && bux-hermes ...` can use the Browser + Use browser. +7. Enable Telegram and switch a topic with `/hermes`. +8. Send a browser task and confirm Hermes uses the existing Box browser. + +## Main Risks + +- Hermes' real CLI contract is unknown. Keep `bux-hermes` as the adapter so only + one file changes when the contract is known. +- Hermes may already manage `SOUL.md` itself. Use markers and backups, or switch + to includes if Hermes supports them. +- Hermes may need an interactive login. Do not automate it until the exact flow + is known; use `/terminal hermes login` as the fallback. +- Defaulting a lane to Hermes should not hide install failures by silently + routing to Claude. For an explicit Hermes default, show a clear Hermes error. + +## Recommendation + +Implement this in Bux first: + +1. `agent/install-hermes` +2. `agent/bux-hermes` +3. `agent/HERMES.md` +4. Telegram `/hermes` +5. `BUX_DEFAULT_AGENT` +6. BuxFather/cloud pass-through + +That order makes Hermes real on the Box before cloud markets or provisions it as +a Hermes Box. diff --git a/docs/hermes-local-smoke.md b/docs/hermes-local-smoke.md new file mode 100644 index 0000000..634c190 --- /dev/null +++ b/docs/hermes-local-smoke.md @@ -0,0 +1,51 @@ +# Hermes Local Smoke Test + +This exercises the Bux Hermes integration without touching a production Box, +systemd unit, `/home/bux`, `/etc/bux`, or a real Hermes account. + +Run from the Bux repo root: + +```bash +tmp=$(mktemp -d) +mkdir -p "$tmp/home/.claude" "$tmp/home/.hermes" "$tmp/bin" + +printf 'BU_CDP_WS=local-test-ws\nBU_BROWSER_ID=local-test-browser\nBU_PROFILE_ID=local-test-profile\n' \ + > "$tmp/home/.claude/browser.env" +printf 'existing hermes soul\n' > "$tmp/home/.hermes/SOUL.md" + +cat > "$tmp/hermes" <<'EOF' +#!/usr/bin/env bash +set -euo pipefail +if [ "${1:-}" = "--version" ]; then echo "fake-hermes 0.1"; exit 0; fi +if [ "${1:-}" = "status" ]; then echo "fake-hermes status ok"; exit 0; fi +if [ "${1:-}" = "--oneshot" ]; then + shift + echo "fake-hermes prompt: $*" + unset BU_BROWSER_ID BU_CDP_WS BU_PROFILE_ID + browser-harness -c 'ensure_real_tab(); print(page_info()["title"])' + exit 0 +fi +echo "fake-hermes args: $*" +EOF +chmod +x "$tmp/hermes" + +BUX_HOME="$tmp/home" BUX_BIN_DIR="$tmp/bin" HERMES_BIN="$tmp/hermes" \ + ./agent/install-hermes + +BUX_HOME="$tmp/home" HERMES_BIN="$tmp/hermes" \ + ./agent/bux-hermes --bux-env-check + +BUX_HOME="$tmp/home" HERMES_BIN="$tmp/hermes" \ + ./agent/bux-hermes run 'open the browser smoke page' +``` + +Expected evidence: + +- `install-hermes` reports that it linked `bux-hermes` and updated + `$tmp/home/.hermes/SOUL.md`. +- `$tmp/home/.hermes/SOUL.md` still contains `existing hermes soul` plus the + Bux managed block. +- `--bux-env-check` prints the temp `BUX_HOME`, `BUX_HERMES_SOUL`, + `BU_CDP_WS`, `BU_BROWSER_ID`, and `BU_PROFILE_ID`. +- The final wrapper call prints the fake Hermes prompt and a Browser Use page + title from `browser-harness`. diff --git a/install.sh b/install.sh index 871176b..c24a6db 100755 --- a/install.sh +++ b/install.sh @@ -25,6 +25,12 @@ # user is and can supply this directly. # TG_OWNER_USERNAME — Optional companion to TG_OWNER_ID (display only). # TG_OWNER_NAME — Optional companion to TG_OWNER_ID (display only). +# BUX_DEFAULT_AGENT — Optional default Telegram lane agent +# (claude, codex, hermes). +# WITH_HERMES — configure Hermes support (default 0 while the +# Hermes package source is still operator-defined). +# HERMES_BIN — Optional existing Hermes binary path. +# HERMES_INSTALL_CMD — Optional install command, run as the bux user. # WITH_ZTK — install ztk (default 1; set to 0 to skip). ztk is a # Zig CLI that compresses long Bash tool outputs # (git diff, ls, test runners) before they hit @@ -36,6 +42,7 @@ set -euo pipefail BUX_REF="${BUX_REF:-main}" WITH_ZTK="${WITH_ZTK:-1}" +WITH_HERMES="${WITH_HERMES:-0}" # --- pinned versions ------------------------------------------------------- # Keep all third-party version pins together so bumping is a single edit. @@ -579,6 +586,16 @@ fi chmod 0644 "$CODEX_CONFIG" ' +# --- Hermes support (optional third lane agent) --------------------------- +# Hermes' package/source is still operator-defined, so this is opt-in for now. +# The installer is conservative: it preserves existing Hermes auth/subscription +# state under /home/bux and only installs the Bux wrapper + context. +if [ "$WITH_HERMES" = "1" ]; then + say 'configuring Hermes support for bux' + /bin/bash "$REPO_DIR/agent/install-hermes" \ + || warn 'hermes install failed (non-fatal — /hermes will report the issue)' +fi + # --- login banner: print live browser URL on each ssh login --------------- if ! grep -q 'BU_BROWSER_LIVE_URL' /home/bux/.profile 2>/dev/null; then cat >> /home/bux/.profile <<'PROFILE' @@ -627,6 +644,16 @@ EOF [ -n "${TG_OWNER_USERNAME:-}" ] && printf 'TG_OWNER_USERNAME=%s\n' "$TG_OWNER_USERNAME" >> /etc/bux/tg.env [ -n "${TG_OWNER_NAME:-}" ] && printf 'TG_OWNER_NAME=%s\n' "$TG_OWNER_NAME" >> /etc/bux/tg.env fi + case "${BUX_DEFAULT_AGENT:-}" in + claude|codex|hermes) + printf 'BUX_DEFAULT_AGENT=%s\n' "$BUX_DEFAULT_AGENT" >> /etc/bux/tg.env + ;; + "") + ;; + *) + warn "ignoring invalid BUX_DEFAULT_AGENT=$BUX_DEFAULT_AGENT" + ;; + esac # 0o640 root:bux so the tg-send helper can read the bot token from # `at` jobs running as bux. Worst-case leak: someone with bux access # can call sendMessage, but only the bound chat receives it — they