mirror of
https://github.com/hanzoai/python-sdk.git
synced 2026-10-23 20:23:00 +00:00
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]>
844 lines
28 KiB
Python
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)
|