Commit Graph
655 Commits
Author SHA1 Message Date
zeekayandHanzo Dev 0178264695 deps: floor hanzo-tools at the version that actually has HanzoCloud
The unified `hanzo` tool imports HanzoCloud from hanzo_tools.core, which
hanzo-tools first shipped in 0.3.3 — but nothing in the dependency graph
required it. hanzo-tools-api asked for `hanzo-tools-core>=0.1.0`, an empty
shim whose own floor is hanzo-tools>=0.3.2, and 0.3.2 has no cloud.py at all.
A resolver was therefore free to satisfy every constraint and still produce an
install where `from hanzo_tools.core import HanzoCloud` raises ImportError.
That is exactly what happened: the legacy-tool gate logged "unified hanzo tool
not importable" and fell back, and the dev venv had to install hanzo-tools
editable to get a working `hanzo`.

Name the real dependency at the real floor:
- hanzo-tools-api: hanzo-tools-core>=0.1.0 -> hanzo-tools>=0.3.4. It imports
  hanzo_tools.core directly, so it must depend on the package that owns it;
  the >=0.1.0 shim floor also still admitted the shadowing duplicate.
- hanzo-tools-vector: hanzo-tools>=0.3.0 -> >=0.3.4 (cloud_vector.py).
- hanzo-mcp: hanzo-tools>=0.3.2 -> >=0.3.4, hanzo-tools-api>=0.3.1 -> >=0.3.2.

Metadata only, on versions not yet published. No version bumps.

Co-authored-by: Hanzo Dev <[email protected]>
2026-07-27 20:44:05 -07:00
zeekayandHanzo Dev d0c125f571 tools: generate the hanzo surface from cloud's OpenAPI registry; delete the vector fake
The `hanzo` dispatcher hand-listed 10 services in SERVICE_TOOL_PATHS, so the
other ~150 cloud products were unreachable until someone cut a Python release.
Project the surface from cloud's /v1/openapi.json instead: cloud tags every
operation with its product (the first path segment after /v1/), so that document
already IS a service/action catalog. 166 services are now reachable, and a newly
mounted app is callable the moment cloud serves it.

Still exactly one MCP tool — agents degrade badly with hundreds — now shaped
hanzo(service, action, params, method), with `services` listing the catalog and
an empty action listing a service's actions.

Delete the local Infinity vector store (3,546 lines). It shipped no embedder, so
its only possible output was random vectors: it ranked "Bananas are yellow" above
an auth document for the query "authentication oauth jwt". A previous fix put the
mock behind HANZO_VECTOR_ALLOW_MOCK, but a fake that a flag can re-enable is
still a fake, and a tool that lies to an agent is worse than a missing one. The
package keeps only the cloud-backed VectorTool, and a test now fails if any
module in it imports `random`.

Make the legacy-tool gate honest: hide the per-service tools only once the
unified tool genuinely imports. It does not import against released hanzo-tools
(HanzoCloud is newer), and disabling them unconditionally would have left no
cloud tools at all.

- spec.py: catalog projection, cache keyed per spec source, stale cache beats
  failing a call. HANZO_OPENAPI_URL decouples catalog from target so a partial
  local host can be driven with the full registry.
- HanzoCloud.call: one generic seam for the verbs the spec names (PUT/PATCH/
  DELETE); auth also resolves the hanzo CLI's IAM session, memoized.
- 40 new tests covering action naming, template binding, method inference,
  param split, and cache fallback.

Co-authored-by: Hanzo Dev <[email protected]>
2026-07-27 20:25:54 -07:00
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
zeekayandHanzo Dev 8f3d611abf fix: complete the TypeScript 7 tsconfig migration
The first pass left three classes of config that BOTH tsc 5.9 and tsc 7
reject. Each was proven against both compilers before changing:

  TS5090  A `paths` target must be relative once `baseUrl` is gone. The
          first pass skipped the "./" prefix wherever baseUrl pointed at
          the config's own directory, reasoning it was semantically
          equivalent. It is not — without baseUrl a non-relative target
          is rejected outright, by 5.9 as well as 7.

  TS5110  `moduleResolution: node16` requires `module: node16`. The first
          pass mapped commonjs projects to node16 resolution alone, which
          BROKE those configs for the current toolchain. Both are now set.

  TS5102  `downlevelIteration` is also removed in TS7; it was missing from
          the dead-flag list.

Verified: repos that tsc 7 previously refused (base-studio, js-sdk, kv-js)
now report zero config errors on tsc 7 AND tsc 5.9.

Co-authored-by: Hanzo Dev <[email protected]>
2026-07-27 17:56:08 -07:00
zeekayandHanzo Dev 5a5702ec43 build: migrate tsconfig to TypeScript 7 (native compiler)
TypeScript 7 is the native Go compiler and removes `baseUrl` and
`moduleResolution: node|node10`. Both appear here, so `tsc` from TS7
refuses the config outright (TS5102 / TS5108) and cannot typecheck.

`paths` targets resolve relative to `baseUrl` when it is set and relative
to the tsconfig file otherwise. Every `baseUrl` folded here already
pointed at the config's own directory, so dropping it moves nothing and
the targets are left byte-identical. Where a baseUrl pointed elsewhere,
each affected target was rewritten as join(baseUrl, target).

`moduleResolution` was chosen from the declared `module`: commonjs ->
node16, esnext/preserve -> bundler. Configs whose `module` is unset or
exotic were left alone rather than guessed at.

The result is accepted by BOTH toolchains, so nothing has to upgrade
TypeScript in lockstep. Verified on hanzo/chat packages/api: tsc 5.9
779 -> 778 errors (no regression), and tsc 7.0.2 now runs the project
in 2s where it previously refused the config.

Co-authored-by: Hanzo Dev <[email protected]>
2026-07-27 14:18:30 -07:00
zeekay 98b5c1aaeb ci: drop the Gitea mirror-sync nudge
Superseded: the Hanzo GitHub App pushes a webhook, so the forge tracks GitHub
without a per-repo workflow. This file called git.hanzo.ai/api/v1/.../mirror-sync
— a Gitea API for a system we no longer drive — and would sit inert in every repo.

One mechanism, in one place, instead of ~350 copies of a cron.
2026-07-27 10:41:13 -07:00
zeekay d3c5724760 fix: point forge API calls at /v1 — /api/v1 is gone
The fork moved its API off /api to /v1, so every call built against
${{ github.server_url }}/api/v1/... now 404s. Verified live with a control:
/v1/version 200, /api/v1/version 404, a nonsense path 404.

This is the build-dispatch in sync-from-github, so a fast-forward from GitHub
was landing commits and then silently failing to trigger the build.
2026-07-26 15:51:49 -07:00
zeekayandHanzo Dev 0f9890725e test(cli): assert the package version, not a hardcoded literal
test_version read `assert "0.1.0" in r.stdout` and passed only because
hanzo_cli.__version__ was stale at 0.1.0 while pyproject said 0.2.2. The one
test positioned to catch the version drift was asserting the drifted value, so
single-sourcing the version in f453dae8 is what finally made it fail. It now
compares against hanzo_cli.__version__ and cannot enshrine a wrong value again.

Co-authored-by: Hanzo Dev <[email protected]>
2026-07-26 14:58:15 -07:00
zeekayandHanzo Dev 6a0898b300 fix(sdk): single-source every package version; patch-bump the two changed pkgs
Same drift as the CLI, in all three packages: a hardcoded __version__ that no
longer matched pyproject.

  hanzo_iam/__init__.py  1.1.1  vs pyproject 1.30.0   (29 minor versions apart)
  hanzo_cli/__init__.py  0.1.0  vs pyproject 0.2.2

The hanzo_cli one was load-bearing: click's version_option reads it, so it is
the reason `hanzo --version` answered "0.1.0" — a release that never existed.
All three now resolve from the installed distribution, so there is one answer.

Patch bumps for the two packages changed in 68877409 (never a lazy major):
  hanzo-cli  0.2.2 -> 0.2.3   entry point removed, __main__ added, PKCE, token path
  hanzo-iam  1.30.0 -> 1.30.1 endpoint properties on the exported IAMConfig

Verified on a clean venv: __version__ == importlib.metadata.version for all
three, `hanzo --version` 0.4.4, `python -m hanzo_cli --version` 0.2.3, 58
commands. hanzo-iam 19/19; hanzo-cli's failures are unchanged live-service auth.

Co-authored-by: Hanzo Dev <[email protected]>
2026-07-26 14:54:28 -07:00
zeekayandHanzo Dev 9a8b4b5c46 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 <[email protected]>
2026-07-26 14:51:42 -07:00
zeekay 5cd100d336 gitignore: .hanzo/* not .hanzo/ — the negation only works on the glob form
Git never descends into an EXCLUDED DIRECTORY, so `.hanzo/` followed by
`!.hanzo/workflows/` does not re-include anything: the directory is pruned
before the negation is ever considered. My previous commit only landed the two
workflow files because I forced them with `git add -f`; the next workflow file
added would have been silently dropped, which is the same failure mode that left
this repo briefly with no CI at all.

Verified both ways rather than reasoned about — `git add` on a fresh file under
.hanzo/workflows/ is refused with `.hanzo/`, accepted with `.hanzo/*`.

hanzoai/universe already documents this exact trap in its own .gitignore; this
now matches that proven form.
2026-07-26 13:28:33 -07:00
zeekay c0e56367c9 ci: actually track the native workflows (.hanzo/ was gitignored)
The previous commit removed 13 GitHub workflows and added none, because
`.hanzo/` is in .gitignore here — so `git add .hanzo` silently did nothing and
the repo was left with no CI at all. `git status` reported the deletions
cleanly; only the missing additions gave it away.

`.hanzo/` is ignored on purpose (it also holds local agent scratch), so this
negates just the workflows path — the same shape hanzoai/universe already uses
to ignore `.hanzo/` while tracking its six workflow files.

Now landed: .hanzo/workflows/publish-pypi.yml (native, runs on
hanzo-build-linux-amd64) and .hanzo/workflows/sync-from-github.yml.
2026-07-26 13:28:02 -07:00
zeekay 32af11bf85 ci: go native — 13 GitHub workflows out, publish moves to git.hanzo.ai
git.hanzo.ai is the canonical forge and GitHub is a mirror, so this repo's CI
had no business living in .github/workflows. It also did not work: Actions is
disabled at the ACCOUNT level for the identity that pushes here —

  gh workflow run publish-pypi.yml
  HTTP 422: Actions has been disabled for this user

— and a tag push produced no run for the same reason. Thirteen workflows (ci,
test, test-windows, test-hanzo-mcp, test-hanzo-tools, quality-gate, docs,
generate, generate-api-providers, hanzo-packages-ci, publish-external-dists,
publish-pypi, test-auto-publish) looked like CI and could not run. That is worse
than having none, and it is how `hanzo` 0.4.4 sat tagged-but-unpublished while
PyPI kept serving 0.4.3 — a build whose `hanzo auth login` cannot complete,
because Cloudflare rejects urllib's default User-Agent with `error code: 1010`
and the token exchange 403s after the user has already signed in.

Depending on GitHub to ship a fix for our own CLI was the actual defect.

Now:
  .hanzo/workflows/publish-pypi.yml     — native, runs on hanzo-build-linux-amd64
  .hanzo/workflows/sync-from-github.yml — the proven fast-forward-only pull
  .github/workflows/sync.yml            — a mirror nudge, nothing else

The tag → package mapping is carried over intact and checked against real tag
shapes: hanzo-v0.4.4 → hanzo, hanzo-dev-0.1.0 → hanzo-dev, hanzoai-3.1.1 →
hanzoai, hanzo-tools-vcs-v0.1.2 → the tools set, v1.0.0 → all. The package list
is auto-discovered from pkg/ rather than hardcoded, because the old hardcoded
lists silently dropped newly added packages.

Both native workflows parse as valid YAML. The PyPI token must exist as a secret
on git.hanzo.ai for the publish to run; the job fails loudly with that message
rather than half-publishing.
2026-07-26 13:27:33 -07:00
zeekay a74176fa6c release(cli): hanzo 0.4.4 — auth login actually works
Ships 50d442eb. 0.4.3 cannot complete a login at all: Cloudflare rejects
urllib's default User-Agent in front of hanzo.id with `error code: 1010`, so the
token exchange 403s AFTER the user has already signed in through the browser.
Also adds the PKCE S256 that flow never had, and moves off the legacy
/oauth/* + /api/device/code paths onto the canonical /v1/iam/oauth/* surface
hanzo.id advertises.

Patch bump from the last published version (0.4.3), per the release rule.
hanzo-v0.4.4 forge/hanzo-v0.4.4
2026-07-26 11:26:33 -07:00
zeekay 8183b16aa4 fix(cli): auth login was broken end-to-end — Cloudflare 1010, no PKCE, legacy paths
Three defects in `hanzo auth login`, found while auditing HIP-0111 compliance.

1. LOGIN DID NOT WORK AT ALL. urllib defaults its User-Agent to
   `Python-urllib/3.x`, which Cloudflare refuses in front of hanzo.id with
   `error code: 1010` — a 403 that never reaches IAM. The browser leg looked
   fine, the user signed in, and THEN the token exchange died, so the failure
   landed after the user had already authenticated. Measured on the live host:

     POST /v1/iam/oauth/token   UA=Python-urllib/3.11 -> 403 (CF 1010)
     POST /v1/iam/oauth/token   UA=hanzo-cli/python   -> 400 (reaches IAM)
     POST /v1/iam/oauth/device  UA=Python-urllib/3.11 -> 403 (CF 1010)
     POST /v1/iam/oauth/device  UA=hanzo-cli/python   -> 400 (reaches IAM)

   Every outbound request now identifies itself. The device flow was dead the
   same way.

2. NO PKCE. The flow had zero code_challenge/code_verifier — `state` only,
   which is CSRF protection, not interception protection. HIP-0111 Security
   Considerations makes PKCE S256 mandatory, and RFC 8252 §8.1 requires it for
   native apps specifically: this redirects to a fixed loopback port (1456) that
   any local process can bind or race, so an intercepted authorization code was
   directly redeemable. Now S256, with the verifier bound into the exchange.
   hanzo.id advertises `code_challenge_methods_supported: ["S256"]`.

3. LEGACY PATHS (HIP-0111 §4.4). It called `/oauth/authorize`, `/oauth/token`
   and `/api/device/code` — the `/oauth/*` and `/api/*` spellings the standard
   retired, the latter breaking the absolute "no /api/" rule. Replaced with the
   canonical paths hanzo.id actually advertises in its discovery document, held
   in ONE constant block so no call site spells a path itself (§4.3):

     /v1/iam/oauth/authorize   /v1/iam/oauth/token   /v1/iam/oauth/device

   `/api/device/code` was not merely non-canonical — it answers 401; the real
   device endpoint is `/v1/iam/oauth/device`, per discovery.

Also drops a redundant function-local `import base64` now that the module
imports it.
2026-07-26 11:09:23 -07:00
zeekayandHanzo Dev c84d3a352d ci: allow manual runs of Hanzo Packages CI
Push-event dispatch in this repo is intermittent — the two commits before
this one produced zero workflow runs — so there was no way to exercise a
package's test job on demand. Both publish jobs are already gated
(auto-publish on github.event_name == 'push', publish-to-pypi on a tag ref),
so a manual run can only test.

Co-authored-by: Hanzo Dev <[email protected]>
2026-07-26 08:33:17 -07:00
zeekayandHanzo Dev c80140810a kms: migrate hanzo-kms off Infisical paths onto the luxfi/kms surface
kms.hanzo.ai runs luxfi/kms, which has never served an /api/* route. This
SDK was sending Infisical's: /api/v3/secrets/raw for every secret operation,
/api/v3/auth/login for user auth, /api/v1/auth/kubernetes-auth/login for k8s.
Only one call in the whole package — /v1/kms/auth/login — was ever real.

It stayed invisible because older luxfi/kms builds embedded a console SPA
behind a root catch-all that answered every unmatched path with 200
text/html. A wrong URL came back as a JSON decode error, which reads like a
parsing bug in the client rather than "this endpoint does not exist." That
catch-all is gone (cmd/kms/main.go ends in notFoundJSON), so those paths now
return honest JSON 404s and the SDK fails outright.

The route table is not the only thing that was wrong. The data model was
Infisical's too — project_id, workspace, secret version, secret comment,
shared-vs-personal type — and luxfi/kms has none of those concepts. It keys
one value by (org, path, name, env) in ZapDB at kms/secrets/{path}/{env}/{name}
and a write upserts it in place. Half-migrating would have left response
models that cannot validate what the server sends, so the vocabulary moves
with the paths:

  list_secrets(path, env) -> names   GET  /v1/kms/orgs/{org}/secrets
  get_secret(path, name, env)        GET  /v1/kms/orgs/{org}/secrets/{path}/{name}
  put_secret(path, name, value, env) POST /v1/kms/orgs/{org}/secrets   (create AND replace)
  delete_secret(path, name, env)     DELETE .../secrets/{path}/{name}
  health()                           GET  /v1/kms/healthz

Two server behaviors now have one home, hanzo_kms/routes.py, shared by the
sync and async clients so they stay mirror images:

  - The server splits the trailing path at its LAST slash into (path, name),
    so each segment is escaped individually. Escaping the joined string
    encodes the separators away and the server reads one long name. A name
    containing "/" is rejected outright: it would be written under one key
    and read back under another, so the write looks like it succeeded and
    the read never finds it.
  - There is no versioned read. get_secret(version=N) raises
    VersionUnsupportedError rather than quietly returning the current value.

org is new and required — it scopes both the URL and the JWT owner claim.
Constructor field, HANZO_KMS_ORG, defaults to "hanzo".

Auth collapses to what the server actually offers: client credentials
exchanged at /v1/kms/auth/login, or a pre-issued IAM bearer token. The AWS,
Azure, GCP, Kubernetes and SRP methods were Infisical's and none were served.
That also fixes a divergence where the async client silently ignored
HANZO_KMS_TOKEN — env parsing is now one function.

Callers migrated with it: hanzo-cli's `hanzo kms`, the hanzo-tools-kms MCP
tool, and hanzo-tools-auth's session client, all of which were building the
removed auth models and calling the removed create/update pair. Each now
constructs KMSClient() and lets it read the environment.

Tests: pkg/hanzo-kms/tests pins the wire shape — no request URL may contain
"/api/", per-segment escaping survives the server's last-slash split, sync
and async emit byte-identical request lines, and a decoder rejects the
200-HTML/JSON-404 shapes instead of reading them as empty results. Wired into
hanzo-packages-ci without `|| true`, so a regression turns CI red instead of
waiting for a decode error in production.

hanzo-kms 1.1.0 -> 1.1.1.

Co-authored-by: Hanzo Dev <[email protected]>
2026-07-26 08:29:59 -07:00
zeekay c53d6a7c81 refactor(iam): route every OIDC call through the canonical path constants
The client had OIDC paths written inline at each call site, so the canonical
route existed in as many places as it was used and drifted per method. Moves
them to named constants in models.py — OIDC_DISCOVERY_PATH, OIDC_JWKS_PATH,
OIDC_TOKEN_PATH, OIDC_INTROSPECT_PATH — and has both the sync and async
clients read those.

Values match the canonical IAM surface (HIP-0111): discovery
/.well-known/openid-configuration, jwks /v1/iam/.well-known/jwks, token
/v1/iam/oauth/token, introspect /v1/iam/oauth/introspect, authorize
/v1/iam/oauth/authorize. No legacy /oauth/* and no /api/ prefix.

One definition per route, so sync and async can no longer disagree.
2026-07-26 01:55:28 -07:00
zeekay bfafe0f020 docs: modernize + LLM.md + cross-links (one-way SDK model) 2026-07-24 12:29:42 -07:00
zeekay b3227594f3 docs: modernize + LLM.md + cross-links (one-way SDK model) 2026-07-24 12:29:41 -07:00
zeekay 84bc6f5b7e research-sdk: rename the running-log method note()->log() (fix method/field shadow)
The one-shot constructor note= sets self.note (a str), which shadowed the
note() method → 'str object is not callable' at call time. The running-log
accumulator is now log(); note= stays the one-shot note. Proven end-to-end
logging 9 kernel-perf experiments (4 proven / 5 refuted) to the live
api.hanzo.ai/v1/research with hypothesis+verdict+provenance.
2026-07-22 22:11:38 -07:00
zeekay 609546db00 research-sdk: structure runs as falsifiable tests — hypothesis, notes, verdict
Add the scientific-method frame to the experiment handle: state a hypothesis
+ prediction up front, .note() the running log, and .conclude(verdict ∈
proven|refuted|inconclusive, because=...) to seal it. A refutation is
recorded as clearly and durably as a proof. finish() defaults a stated
hypothesis to inconclusive — a finished run never silently reads as proof.
All fields serialize into the record's meta alongside the auto-captured
provenance (git sha + lib versions), so a verdict is queryable evidence.
Backward-compatible: every new param is optional.
2026-07-22 21:56:07 -07:00
zeekay f991ce81ff research SDK: content-address artifacts server-side; drop X-User-Id minting (Red L1/M1)
snapshot/report now SUBMIT the bytes (base64 content); the server hashes them and owns
the sha256 + ref — the SDK no longer asserts a (poisonable) hash or a client ref. _headers
sends ONLY the per-org key (Bearer); it never mints X-User-Id/X-Org-Id (a cross-tenant
forge the gateway strips) — any dev bypass is server-side.
2026-07-22 19:57:31 -07:00
zeekay eb5eb00fe9 research: hanzo-research SDK — ergonomic, zero-config, auto-instrumenting client
The ONE way producers record/query R&D evidence via /v1/research (HIP-0512),
generated-shape from openapi/research and wrapped in a tiny verb surface so
hand-rolling is the worse choice:

  exp = research.experiment(kind, subject, task)   # get/create → handle
  exp.record(item, model, result)                  # attempt, idempotent by stable id
  exp.snapshot(bytes) / exp.report(text)           # diary artifacts (sha256-addressed)
  exp.finish(value)                                # seal the run
  research.query(project=, kind=)                  # read canonical

Zero-config auto-instrumentation (caller supplies no provenance): captures git
sha/branch/dirty, lib versions, host, and THREE self-documenting narrative sources —
the calling code's docstring (what this experiment is), the commit messages since this
experiment's last recorded run (what changed + why), and an optional note. The project
self-documents as a side effect of running.

Importer-agnostic: ingest() is one idempotent POST; any source (SQLite backfill, git
history, kernel-perf campaign) maps to stable ids and re-imports as a no-op. Stdlib
only (urllib); per-org key auth; private-by-default.
2026-07-22 18:24:12 -07:00
zeekay 3b5ba25ddd merge(feat/browser-zapd-autospawn): consolidate onto main 2026-07-21 17:18:50 -07:00
zeekay 55360b124d chore(hanzo-flags): commit uv.lock 2026-07-19 20:02:48 -07:00
zeekay b98e6de447 feat(tools): hybrid local+cloud tools -- HanzoCloud client, cloud code/vector actions, vision
Adds a shared HanzoCloud client (core/cloud.py; lazy httpx, api.hanzo.ai /v1)
and wires the cloud half of the hybrid tools: code gains cross-repo
search/context/ask/index over the cloud index, vector gains a cloud-backed
store plus an infinity-embedded local option, net gains a vision tool. Bumps
hanzo-tools 0.3.2->0.3.3, -code 0.1.2->0.1.3, -net 0.1.2->0.1.3, -vector
0.2.0->0.2.1 and declares httpx where the cloud path is used. New tests cover
the code/net cloud actions. import hanzo_tools.core is green.
2026-07-19 20:02:48 -07:00
d8dd174ee0 fix(generate): authenticated spec fetch — hanzoai/openapi is private (#49)
raw.githubusercontent.com/hanzoai/openapi/main/hanzo.yaml 404s (openapi is a
private repo), so every spec-update regen failed at the fetch. Fetch hanzo.yaml
through the GitHub Contents API with a SPEC_TOKEN (repo/contents:read on
hanzoai/openapi) instead. Local SPEC=... override unchanged.

Co-authored-by: zeekay <[email protected]>
2026-07-18 12:31:17 -07:00
zeekay 6ef78a34c9 hanzo-flags: the Python client for the native flags engine
Flags were natively supported in Rust (the hanzo-flags crate — the evaluation
core, pub-exported for Rust callers and FFI-exported for the rest), Go (in-process
via CGO to that core), and TypeScript (@hanzo/flags over /v1/flags). Python was the
gap. This closes it.

hanzo-flags POSTs an evaluation context to cloud /v1/flags — the same
PostHog-compatible endpoint every other client speaks — and answers is_enabled /
variant / payload from the response. It mirrors @hanzo/flags exactly: a HanzoFlags
client bound to one host, load(distinct_id, person_properties=, groups=), the same
three-field result, an async twin, and a one-shot evaluate().

Two invariants match the family: fail-open (a transport error yields the last good
or empty result with errors_while_computing set, never a raise on the hot path)
and cache-by-context+TTL. Zero runtime dependencies — stdlib urllib only, so a
flag check never drags httpx/pydantic into a service.

Tests stand up a local /v1/flags stub and prove the request shape is
PostHog-compatible, the accessors resolve booleans/variants/payloads, the TTL
cache holds, and both a 5xx and an unreachable host fail open. 7/7 green.
2026-07-17 13:00:33 -07:00
zeekay 6101ccbc41 feat(hanzo-train): add Tinker-shaped training client (0.1.0)
Hand-written package for the engine /v1/training API: ServiceClient +
TrainingClient (create/list/get/delete/forward_backward/optim_step/
sample/save_weights), dataclasses, sync httpx, completed-future
.result() wrapper so tinker fut.result() code ports 1:1. 22 pytest
tests over httpx.MockTransport, no network.

Wired like siblings: publish-pypi hanzo-train-* tag case, rye+uv
workspace member, uv source, pyright exclude. uv.lock relock also
catches the lockfile up to on-disk truth (hanzoai 3.1.1,
hanzo-tools-browser 0.5.10) -- no package versions changed.
hanzo-train-0.1.0 forge/hanzo-train-0.1.0
2026-07-15 18:16:32 -07:00
zeekay 61116e2800 ci: route linux/amd64 jobs to hanzo-build-linux-amd64 ARC scale set 2026-07-10 21:51:56 -07:00
zeekay a158e0165c release(hanzo-tools-browser): 0.5.10 — zapd connect-or-spawn auto-start hanzo-tools-browser-v0.5.10 forge/hanzo-tools-browser-v0.5.10 2026-07-08 08:50:48 -07:00
zeekayandGitHub 2d964c305d feat(browser): auto-start zapd when absent (connect-or-spawn) (#47)
The browser MCP tool no longer requires a human to have started zapd.
ZapdConsumer.connect() now dials the router and, on failure, auto-starts
the shared singleton (locate the zapd binary, spawn it detached, wait for
the socket) then dials once more — mirroring zapd's own host-mode
connect-or-spawn. No consumer-side lock: the singleton invariant lives in
zapd (advisory-lock bind), so concurrent spawns are safe. The OS unit is
the primary starter; this is the bare-machine fallback so the tool just
works.
2026-07-08 08:49:21 -07:00
zeekay 3e983b2831 feat(browser): auto-start zapd when absent (connect-or-spawn)
The browser MCP tool no longer requires a human to have started zapd.
ZapdConsumer.connect() now dials the router and, on failure, auto-starts
the shared singleton (locate the zapd binary, spawn it detached, wait for
the socket) then dials once more — mirroring zapd's own host-mode
connect-or-spawn. No consumer-side lock: the singleton invariant lives in
zapd (advisory-lock bind), so concurrent spawns are safe. The OS unit is
the primary starter; this is the bare-machine fallback so the tool just
works.
2026-07-08 08:36:14 -07:00
zeekay 9f9f610329 fix(hanzoai): 3.1.1 — guard optional ZAP import so import hanzoai works without the [zap] extra
zap.py is an httpx.BaseTransport; httpx is not a base dep (client core is urllib3).
3.1.0 imported zap.py eagerly in __init__, so `import hanzoai` crashed on missing
httpx in any clean install. Guard the import (try/except ImportError) and declare
httpx under the [zap] extra. import hanzoai now works with or without extras.
hanzoai-v3.1.1 forge/hanzoai-v3.1.1
2026-07-05 19:49:03 -07:00
zeekay 03ff27ab6a release(hanzoai): 3.1.0 — opt-in ZAP-native transport hanzoai-v3.1.0 forge/hanzoai-v3.1.0 2026-07-05 19:20:31 -07:00
cd539c99b3 feat(hanzoai): optional ZAP-native transport (opt-in, public API unchanged) (#46)
Add hanzoai.zap: an httpx.BaseTransport / AsyncBaseTransport that routes the
SAME typed client calls through hanzo_zap.CloudClient over the ZAP binary wire.
Opt in with Hanzo(http_client=hanzoai.zap_http_client(...)); default stays HTTPS.

- hanzo-zap is an optional extra [zap], imported lazily so "import hanzoai"
  still works when it (and zap-proto) are not installed.
- Public __all__ is byte-identical; new names are additive and kept out of __all__.
- Request to ZAP mapping: path to dotted method, api-key header to Bearer auth,
  body forwarded verbatim.
- Tests: back-compat lock + request-translation via mock CloudClient (no network).

Co-authored-by: hanzo-dev <[email protected]>
2026-07-05 19:18:09 -07:00
zeekay 3884899533 fix(workspace): complete member set — install real toolset, fix 20 integration failures
The uv workspace listed only 10 of 61 packages as members, so `uv sync`
never installed fs/shell/agent/reasoning/lsp/refactor/todo/jupyter/memory —
and hanzo-tools-core's integration suite (which asserts each imports with an
exact TOOLS count) failed 20 tests against the absent packages. Add the 9
required tool packages as members + [tool.uv.sources] workspace pins so the
real toolset installs and the suite validates live code. Honest fix — install
what the test asserts, don't weaken the assertions. Suite: 24 passed / 12
skipped (was 20 failed / 4 passed). Lock resolves clean (tree-sitter, tornado,
nbformat added). No regressions: hanzoai 3/3, browser 11/11, hanzo-mcp boots.
2026-07-05 12:11:35 -07:00
zeekay f42abb78d6 fix(workspace): pin hanzo-tools + hanzo-tools-core to on-disk source (kill PyPI shadow)
hanzo-tools-core was a [tool.uv.sources] gap: not a workspace member, so uv
resolved it from PyPI 0.3.0 — the OLD package that still ships the duplicate
hanzo_tools/core/ WITHOUT ToolImage, shadowing canonical hanzo-tools.core and
breaking 'from hanzo_tools.core import ToolImage' (browser tests: 6 fail/5 error).
Add hanzo-tools + hanzo-tools-core as workspace members AND [tool.uv.sources]
= { workspace = true } so both resolve on-disk (0.3.1 empty shim), never the
shadowing wheel. Purge the stale untracked pkg/hanzo-tools-core/hanzo_tools/
build artifact. Finishes the decomplect 711eb498 started. Browser tests: 11/11 green.
2026-07-05 12:07:52 -07:00
zeekay f62289c667 fix(hanzoai): restore core SDK modules wiped by #45 regen — unbreaks hanzo-mcp (3.0.1)
#45 regenerated hanzoai as a pure OpenAPI client and DELETED the hand-written
core-SDK modules (config/mcp/session/protocols/…) that live in the same package
but aren't part of API generation. hanzo-mcp imports all four → the published
hanzoai 3.0.0 crashed EVERY hanzo-mcp consumer on startup ('No module named
hanzoai.protocols') → the MCP server wouldn't boot → agent couldn't
reconnect. Restore the 4 self-contained modules hanzo-mcp needs (verified: zero
imports of the removed Stainless internals); they coexist with the generated
api/models client. Left agents/auth/cluster/llm_client OUT — they import the
removed ._client and need migration to the new client (separate follow-up, no
reconnect-path consumer). Bump 3.0.0→3.0.1. Boot-proven: hanzo-mcp starts,
26 tool packages / 29 tools, MCP initialize OK.
2026-07-05 09:50:51 -07:00
zeekay 4991923b67 fix(browser): route MCP register through _result_to_mcp — ToolImage→ImageContent
The browser tool's bespoke register() returned execute()'s dict RAW, so FastMCP
flat-JSON-serialized a screenshot's ToolImage (base64 inline, 250K chars) →
overflowed the agent context and wedged the run — the SAME bug main's
ImageContent converter (711eb498) fixed for every OTHER tool via
BaseTool.register→_result_to_mcp, but the browser tool bypassed that path.
Now both registration paths share the one converter: nested ToolImage anywhere
in the result becomes a native MCP ImageContent block the client SEES. One way.
2026-07-05 04:29:07 -07:00
82df3bdcb3 feat(hanzoai): regenerate client from unified OpenAPI spec (retire Stainless) (#45)
Replace the Stainless-generated `hanzoai` client (188 legacy LLM-gateway
endpoints) with an openapi-generator `python` (urllib3 + pydantic v2)
client built from hanzoai/openapi `hanzo.yaml` — the full unified
api.hanzo.ai/v1 surface (493 API groups, 1710 models, all /v1 products).

- scripts/generate.sh: the one-way generator; emits only pkg/hanzoai
  (other pkg/hanzo-* packages untouched).
- pyproject: deps -> urllib3 / python-dateutil / pydantic>=2 /
  typing-extensions; version 2.2.3 -> 3.0.0 (breaking surface change).
- tests/: Stainless client tests replaced with a surface smoke test.
- .github/workflows/generate.yml: regenerate on openapi spec-update.
- Base URL https://api.hanzo.ai, Bearer hk- / JWT auth.

Verified: wheel hanzoai-3.0.0 builds, installs into a clean venv, and
`import hanzoai` + product API construction succeed.

Co-authored-by: Hanzo Dev <[email protected]>
v3.0.0 forge/v3.0.0
2026-07-04 20:07:32 -07:00
zeekay 9155b4ccf3 Merge branch 'chore/ui-rag-v1-paths'
# Conflicts:
#	pkg/hanzo-tools-browser/hanzo_tools/browser/browser_tool.py
2026-07-04 13:59:42 -07:00
zeekay af29483757 fix(browser): screenshots/pdf save-to-disk + return path, never inline huge base64
A full-page PNG returned as inline base64 (100K+ chars) overflows an agent's
context window and wedges the whole run — the #1 cause of 'the browser tool
hangs' (two mobile-QA agents died on 6.6MB transcripts). Both capture paths
(native-zap _extension_command + the playwright fallback) now persist to
~/.hanzo/screenshots (or a caller path) and return a compact {path,size};
base64 is inlined ONLY for a small PNG (<=40KB). Adds _save_capture + _extract_b64.
2026-07-04 12:33:52 -07:00
zeekayandHanzo Dev 56dc452335 fix(billing): drop extraneous /api prefix — api.hanzo.ai/v1/billing
The billing tool hardcoded api.hanzo.ai/api/v1/billing (double prefix). Per
the /v1-only canonical API contract, the gateway serves everything under bare
/v1/* — never /api/. Scrub to api.hanzo.ai/v1/billing.

Co-authored-by: Hanzo Dev <[email protected]>
2026-07-03 12:31:09 -07:00
zeekayandHanzo Dev d70222ccce browser/zapd_consumer: self-heal a dead cached socket
connect() trusted a cached ZapClient forever; if the router restarted, every
call raised BrokenPipeError and the consumer stayed wedged. list_providers/route
now drop the dead client and reconnect once.

Co-authored-by: Hanzo Dev <[email protected]>
2026-07-03 12:31:09 -07:00
zeekay 85bf05c796 feat(mcp): native MCP ImageContent — tools return images the client can SEE
HIP-0300 tools serialize results to JSON text, which flattened images into
useless base64-in-text. Add a ToolImage value type + one converter in
BaseTool.register: any ToolImage anywhere in a result becomes a real
mcp.types.ImageContent block; text-only results stay byte-for-byte JSON.
Emitters wired: fs read of image files, browser screenshots (bidi+playwright).
This gives hanzo-mcp vision parity with zai-mcp — an agent with native vision
(Claude) sees pixels directly, no per-task vision tool needed.

Also decomplect the core-package collision that caused this class of bug:
hanzo-tools-core shipped a DUPLICATE hanzo_tools/core/ that nondeterministically
shadowed canonical hanzo-tools (dropping ToolImage, the sophisticated register,
etc. — same shadow class as the 0.3.0 error-reexport gap). Finish the
'merge core into hanzo-tools' refactor: move id_tool into hanzo-tools, make
hanzo-tools-core a pure empty metapackage. Now ONE package owns hanzo_tools.core.

Versions: hanzo-tools 0.3.2, -core 0.3.1 (shim), -browser 0.5.9, -fs 0.3.4,
hanzo-mcp 0.15.12 (floors bumped). Verified: fs read PNG -> [text, image];
fs stat -> [text]; 29 tools intact; 9 tests pass.
2026-07-03 00:45:49 -07:00
zeekay f34b5535d1 test(hanzo-mcp): guard every hanzo.tools entry point imports cleanly
Regression guard for the hanzo-tools 0.3.0 core re-export gap that silently
dropped fs/exec/code/git/fetch/agent, leaving hanzo-mcp with only 13 tools.
Fails loudly if any entry point stops loading or the core error contract
(ToolError/ConflictError/InvalidParamsError/NotFoundError) regresses.
2026-07-02 23:57:21 -07:00
zeekay 16cb538ec5 release(hanzo-tools): 0.3.1 — ship core error-class re-exports
PyPI hanzo-tools 0.3.0 shipped a core/__init__.py that never re-exported
ToolError/ConflictError/InvalidParamsError/NotFoundError from core.unified.
Six tool packages (fs, shell, agent, code, vcs, net) import those names at
entry-point load and silently failed, so hanzo-mcp exposed only 13 tools
instead of the full axis set. The re-export fix already landed in git
(0d8d52d9) but 0.3.0 was never republished. Bump to 0.3.1 to ship it.

Verified: entry points 20→26 loaded, hanzo-mcp tools/list 13→29.
2026-07-02 23:55:25 -07:00
zeekay fc76d8dc3c fix(hanzo-mcp): 'tool list' crash on @property descriptions (v0.15.11)
DynamicToolRegistry stored getattr(tool_class, 'description'), which for a
class-level @property returns the property DESCRIPTOR (not a str). 'tool list'
then did len(config.description) → 'object of type property has no len()',
breaking the entire tool-catalog command. Add _extract_tool_description
(mirrors _extract_tool_name): resolve a @property via a cheap instance, else
coerce to str, always returns str. Plus a defensive isinstance guard in the
list display path. Regression test covers property/unevaluable-property/plain/
absent + asserts every registered entry's description is len()-safe. 2 passed.
2026-07-02 23:00:52 -07:00