feat(hanzo-cli): add bot node agent commands with browser OAuth login

Add install, login, logout, run, stop subcommands to `hanzo bot` for
managing local bot agent nodes that connect to gw.hanzo.bot. Browser
OAuth flow authenticates against hanzobot-client-id with dedicated token
storage at ~/.hanzo/bot/token.json. Password grant fallback via
--no-browser flag.

Normalize env var lookup: IAM_ canonical prefix with HANZO_IAM_ accepted
as backwards-compatible fallback via _env() helper in both auth.py and
bot/commands.py.
This commit is contained in:
Zoo Queen
2026-02-22 14:49:04 -08:00
parent 0863631ac1
commit 9ab8514f12
2 changed files with 633 additions and 21 deletions
+16 -19
View File
@@ -1,7 +1,7 @@
"""Authentication and token management for Hanzo CLI.
Credential chain:
1. HANZO_IAM_CLIENT_ID + HANZO_IAM_CLIENT_SECRET env vars
1. IAM_CLIENT_ID + IAM_CLIENT_SECRET env vars
2. ~/.hanzo/auth/token.json from `hanzo login`
3. Exit with help message
"""
@@ -56,37 +56,36 @@ def _clear_token() -> None:
TOKEN_FILE.unlink()
def _env(name: str) -> str:
"""Read env var, accepting both IAM_ and legacy HANZO_IAM_ prefixes."""
return os.getenv(name) or os.getenv(f"HANZO_{name}") or ""
def _iam_url() -> str:
return os.getenv("HANZO_IAM_URL") or os.getenv("IAM_URL") or DEFAULT_IAM_URL
return _env("IAM_URL") or DEFAULT_IAM_URL
def _iam_org() -> str:
return os.getenv("HANZO_IAM_ORG") or os.getenv("IAM_ORG") or DEFAULT_ORG
return _env("IAM_ORG") or DEFAULT_ORG
def _iam_app() -> str:
return os.getenv("HANZO_IAM_APP") or os.getenv("IAM_APP") or DEFAULT_APP
return _env("IAM_APP") or DEFAULT_APP
def _iam_client_id() -> str:
return (
os.getenv("IAM_CLIENT_ID")
or os.getenv("HANZO_IAM_CLIENT_ID")
or DEFAULT_CLIENT_ID
)
return _env("IAM_CLIENT_ID") or DEFAULT_CLIENT_ID
def get_client(ctx: click.Context | None = None) -> IAMClient:
"""Build an IAMClient using the credential chain.
1. Env vars HANZO_IAM_CLIENT_ID + HANZO_IAM_CLIENT_SECRET
1. Env vars IAM_CLIENT_ID + IAM_CLIENT_SECRET
2. Stored bearer token from `hanzo login`
3. Exit with instructions
"""
client_id = os.getenv("IAM_CLIENT_ID") or os.getenv("HANZO_IAM_CLIENT_ID") or ""
client_secret = (
os.getenv("IAM_CLIENT_SECRET") or os.getenv("HANZO_IAM_CLIENT_SECRET") or ""
)
client_id = _env("IAM_CLIENT_ID")
client_secret = _env("IAM_CLIENT_SECRET")
if client_id and client_secret:
config = IAMConfig(
@@ -114,8 +113,8 @@ def get_client(ctx: click.Context | None = None) -> IAMClient:
)
click.echo(
"Not authenticated. Run 'hanzo login' or set HANZO_IAM_CLIENT_ID"
" + HANZO_IAM_CLIENT_SECRET environment variables.",
"Not authenticated. Run 'hanzo login' or set IAM_CLIENT_ID"
" + IAM_CLIENT_SECRET environment variables.",
err=True,
)
sys.exit(1)
@@ -310,9 +309,7 @@ def password_login(
org = _iam_org()
app = _iam_app()
client_id = _iam_client_id()
client_secret = (
os.getenv("IAM_CLIENT_SECRET") or os.getenv("HANZO_IAM_CLIENT_SECRET") or ""
)
client_secret = _env("IAM_CLIENT_SECRET")
if not username:
username = click.prompt("Username or email")
+617 -2
View File
@@ -1,7 +1,7 @@
"""Hanzo CLI — Bot gateway management.
Manage the Hanzo bot-gateway deployment via PaaS API.
Defaults to the Hanzo production bot container.
Manage the Hanzo bot-gateway deployment via PaaS API,
and run local bot agent nodes that connect to gw.hanzo.bot.
Usage:
hanzo bot status Show container + pod status
@@ -9,11 +9,21 @@ Usage:
hanzo bot deploy Trigger redeploy
hanzo bot env [KEY=VAL ...] Show/set env vars
hanzo bot events Show container events
hanzo bot install Install @hanzo/bot locally
hanzo bot run [--daemon] Run local bot node agent
hanzo bot stop Stop local bot node daemon
"""
from __future__ import annotations
import http.server
import json
import os
import shutil
import signal
import subprocess
import sys
from pathlib import Path
from typing import Any
import click
@@ -25,6 +35,22 @@ from hanzo_cli.paas.context import resolve
console = Console()
# ── local node constants ─────────────────────────────────────────────
HANZO_BIN = Path.home() / ".hanzo" / "bin"
BOT_PID_FILE = Path.home() / ".hanzo" / "bot" / "node.pid"
BOT_LOG_FILE = Path.home() / ".hanzo" / "bot" / "node.log"
DEFAULT_GATEWAY_HOST = "gw.hanzo.bot"
DEFAULT_GATEWAY_PORT = 443
NPM_PACKAGE = "@hanzo/bot"
BOT_IAM_CLIENT_ID = "hanzobot-client-id"
BOT_IAM_SERVER_URL = "https://hanzo.id"
BOT_IAM_ORG = "hanzo"
BOT_IAM_APP = "app-hanzobot"
BOT_CALLBACK_PORT = 8398
BOT_CALLBACK_PATH = "/callback"
BOT_TOKEN_FILE = Path.home() / ".hanzo" / "bot" / "token.json"
# ── defaults ──────────────────────────────────────────────────────────
# Known Hanzo production bot container context.
@@ -384,3 +410,592 @@ def bot_events(
console.print(table)
finally:
client.close()
# ── local node helpers ───────────────────────────────────────────────
def _find_bot_binary() -> str | None:
"""Search PATH and ~/.hanzo/bin for the hanzo-bot binary."""
for name in ("hanzo-bot",):
path = shutil.which(name)
if path:
return path
candidate = HANZO_BIN / name
if candidate.is_file() and os.access(candidate, os.X_OK):
return str(candidate)
return None
def _install_bot() -> bool:
"""Install @hanzo/bot via npm. Returns True on success."""
npm = shutil.which("npm") or shutil.which("pnpm")
if not npm:
console.print("[red]npm/pnpm not found. Install Node.js first:[/red]")
console.print(" curl -fsSL https://hanzo.bot/install.sh | bash")
return False
try:
console.print(f"[cyan]Installing {NPM_PACKAGE}...[/cyan]")
result = subprocess.run(
[npm, "install", "-g", NPM_PACKAGE],
check=True,
capture_output=True,
text=True,
timeout=120,
)
console.print(f"[green]Installed {NPM_PACKAGE}[/green]")
return True
except subprocess.CalledProcessError as e:
console.print(f"[red]Install failed:[/red] {e.stderr.strip()}")
return False
except subprocess.TimeoutExpired:
console.print("[red]Install timed out.[/red]")
return False
def _ensure_bot_binary() -> str:
"""Find or install the hanzo-bot binary. Returns the path or exits."""
binary = _find_bot_binary()
if binary:
return binary
console.print("[yellow]hanzo-bot not found. Installing...[/yellow]")
if _install_bot():
binary = _find_bot_binary()
if not binary:
console.print("[red]Failed to install hanzo-bot.[/red]")
console.print("Install manually: npm install -g @hanzo/bot")
console.print(" or: curl -fsSL https://hanzo.bot/install.sh | bash")
raise SystemExit(1)
return binary
def _env(name: str) -> str:
"""Read env var, accepting both IAM_ and legacy HANZO_IAM_ prefixes."""
return os.environ.get(name, "") or os.environ.get(f"HANZO_{name}", "")
def _save_bot_token(data: dict[str, Any]) -> None:
"""Save bot gateway token to disk."""
BOT_TOKEN_FILE.parent.mkdir(parents=True, exist_ok=True)
BOT_TOKEN_FILE.write_text(json.dumps(data, indent=2))
BOT_TOKEN_FILE.chmod(0o600)
def _load_bot_token() -> dict[str, Any] | None:
"""Load stored bot gateway token."""
if not BOT_TOKEN_FILE.exists():
return None
try:
import time
data = json.loads(BOT_TOKEN_FILE.read_text())
# Check expiry
exp = data.get("expires_at", 0)
if exp and exp < time.time():
return None
return data
except (json.JSONDecodeError, OSError):
return None
class _BotOAuthCallbackHandler(http.server.BaseHTTPRequestHandler):
"""HTTP handler that captures the bot OAuth callback code."""
code: str | None = None
state: str | None = None
error: str | None = None
def do_GET(self) -> None: # noqa: N802
from urllib.parse import parse_qs, urlparse
parsed = urlparse(self.path)
if parsed.path != BOT_CALLBACK_PATH:
self.send_response(404)
self.end_headers()
return
qs = parse_qs(parsed.query)
if "error" in qs:
_BotOAuthCallbackHandler.error = qs["error"][0]
self._respond("Login failed. You can close this window.", success=False)
return
_BotOAuthCallbackHandler.code = qs.get("code", [None])[0]
_BotOAuthCallbackHandler.state = qs.get("state", [None])[0]
self._respond("Bot login successful! You can close this window.")
def _respond(self, message: str, success: bool = True) -> None:
self.send_response(200)
self.send_header("Content-Type", "text/html")
self.end_headers()
accent = "#6C5CE7" if success else "#e74c3c"
body = f"""<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Hanzo Bot</title>
<style>
*{{margin:0;padding:0;box-sizing:border-box}}
body{{
font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif;
background:#0a0a0a;color:#fafafa;
display:flex;align-items:center;justify-content:center;
min-height:100vh;
}}
.card{{
text-align:center;max-width:420px;padding:48px 32px;
background:#111;border:1px solid #222;border-radius:16px;
}}
.logo{{font-size:28px;font-weight:700;letter-spacing:-.5px;margin-bottom:32px;color:#fff}}
.logo span{{color:{accent}}}
.icon{{font-size:48px;margin-bottom:20px}}
h2{{font-size:20px;font-weight:600;margin-bottom:8px;color:#fff}}
.sub{{color:#888;font-size:14px;line-height:1.5;margin-bottom:28px}}
.tagline{{
font-size:13px;color:#555;border-top:1px solid #222;
padding-top:20px;margin-top:8px;
}}
</style>
</head>
<body>
<div class="card">
<div class="logo"><span>&#x25B2;</span> hanzo bot</div>
<div class="icon">{"&#x2705;" if success else "&#x274C;"}</div>
<h2>{message}</h2>
<p class="sub">You can close this window and return to your terminal.</p>
<p class="tagline">Build something you love.</p>
</div>
</body>
</html>"""
self.wfile.write(body.encode())
def log_message(self, format: str, *args: Any) -> None:
"""Suppress default request logging."""
def _bot_browser_login() -> str:
"""Run browser OAuth login flow for the bot gateway.
Opens the user's browser to the IAM login page (hanzobot application),
starts a local HTTP server to receive the callback, exchanges the code
for tokens scoped to hanzobot-client-id.
Returns:
Access token string.
"""
import secrets
import time
import webbrowser
from hanzo_iam import IAMClient, IAMConfig
config = IAMConfig(
server_url=BOT_IAM_SERVER_URL,
client_id=BOT_IAM_CLIENT_ID,
client_secret="",
organization=BOT_IAM_ORG,
application=BOT_IAM_APP,
)
client = IAMClient(config=config)
redirect_uri = f"http://localhost:{BOT_CALLBACK_PORT}{BOT_CALLBACK_PATH}"
state = secrets.token_urlsafe(32)
auth_url = client.get_authorization_url(
redirect_uri=redirect_uri,
state=state,
scope="openid profile email",
)
# Reset handler state
_BotOAuthCallbackHandler.code = None
_BotOAuthCallbackHandler.state = None
_BotOAuthCallbackHandler.error = None
server = http.server.HTTPServer(
("127.0.0.1", BOT_CALLBACK_PORT), _BotOAuthCallbackHandler
)
server.timeout = 120
console.print(f"[cyan]Opening browser to login at {BOT_IAM_SERVER_URL}...[/cyan]")
webbrowser.open(auth_url)
console.print(
f"Waiting for callback on http://localhost:{BOT_CALLBACK_PORT}{BOT_CALLBACK_PATH}"
)
while (
_BotOAuthCallbackHandler.code is None
and _BotOAuthCallbackHandler.error is None
):
server.handle_request()
server.server_close()
if _BotOAuthCallbackHandler.error:
console.print(f"[red]Login failed:[/red] {_BotOAuthCallbackHandler.error}")
raise SystemExit(1)
if _BotOAuthCallbackHandler.state != state:
console.print("[red]State mismatch — possible CSRF attack.[/red]")
raise SystemExit(1)
code = _BotOAuthCallbackHandler.code
if not code:
console.print("[red]No authorization code received.[/red]")
raise SystemExit(1)
tokens = client.exchange_code(code=code, redirect_uri=redirect_uri)
client.close()
access_token = tokens.access_token
if not access_token:
console.print("[red]No access token in response.[/red]")
raise SystemExit(1)
_save_bot_token({
"access_token": access_token,
"refresh_token": tokens.refresh_token or "",
"id_token": tokens.id_token or "",
"expires_at": time.time() + (tokens.expires_in or 604800),
"client_id": BOT_IAM_CLIENT_ID,
"server_url": BOT_IAM_SERVER_URL,
"organization": BOT_IAM_ORG,
"application": BOT_IAM_APP,
"login_time": int(time.time()),
})
console.print("[green]Bot login successful.[/green]")
return access_token
def _bot_password_login(
username: str | None = None, password: str | None = None
) -> str:
"""Login via password grant using the hanzobot client. Returns access_token."""
import httpx
import time
if not username:
username = click.prompt("Email")
if not password:
password = click.prompt("Password", hide_input=True)
resp = httpx.post(
f"{BOT_IAM_SERVER_URL}/api/login/oauth/access_token",
data={
"grant_type": "password",
"client_id": BOT_IAM_CLIENT_ID,
"username": username,
"password": password,
"scope": "openid profile email",
},
headers={"Content-Type": "application/x-www-form-urlencoded"},
timeout=30.0,
)
data = resp.json()
access_token = data.get("access_token", "")
if not access_token:
err = (
data.get("error_description")
or data.get("msg")
or data.get("error", "unknown error")
)
console.print(f"[red]Login failed:[/red] {err}")
raise SystemExit(1)
_save_bot_token({
"access_token": access_token,
"refresh_token": data.get("refresh_token", ""),
"expires_at": time.time() + data.get("expires_in", 604800),
"client_id": BOT_IAM_CLIENT_ID,
"username": username,
"login_time": int(time.time()),
})
return access_token
def _get_iam_token() -> str:
"""Get an IAM access token for the bot gateway.
Credential chain:
1. BOT_GATEWAY_TOKEN env var
2. Stored bot token (~/.hanzo/bot/token.json)
3. IAM_CLIENT_ID + IAM_CLIENT_SECRET client credentials
4. Interactive login prompt
"""
# Check env var first
token = _env("BOT_GATEWAY_TOKEN").strip()
if token:
return token
# Try stored bot-specific token (issued by hanzobot-client-id)
bot_data = _load_bot_token()
if bot_data and bot_data.get("access_token"):
return bot_data["access_token"]
# Try client credentials grant
client_id = _env("IAM_CLIENT_ID")
client_secret = _env("IAM_CLIENT_SECRET")
if client_id and client_secret:
try:
import httpx
resp = httpx.post(
f"{BOT_IAM_SERVER_URL}/api/login/oauth/access_token",
data={
"grant_type": "client_credentials",
"client_id": client_id,
"client_secret": client_secret,
"scope": "openid profile email",
},
headers={"Content-Type": "application/x-www-form-urlencoded"},
timeout=30.0,
)
data = resp.json()
if data.get("access_token"):
return data["access_token"]
except Exception:
pass
# Interactive login — browser OAuth flow
console.print("[cyan]Login required for bot gateway.[/cyan]")
return _bot_browser_login()
def _read_pid() -> int | None:
"""Read the daemon PID from the pid file."""
if not BOT_PID_FILE.exists():
return None
try:
pid = int(BOT_PID_FILE.read_text().strip())
# Check if process is alive
os.kill(pid, 0)
return pid
except (ValueError, OSError):
BOT_PID_FILE.unlink(missing_ok=True)
return None
def _write_pid(pid: int) -> None:
"""Write daemon PID to file."""
BOT_PID_FILE.parent.mkdir(parents=True, exist_ok=True)
BOT_PID_FILE.write_text(str(pid))
# ── install ──────────────────────────────────────────────────────────
@bot.command("install")
def bot_install() -> None:
"""Install the @hanzo/bot agent locally.
Downloads and installs the hanzo-bot CLI from npm.
Requires Node.js (v22+) to be installed.
"""
binary = _find_bot_binary()
if binary:
# Show current version
try:
result = subprocess.run(
[binary, "--version"],
capture_output=True,
text=True,
timeout=10,
)
version = result.stdout.strip() if result.returncode == 0 else "unknown"
except Exception:
version = "unknown"
console.print(f"[green]hanzo-bot already installed:[/green] {binary} ({version})")
return
if _install_bot():
binary = _find_bot_binary()
if binary:
console.print(f"[green]Installed at:[/green] {binary}")
else:
raise SystemExit(1)
# ── login ─────────────────────────────────────────────────────────────
@bot.command("login")
@click.option(
"--no-browser",
is_flag=True,
help="Use password login instead of browser OAuth.",
)
def bot_login_cmd(no_browser: bool) -> None:
"""Authenticate with the bot gateway.
Opens your browser to log in via Hanzo IAM. The token is stored
at ~/.hanzo/bot/token.json and used for subsequent bot commands.
\b
Examples:
hanzo bot login Browser OAuth login
hanzo bot login --no-browser Password login
"""
if no_browser:
token = _bot_password_login()
else:
token = _bot_browser_login()
# Decode and show who we logged in as
try:
import base64
parts = token.split(".")
payload = json.loads(base64.urlsafe_b64decode(parts[1] + "=="))
name = payload.get("name", "unknown")
email = payload.get("email", "")
console.print(f"[green]Logged in as:[/green] {name} ({email})")
except Exception:
console.print("[green]Login stored.[/green]")
@bot.command("logout")
def bot_logout_cmd() -> None:
"""Clear stored bot gateway credentials."""
if BOT_TOKEN_FILE.exists():
BOT_TOKEN_FILE.unlink()
console.print("[green]Bot credentials cleared.[/green]")
else:
console.print("[yellow]No bot credentials stored.[/yellow]")
# ── run ──────────────────────────────────────────────────────────────
@bot.command("run")
@click.option(
"--host",
default=DEFAULT_GATEWAY_HOST,
help=f"Gateway host (default: {DEFAULT_GATEWAY_HOST}).",
)
@click.option(
"--port",
default=DEFAULT_GATEWAY_PORT,
type=int,
help=f"Gateway port (default: {DEFAULT_GATEWAY_PORT}).",
)
@click.option("--no-tls", is_flag=True, help="Disable TLS.")
@click.option(
"--display-name",
default=None,
help="Node display name (default: hostname).",
)
@click.option(
"--daemon",
"-d",
is_flag=True,
help="Run as background daemon.",
)
@click.option(
"--token",
default=None,
help="IAM token (default: from hanzo login).",
)
def bot_run(
host: str,
port: int,
no_tls: bool,
display_name: str | None,
daemon: bool,
token: str | None,
) -> None:
"""Run a local bot agent node connected to the gateway.
Starts a headless bot node on this machine that connects to
the Hanzo gateway (gw.hanzo.bot by default). The node appears
in the Playground dashboard at app.hanzo.bot.
\b
Examples:
hanzo bot run Run in foreground
hanzo bot run -d Run as daemon
hanzo bot run --display-name "My Mac" Custom display name
hanzo bot run --host my-gateway.example.com Custom gateway
"""
# Check for existing daemon
existing_pid = _read_pid()
if existing_pid:
console.print(
f"[yellow]Bot node already running (PID {existing_pid}).[/yellow]"
)
console.print("Use 'hanzo bot stop' to stop it first.")
raise SystemExit(1)
# Find or install hanzo-bot
binary = _ensure_bot_binary()
# Resolve IAM token
iam_token = token or _get_iam_token()
# Build command
cmd = [binary, "node", "run", "--host", host, "--port", str(port)]
if not no_tls:
cmd.append("--tls")
if display_name:
cmd.extend(["--display-name", display_name])
else:
import platform as platform_mod
cmd.extend(["--display-name", platform_mod.node()])
# Set up environment
env = os.environ.copy()
env["BOT_GATEWAY_TOKEN"] = iam_token
if daemon:
# Run as background daemon
BOT_LOG_FILE.parent.mkdir(parents=True, exist_ok=True)
log_fd = open(BOT_LOG_FILE, "a")
proc = subprocess.Popen(
cmd,
env=env,
stdout=log_fd,
stderr=log_fd,
start_new_session=True,
)
_write_pid(proc.pid)
console.print(f"[green]Bot node started (PID {proc.pid}).[/green]")
console.print(f" Gateway: {'wss' if not no_tls else 'ws'}://{host}:{port}")
console.print(f" Logs: {BOT_LOG_FILE}")
console.print(f" PID: {BOT_PID_FILE}")
console.print()
console.print("Stop with: [cyan]hanzo bot stop[/cyan]")
else:
# Run in foreground
console.print(
f"[cyan]Connecting to {'wss' if not no_tls else 'ws'}://{host}:{port}...[/cyan]"
)
try:
proc = subprocess.Popen(cmd, env=env)
proc.wait()
except KeyboardInterrupt:
console.print("\n[yellow]Shutting down...[/yellow]")
proc.terminate()
proc.wait(timeout=5)
# ── stop ─────────────────────────────────────────────────────────────
@bot.command("stop")
def bot_stop() -> None:
"""Stop the local bot node daemon."""
pid = _read_pid()
if not pid:
console.print("[yellow]No bot node daemon running.[/yellow]")
return
try:
os.kill(pid, signal.SIGTERM)
console.print(f"[green]Stopped bot node (PID {pid}).[/green]")
except ProcessLookupError:
console.print("[yellow]Process already exited.[/yellow]")
finally:
BOT_PID_FILE.unlink(missing_ok=True)