Files
python-sdk/pkg/hanzo-cli/hanzo_cli/bot/commands.py
T
zeekayandHanzo Dev d08a7d5599 auth: verify tokens instead of asserting they exist
is_authenticated() was `load_token() is not None` — a string-presence check
that returned True for the literal "fake.not.a.real.jwt". Every permission
decision downstream of it was therefore a lie. This makes the client side of
Hanzo auth actually work, end to end. Builds on the /v1/iam prefix already on
main; the endpoint paths were necessary but not sufficient.

hanzo_iam.tokens — the ONE credential judge. Verifies the signature against
the issuer's published JWKS plus exp/iss/aud, and fails CLOSED: an unreachable
JWKS is not a pass, because a client that cannot check a signature does not
know the token is good. Reason codes distinguish "expired" from "offline".
`alg: none` is absent from the accepted list by construction. An opaque API
key cannot be judged offline, so it is confirmed against userinfo rather than
assumed valid for being a non-empty string.

hanzo_iam.oauth — a real login: authorization code + PKCE (S256) over a
loopback redirect, with a deadline. Not the device grant, which would be the
better CLI UX: iam implements RFC 8628 fully and advertises it, but no PUBLIC
client is registered, so POST /v1/iam/oauth/device answers 401 invalid_client
for hanzo-cli, hanzo-app, hanzo-cloud and hanzo-console alike. Registering one
public app is the whole server-side fix; shipping a device path that always
401s would be a lie in code. The password grant is out for the same reason
(401 invalid_client without a secret), so `--no-browser` now prints the URL
instead of prompting for a password it cannot use.

The loopback listener binds a REGISTERED redirect_uri. iam compares
redirect_uri by exact string and does not apply RFC 8252 §7.3 port-agnostic
loopback matching, so a CLI cannot pick a free ephemeral port — the previous
code bound 8399 (cli) and 8398 (bot), neither registered, and /authorize
refused both with a bare 400 before the user saw a login page. It listens on
127.0.0.1 and ::1 because the registered URIs spell "localhost", and sets
SO_REUSEADDR because the previous callback sits in TIME_WAIT for ~60s and
would otherwise EADDRINUSE a second login on a port nothing is using.

hanzo_iam.store — keyring first, else an atomic 0600 file. The old path did
write_text() then chmod(0600), so with umask 022 a bearer token sat at 0644
between the two calls. Now it is created private and renamed into place. The
PaaS session cache goes through the same writer.

Two definitions of IAMConfig existed; models.py's was the one every client
imported, so the endpoint properties main had just fixed on config.py's were
dead code. One now, in config.py, plus jwks_uri and device_endpoint.

The bot and CLI login copies are gone; both call the one flow. whoami and
`hanzo bot login` no longer print claims from an unverified decode — that
rendered an attacker-chosen identity as fact.

The Team tool refuses instead of pretending: api.hanzo.ai/team is the
marketing SPA (200 text/html), so raise_for_status() passed and .json() blew
up. There is no Team API; HANZO_TEAM_URL stays as the seam for when one ships.

PaaS token exchange reports its real diagnosis: POST /v1/auth/login 404s while
/v1/org and /v1/user 401, so the API is up and gated but the exchange route is
not deployed at that edge. No client-side workaround exists, so it says so.

Tests fail against the old behaviour, which is the point: 5 session tests fail
against the shipped is_authenticated and 4 store tests fail against
write-then-chmod. 134 pass alongside main's KMS suite.

Co-authored-by: Hanzo Dev <[email protected]>
2026-07-27 18:55:24 -07:00

844 lines
28 KiB
Python

"""Hanzo CLI — Bot gateway management.
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
hanzo bot logs [--tail N] View recent logs
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 json
import os
import shutil
import signal
import subprocess
from pathlib import Path
from typing import Any
import click
from rich.console import Console
from rich.table import Table
from hanzo_cli.paas.client import PaaSClient
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 = "hanzo-bot"
BOT_IAM_SERVER_URL = "https://hanzo.id"
BOT_IAM_ORG = "hanzo"
BOT_IAM_APP = "hanzo-bot"
BOT_CALLBACK_PORT = 8398
BOT_CALLBACK_PATH = "/callback"
BOT_TOKEN_FILE = Path.home() / ".hanzo" / "bot" / "token.json"
# ── defaults ──────────────────────────────────────────────────────────
# Known Hanzo production bot container context.
# These can be overridden via --org / --project / --env flags.
DEFAULT_BOT_ORG = "698cda6739f65183b3009313"
DEFAULT_BOT_PROJECT = "698cda6739f65183b3009318"
DEFAULT_BOT_ENV = "698cda6739f65183b300931c"
DEFAULT_BOT_NAME = "bot"
# ── shared options ────────────────────────────────────────────────────
def _org_option(fn):
return click.option(
"--org", default=None, help="Organization ID (default: Hanzo)."
)(fn)
def _project_option(fn):
return click.option(
"--project", default=None, help="Project ID (default: Platform)."
)(fn)
def _env_option(fn):
return click.option(
"--env", "env_id", default=None, help="Environment ID (default: production)."
)(fn)
def _name_option(fn):
return click.option(
"--name",
"-n",
default=DEFAULT_BOT_NAME,
help="Container name (default: bot).",
)(fn)
def _resolve_bot(
org: str | None,
project: str | None,
env_id: str | None,
) -> tuple[str, str, str]:
"""Resolve org/project/env, falling back to bot defaults."""
ctx_org, ctx_proj, ctx_env = resolve(org, project, env_id)
return (
ctx_org or DEFAULT_BOT_ORG,
ctx_proj or DEFAULT_BOT_PROJECT,
ctx_env or DEFAULT_BOT_ENV,
)
def _find_container(
client: PaaSClient,
org_id: str,
project_id: str,
env_id: str,
name: str,
) -> dict[str, Any]:
"""Look up a container by name."""
containers = client.list_containers(org_id, project_id, env_id)
items = (
containers
if isinstance(containers, list)
else containers.get("data", containers)
)
for c in items:
if c.get("iid") == name or c.get("name") == name or c.get("slug") == name:
return c
console.print(f"[red]Bot container '{name}' not found.[/red]")
raise SystemExit(1)
# ── group ─────────────────────────────────────────────────────────────
@click.group()
def bot() -> None:
"""Manage the Hanzo bot-gateway deployment."""
# ── status ────────────────────────────────────────────────────────────
@bot.command("status")
@_name_option
@_org_option
@_project_option
@_env_option
def bot_status(
name: str,
org: str | None,
project: str | None,
env_id: str | None,
) -> None:
"""Show bot container status and pods."""
org_id, project_id, env_id = _resolve_bot(org, project, env_id)
client = PaaSClient.from_auth()
try:
container = _find_container(client, org_id, project_id, env_id, name)
cid = str(container.get("_id", container.get("id")))
# Container info
table = Table(title=f"Bot: {name}")
table.add_column("Field", style="cyan")
table.add_column("Value", style="white")
table.add_row("ID", cid)
table.add_row("Type", container.get("type", "—"))
pipeline = container.get("pipelineStatus", "—")
if isinstance(pipeline, dict):
pipeline = pipeline.get("status", str(pipeline))
table.add_row("Pipeline", str(pipeline))
reg = container.get("registry", {})
if isinstance(reg, dict) and reg.get("imageUrl"):
table.add_row("Image", reg["imageUrl"])
status = container.get("status", {})
if isinstance(status, dict):
table.add_row("Desired", str(status.get("desiredReplicas", "—")))
table.add_row("Ready", str(status.get("readyReplicas", "—")))
table.add_row("Available", str(status.get("availableReplicas", "—")))
console.print(table)
# Pods
try:
pods_data = client.get_container_pods(org_id, project_id, env_id, cid)
pods = (
pods_data if isinstance(pods_data, list) else pods_data.get("data", [])
)
if pods:
pod_table = Table(title="Pods")
pod_table.add_column("Name", style="cyan")
pod_table.add_column("Status", style="green")
pod_table.add_column("Restarts", style="yellow", justify="right")
pod_table.add_column("Age", style="dim")
for p in pods:
pod_table.add_row(
p.get("name", "—"),
p.get("status", "—"),
str(p.get("restartCount", 0)),
p.get("age", "—"),
)
console.print(pod_table)
else:
console.print("[yellow]No pods found.[/yellow]")
except Exception:
pass
finally:
client.close()
# ── logs ──────────────────────────────────────────────────────────────
@bot.command("logs")
@click.option("--tail", "-t", type=int, default=100, help="Number of lines to show.")
@_name_option
@_org_option
@_project_option
@_env_option
def bot_logs(
tail: int,
name: str,
org: str | None,
project: str | None,
env_id: str | None,
) -> None:
"""Show bot container logs."""
org_id, project_id, env_id = _resolve_bot(org, project, env_id)
client = PaaSClient.from_auth()
try:
container = _find_container(client, org_id, project_id, env_id, name)
cid = str(container.get("_id", container.get("id")))
logs_data = client.get_container_logs(org_id, project_id, env_id, cid)
if isinstance(logs_data, dict) and "logs" in logs_data:
pod_logs = logs_data["logs"]
if isinstance(pod_logs, list):
for pod in pod_logs:
if isinstance(pod, dict):
pod_name = pod.get("podName", "?")
lines = pod.get("logs", [])
if len(pod_logs) > 1:
click.echo(click.style(f"--- {pod_name} ---", fg="cyan"))
shown = lines[-tail:] if tail else lines
for line in shown:
if line:
click.echo(line)
else:
click.echo(str(pod))
else:
click.echo(str(pod_logs))
elif isinstance(logs_data, str):
lines = logs_data.splitlines()
for line in lines[-tail:]:
click.echo(line)
elif isinstance(logs_data, list):
for line in logs_data[-tail:]:
click.echo(line)
else:
click.echo(json.dumps(logs_data, indent=2))
finally:
client.close()
# ── deploy ────────────────────────────────────────────────────────────
@bot.command("deploy")
@_name_option
@_org_option
@_project_option
@_env_option
def bot_deploy(
name: str,
org: str | None,
project: str | None,
env_id: str | None,
) -> None:
"""Trigger a redeploy of the bot container."""
org_id, project_id, env_id = _resolve_bot(org, project, env_id)
client = PaaSClient.from_auth()
try:
container = _find_container(client, org_id, project_id, env_id, name)
cid = str(container.get("_id", container.get("id")))
client.redeploy_container(org_id, project_id, env_id, cid)
finally:
client.close()
console.print(f"[green]Redeployment triggered for '{name}'.[/green]")
# ── env ───────────────────────────────────────────────────────────────
@bot.command("env")
@click.argument("vars", nargs=-1)
@_name_option
@_org_option
@_project_option
@_env_option
def bot_env(
vars: tuple[str, ...],
name: str,
org: str | None,
project: str | None,
env_id: str | None,
) -> None:
"""Show or set bot environment variables.
Without arguments, shows current vars. With KEY=VAL pairs, sets them.
\b
Examples:
hanzo bot env Show current vars
hanzo bot env NODE_ENV=production Set a var
hanzo bot env BOT_TOKEN=xxx LOG_LEVEL=debug Set multiple
"""
org_id, project_id, env_id = _resolve_bot(org, project, env_id)
client = PaaSClient.from_auth()
try:
container = _find_container(client, org_id, project_id, env_id, name)
cid = str(container.get("_id", container.get("id")))
if not vars:
# Show current vars
variables = container.get("variables", [])
if not variables:
console.print("[yellow]No environment variables set.[/yellow]")
return
table = Table(title=f"Env vars: {name}")
table.add_column("Key", style="cyan")
table.add_column("Value", style="dim")
for v in variables:
val = v.get("value", "")
masked = f"{val[:4]}***" if len(val) > 4 else val
table.add_row(v.get("name", "—"), masked)
console.print(table)
else:
# Set vars
new_vars = []
for kv in vars:
if "=" not in kv:
console.print(f"[red]Invalid format: '{kv}'. Use KEY=VALUE.[/red]")
raise SystemExit(1)
k, v = kv.split("=", 1)
new_vars.append({"name": k, "value": v})
# Merge with existing
existing = {v["name"]: v["value"] for v in container.get("variables", [])}
for nv in new_vars:
existing[nv["name"]] = nv["value"]
merged = [{"name": k, "value": v} for k, v in existing.items()]
container["variables"] = merged
client.update_container(org_id, project_id, env_id, cid, container)
console.print(
f"[green]Set {len(new_vars)} variable(s) on '{name}'.[/green]"
)
finally:
client.close()
# ── events ────────────────────────────────────────────────────────────
@bot.command("events")
@_name_option
@_org_option
@_project_option
@_env_option
def bot_events(
name: str,
org: str | None,
project: str | None,
env_id: str | None,
) -> None:
"""Show bot container events."""
org_id, project_id, env_id = _resolve_bot(org, project, env_id)
client = PaaSClient.from_auth()
try:
container = _find_container(client, org_id, project_id, env_id, name)
cid = str(container.get("_id", container.get("id")))
events_data = client.get_container_events(org_id, project_id, env_id, cid)
events = (
events_data
if isinstance(events_data, list)
else events_data.get("data", [])
)
if not events:
console.print("[yellow]No events found.[/yellow]")
return
table = Table(title=f"Events: {name}")
table.add_column("Type", style="cyan")
table.add_column("Reason", style="yellow")
table.add_column("Message", style="white")
table.add_column("Age", style="dim")
for e in events:
table.add_row(
e.get("type", "—"),
e.get("reason", "—"),
e.get("message", "—"),
e.get("age", e.get("lastTimestamp", "—")),
)
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]")
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 (IAM_*)."""
return os.environ.get(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
def _bot_browser_login() -> str:
"""Sign the bot gateway in through the browser (auth code + PKCE).
Delegates to hanzo_iam.oauth — the ONE login flow. This used to be a third
hand-rolled copy that posted to the unprefixed /oauth/token, sent no PKCE,
bound port 8398 (which is NOT a registered redirect_uri for hanzo-bot, so
/authorize refused it with a bare 400), and waited for its callback in an
unbounded handle_request() loop.
"""
import time
from hanzo_iam import oauth
redirect_uris = tuple(
u for u in (_env("BOT_REDIRECT_URI"),) if u
) or oauth.LOOPBACK_REDIRECTS
try:
data = oauth.login(
server_url=BOT_IAM_SERVER_URL,
client_id=BOT_IAM_CLIENT_ID,
organization=BOT_IAM_ORG,
redirect_uris=redirect_uris,
on_url=lambda u: console.print(f"[cyan]Open this URL to sign in:[/cyan]\n\n {u}\n"),
)
except oauth.LoginError as e:
console.print(f"[red]Bot login failed:[/red] {e}")
console.print(
"[yellow]Note:[/yellow] the hanzo-bot IAM application has no loopback"
" redirect_uri registered. Register one (or set BOT_REDIRECT_URI to a"
" registered value) before browser login can complete."
)
raise SystemExit(1) from e
data["application"] = BOT_IAM_APP
data["expires_at"] = time.time() + (data.get("expires_in") or 604800)
_save_bot_token(data)
console.print("[green]Bot login successful.[/green]")
return data["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 the hanzo-bot client)
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}/oauth/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")
def bot_login_cmd() -> None:
"""Authenticate with the bot gateway (OAuth2 authorization code + PKCE).
The token is stored at ~/.hanzo/bot/token.json and used for subsequent bot
commands. Password login is gone: iam's password grant requires client
authentication (401 invalid_client without a secret), which a CLI cannot
supply, so `--no-browser` never worked.
"""
from hanzo_iam import tokens
from hanzo_iam.models import OIDC_JWKS_PATH
token = _bot_browser_login()
# Identity from a VERIFIED token. This used to base64-decode the payload
# and print whatever it said, with no signature check at all.
result = tokens.verify(
token,
jwks_uri=f"{BOT_IAM_SERVER_URL}{OIDC_JWKS_PATH}",
issuer=BOT_IAM_SERVER_URL,
)
if not result.valid:
console.print(f"[red]Token did not verify:[/red] {result.reason}: {result.detail}")
raise SystemExit(1)
console.print(
f"[green]Logged in as:[/green] {result.claims.get('name', 'unknown')}"
f" ({result.claims.get('email', '')})"
)
@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 — the installed @hanzo/bot binary reads
# OPENCLAW_GATEWAY_TOKEN for the node run command's token resolution.
env = os.environ.copy()
env["OPENCLAW_GATEWAY_TOKEN"] = iam_token
# Also write token into bot.json config so the config loader picks it up
bot_config_file = Path.home() / ".hanzo" / "bot" / "bot.json"
bot_config_file.parent.mkdir(parents=True, exist_ok=True)
try:
bot_config: dict[str, Any] = {}
if bot_config_file.exists():
bot_config = json.loads(bot_config_file.read_text())
gw = bot_config.setdefault("gateway", {})
gw["mode"] = "remote"
remote = gw.setdefault("remote", {})
remote["url"] = f"{'wss' if not no_tls else 'ws'}://{host}"
remote["transport"] = "direct"
remote["token"] = iam_token
auth = gw.setdefault("auth", {})
auth["mode"] = "iam"
auth["token"] = iam_token
iam_cfg = auth.setdefault("iam", {})
iam_cfg["serverUrl"] = BOT_IAM_SERVER_URL
iam_cfg["clientId"] = BOT_IAM_CLIENT_ID
iam_cfg["orgName"] = BOT_IAM_ORG
bot_config_file.write_text(json.dumps(bot_config, indent=2))
except Exception:
pass # Non-fatal — env var is the primary mechanism
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)