fix(cli): pip install hanzo shipped a 10-command CLI, not the 56-command one
`hanzo` depends on `hanzo-cli`, and BOTH declared `[project.scripts] hanzo`. Entry-point resolution is install-order dependent, so which CLI a user got was a coin flip — and hanzo-cli kept winning. The result: $ hanzo auth login Error: No such command 'auth'. So the "auth login is broken by Cloudflare 1010" story was real but unreachable: users never got as far as the HTTP call, because the binary they had did not have an `auth` command at all. Publishing that fix would have changed nothing. One command, one owner: - hanzo-cli drops its console script. `hanzo` is the ONE command. - `paas` and `bot` are mounted into it — the only two groups with no equivalent. `kms` is NOT mounted: `hanzo secrets` is a strict superset (audit/grant/ revoke/rollback/rotate/versions on top of the same get/list/set/delete), and two commands for one concern is what we are removing. - hanzo_cli grows a __main__ so it stays invocable as `python -m hanzo_cli`, and its e2e suite targets that instead of whatever `hanzo` resolves to. Endpoints, one definition: - models.IAMConfig (the class the package EXPORTS) gains the HIP-0111 endpoint properties. config.IAMConfig had them, but nothing imports that one — a second same-named class nobody uses is how the legacy path survived. - config.py + fastapi.py stop hand-assembling paths and read the constants. - password_login posted to `/oauth/token`, which is not a 404: IAM serves a 200 text/html SPA catch-all for unregistered paths, so it received a login PAGE and json() blew up on HTML. Verified: legacy 200 text/html vs canonical 401 application/json. PKCE, because this is a public client: - browser_login ships no client_secret, so the authorization code was the only secret in the flow and it arrives over a plaintext loopback redirect. The SDK already accepted code_challenge/code_verifier; the CLI simply never passed them. RFC 8252 §8.1 / RFC 7636 S256. Version, one source: - pyproject said 0.4.4, __init__ said 0.3.47, cli.py said 0.3.48, and the installed binary reported 0.1.0 — four answers, none right. Now resolved from the installed distribution. Verified on a clean venv: entry-point providers 2 -> 1, commands 10 -> 58, `hanzo --version` 0.1.0 -> 0.4.4, all four endpoints canonical. hanzo-iam 19/19 pass; hanzo-cli's 20 failures are unchanged before and after and are all live-service auth (no stored token in this environment). Co-authored-by: Hanzo Dev <dev@hanzo.ai>
This commit is contained in:
@@ -0,0 +1,11 @@
|
||||
"""Entry point for `python -m hanzo_cli`.
|
||||
|
||||
This package no longer installs a `hanzo` console script — that command name
|
||||
belongs to the `hanzo` package alone. This module is how hanzo_cli's own
|
||||
command tree is invoked directly, which is what its test suite targets.
|
||||
"""
|
||||
|
||||
from hanzo_cli.cli import main
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -8,6 +8,8 @@ Credential chain:
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import http.server
|
||||
import json
|
||||
import os
|
||||
@@ -231,10 +233,24 @@ def browser_login(port: int = CALLBACK_PORT) -> dict[str, Any]:
|
||||
redirect_uri = f"http://localhost:{port}{CALLBACK_PATH}"
|
||||
state = secrets.token_urlsafe(32)
|
||||
|
||||
# PKCE is not optional here. This is a native app with a public client
|
||||
# (client_secret=""), so the authorization code is the ONLY secret in the
|
||||
# flow and it arrives over a plaintext loopback redirect that any other
|
||||
# local process can race. RFC 8252 §8.1 requires PKCE for exactly this
|
||||
# shape; without a verifier an intercepted code is directly redeemable.
|
||||
code_verifier = secrets.token_urlsafe(64)[:128]
|
||||
code_challenge = (
|
||||
base64.urlsafe_b64encode(hashlib.sha256(code_verifier.encode()).digest())
|
||||
.decode()
|
||||
.rstrip("=")
|
||||
)
|
||||
|
||||
auth_url = client.get_authorization_url(
|
||||
redirect_uri=redirect_uri,
|
||||
state=state,
|
||||
scope="openid profile email",
|
||||
code_challenge=code_challenge,
|
||||
code_challenge_method="S256",
|
||||
)
|
||||
|
||||
# Reset handler state
|
||||
@@ -266,7 +282,9 @@ def browser_login(port: int = CALLBACK_PORT) -> dict[str, Any]:
|
||||
raise click.ClickException("No authorization code received.")
|
||||
|
||||
# Exchange code for tokens
|
||||
tokens = client.exchange_code(code=code, redirect_uri=redirect_uri)
|
||||
tokens = client.exchange_code(
|
||||
code=code, redirect_uri=redirect_uri, code_verifier=code_verifier
|
||||
)
|
||||
client.close()
|
||||
|
||||
token_data = {
|
||||
@@ -316,9 +334,20 @@ def password_login(
|
||||
if not password:
|
||||
password = click.prompt("Password", hide_input=True)
|
||||
|
||||
# Use ROPC grant to get tokens directly
|
||||
# The endpoint comes from IAMConfig, never a literal. The hardcoded
|
||||
# "/oauth/token" that used to be here is not a 404 — IAM serves a
|
||||
# 200 text/html SPA catch-all for any unregistered path, so this call
|
||||
# returned a login PAGE and resp.json() blew up on HTML. A wrong path
|
||||
# here is silent breakage, which is why there is exactly one definition.
|
||||
config = IAMConfig(
|
||||
server_url=server_url,
|
||||
client_id=client_id,
|
||||
client_secret=client_secret,
|
||||
organization=org,
|
||||
application=app,
|
||||
)
|
||||
resp = httpx.post(
|
||||
f"{server_url}/oauth/token",
|
||||
config.token_endpoint,
|
||||
data={
|
||||
"grant_type": "password",
|
||||
"client_id": client_id,
|
||||
|
||||
@@ -36,8 +36,13 @@ dev = [
|
||||
"ruff>=0.5.0",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
hanzo = "hanzo_cli.cli:main"
|
||||
# No [project.scripts]. The `hanzo` command belongs to the `hanzo` package and
|
||||
# to nothing else. Both packages used to declare `hanzo = ...`, and since
|
||||
# `hanzo` DEPENDS on this one, the winner was decided by pip install order —
|
||||
# a coin flip between a 56-command CLI and this 10-command one. hanzo-cli kept
|
||||
# winning, so `pip install hanzo` silently produced a CLI with no `auth`,
|
||||
# `chat`, `agent`, `cloud`, `dev` or `mcp` at all. This package remains
|
||||
# importable (`python -m hanzo_cli`) and its groups are mounted by `hanzo`.
|
||||
|
||||
[project.urls]
|
||||
Homepage = "https://hanzo.ai"
|
||||
|
||||
@@ -12,18 +12,23 @@ Run:
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
|
||||
import pytest
|
||||
|
||||
HANZO = "hanzo"
|
||||
# Invoke THIS package's command tree, not whatever `hanzo` resolves to on PATH.
|
||||
# hanzo_cli no longer installs a `hanzo` script (that name belongs to the
|
||||
# `hanzo` package), so a bare "hanzo" here would silently exercise a different
|
||||
# CLI than the one these tests are written against.
|
||||
HANZO_CMD = [sys.executable, "-m", "hanzo_cli"]
|
||||
TIMEOUT = 30
|
||||
|
||||
|
||||
def run(args: list[str], check: bool = True) -> subprocess.CompletedProcess:
|
||||
"""Run a hanzo CLI command and return the result."""
|
||||
result = subprocess.run(
|
||||
[HANZO] + args,
|
||||
HANZO_CMD + args,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=TIMEOUT,
|
||||
|
||||
@@ -7,7 +7,11 @@ from typing import ClassVar
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
from hanzo_iam.models import OIDC_USERINFO_PATH
|
||||
from hanzo_iam.models import (
|
||||
OIDC_AUTHORIZE_PATH,
|
||||
OIDC_TOKEN_PATH,
|
||||
OIDC_USERINFO_PATH,
|
||||
)
|
||||
|
||||
|
||||
class IAMConfig(BaseModel):
|
||||
@@ -77,13 +81,13 @@ class IAMConfig(BaseModel):
|
||||
|
||||
@property
|
||||
def token_endpoint(self) -> str:
|
||||
"""OAuth2 token endpoint URL."""
|
||||
return f"{self.server_url}/oauth/token"
|
||||
"""OAuth2 token endpoint URL (HIP-0111 canonical path)."""
|
||||
return f"{self.server_url}{OIDC_TOKEN_PATH}"
|
||||
|
||||
@property
|
||||
def authorize_endpoint(self) -> str:
|
||||
"""OAuth2 authorization endpoint URL."""
|
||||
return f"{self.server_url}/oauth/authorize"
|
||||
"""OAuth2 authorization endpoint URL (HIP-0111 canonical path)."""
|
||||
return f"{self.server_url}{OIDC_AUTHORIZE_PATH}"
|
||||
|
||||
@property
|
||||
def userinfo_endpoint(self) -> str:
|
||||
|
||||
@@ -35,6 +35,7 @@ from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||
from jwt import PyJWKClient
|
||||
|
||||
from hanzo_iam.models import (
|
||||
OIDC_JWKS_PATH,
|
||||
OIDC_USERINFO_PATH,
|
||||
IAMConfig,
|
||||
JWTClaims,
|
||||
@@ -115,7 +116,7 @@ def _get_jwks_client() -> PyJWKClient:
|
||||
|
||||
if _jwks_client is None:
|
||||
config = get_config()
|
||||
jwks_url = f"{config.server_url}/.well-known/jwks.json"
|
||||
jwks_url = f"{config.server_url}{OIDC_JWKS_PATH}"
|
||||
_jwks_client = PyJWKClient(jwks_url)
|
||||
|
||||
return _jwks_client
|
||||
|
||||
@@ -50,6 +50,30 @@ class IAMConfig(BaseModel):
|
||||
default="", description="JWT verification certificate (PEM)"
|
||||
)
|
||||
|
||||
# HIP-0111 endpoints, derived from the constants above so a caller can never
|
||||
# hand-assemble a path. This is the IAMConfig the package exports, so these
|
||||
# have to live here: callers doing `from hanzo_iam import IAMConfig` get
|
||||
# this class, not the one in config.py.
|
||||
@property
|
||||
def authorize_endpoint(self) -> str:
|
||||
"""OAuth2 authorization endpoint URL."""
|
||||
return f"{self.server_url.rstrip('/')}{OIDC_AUTHORIZE_PATH}"
|
||||
|
||||
@property
|
||||
def token_endpoint(self) -> str:
|
||||
"""OAuth2 token endpoint URL."""
|
||||
return f"{self.server_url.rstrip('/')}{OIDC_TOKEN_PATH}"
|
||||
|
||||
@property
|
||||
def userinfo_endpoint(self) -> str:
|
||||
"""OIDC UserInfo endpoint URL."""
|
||||
return f"{self.server_url.rstrip('/')}{OIDC_USERINFO_PATH}"
|
||||
|
||||
@property
|
||||
def jwks_endpoint(self) -> str:
|
||||
"""OIDC JWKS endpoint URL."""
|
||||
return f"{self.server_url.rstrip('/')}{OIDC_JWKS_PATH}"
|
||||
|
||||
|
||||
class TokenResponse(BaseModel):
|
||||
"""OAuth2 token response."""
|
||||
|
||||
@@ -1,6 +1,16 @@
|
||||
"""Hanzo - Complete AI Infrastructure Platform with CLI, Router, MCP, and Agent Runtime."""
|
||||
|
||||
__version__ = "0.3.47"
|
||||
from importlib.metadata import PackageNotFoundError, version as _version
|
||||
|
||||
try:
|
||||
# The installed distribution is the single source of the version. Hardcoding
|
||||
# it here meant three declarations (pyproject 0.4.4, this file 0.3.47,
|
||||
# cli.py 0.3.48) that drifted apart, so `hanzo --version` reported a release
|
||||
# that did not exist and no one could tell what they were actually running.
|
||||
__version__ = _version("hanzo")
|
||||
except PackageNotFoundError: # running from a source tree, not installed
|
||||
__version__ = "0.0.0+dev"
|
||||
|
||||
__all__ = ["main", "cli", "__version__"]
|
||||
|
||||
from .cli import cli, main
|
||||
|
||||
@@ -52,8 +52,8 @@ from .commands import (
|
||||
)
|
||||
from .utils.output import console
|
||||
|
||||
# Version
|
||||
__version__ = "0.3.48"
|
||||
# Version — resolved from the installed distribution, never restated here.
|
||||
from hanzo import __version__
|
||||
|
||||
HANZO_BIN = Path.home() / ".hanzo" / "bin"
|
||||
|
||||
@@ -174,6 +174,24 @@ cli.add_command(tasks.tasks_group)
|
||||
cli.add_command(tools.tools_group)
|
||||
cli.add_command(vector.vector_group)
|
||||
|
||||
# Groups owned by the hanzo_cli package. `hanzo` is the ONE command, so
|
||||
# anything hanzo_cli implements has to be reachable from here — it no longer
|
||||
# ships a competing console script. Only the two groups with no equivalent are
|
||||
# mounted: `hanzo-cli kms` is not, because `hanzo secrets` is a strict superset
|
||||
# of it (audit/grant/revoke/rollback/rotate/versions on top of the same
|
||||
# get/list/set/delete), and two commands for one concern is the thing we are
|
||||
# removing, not adding.
|
||||
try: # pragma: no cover - optional dependency
|
||||
from hanzo_cli.cli import main as _hanzo_cli
|
||||
|
||||
for _name in ("paas", "bot"):
|
||||
_group = _hanzo_cli.commands.get(_name)
|
||||
if _group is not None:
|
||||
cli.add_command(_group, name=_name)
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
|
||||
# Aliases
|
||||
cli.add_command(doc.doc_group, name="docdb") # docdb alias for doc
|
||||
cli.add_command(fn.fn_group, name="fn") # fn alias for function
|
||||
|
||||
Reference in New Issue
Block a user