Compare commits

..
Author SHA1 Message Date
antje 139bd734f0 deps: luxfi/consensus v1.36.2 -> v1.36.3 — the v1.36.2 tag was re-pushed
Cold builds fail sumdb verification against the moved tag (downloaded
eKzasq4O... vs sealed IbeWQF1w...). v1.36.3 is the immutable successor;
never re-tag a published version.
2026-07-17 01:26:35 -07:00
antje 6e24fcfd28 mirror: skip hidden files — AppleDouble forks pass the extension check
._foo.png is a mac resource fork, not a render; 700+ of them poisoned a
library within an hour of the mirror going live.
2026-07-17 01:15:16 -07:00
antje 9e7931e81c render: poll window matches the dispatch cap; engine recycles after each render
The 10m local history poll undercut the 4h startToCloseTimeout the dispatcher
grants — live renders (observed 8-70m) were marked failed while still sampling;
only the mirror later delivered them. renderWindow now matches the cap.

The engine leaks ~58GB per render. The handler signals a recycle after each
COMPLETED render (never on timeout — the engine may still be sampling and the
mirror rescues late finishes); the supervisor restarts on the signal.
2026-07-17 01:09:39 -07:00
494a6582c4 bench(finance): BenchmarkListUsage — the co-resident usage-read path (BUG 2) (#325)
The finance domain had no benchmark; this measures the read that replaced the
commerceinproc self-dispatch — finance.ListUsage over a per-org SQLite ledger,
at 100/1000/5000 seeded debits. Backs the reproducibility claim in the
hanzo-unified-tenant-cloud paper (1.25/10.5/61 ms). Also surfaces a real N+1:
store.Entries fetches postings per row the usage view never uses — a
postings-free read would cut this ~10x (follow-up).

Claude-Session: https://claude.ai/code/session_015Z1iLf7QBrq1LhignJrzDw

Co-authored-by: hanzo-dev <dev@hanzo.ai>
2026-07-17 01:06:20 -07:00
zandhanzo-dev b0254454c9 Money names its unit: Int -> Atto
There were two words for one idea. money.Amount.Minor() returns an integer at the
CURRENCY's decimals — money.USD declares 2, so cents. cloudmoney.Amount.Int()
returns an integer at 18 decimals — atto. Both are "the backing integer", neither
name says which, and they differ by 10^16.

That is not a style complaint. It is how

    Amount: cloudmoney.FromInt(u.Charge.Minor()), // exact 18-dp USD, no floor

got written, reviewed, and shipped. It type-checked (both sides *big.Int), it read
as "make an Amount from the integer", and it billed a $17.376 zen call as
$0.0000000000000017 until v1.801.44. The package documented the trap in prose —
"Take the decimal, never Amount.Minor()" — because a comment was the only place
the unit existed. Prose is not a type.

So name the unit, not the Go type: FromInt -> FromAtto, Int() -> Atto(),
IntString() -> AttoString(). FromCents/Cents already did this and were never
confused with anything. Now the units are visible at the call site, and the
mistake reads as one: FromAtto(x.Cents()) is obviously wrong where
FromInt(x.Minor()) was obviously fine. It no longer type-checks either —
FromAtto takes *big.Int, Cents() returns int64 — so the pairing that cost us the
money is now two independent kinds of error instead of zero.

Values are untouched: Atto/AttoString return exactly what Int/IntString did, so
the treasury ledger hash and every stored 18-decimal string are byte-identical.
No migration, no data change — only the names, and the compiler found every one
of them (45 sites; a regex could not have, because .Int() also belongs to big.Int
and decimal).

Zero regressions: the failing set is identical to origin/main.
2026-07-17 00:52:06 -07:00
zeekayandClaude Opus 4.8 c93ddf92bf build(deps): adopt stable luxfi/keys v1.4.1 (unbreak force-moved v1.4.0 checksum)
luxfi/keys v1.4.0 was force-moved on the remote (tree 5153d639→80a3745a),
producing a go.sum checksum mismatch that broke `go mod tidy`/`go build` and
the hanzoai/cloud image build in CI. Every keys tag v1.1.0..v1.4.0 was
force-moved with content changes; only v1.4.1 is byte-stable (local==remote
tree 17551acd). Adopt v1.4.1 as the single stable version. Its go.mod floors
the unified luxfi stack, so the transitive set moves forward (geth 1.17.12→
1.20.1, consensus 1.35.32→1.36.2, crypto/database/warp/zap/…), all within v1.

go.mod/go.sum only; diff vs origin/main is luxfi/* exclusively. Embedded
iam2 v0.14.0, apps.go, and concurrent commerce/agent work untouched.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 00:50:13 -07:00
74478bb9a2 build: decomplect the cloud image — cloud compiles ONLY Go (#321)
The console SPA, agent-skills catalog, and native flags staticlib are each now
built by their OWN CI as a versioned immutable image and PULLED into the cloud
build, instead of rebuilding node+python+rust from scratch every release. The
console stage (cold npm install + full Next static export, cache-busted every
build) was the ~20-min long pole; it is now a registry pull.

- Dockerfile: console/skills/flagslib stages -> FROM ${CONSOLE_IMAGE}/
  ${SKILLS_IMAGE}/${FLAGS_IMAGE} prebuilt pulls; COPY sources updated
  (/dist, /catalog, /libhanzo_flags.a). Mirror golang+alpine bases, GOPRIVATE,
  and every RED gate (SQLCipher proof, modernc guard, cek frozen-format) are
  unchanged. Pins are ghcr.io so both buildx lanes pull directly; mirrored to
  registry.hanzo.ai (S3) for GET-flow consumers. release.yml still owns the
  cloud image + v* tags (it resolves CONSOLE_IMAGE to a fresh console-embed
  digest, as CONSOLE_CACHEBUST did).
- native/flags/Dockerfile: the cloud-flags artifact (rust -> scratch /libhanzo_flags.a).
- hanzo.yml: images: cloud-flags (distinct artifact, never a v* tag) + zccache
  RUSTC_WRAPPER on the native-flags gate (no-op unless the runner carries it).

Companion artifact publishers: hanzoai/console#(console-embed),
hanzoai/openapi#(agent-skills).

Claude-Session: https://claude.ai/code/session_015Z1iLf7QBrq1LhignJrzDw

Co-authored-by: hanzo-dev <dev@hanzo.ai>
2026-07-17 00:49:57 -07:00
hanzo-dev f76e714d37 refactor(flags): extract the Policy primitive — fold featuregate's waitlist into the flags engine
featureflags -> flags (Hanzo's Unleash-analog / hanzoai/flags): ONE runtime
decision engine, (Principal, context) -> verdict, evaluated in-process, hot.

Fold the launch-control waitlist off its DUPLICATE SQLite mode store
(clients/featuregate) onto the one engine:

  - a service's waitlist mode IS the platform switch waitlist.<svc>,
    evaluated through the same native evaluator as every other flag
  - the host->service registry folds into clients/flags (waitlist_store.go,
    mode column dropped — the mode is the switch)
  - featuregate.Enforce stays but as a CONSUMER of flags.WaitlistModeForHost
    (the decide), via an injectable Gate seam
  - /v1/featuregate/mode -> served by flags (the decide), same wire shape
  - /v1/admin/services -> an admin lens like /v1/admin/flags
  - per-user approval still reuses IAM (featuregate/approval.go, unchanged)
  - featuregate dropped from apps Wire() — it exposes only Enforce now

The flags package doc names the aspirational end-state: authz (access policy)
and entitlements (product-access policy) are the SAME (Principal,context)->
verdict shape and could COMPOSE this one engine. NOT touched here — flagged only.

Tests (green): featuregate RULE acceptance matrix + approvals; folded-registry
store (Seed/ServiceForHost/List/Upsert); flags env/board/parsers;
apps TestWireOrderMatchesFrozen. Native-engine + cek store tests need
CGO+libsqlcipher+libhanzo_flags (CI), unchanged.
2026-07-17 00:48:30 -07:00
hanzo-dev 53cd7c585d billing bridge: ask commerce what it gates, stop keeping a copy
The bridge's mint guard kept its own list of commerce's mint routes under the
comment 'kept in lockstep with api/billing/handlers.go'. A comment cannot hold
two lists together, and it hadn't: 10 paths here against the 16 commerce gates.
Six money-mint routes were outside this guard entirely.

Commerce now DECLARES its gated surface — middleware.Mint records what it gates,
MintRoutes() exports it (commerce v1.49.0) — so the guard reads that declaration
instead of copying it. A mint route added in commerce is covered here with nobody
remembering anything, which is the only version of this that survives contact
with a busy repo.

Registration is what populates the registry, so the guard registers commerce's
billing routes before reading it. The request-level assertion is unchanged and is
the part that matters: an ordinary org user's call must never REACH commerce,
because arriving at all means arriving with the admin service token that
satisfies MayMintMoney.

Proof it bites: allowlisting 'deposit' fails with the escalation itself —
'POST /v1/billing/deposit reached commerce carrying Bearer svc-tok'.

16/16 gated routes refused. All six bridge tests pass.
2026-07-17 00:41:23 -07:00
antjeandGitHub 8479418021 gpu: mirror local studio renders to the library (independent of claims) (#324)
Every 30s (its own ticker in the connect select loop), scan --studio-dir/output
recursively for image files new or changed since the last scan and POST each to
<studio-url>/v1/library/upload with the worker's bearer, tagged ?node=<identity>
and ?subpath=<subfolder>. So EVERY render lands in studio.hanzo.ai — including
ones produced OUTSIDE the job path: a graph hand-run on the node, or a render
that finished after its activity was reaped (the stranded-late-render class). The
mirror's independence from claims/activities is the point.

State is a tiny in-memory map[relpath]size that skips unchanged files; the studio
endpoint dedupes byte-identical uploads, so a re-scan after restart is cheap and
harmless. One log line per newly stored file; upload failures are summarized once
per scan and retried next tick (no 5xx spam). New --studio-url flag (default
https://studio.hanzo.ai, HANZO_STUDIO_UPLOAD_URL honored) so the mirror works
WITHOUT jobs. No new deps. Test: cli/gpu_mirror_test.go (new/changed uploaded,
unchanged skipped, node+subpath+bearer carried).
2026-07-17 00:20:24 -07:00
antje 309c830abb agent: v0.1.2 — pass a completion billing refusal (402) through, not a 502
Bump github.com/hanzoai/agent v0.1.1 -> v0.1.2 and have the in-process completer
return the agent typed hz.UpstreamError{Status,Body} on a non-2xx completion. The
round then passes a caller-facing 4xx (402 insufficient_balance, 429, 403) through
verbatim so a no-credit user sees "add credits", not an opaque "agent: completion"
502. Proven live: POST /v1/agent with a real hk- key ran the round end-to-end and
the completion returned 402. Dropped the now-unused clip() helper. Simplified
commerce_errorscope_test to the real invariant (typed 403 never flattened to 5xx)
now that commerce v1.48.10 honors status itself; the scope stays as the boundary.
2026-07-17 00:01:18 -07:00
antje f4ab7e2bd3 deps: commerce v1.48.5 -> v1.48.10 — free $0 tiers stay self-serve (paid-tier gate keys on price, not includedCreditUsd) 2026-07-16 22:38:59 -07:00
zandhanzo-dev 94873e3e0c MountAll's doc outlived Typed
It still said the MountFunc takes the app as `any` and that in-repo subsystems
recover it via Typed. Neither is true: MountFunc names *zip.App and Typed is
deleted. The app is handed to each mount as itself.
2026-07-16 22:24:17 -07:00
antje 0e377c52ad feat(sync,git): Gitea-native sync provider + webhook reject parity; fix LoadConfig flag panic
CHANGE 1 — the /v1/sync git provider drives GITEA (the one git store), not the
retired cloud embedded store:
- clients/sync/gitea.go: a Gitea REST + go-git client (GIT_ADMIN_TOKEN, GITEA_URL).
  Inbound = fast-forward-only go-git fetch(source) -> push(Gitea), so a diverged ref
  is a conflict, never overwritten (split-brain guard, now on Gitea). Outbound = a
  Gitea push-mirror (sync_on_commit) so Gitea itself propagates every commit. Fails
  closed when GIT_ADMIN_TOKEN is unset.
- git_provider.go Reconcile and sync_api.go patch/delete now compose those Gitea
  primitives; the cloud embedded seams (InboundGitSync/ImportGitRepo/EnsureGitMirror)
  are retired from the sync path. resolve() decision, loop guard, cursor idempotency,
  and hop limit are unchanged.

CHANGE 3 — webhook reject parity + a pre-existing root test panic:
- Wrap /v1/git/webhook and the slack(events,commands)/discord/teams/telegram inbound
  webhooks in cloud.Terminal so a bad-signature 401 / malformed 400 survives the
  commerce /v1 500-flatten (uniform reject codes, matching /v1/sync and
  /v1/connector/github/webhook).
- config.go LoadConfig: guard the process-global flag registration with a sync.Once,
  so a re-entrant LoadConfig (many test callers in one binary) no longer panics
  "flag redefined: enable".

CHANGE 2 (retire the embedded git server) is NOT done: it is still a live dependency.
cloneURL resolves to api.hanzo.ai/v1/git (cloud's OWN embedded server) and the
coding-agent orchestrator clones from uploadPack, pushes to receivePack, and reads the
store via VerifyRef. Deferred — migrate coding to Gitea first.

Tests (CGO_ENABLED=0): clients/sync + clients/integrations green; new
clients/sync/gitea_test.go proves reconcile acts on Gitea and the fast-forward guard.
2026-07-16 22:20:41 -07:00
hanzo-dev 66e4999a44 Merge: Mount takes *zip.App — delete the Typed shim and its 85 wrappers 2026-07-16 21:57:01 -07:00
antje 1cf505ebd1 edge: scope commerce error envelope to its own routes (unblock release smoke — 14 endpoints 500→4xx)
commercemid.ErrorHandlerJSON() was installed as a /v1 GROUP middleware, but fiber
matches group middleware by PREFIX, not by the handle a route registered on. So on
the shared /v1 it wrapped EVERY subsystem mounted after commerce (projects, agents,
wallets, functions, integrations, marketplace, team, s3, analytics, knowledge,
automations, deploy, billing) and flattened their typed zip.HTTPError (403 "X-Org-Id
required", 400, …) into a blanket 500 — the store envelope always renders 500. The
authenticated release smoke (#322) correctly fails on 5xx, so this blocked every
release since it landed.

Fix stays cloud-side (no commerce dep bump, no money-path churn): commerceErrorScope
guards the envelope by commercePrefixes, so it stays on commerce and every other
subsystem renders its own status via zip default. Pre-commerce subsystems (kms,
o11y) already did; this makes the post-commerce ones match. Verified with the REAL
commerce middleware: /v1/projects 403 (was 500), /v1/store/current keeps the 500
envelope. Regression test added.
2026-07-16 21:50:00 -07:00
zandhanzo-dev 25fcaa4351 Mount takes *zip.App: delete Typed and the 85 wrappers
MountFunc took `app any` and cloud.Typed asserted it back to *zip.App on every
mount — a runtime check doing the type system's job, with a failure branch that
could not fire because the only value ever passed is a *zip.App. Every subsystem
paid for it: 85 call sites read cloud.Typed(x.Mount) instead of x.Mount.

The reason given was circular. cloud said `any` was load-bearing because an
external module (licensing) exposed func(any, Deps) error; licensing said it used
`any` to avoid an import cycle in pkg/cloud. Each pointed at the other, and the
cycle cannot exist: this package already imports zip (build.go), and zip does not
import cloud. The `any` was justified by nothing.

So name the type. MountFunc is func(*zip.App, Deps) error — what every subsystem
already exported and what licensing's own doc claimed all along. Typed is deleted,
the 85 wrappers are gone, and the three in-repo mounts that hand-rolled the same
assertion (mountZen, mountMetrics, MountO11y) just take the app.

The compiler immediately found what the `any` had been hiding: four mounts still
shaped func(any, ...), one of them across a module boundary. That is the point —
a signature drift is now a build failure instead of a runtime error nobody would
see until a subsystem mounted.

Also here, because the same rip surfaced them:

  - iam: v1.31.27-0.20260716191958-4400762928a2 -> v1.31.28, and the replace
    pinning a second pseudo-version is dropped. The required pseudo-version named
    a commit that no longer exists (it was rebased away), so `go mod tidy` could
    not resolve it; v1.31.28 is a real tag and a strict superset of what the
    replace pointed at. A version, not a coordinate, and no replace.
  - licensing -> v0.1.5, which is where the typed Mount ships.
  - cloud.OrgConfig is aliased next to LicenseEntitlement. Both are named by
    CommerceClient's methods, but only one was exported, so the exported
    interface could not be implemented from outside without reaching into
    cloud/types — an omission, not a boundary.
  - build_registration_test.go tested Typed and nothing else. "Recovers the
    *zip.App" proved an adapter passed through its argument; "fails closed on a
    wrong type" cannot be compiled now. What is left is the assertion that a
    subsystem signature IS a MountFunc — which the build checks.

No regressions: the failing set is byte-identical to origin/main (11 pre-existing
TestAudit_*).
2026-07-16 21:47:19 -07:00
zeekay f49d283282 build(iam2): bump embedded iam2 v0.1.1 → v0.14.0 (parity-complete surface)
The CLOUD_IAM_IMPL=iam2 identity fold (clients/iam2, identitySpec) was pinned
to iam2 v0.1.1 — an early cut missing the whole console-parity surface. Bump
to v0.14.0 so the embed actually serves what hanzo.id needs:

  - RFC/IETF surface (HIP-0111): OAuth2 code+PKCE/refresh/client_credentials/
    password, RFC 8693 token-exchange, 7662 introspection, 7009 revocation,
    8414 AS-metadata, OIDC UserInfo, SCIM 2.0 Users
  - Casdoor verb-alias compat (transitional cutover bridge) for every verb the
    live console/gateway hard-code: users/orgs/apps/providers/roles/projects
  - operator bootstrap upsert (IAM CR reconciliation), TOTP MFA enrollment,
    organization-scoped projects (ScopeSwitcher)

No wiring change — main's identitySpec + co-mingle iam2server.Mount(app, db)
compile unchanged against the v0.14.0 API. zip already at v1.8.3.

Assisted-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 21:43:03 -07:00
antje f20e7679ef feat(connector,sync): /v1/connector/github/webhook namespace + reject paths survive the commerce /v1 500-flatten
CHANGE A — first-party vs external route naming. Rename /v1/github-webhook ->
/v1/connector/github/webhook, opening the external-platform namespace
/v1/connector/<provider>/webhook (github now; gitlab/others are sibling literal
routes later, each with its own signature scheme). /v1/git/webhook (first-party
Hanzo Git) and /v1/sync (the bridge) are unchanged. No live consumer breaks:
the GitHub App isn't created yet.

CHANGE B — 500 -> real 4xx on the reject paths. mountCommerce registers
commerce's ErrorHandlerJSON on app.Group("/v1"); that filter rewrites ANY error
a downstream /v1 handler PROPAGATES into a hardcoded HTTP 500. Every /v1
subsystem mounted after commerce is wrapped the same way -- git, sync AND
integrations alike. git does NOT escape it: its isolated tests read 401 only
because they don't co-mount commerce (its bad-sig path is never exercised in
prod, so the flatten went unseen). Add cloud.Terminal, which writes a returned
*zip.HTTPError in-band and returns nil, so the filter's c.Next() sees nil and
has nothing to flatten. Wrap /v1/sync (all verbs) and the connector webhook.
sync no-principal is now 401 (was 403) -- an authentication failure, matching
the webhook's bad-sig 401. The commerce filter is left untouched.

Tests reproduce the /v1 flatten filter and assert: sync unauth -> 401, connector
bad-sig -> 401, malformed body -> 400; connector resolves at the new path and
the old /v1/github-webhook 404s.
2026-07-16 21:32:46 -07:00
antje c93fd2c4dd edge: /v1/agent self-meters (fix 503 balance_unavailable on the agent orchestrator)
The global BillingGate priced /v1/agent/* at a flat 1c via a dead legacy
bot-reverse-proxy rule in DefaultPrice, so every agent request was gated: the
unauthenticated preset/conversation reads hit the balance check, failed closed,
and 503d before the orchestrator handler ever ran. The route was mounted and
winning precedence the whole time — the gate short-circuited ahead of it.

/v1/agent is self-metered: the reads are free and POST /v1/agent bills through
the /v1/chat/completions it runs in-process (gated + metered downstream), so the
edge must price it 0 or double-bill. Drop the legacy branch (and its now-unused
cloudEdgePriceCents const); price /v1/agent and /v1/agent/* at 0. Tests updated.
2026-07-16 21:06:09 -07:00
hanzo-dev 2f4095e68a Merge feat/openapi-spec: /v1/openapi.json generated from the live router
The spec IS the router, not a description of it: apps.Wire() -> MountAll ->
app.Fiber().GetRoutes(). 983 operations / 692 paths / 109 products, served beside
/zap so ZAP and OpenAPI are two projections of one route table rather than two
sources that can disagree.

A drift test proves the bijection and was proved to fire; it already caught
/v1/pricing-policy and /v1/pricing/policy collapsing onto one operationId.

# Conflicts:
#	serve.go
2026-07-16 20:42:42 -07:00
6233b3c805 fix(billing,smoke): usage reads the co-resident ledger (not a self-dispatch) + a real authenticated release smoke (#322)
A live authenticated smoke surfaced two production bugs; this fixes both and
adds the durable smoke that now guards every release.

BUG — /v1/billing/usage 500 for a valid caller. usage() proxied
"/v1/billing/usage" through commerceinproc, which re-dispatches BY PATH.
Commerce's own billing routes are behind //go:build cloud and never compiled
here, so the ONLY registration of that path is usage() itself — the S2S hop
re-entered the handler, which self-answered "sign in to view billing". This is
the SAME defect balance() was already fixed for. usage() now reads the usage
ledger DIRECTLY from cloud's own finance ledger (finance.ListUsage → the
wallet→revenue debits RecordUsage wrote), off the self-dispatching hop;
split-deploy falls back to the commerce S2S read, unchanged.

BUG — the balance gate 402'd read-only GETs (fixed in hanzoai/ai, pinned via
the go.mod bump). A $0-balance org could not VIEW its own resources. Fixed in
the ai module's BalanceGateFilter — reads never spend, so GET/HEAD/OPTIONS are
exempt; only writes/metered POSTs gate on balance.

SMOKE — cmd/smoke: a durable, authenticated per-subsystem prober. One
side-effect-free read per core subsystem; a read that 402s (balance gate) or
5xx-es (crash) fails the release. Baked into the image (Dockerfile) and wired
into release.yml as the functional gate after the boot check, so a release can
never ship with chat/billing/projects/kms/... down. The smoke token is
KMS/secret-sourced (never hardcoded); absent → the anonymous matrix still gates
public/authed and catches every 402-on-read / 5xx.

Claude-Session: https://claude.ai/code/session_015Z1iLf7QBrq1LhignJrzDw

Co-authored-by: hanzo-dev <dev@hanzo.ai>
2026-07-16 20:35:56 -07:00
hanzo-dev 52acb303c2 cli: multi-identity credential store with active-pointer switch
Store every logged-in identity in ~/.hanzo/identities.json keyed by a stable
<owner>/<name> key (admin/z, hanzo/z), with an Active pointer; mirror the active
identity into credentials.json so every legacy single-file reader is unchanged.
A second login as a different owner for the same email (privilege separation:
hanzo-admin-guard vs hanzo-console) is stored beside the first, not over it.

New: auth list (whoami --all), auth switch <owner|owner/name> (alias use),
logout [<owner>]. Token refresh writes through the store (SaveActive) so the
active identity stays fresh after rotation. All files stay mode 0600.

One credential store, active-pointer switch, backward compatible.
2026-07-16 20:20:53 -07:00
hanzo-dev 951703f486 dedup: fold hanzoai/o11y module wildcard into the one o11y subsystem
apps.Wire() had TWO {Name:"o11y"} entries: the in-repo read plane
(o11y.MountO11y) and the external hanzoai/o11y module wildcard
(cloud.Typed(o11ymod.Mount)). Fold the module wildcard in as the TERMINAL
sub-mount inside o11y.MountO11y (registered after every specific /v1/o11y/*
route, so Fiber in-order match still gives them precedence), delete the 2nd
Wire entry and the now-unused o11ymod import -> ONE o11y spec.

/v1/o11y/health is preserved exactly: the merged entry keeps OwnsHealth=false,
so the generic always-ok route is registered before MountAll (ahead of the
wildcard) — byte-identical to when the module co-entry, also OwnsHealth=false,
triggered it. Frozen wire order updated (two co-owned rows -> one); the
no-duplicate test no longer needs an o11y exemption.
2026-07-16 20:17:18 -07:00
hanzo-dev 7022064723 dedup: delete dead clients/session
Zero importers, no cmd/session, absent from apps.Wire(); its /v1/code/sessions
surface was never mounted (dark). Removes session.go, store.go, session_test.go.
2026-07-16 20:10:06 -07:00
hanzo-dev 8dabc5c469 fix(cloud): serve /v1/iam/* — the org-scoped edge to Hanzo IAM
The one-binary console (console.hanzo.ai — cloud serves console's static export)
reads org members + projects at /v1/iam/*, but cloud 404'd those (IAM isn't
folded in-process yet) and the old /org/iam BFF proxy is pruned from the static
export. So the browser got the SPA shell (HTTP 200 HTML), which the client
surfaced as "Request failed (HTTP 200)" — the Platform page died on "Could not
load".

iam_edge.go adds an org-scoped reverse edge at /v1/iam/* → the standalone IAM,
mounted when IAM is NOT folded in-process (else that subsystem owns the path — no
double-mount). The org is PINNED to the caller's validated, server-minted
X-Org-Id (never a raw client header) — load-bearing, since IAM's own authz is
permissive on the org-keyed routes, so without the pin one tenant could read
another's projects. A super admin may cross; a tenant may not; writes require an
org admin and must carry the caller's own org. Shares the ONE IAM identity
(iamHost/iamCred) with the API-key resolver (DRY).

Verified: 7 gate tests (pin, cross-tenant refuse, super-cross, allow-list, 401,
write-gate, own predicate) + go build clean.

Claude-Session: https://claude.ai/code/session_01SpMZ69ur3tjAXCiwaa7Wv2
2026-07-16 20:07:12 -07:00
zandGitHub ac65cf4ec3 chore(deps): bump hanzoai/tasks → v1.51.1 (fix api.hanzo.ai concurrent-map-writes crash)
Fixes the live recurring tasksd concurrent-map-writes fatal (hanzoai/tasks#18, v1.51.1). Build green; the failing 'Test' check is the pre-existing repo-wide LoadConfig flag-redefine panic (fails on main + all branches), unrelated to this go.mod-only bump.
2026-07-16 20:02:07 -07:00
hanzo-dev 5e06e29de5 Merge: tenant apps are App CRs — the last writer of the Service kind
clients/platform was the only thing still minting Service CRs; every other
declarer is already App. A role-less App dispatches to the operator's service
profile, the same reconcile the Service kind ran, so a tenant workload carries
over verbatim.

Existing tenant CRs still resolve (App first, Service second). A redeploy patches
the kind it IS rather than minting a twin — two CRs on one name is the
commerce-admin ownerRef flap, not a migration. Teardown deletes BOTH kinds:
either alone rebuilds the app the tenant just deleted, still billing.

113 tests pass, 7 new.
2026-07-16 19:40:04 -07:00
hanzo-dev 095e1cce4e platform: tenant apps are App CRs — the last writer of the Service kind
clients/platform was the only thing still minting Service CRs. Every other
declarer is already App: universe git has zero hanzo.ai/v1 kind:Service, the
operator calls App "the sole workload reconciler for the collapsed fleet", and 69
of the 80 live CRs are Apps. A tenant app was the exception for no reason — a
role-less App dispatches to the operator's service profile (controllers/app.rs
`classify("") => Dispatch::Service`), which is the same reconcile the Service kind
ran, so an App carries a tenant workload verbatim.

Nothing mints a new Service CR after this. What already exists still resolves:
reads, patches, scales and deletes walk crGVRs() — App first, Service second — so
the 3 live tenant CRs written before this keep working untouched.

A redeploy of a pre-collapse app patches the kind it IS rather than declaring an
App twin. Two CRs claiming one name is not a migration, it is the commerce-admin
flap: both kinds materialize the same children through the same materializer under
one field manager, so the Deployment's ownerRef just flips between them.

Teardown deletes BOTH kinds. Either kind alone re-materializes the Deployment, so
removing only the one that resolves first would rebuild the app the tenant just
deleted — running, and still billing, minutes after a successful delete.

Sequencing (deploy order matters): operator v0.7.7 carries the Claim guard that
makes App the deterministic owner when both kinds claim a name. This is safe
before it — the no-twin rule means a colliding pair is never created here — but
the guard is what makes an existing collision safe to clean up.

REMOVABLE once no Service CR remains in any tenant namespace: drop servicesGVR
from crGVRs() and the delete-both, and this file is App-only.

Tests: 113 pass; 7 new (kind is App, no twin on legacy redeploy, delete removes
both kinds, idempotent delete, resolution order, absence honest). The 10 tests
that asserted a Service CR is written now assert the kind we write.
TestMigrateOverLegacyPlatformApps fails identically on pristine main (sqlcipher
codec, environmental).
2026-07-16 19:39:56 -07:00
antje bffe597ff0 apps: frozen wire list — add sync entry (main added /v1/sync to Wire; frozen drifted, breaking TestWireOrderMatchesFrozen); fix agent route comment 2026-07-16 19:38:00 -07:00
hanzo-dev 601d5660b7 Merge: paas reads the kind the fleet runs on, and does not fight the git declarer
The PaaS control plane serves platform.hanzo.ai and read services only: 69 App CRs
run in the scanned namespaces against 7 Service CRs, so the SUPERADMIN drift board
rendered 7 rows for a 69-app fleet — 1 in production — and /v1/paas/health probed
the Service CRD, found it served, and reported ok over a blind board.

Reads now walk App first, Service second, deduped by name. Writes refuse a
git-declared App (Hanzo CD syncs it with selfHeal, so a patch is reverted on the
next sync) and name the file to commit to; a Service CR still patches.

35 tests pass, 0 fail.
2026-07-16 19:30:16 -07:00
hanzo-devandz 8bf2dda4d1 feat(shard): in-binary org→owner shard router for horizontal writer scale
Lifts the unified binary off replicas:1 on DigitalOcean (RWO-only block
storage, no RWX) WITHOUT any shared volume. Each org is pinned by rendezvous
hashing (ha.Owner over the static CLOUD_PEERS ring) to exactly one owner pod;
a request whose org this pod does not own is transparently forwarded to the
owner, so every per-org SQLite store (KMS, finance, and every org-keyed store),
audit append, per-org rate ceiling, and prepaid billing debit runs on ONE pod.

THE INVARIANT — no two pods ever write one tenant's SQLite file — is upheld by
two independent guarantees that compose: per-pod RWO PVC (different physical
files per pod) + org→owner routing (all of an org's writes on one pod).

- shardrouter.go: the middleware. Runs immediately after SanitizeIdentity so it
  keys on the VALIDATED, server-minted X-Org-Id (never a client header), hashes
  the SAME injective SanitizeOrg slug the on-disk path uses (routing key ≡ file
  key), and forwards via a fasthttp streaming proxy (SSE/chat pass through — a
  streaming chat still carries a per-org billing debit, so it routes too) with
  dial-only retry across an owner's roll gap and a 421 loop-guard on divergence.
  Static identical membership rules out dual-owner and routing loops.
- config.go: CLOUD_PEERS (id@addr ring) + POD_NAME (self). Sharding auto-enables
  only when >1 peer; Validate fails closed if self is not in the ring or if
  embedded iam (non-shardable process-local sessions) is co-enabled.
- serve.go: wires the middleware after IdentityMiddleware; shard-aware boot log.
- audit_serve.go: per-shard audit is automatic on the per-pod PVC; stamp the
  shard id on the AU-9 checkpoint stream so the tail-truncation monitor tracks
  N heads. writerpin.SingleWriter is correct PER SHARD (each pod sole writer of
  its orgs); the writer lease is pod-local under per-pod PVC and stays off.

No-op (byte-identical to today) when CLOUD_PEERS names ≤1 pod. N pinned at 3;
rebalance-on-N-change (a tenant-file move) is the documented follow-up.

Tests prove: exactly one owner per org (deterministic, total, evenly
distributed); all pods agree (no dual-writer); owned served locally, unowned
forwarded to the owner (local chain never runs, hop header set, response
streamed); org-less served locally; loop-guard 421; boot-gate fail-closed.
2026-07-16 19:28:27 -07:00
hanzo-dev db226b321b Merge: bill zen in the credit unit — FromInt(Minor()) understated every debit by 10^16 2026-07-16 19:26:12 -07:00
hanzo-dev f24596453e Bill zen in the credit unit, not the currency's minor unit
zen prices every SKU as an exact 18-dp value tagged money.USD. money.USD
declares 2 decimals, so Amount.Minor() rescales that value to CENTS, while
cloudmoney.FromInt reads its argument as 18-dp. Composing the two divided
every zen debit by 10^16: a $17.376 charge debited $0.000000000000001738,
and any charge under half a cent folded to a zero that metering.Record drops
before it reaches the ledger — no debit row, call served free.

The spend gate read the same composition. FromInt(est.Minor()).Cents() is
always 0, and AuthorizeVerdict only compares balance against size when
AmountCents > 0, so the size check was dead code and any org with a positive
balance could draw a request of any size. The dust debits never accumulated,
so the cap could not trip either.

Route the seam through one conversion. credit() carries the exact decimal
across and changes only the minor-unit convention — no rescale, no rounding,
no factor — because the decimal is the value and a currency's Decimals is a
rendering convention. cloudmoney.FromDecimal is the typed counterpart of
ParseUSD that makes this expressible without a string round-trip. nano()
takes the typed Amount rather than a bare *big.Int, so a cents integer can no
longer reach the warehouse fold.

The tests asserted Amount.Int() against Charge.Minor() — the same integer on
both sides — so they proved a round-trip and were blind to the unit. They now
assert against known dollar values built through a different constructor: a
$17.376 charge debits $17.376. Reintroducing Minor()-into-FromInt fails all
of them, including a gate test that admits an over-cap request.
2026-07-16 19:24:29 -07:00
hanzo-dev 185cca68f2 Forward only the billing paths the console reads
The /v1/billing/* bridge attaches the commerce service token, and that token
satisfies commerce's MayMintMoney. The subpath was checked for traversal but
not against a set, so POST /v1/billing/deposit forwarded a mint for any
signed-in user — pinned to their own subject, which aims the mint rather than
stopping it. Commerce closed this on its direct path the day after the bridge
reopened it, and wider: the bridge needs only a validated principal, never an
admin bit.

The forwardable set is now a per-method table, consulted before the token is
attached, 404 on anything else. Per-method because payouts is a GET read and a
POST mint on one path — a method-blind set hands the mint to every reader.

Nothing reachable today: commerce's mint is not compiled into this binary and
commerce.hanzo.svc resolves here. But devnet runs a real mint-capable commerce
and points cloud-api at it under a second name for the same thing; unifying
those names would arm it. The gate should exist before that cleanup does.
2026-07-16 18:54:20 -07:00
blue db4801d787 Gate the billing bridge on an allowlist: the service token is authorization
/v1/billing/* forwarded any subpath to commerce carrying the admin
COMMERCE_SERVICE_TOKEN, validated only against path traversal. Forwarding IS
authorization there: commerce gates money-mint on
MayMintMoney = IsServiceToken || IsSuperAdmin, so every forwarded path ran with
platform authority. Commerce 403s an org admin who POSTs /v1/billing/deposit
directly; through this bridge the same person was handed the platform's own
credential, and the subject-pinning aimed the credit at their own account.
Pinning is an IDOR control, not an authority control.

Add billingForwardable: a per-method allowlist of the endpoints the console
actually calls, enforced before the token is attached. An unlisted path 404s.
Allowlist, not denylist — a mint route commerce adds tomorrow is unreachable
with no change here. GET and POST are separate sets because `payouts` is a read
AND a mint-gated write; one method-blind set would hand the mint to every reader.
The POST set holds nothing that creates balance from a client-named amount.

Not exploitable in the current topology: commerce is co-resident in cloud, whose
build never compiles mount.go (//go:build cloud), so no api.Route billing bundle
is reachable and the forward loops back to this bridge. It is one COMMERCE_URL
away from live — devnet already runs a standalone commerce.

Tests: an ordinary org user's deposit/credit/refund/credit-grants/husd/allotment
now never reach commerce; the console's 12 real calls still forward; the store
bridge still cannot tunnel into billing.
2026-07-16 18:36:07 -07:00
hanzo-dev 337c159db0 Embed zen v1.4.0 so the prompt-cache fix reaches production
zen v1.4.0 bills the prompt cache: cache_read was priced in the catalog,
published in /v1/models, and never charged. cloud pinned v1.3.11, so the
fix could not reach api.hanzo.ai, where the traffic lands.

The module graph cannot move: zen's own go.mod is byte-identical between
v1.3.11 and v1.4.0 (same sha256), and cloud holds exactly one zen edge.

NOTE: this is necessary but NOT sufficient. hmoney.Minor() returns cents
while cloudmoney.FromInt expects atto, so apps/zen.go understates every
debit by 1e16 and the spend gate reads AmountCents 0. Correct pricing is
still zeroed downstream. Tracked separately; that fix is what makes cache
billing real.
2026-07-16 18:21:12 -07:00
hanzo-dev 20e7591f53 openapi: serve GET /v1/openapi.json generated from the live router
The route table gets a third projection. /zap replays the /v1 handlers, the
console renders them, and GET /v1/openapi.json now describes them — all read
from the ONE router after MountAll, so none can drift and none holds a second
copy. There is no checked-in spec file and no second registry.

openapi.Live(app) reads app.Fiber().GetRoutes(true) (fiber's own filter drops
Use() middleware); every other function is pure over that []Route. Each
operation is tagged with its product — the first path segment after /v1/ —
so a CLI can build `hanzo <product> <resource> <verb>` with no judgment.

Reading the LIVE router is the only total source: POST /v1/kms/auth/login is
registered as Group("/v1/kms/auth").Post("/login") and no grep can find it,
and the route set is a function of deployment config, so the document varies
per deployment — correctly. Unauthenticated: it grants no capability, every
route it names stays auth-gated, and `hanzo --help` must build its tree
before login.

The drift guard (cmd/cloud/openapi_test.go) is a bijection over the fully
mounted apps.Wire(): 983 operations / 692 paths / 109 products, every live
route present, no operation invented. Shown to fail on a broken translation
(353 routes reported missing) before being restored.

Honest boundaries, asserted rather than papered over:
  - No schemas. The router holds func(*zip.Ctx) error; the request type is a
    local inside the handler (var req secretPutRequest; json.Unmarshal(...)),
    unreachable by reflection. cloud.Handle[S] is generic over the SERVICE,
    not the payload. The path to schemas is zip's typed ops, which today
    number zero — which is why zip's own generator emits nothing here.
  - No responses block. OpenAPI 3.1 makes it optional; fabricating 200/ok on
    ~900 routes would assert what nothing knows.
  - HEAD/CONNECT excluded — forced, not taste. CONNECT has no OpenAPI field;
    fiber auto-generates HEAD in startupProcess(), so including it would make
    the document depend on lifecycle stage.
  - Catch-alls are opaque: POST /v1/billing/deposit is not a route here.

Corrects LLM.md, which the code contradicted: Wire() does exist (apps.go:188),
MountAll does not sort, and byte-identical patterns MERGE rather than panic.
A high handler count is not a collision — app.Post(path, mw1, mw2, mw3, h) is
one registration with four handlers (apps/commerce.go:151), and 34 live routes
are that shape, so the generator never reads the count.
2026-07-16 18:14:23 -07:00
antje 9d5d51936a cloud: mount hanzoai/agent /v1/agent (no-shim); delete dead clients/chat
clients/agent adapter injects the ai completion (in-process, billed) + tools.Default()
into github.com/hanzoai/agent; POST /v1/agent live path. Dispatch resolves the caller
via the ONE canonical tools.PrincipalFrom — no reconstructed principal. Builds green
on the v1.801.35 base. DEPLOY-BLOCKED: go.mod uses a local replace for hanzoai/agent
(CI needs a fetchable release + the local module-cache shallow-clone bug resolved).
2026-07-16 18:00:50 -07:00
hanzo-dev c5cefc39a6 Bill the prompt cache in production: zen v1.3.11 -> v1.4.0
cache_read was priced in the catalog and published in /v1/models, but never
charged: on an openai upstream every cached input token billed at the full
`in` rate, and on an anthropic one it left the bill entirely. zen v1.4.0
normalizes both dialects into one tally over three disjoint classes
(fresh + cached + cacheWrite == the whole prompt) and derives once per
response. cloud embeds zen via apps/zen.go, so the fix is inert at
api.hanzo.ai until this pin moves.

zen v1.4.0's go.mod is byte-identical to v1.3.11's, so the require edit and
the two go.sum lines are the whole change: no transitive pin moves, and
nothing else in the graph names zen.
2026-07-16 17:49:16 -07:00
hanzo-dev 7f6b3a4cfe Merge: fix the zen margin test's decimal + money types (unblocks CI on main) 2026-07-16 17:48:36 -07:00
z e538ae0b7f apps: price the zen test with the decimal money actually uses
The zen margin test never compiled, so CI has been red on main since it landed:
it parsed prices with shopspring/decimal and handed the result to hmoney.New,
which takes hanzoai/decimal. Two identically-named types, one of them wrong.

    vet: cannot use d (struct type "github.com/shopspring/decimal".Decimal)
         as "github.com/hanzoai/decimal".Decimal value in argument to hmoney.New

Money has exactly one decimal. A second one that merely LOOKS like it is how a
price silently becomes a different number, which is why the compiler is right to
refuse. Parse with hanzoai/decimal (decimal.Parse — the same call zen itself
prices with); this was the only file in cloud importing shopspring.

Read the debit through Amount.Int(), not Amount.Minor(). The test held two money
types at once: zen's hanzoai/money.Amount (Charge/Cost) has Minor(), but
meterUsage returns cloud's clients/money.Amount, whose accessor is Int() — it
wraps Minor() and returns the identical *big.Int, so the assertions are unchanged.

The tests themselves are worth keeping: they prove the debit is the retail Charge
and never the upstream COGS, that a 3x-margin tier collects 3x, and that a
sub-cent call does not floor to zero. They just could not run.

Not run locally: ./apps links a prebuilt Rust staticlib (libhanzo_flags.a) that
is built in CI, not here. vet — the step CI actually failed — passes, and the
package builds.
2026-07-16 17:48:26 -07:00
hanzo-dev 2593b1b18e GOPRIVATE names the namespace that is actually private
GOPRIVATE named zap-proto/*, which is public -- all 55 repos, all 6 modules
cloud needs served anonymously from the public proxy. The namespace that is
private went unnamed: github.com/hanzoai/* (467 private repos incl. ai,
account, commerce, orm, xorm, beego, csqlite). It resolved only by falling
through GOPROXY's direct fallback, and passed checksums only because go.sum
already pins everything.

containment.yml then set GOSUMDB=off to compensate. GOSUMDB does not scope to
a namespace: that disabled checksum verification for every module in the
build, public ones included, in the image that handles payments -- breaking
the invariant the Dockerfile three files away states and honors.

Public modules keep proxy and sumdb immutability; private ones go direct and
authenticated. Verified under the CI and Dockerfile env with -mod=readonly and
sumdb on: build 0, go mod verify all verified.
2026-07-16 17:33:16 -07:00
hanzo-devandantje 40ca6a48a8 feat(sync): universal /v1/sync engine — GitHub/GitLab ⇆ native Hanzo Git
One reconcile loop, one place a sync happens. clients/sync owns the Sync
record (source/target/direction/trigger/cursor), a per-org sqlite `sync`
table, /v1/sync CRUD + /v1/sync/:id/run, and a kind→Provider registry with a
single Reconcile(sync,event) contract. Git is the first provider, composing the
existing git object-plane seams (InboundGitSync inbound, ImportGitRepo reconcile,
EnsureGitMirror outbound) — no second copy of any git op.

Triggers resolve to Syncs and enqueue the engine (cloud.Sync), never sync
directly: the GitHub App webhook now serves the path it actually fires at
(/v1/github-webhook, was a prod 404 at /v1/integrations/github/webhook) and hands
the verified push to the engine; the Gitea push webhook gains a loop guard
(skip pusher == GIT_SYNC_ACTOR). Loops break on the engine cursor (identical
fingerprints are a no-op) + actor guard; chained propagation (a sync's target is
another's source) is bounded by a hop limit.

Seams (root cloud): SyncFunc + RegisterSync/Sync, GitMirrorController +
EnsureGitMirror. Fail-closed on unmounted engine / missing secret. Tests
(CGO_ENABLED=0): engine loop-guard/idempotency/chain/hop-limit, git resolve,
CRUD+patch+run, webhook signature/isolation/enqueue, gitea loop guard.
2026-07-16 17:26:06 -07:00
hanzo-dev 2cc52957b4 build: GOPRIVATE names the namespace that is actually private
GOPRIVATE listed github.com/zap-proto/*. Every zap-proto repo is public — all 55
of them — and every zap-proto module this build needs is served by the public
proxy anonymously. It was never the reason anything resolved direct.

The namespace that IS private went unnamed: github.com/hanzoai/* — ai, account,
commerce, orm, xorm, beego, csqlite and ~30 more. Those only ever built by
falling through GOPROXY's `direct` fallback after the proxy 404'd them, and only
kept passing the checksum step because go.sum already pins them, so no sumdb
lookup happens. It worked by accident, one added dependency away from failing.

containment.yml compensated for that unnamed namespace with GOSUMDB=off, which
does not scope to hanzoai — it disables checksum verification for EVERY module in
the build, the public majority included. The Dockerfile's comment reasoned the
same way inverted ("hanzoai/* and luxfi/* are PUBLIC ... only zap-proto/* is
exempt"); hanzoai/* is largely private and luxfi/* (all 37 deps here) is public.

Naming github.com/hanzoai/* is what the off switch was standing in for. GOPRIVATE
implies GONOPROXY+GONOSUMDB for exactly that namespace, so the private modules go
direct+authenticated and skip the sumdb that cannot see them, while zap-proto and
luxfi keep the public proxy + checksum db — the immutable hashes that make a
force-moved tag unable to break or poison the build. GONOSUMDB and GOSUMDB=off
are dropped: scoped by GOPRIVATE, they are redundant, and blanket-off is a
supply-chain regression in a money image.

hanzoiam/* is not listed: a74b7de reverted the scim/saml/ldap embed, so nothing
in go.mod requires it. It goes back when the modules resolve, named as private.

Verified under the exact CI/Dockerfile env (GOPRIVATE=github.com/hanzoai/* only,
GOSUMDB=sum.golang.org, GOFLAGS=-mod=readonly): build exit 0, vet exit 0,
`go mod verify` = all modules verified, and `go mod download` resolves both a
public module (zap-proto/zip) and a private one (hanzoai/ai).
2026-07-16 17:21:31 -07:00
antje 28aaa39d56 deps: ai v1.816.0 -> v1.817.0 — global auto-routing switch is the '*' settings row, not ROUTER_ENABLED env (config-as-Base)
Runtime routing policy now sources from the GlobalDefaultOwner OrgSettings
row (admin.hanzo.ai-editable SQLite), env demoted to deprecated fallback.
Per-org override unchanged.
2026-07-16 17:16:33 -07:00
hanzo-dev dfe30a3883 paas: read the kind the fleet runs on, and don't fight the git declarer
The PaaS control plane serves platform.hanzo.ai and read `services` only. The
fleet collapsed onto `kind: App` and this reader never followed: 69 App CRs run
across the scanned namespaces against 7 Service CRs, so the SUPERADMIN drift
board rendered 7 rows for a 69-app fleet — 1 row in `hanzo`, production, and that
row is a duplicate CR. It was not an error anyone could see. The board looked
plausible and was blind to 68 of 69 services; every read of an App-declared
service 404'd; and /v1/paas/health probed the Service CRD, found it served, and
reported ok — status theater over a blind board. clients/deploy already solved
this with an App-first/Service-second read order; this gives the same order to
the reader that needed it.

Both kinds are listed and deduped by name: one workload is one row even when an
App CR and a Service CR both claim the name, because a Deployment has one
controller ownerRef and the operator's Claim guard gives it to the App.

The write path is the harder half. Hanzo CD syncs 68 of the 69 App CRs from
universe `infra/k8s/operator/crs/` with selfHeal on, so patching an App CR here
is reverted on the next sync — the deploy would report success and silently roll
back. That is worse than refusing, so deploy and release now resolve which kind
holds the workload and refuse a git-declared one, naming the file to commit to.
A Service CR has no git declarer (cloud and kubectl write them directly), so it
still patches exactly as before — the tenant plane and the untransitioned CRs are
untouched. release.go's "no ArgoCD" claim was true when written and is not now.

This does not restore an admin deploy button for the git-declared fleet. Under
GitOps that button belongs in git, and ImageUpdate already names that seam
(registry→git→cluster). Whether /v1/paas/deploy should commit to universe or be
retired is a CTO call, not one to make silently inside a read fix.

Tests: 35 pass, 0 fail, including the pre-existing release suite. The fleet-sees-
App-CRs case is seeded to the shape of the real `hanzo` namespace.
2026-07-16 17:12:40 -07:00
antje a74b7de5c4 revert(iam2): back out scim/saml/ldap embed — hanzoiam repos do not resolve
Reverts e4b3c88f. go.mod pinned github.com/hanzoiam/{ldap,saml,scim}, but none
of the three repos exist: https, ssh, and API all return not found, and ldap
was never fetched by any proxy or cache. Cold machines — including release
runners — fail go mod download, so main could neither build nor release.
Local builds passed only on module caches warmed 21:36-21:48 UTC today.

iam2 restored to v0.1.1, the state prod v1.801.38 ships. Re-land the embed
unchanged once the hanzoiam repos exist and resolve from a cold GOMODCACHE.
2026-07-16 17:01:45 -07:00
hanzo-dev f662973eb2 Merge: one name for the account, one for the org — and the last copy of the payer rule deleted 2026-07-16 16:38:08 -07:00
z e72d77b21b billing: one name for the account, one name for the org
Two functions were called Payer and returned different things: account.Payer
returns the ACCOUNT that pays, principal.Payer returned the ORG whose ledger
holds it. Those are different values on the same request — a person in the shared
signup org pays from account "hanzo/alice" held in ledger "hanzo" — and one name
for both is how the gate came to key the pool while the debit spent the person.
Rename it to what it returns: principal.HomeOrg. An org names a ledger; an
account names a wallet within it.

Fix the last copy of the rule with it. clients/metering is cloud's vendored
metering client, and its IdentityFromGatewayHeaders still hardcoded `user := org`
under the same false premise, while its doc claimed cloud "mirrors" the module
"exactly so every product keys the SAME ledger entry" — a promise two independent
copies cannot keep. Both now call hanzoai/account.Payer, so they agree by
construction and the comment is true for a reason.

Its test asserted the divergence in words — "want hanzo (per-org billing key, not
org/sub)" — which is how a premise outlives the code that disproved it. It now
asserts equality with the rule.
2026-07-16 16:37:43 -07:00
hanzo-devandz b2c5b4f81e build(cloud): mirror base images to ghcr.io/hanzoai/mirror/* (kill public.ecr.aws 429s)
public.ecr.aws (ECR Public) rate-limits anonymous pulls with HTTP 429 on shared
CI runners; a 429 on any base pull aborts the release (a release died pulling
python:3.12-alpine). Repoint all five FROMs to 1:1 linux/amd64 mirrors in our
own GHCR namespace, pinned by digest for immutability:

  node    ghcr.io/hanzoai/mirror/node:24-alpine@sha256:0cb0e7c3195bce740b6c8d8b27432c92360e3b7f1528087f2c50640b177950c6
  python  ghcr.io/hanzoai/mirror/python:3.12-alpine@sha256:aa679aa4eed6eb56c1dc6ad3f1b98b7d2d788fd961596779d188fdedad97fb38
  rust    ghcr.io/hanzoai/mirror/rust:1-alpine3.22@sha256:b348cb409ac0a73de15065997a360063cf87465574a15e3e4469862cb8996f02
  golang  ghcr.io/hanzoai/mirror/golang:1.26-alpine3.22@sha256:47d47cb5cc3c7dac409dcb6c3a98a6263571218046cd02d709527feef804a77c
  alpine  ghcr.io/hanzoai/mirror/alpine:3.22@sha256:7c8cb692ae09657cbc4a3f3cbd0e8d5a2690ba38386aaaf252dbb060bf5eb2e6

The four the task named plus alpine:3.22 (the final-stage base — same registry,
same 429 exposure) so no FROM still hits public.ecr.aws. release.yml already
logs the build into ghcr.io (GH_PAT, docker/login-action) before building, so
buildx resolves these private mirrors today with no new plumbing. Only the five
FROM lines + one rationale comment change; nothing else in the Dockerfile.

Claude-Session: https://claude.ai/code/session_015Z1iLf7QBrq1LhignJrzDw
2026-07-16 15:48:48 -07:00
hanzo-dev 6c77cfccab feat(ai): bump ai v1.814.0 → v1.816.0 — router self-probe, flywheel trainer, casibase metering
Ships the full router flywheel into the cloud binary: the self-probe
(continuous tagged auto traffic → reward ledger), the fit→gate→auto-deploy
→publish trainer, the routing-latency guard (~0.68us heuristic), and
casibase-chat usage/o11y metering. Also carries the zen warehouse+span
wiring already on main.

Migrates the billing-subject callers (balance.go, account/billing.go +
tests) from the ai/object.Payer that v1.816.0 REMOVED to the extracted
github.com/hanzoai/account.Payer — the same rule in its new home (the
concurrent decomplect). Same replace directive for the force-pushed iam
pseudo-version account v0.2.0 pins.

Claude-Session: https://claude.ai/code/session_018PmFAHZvbBSTsuWyebwMra
2026-07-16 15:32:35 -07:00
hanzo-dev 34800f6991 ci(cloud): GOPRIVATE += github.com/hanzoiam/* (enterprise IAM modules)
The iam2 embed pulls github.com/hanzoiam/{scim,saml} (+ ldap under -tags iam2_ldap).
hanzoiam is a distinct org from hanzoai, so hanzoai/* did NOT cover it — the test
phase would route these private modules through the public proxy/sumdb and 404.
Git auth is already handled by the reusable CI's GH_PAT insteadOf (covers any
github.com private repo the PAT reads).
2026-07-16 15:04:30 -07:00
zeekay e4b3c88f82 feat(iam2): wire SCIM + SAML into the cloud embed; LDAP GPL-isolated behind -tags iam2_ldap
Last code step of the iam2 migration. clients/iam2 blank-imports the Apache-2.0
enterprise features so their init() self-registers into iam2's feature registry;
iam2server.Mount -> feature.MountAll auto-mounts them under CLOUD_IAM_IMPL=iam2.
ONE mechanism (database/sql driver pattern) — no feature.Register call in cloud,
which (feature.Register appends with no dedup) would double-mount and collide routes.

  _ github.com/hanzoiam/scim   SCIM 2.0 provisioning  (/scim/*)
  _ github.com/hanzoiam/saml   SAML IdP + SP SSO      (/v1/iam/saml/*, /v1/iam/acs, /v1/iam/get-saml-login)

LDAP is GPL-isolated: hanzoiam/ldap links goldap (GPL-2.0), so it is opt-in only via
clients/iam2/ldap_enabled.go (//go:build iam2_ldap). The DEFAULT cloud binary stays
copyleft-free — proven: `go list -deps ./clients/iam2/` has no goldap; only under
-tags iam2_ldap does it pull github.com/lor00x/goldap + hanzoai/ldapserver.

deps: hanzoai/iam2 v0.1.1->v0.1.4; + hanzoiam/{scim,saml,ldap}; go mod tidy.
2026-07-16 14:59:47 -07:00
hanzo-devandz 6a47436062 feat(kms): one-time legacy ZapDB -> per-org SQLite migration (deploy-safe cutover)
Without this, deploying the per-org store over live data boots an EMPTY store and
orphans every secret — cloud KMS is the authoritative source the kms-operator syncs
out to every service, so that is a cluster-wide outage. New() now runs a one-time,
writer-only, keyed, FATAL-on-error migration BEFORE serving: it opens the legacy
{DataDir}/kms ZapDB (encrypted at rest with the master key), streams every SEALED
secret (kms/secrets/ prefix; the JSON value carries the full coordinate + AES-GCM
ciphertext + ML-KEM wrapped DEK) verbatim into its per-org SQLite file via store.put
— NEVER unsealing, so no plaintext is exposed and the AAD path-binding survives —
then archives the legacy dir to {DataDir}/kms.migrated so the OS lock is released for
good. Idempotent (prior .migrated marker or absent store = no-op; put upserts).

Tests: migrate roundtrip (opens to same plaintext), no-legacy-store no-op, and the
cross-org relocation defense still holds after migration. Full kms suite green (2
pre-existing admin-edge fails only).
2026-07-16 14:55:16 -07:00
hanzo-devandz b31c642959 feat(kms): per-org SQLite store — replaces OS-locked embedded ZapDB, lifts replicas=1
The embedded luxfi/kms ZapDB (a Badger fork) held an exclusive OS lock on ONE dir
for ALL orgs — the hard reason cloud ran replicas=1 behind a cross-process writer
lease. This replaces it with per-org encrypted SQLite ({DataDir}/orgs/{org}/kms.db
via cloud.OrgDB->cek, per-db DEK), which has no exclusive-opener lock, so distinct
tenants never contend and different pods can serve different tenants.

Crypto stays in the client (Seal/Open AES-256-GCM envelope, AAD-bound to the FULL
/orgs/{org} path): plaintext never reaches the file, and a record physically moved
into another org's file still fails to Open (cross-org swap defense preserved).
Adds cloud.PlatformDB for the reserved non-tenant (_platform) partition.

Verified: 54 PASS / 2 FAIL in clients/kms; the 2 fails (admin-edge dualmount/authz)
are PRE-EXISTING — proven failing identically on origin/main. concurrent_open_probe
proves per-org SQLite has no exclusive lock. Build + finance (pure-Go) green.

Branch only — NOT for main/deploy until red review passes.
2026-07-16 14:55:16 -07:00
zandhanzo-dev 2ac7f835e2 billing: the edge gate keys the account the debit spends
The gate keyed `user := home` — the org pool, always — on the premise that
prepaid billing is per-org. That premise is false. A person in the shared signup
org holds their OWN account: its members are strangers, not a team, and a shared
org is not a shared wallet. That is what IAM's signed billing_account claim states
and what ai's meter debits.

So the gate authorized against a balance nobody drained. Fund the pool and a
signup-org person still 402s, because their usage comes out of their own account;
fund the person and an empty pool blocks them anyway. Two layers, two answers, one
request.

Resolve through hanzoai/account.Payer — the same function ai debits with, on the
same credential — so the gate and the debit cannot name different accounts. The
premise is removed rather than restated.

The masquerade split is preserved by construction, not by care: the account is
resolved WITHIN the home org, so Account.Org IS the home org and a SuperAdmin
acting in another org still bills their own ledger. A claim naming a foreign
ledger is refused, so it cannot redirect a debit into the org being acted on.

Tests assert against the rule rather than a constant, so they cannot drift the way
the premise did: the signup-org person keys their own account, a real org pools,
the claim wins for a person and a project, and every case is checked equal to what
the debit computes.
2026-07-16 14:42:34 -07:00
hanzo-dev c563d8a4a6 merge: iam2 embed subsystem — CLOUD_IAM_IMPL selects beego (default) or iam2
clients/iam2 mounts the clean-room zip+orm IAM at /v1/iam when CLOUD_IAM_IMPL=iam2,
else beego, byte-for-byte unchanged. Flag OFF by default → inert. iam2 v0.1.1 seam.
2026-07-16 14:38:03 -07:00
hanzo-dev 724d011417 feat(iam2): select /v1/iam impl via CLOUD_IAM_IMPL (default beego, unchanged)
apps.Wire's identity slot now calls identitySpec(): CLOUD_IAM_IMPL=iam2 mounts the clean-room iam2 (zip+orm), anything else — including unset, the production default — keeps the legacy beego Casdoor embed byte-for-byte. The two impls own the SAME absolute prefixes (/v1/iam/*, /login/oauth/*) and cannot co-mount, so exactly one occupies the slot per boot and mount order is preserved. Off by default => completely inert until a canary flips the flag; selection (this) stays orthogonal to activation (cfg.Enabled).
2026-07-16 14:32:15 -07:00
hanzo-dev 53e22f0875 feat(iam2): clean-room IAM v2 embed subsystem (zip+orm, beego-free)
clients/iam2 folds the beego-free Hanzo IAM v2 into the unified cloud binary as an in-process identity plane — the either/or twin of clients/iam. Matches the cloud.Typed contract func(*zip.App, cloud.Deps) error: cloud hands subsystems a cloud.Deps (not an orm.DB), so Mount opens its OWN embedded SQLite ({DataDir}/iam2/iam.db, mirroring the beego embed's {DataDir}/iam layout), seeds config new-only+idempotent from the SAME init_data.json the beego iam uses (non-fatal, honest degrade), then iam2server.Mount registers the whole surface at the canonical absolute paths.

Fail-closed like clients/iam: a store-open or mount failure serves 503 on the identity prefixes while every co-resident subsystem stays up; iam2server.Mount's only panic path (a registered enterprise feature) is recovered in safeMount so it never crashes the shared binary. A TODO marks where the parallel-lane hanzoiam/{scim,saml,ldap} feature.Register lines land.

Pins github.com/hanzoai/iam2 v0.1.1 (seam held stable across the parallel internals refactor); transitive MVS bumps are all patch-level within v1.x (zip 1.8.3, orm promoted to direct, luxfi/crypto 1.20.1, argon2id 1.0.0, pgx 5.9.2). Inert until wired — see the apps.Wire gating follow-up.
2026-07-16 14:32:06 -07:00
hanzo-dev 7b63b49324 fix(ci): bound the SECOND version scan too (compute step) — same unbounded --paginate
The compute-next-version step had the same full-registry --paginate as the tag
step (fixed in f0abd21). It ran first, so it could hang before the build. Bound
it to one page too. Both version scans are now O(1 page), not O(registry).
2026-07-16 14:28:00 -07:00
hanzo-dev 0494a6d3c7 feat(zen): warehouse + gen_ai span emission with exact margin — ai v1.814.0
zen's commerce Meter now also calls ai's TraceServedUsage (recordTrace
WITHOUT recordUsage — the commerce debit stays the ONE billing source,
never doubled), carrying zen's exact per-tier retail (Charge) and upstream
COGS (Cost) folded atto→nano, so zen* traffic in the unified binary lands
in hanzo.cloud_usage + the o11y span plane with TRUE margin instead of
being warehouse-blind. Rides the ai v1.813.1→v1.814.0 bump; balance.go
(+ its drift-guard test) migrated to the renamed Payer/PayerOf API —
same subjects, one rule.

Claude-Session: https://claude.ai/code/session_018PmFAHZvbBSTsuWyebwMra
2026-07-16 14:26:17 -07:00
zandGitHub 27cc091884 Merge pull request #315 from hanzoai/cloud-zen-family-events
feat(zen): embedded-zen meter writes the family RoutingEvent (last link) + ai v1.813.5/zen v1.3.11
2026-07-16 14:08:50 -07:00
hanzo-dev 64b0d1f8a1 feat(zen): embedded-zen meter writes the family RoutingEvent (the last link)
The embedded zen mount serves the zen catalog in-process and never reaches ai's
pipeToFamily, so zen* calls produced ZERO routing events — starving stats, world,
spark retrain, and /v1/feedback joins. Wire cloud's zen Meter to ALSO write the
RoutingEvent through the ONE shared writer object.RecordFamilyRouting (source="family",
served arm = zen.Usage.Upstream, join key = zen.Usage.ResponseID — the client-visible
response id, new in zen v1.3.11 — tokens + retail cost), fire-and-forget beside the
existing debit. Bumps zen v1.3.7 → v1.3.11 (Usage.ResponseID); ai already v1.813.6
carries object.RecordFamilyRouting.

Claude-Session: https://claude.ai/code/session_015Z1iLf7QBrq1LhignJrzDw
2026-07-16 14:08:28 -07:00
antje 54b8bae797 git: Gitea push-webhook ingest (POST /v1/git/webhook)
The external Hanzo Git server (Gitea fork, git.hanzo.ai) POSTs push events
here so a push landing on it drives the SAME push-to-deploy core the embedded
smart-HTTP receive-pack path drives: fireBranchBuild -> cloud.OnGitPush deploy
trigger + EmitLifecycle. One code path, no duplication.

HMAC auth: X-Gitea-Signature is hex HMAC-SHA256 of the raw body, verified
constant-time against GIT_WEBHOOK_SECRET (KMS-synced hanzo/prod:/git/webhook-secret).
Fail-closed 401 on unset secret or mismatch. Only X-Gitea-Event: push acts;
others 204. Zero-SHA / non-branch refs are no-ops.
2026-07-16 12:05:08 -07:00
zandGitHub 5fb8f50e0a Merge decomplect/account-payer: migrate to ai.Payer — unbreaks main, ends the self-serve 402
main pinned ai v1.813.6 (which deletes billing_subject.go for the Payer/Account
refactor) without migrating the call sites, so main did not compile:
  clients/billing/balance.go:59: undefined: aiobject.BillingSubject

This migrates the call sites to the ONE rule: Payer(Credential)->Account
(owner = Person|Org|Project). Ends the live 402 where a self-serve signup's top-up
minted to the shared org pool 'hanzo' while the gate debited 'hanzo/alice' ($0) --
customer paid, locked out, money in a pool their org-mates could spend.

Green: go build -tags 'libsqlite3 sqlite_fts5' ./clients/... rc=0;
go test ./clients/billing ./clients/account rc=0. go.mod/go.sum identical to main.
2026-07-16 11:51:27 -07:00
hanzo-dev bd520de13f fix(billing): migrate off deleted BillingSubject → ai.Payer/PayerOf — unbreaks main
main pins ai v1.813.6, which contains the Payer/Account refactor (billing_subject.go
and its PERSONAL_BILLING_ORGS/ORG_BILLING_ORGS lying default are deleted). But the
call sites were never migrated, so main does not compile:
  clients/billing/balance.go:59: undefined: aiobject.BillingSubject
  clients/billing/balance.go:61: undefined: aiobject.BillingSubjectFromUserKey

Migrates balance.go + finance.go + billing.go to the ONE rule —
Payer(Credential{Owner,Name}).Subject() / PayerOf(org,key).Subject() — and DRYs the
duplicate subject resolvers. This is the cloud half of the fix that ends the live
self-serve 402 (top-up minted to the shared org pool 'hanzo' while the gate debited
'hanzo/alice' = $0).

go.mod/go.sum untouched vs main. Green: go build -tags 'libsqlite3 sqlite_fts5'
./clients/... rc=0; go test ./clients/billing ./clients/account rc=0.
2026-07-16 11:50:51 -07:00
hanzo-dev 65e782a855 refactor(billing): route top-up + console subject through ai.Payer, one rule
The console top-up (clients/account/billing.go) and the finance read
(clients/billing/finance.go) each re-implemented the billing subject as "always
the org" — the reverted lineage's rule. Against ai's gate, which bills a signup
person per-person, that is the split: money minted to subject "hanzo" (the pool)
while the gate debited "hanzo/alice" ($0) → the paid-up member 402'd.

Delete both twins; resolve the subject through the ONE rule, ai/object.Payer,
keyed on the IAM username (X-User-Name) the gate also keys on. Top-up credits and
console reads now land on the SAME account the gate debits — they cannot drift
because there is one function. The killed PERSONAL_BILLING_ORGS / ORG_BILLING_ORGS
env is inert here too (test proves hostile values change nothing).

Requires ai v1.809.5, which must be tagged FROM ai main after decomplect/account-payer
merges — NOT off a branch. The prior one-rule fix was tagged off an unmerged branch
(v1.806.8/.9), main never got it, and every later tag resurrected the allowlists;
that is why this bug is live. go.sum refreshes via `go mod tidy` once the tag exists.
Verified locally via a replace to the ai branch: clients build + twin tests green.
2026-07-16 11:47:10 -07:00
antje 74d94d1f3e ci(release): retire notify-universe — Hanzo CD owns git→cluster sync
The repository_dispatch deploy hub is gone (universe image-update.yml
removed in universe d07cf945; the dispatches were silently suppressed by
the flagged sender account regardless). Deploys are declared-tag bumps in
universe crs/, synced by Hanzo CD (ArgoCD, ns hanzo-cd) and reconciled by
the operator. [skip ci]
2026-07-16 11:36:10 -07:00
antjeandGitHub ce6c4dc374 deps: hanzoai/ai v1.813.1 -> v1.813.6 (balance-exempt routing config + enso family helper) (#314)
ai#102: /v1/get-routing-defaults + org-settings CRUD + routing-ledger
export are configuration metadata, never wallet-gated — unblocks reading
org routing defaults for $0-balance orgs and the operator platform flip.
Edge auto-routing billing tests green.
2026-07-16 11:28:33 -07:00
antje 2134876826 platform: allow the self-hosted fleet registry in the native build lane
registry.hanzo.ai/{hanzoai,luxfi,zooai}/ join the /v1/runner push allowlist —
Wave 0 of the native CI/CD migration. Until now only release.yml's crane
mirror could reach the fleet registry; the native BuildKit lane was
ghcr-only by policy.
2026-07-16 11:13:07 -07:00
hanzo-dev f0abd2116e fix(ci): version-assignment scanned the WHOLE registry — livelocked releases
The "atomic free-version assignment" step paginated every container version
(`gh api --paginate .../versions`) to find the max release number. As the
registry accumulated tags this grew unbounded and hung the step for 30+ min,
livelocking every cloud release. Container versions are created newest-first and
version tags are monotonic, so the max is always on the newest page — query one
bounded page (?per_page=100) instead of the full history. Fast + correct.
2026-07-16 11:11:39 -07:00
hanzo-dev ebf267908c refactor(ledger): drop redundant "core" — the ledger adapter is apps.ledger
Values, not places: the finance-backed credit-ledger adapter is qualified by its
namespace (apps.ledger), not a braided ledgercoreCredit compound. Rename the type
ledgercoreCredit → ledger, the file commerce_ledger.go → ledger.go, and scrub
"ledgercore" from prose (the ledger IS the core — "core" adds nothing). No behavior
change; admin/core tests green, changed packages build.
2026-07-16 10:18:47 -07:00
antje 4a64ef568b feat(chat): clients/chat — one /v1/chat tool-calling orchestrator
POST /v1/chat runs one LLM tool-calling round that lets a model manage a system
via tools. Composes existing cloud pieces, reinventing nothing:
- LLM routing + per-org reserve/settle billing: the ai subsystem's
  /v1/chat/completions, invoked in-process (Fiber Test) — the only path that
  both returns tool_calls AND carries the billing gate.
- tool plane (clients/tools): the org's registered MCP/registry tools are
  offered to the model and dispatched server-side (activation + price gated).
- capabilities: graph (advisory node-ops -> ops the client applies) and create
  (server-executed, tools = the org's registered MCP render services).
Returns {reply, actions, ops}. chat mounts before ai so /v1/chat resolves here
(the ai /v1/chat alias is shadowed); ai keeps /v1/chat/completions.
2026-07-16 10:02:29 -07:00
hanzo-devandantje 73632723f6 feat(ai): bump ai subsystem v1.813.0 → v1.813.1
Carries the PAID-Enso revert + the family learning loop: per-family-call RoutingEvents
+ shadow A/B (records what the learned engine would have picked), /v1/feedback signal
contract (up/down/regenerate/switch/abandon/accept/revert/rating/dismiss) with the
online reward forward to the engine's /route/observe, ROUTER_ADMIN_TOKEN service-auth
on the training-data exports, and Zen/Enso provider branding. Lights up /v1/router/stats
+ world.hanzo.ai (shadow-vs-served agreement).

Claude-Session: https://claude.ai/code/session_015Z1iLf7QBrq1LhignJrzDw
2026-07-16 09:53:56 -07:00
hanzo-dev f5d57e35f1 feat(ai): bump ai subsystem v1.812.0 → v1.813.0 — router policy, rewards, margin, DO backfill
Ships the per-org router policy (get/update-router-policy + the org > '*' >
conf fold on every auto route), the routing-reward ledger, the nano margin
ledger (costNano/billedNano/marginNano + unpriced flagging), and the
gaps-only DigitalOcean usage backfill (POST /v1/admin/usage/backfill-do,
dry-run default). The release image also re-embeds console@main
(CONSOLE_REF=main), picking up the console Router page (v8.4.137+).

Claude-Session: https://claude.ai/code/session_018PmFAHZvbBSTsuWyebwMra
2026-07-16 09:28:20 -07:00
hanzo-dev 8f61d57963 feat(credit): ONE ledger seam — commerce credit + admin grant mint into finance.Current()
The last mile of "one way to grant credit": cloud implements commerce's
creditledger.CreditLedger (v1.48.5) over the native finance ledger and injects it
at mountCommerce (EmbedConfig.Ledger). Now commerce's POST /v1/billing/credit AND
the admin.hanzo.ai grant (/v1/admin/customers/:org/credit) both mint into the SAME
per-org finance wallet the ai prepaid gate reads — a granted credit is immediately
spendable, no split ledger. The admin path drops its parallel finance.Deposit for
the one creditledger.Credit call (idempotency key rides through; commerce HTTP
deposit remains the split-deploy fallback).

- apps/commerce_ledger.go: ledgercoreCredit adapter (compile-time asserted against
  commerce's exported interface); org-pool wallet (Subject==Org); fails closed with
  no co-resident finance.
- apps/commerce.go: inject Ledger: ledgercoreCredit{} at mountCommerce.
- clients/admin/core/grant.go: grantDeposit prefers creditledger.Get() (the one
  ledger) over its own finance.Deposit.

Verified: changed pkgs build green on commerce v1.48.5 + ai v1.812.0; admin/core
grant tests pass. Ships with the ai free-tier removal (v1.811.0+) already on main.
2026-07-16 09:14:59 -07:00
antje 5bfe2842a1 ci(release): pass GIT_AUTH_TOKEN build secret — Dockerfile renamed the id from gh_token (46081689) but the workflow still passed gh_token, so the private console clone ran credentialless and the release failed 2026-07-16 01:50:00 -07:00
antje 4608168991 build: consume the standard GIT_AUTH_TOKEN build secret (was gh_token)
One secret id everywhere: BuildKit's gitsource convention (GIT_AUTH_TOKEN,
which the fabric's buildFrontendCmd already attaches) is also what the
Dockerfile's console-embed and go-mod fetch stages mount. gh_token was a
second name for the same credential.
2026-07-16 01:19:03 -07:00
antje a74c95abc2 deps: hanzoai/ai v1.812.0 + commerce v1.48.5 — reward ledger, router policy, connections import reach prod
ai v1.809.4 → v1.812.0: RoutingEvent reward ledger (/v1/add-routing-reward,
/v1/export-routing-rewards), per-org router policy + observability, Enso
public arms + retrain-status, connected-provider usage import (OpenAI/
Anthropic), own-brand god-view gate (TokenIsOwnBrand), Bearer-aware
get-cloud-usages, billing nano margin ledger, unpriced-model flagging.

commerce v1.48.2 → v1.48.5: mint-gated org-keyed credit primitive +
injected-ledger seam; fixes the phantom luxfi/cevm test import that broke
go mod tidy for every consumer (this repo included).
2026-07-16 01:16:12 -07:00
antje b7e4843cd7 feat(fabric): GIT_AUTH_TOKEN build secret — private-repo fetches for the build Jobs
The buildkit Job now surfaces the console-git-token Secret (optional) as the
GIT_AUTH_TOKEN env, and buildctl attaches it as the same-named build secret;
BuildKit's gitsource presents it as the HTTPS credential for the git context.
Fixes the fabric's inability to build PRIVATE repos (github.com/hanzoai/cloud
itself: 'could not read Username' — the rel_HvInGKU failure). Public repos and
Secret-less clusters fetch anonymously exactly as before.
2026-07-16 01:15:07 -07:00
antje 9b97ca9164 feat(git): public repos — anonymous read for the build fabric
A repo gains a visibility bit: PATCH /v1/git/repos/:name {"public":true}
(or create-time "public"). Public grants ANONYMOUS upload-pack/info-refs
only — receive-pack and the whole control plane stay org-authed, and a
private or missing repo answers the same uniform 404 so anonymous probing
cannot enumerate.

This is what lets the credential-less buildkit build Job (launchDirectBuild
git context) fetch from the embedded git server — the same reason public
GitHub repos build with no env. Private-repo builds remain a later
GIT_AUTH_TOKEN feature.

resolvePackRepo grows the allowPublic branch (fetch-side only): anonymous
callers address org-level repos by the orgRE-validated :org path segment;
the path-vs-identity guard for authenticated callers is unchanged.
2026-07-16 01:05:30 -07:00
antjeandGitHub 4b255e8e9a test(platform): end-to-end git push -> buildFromPush enqueue proof (#311)
Wire a REAL smart-HTTP git push through the actual production seam
(cloud.RegisterPushBuilder <-> cloud.OnGitPush) into the real platform
push builder, and assert the matching app's build is enqueued (a
building deployment for the pushed commit). The two halves were covered
in isolation (clients/git TestPushFiresBuildTrigger; platform
TestBuildFromPush_LaunchesMatchingApp) but nothing connected a live push
to the real builder end to end. Pure-Go dev build (CGO_ENABLED=0).
2026-07-16 00:45:07 -07:00
hanzo-dev 41be25c713 Merge land/slack-on-bridge: Slack chat path onto the generalized bridge — one chat code path 2026-07-16 00:15:57 -07:00
hanzo-dev 301706dd54 refactor(integrations): Slack chat path onto the generalized bridge — one chat code path 2026-07-16 00:13:59 -07:00
zandGitHub 746b42209f Merge feat/bots-control-plane: decomplect the bot plane, proxy instead of duplicate
Deletes clients/bot — a place-name that had collected three concerns (a mount, a
transport, and two unrelated domain wire protocols). One value per module:
  run       -> clients/bots      /v1/bots (no store)
  dispatch  -> clients/coding    /v1/coding-tasks
  transport -> clients/runtime   address, identity, framing, cleartext policy
  machine   -> clients/visor     /v1/compute/bots (moved off /v1/bots)
  session   -> clients/agents    the one registry (reverted to main)

Fixes a live route collision: visor's listBots silently won GET /v1/bots (the
router merges byte-identical patterns, first wins), so cloud served machine rows
as runs and orgs' real runs were invisible.

POST /v1/bots/run now returns 501 instead of charging $1.00 for a bot that never
booted -- the runtime has no launch endpoint. Stop fails closed: a bare 404 is
502, only a structured error body means already-stopped.

Net -613 lines. runtime never imports bots/coding; the reverse is a compile-error
cycle, so the one-way dependency is structurally enforced.
2026-07-15 23:27:48 -07:00
zandGitHub 58d39546b1 Merge fix/billing-balance-collision: read prepaid balance from the finance ledger, not a self-call
Cloud's /v1/billing/balance proxy was the only handler registered (commerce's
GetBalance is behind //go:build cloud, never compiled in) so it called itself,
depth 2. Reads the finance ledger via the existing finance.Current() seam and
single-sources the subject from aiobject.BillingSubject so console and gate
cannot drift.
2026-07-15 23:27:02 -07:00
zandGitHub a7fcf77247 Merge pull request #310 from hanzoai/bump/ai-v1.809.4
chore(ai): bump embedded ai v1.809.3 -> v1.809.4 (enso comp cold-cache fix)
2026-07-15 23:07:00 -07:00
hanzo-dev e324fdb236 chore(ai): bump embedded hanzoai/ai v1.809.3 -> v1.809.4 (enso comp cold-cache fix)
Claude-Session: https://claude.ai/code/session_015Z1iLf7QBrq1LhignJrzDw
2026-07-15 23:06:41 -07:00
hanzo-dev d678ba2f6b bots: proxy the runtime instead of copying its state; name the transport
Red found the substrate was wrong: cloud kept its own registry of runs. It minted
an id the runtime had never heard of, so /v1/bots listed runs that did not exist,
stop closed records for runs that were never started, and run charged $1.00 for a
bot that never booted — the runtime has no launch operation at all, and nothing
in run ever contacted it. The registry that exists is the runtime's own tenant
store; it is the only thing that knows whether a sandbox is alive.

So cloud owns policy and the runtime owns the run:
  - GET /v1/bots and POST /v1/bots/:runId/stop proxy the runtime, gated by the
    validated principal and org. The org is cloud's, never the client's, and the
    runtime keys every run under tenants/{org}/ — so a foreign run resolves under
    the caller's org, where it does not exist, and 404s.
  - POST /v1/bots/run returns 501. There is no launch operation to call, so the
    honest answer is that it is not implemented. It no longer charges.
  - The agents session plane is reverted verbatim: no surface column, no Agent
    filter, no SessionOpen, no in-process stop. It never should have carried a
    second copy of the runtime's state.

Absence is only meaningful from a callee that could have said otherwise, so the
transport separates ErrNotFound (the operation ANSWERED absent) from ErrNotServed
(no such operation). A runtime without the stop route reports absent for every
run; treating that as "already stopped" made a stop that cannot fail. It is 502.

Decomplect the transport: clients/bot was named for the host it dials and braided
three concerns — an ops face plus two unrelated domain protocols. It is now
clients/runtime, a domain-free transport (address, identity, framing, cleartext
policy, /v1/bot/* ops face) that must not import bots/coding. Each domain owns its
own wire stub (bots/wire.go, coding/task.go), so the ZAP swap per HIP-0106/0120 is
a seam swap. Wire spec bot -> runtime; cmd/bot -> cmd/runtime.

The duplicate-route guard was a no-op: the router MERGES byte-identical patterns
into one route with chained handlers, so counting entries never saw the collision
it guarded. It now asserts one handler per route, and a test proves it fires on
the original bug. Fiber's Test() defaults to a 1s wall-clock deadline, which made
the isolation guards flake under load; they now pass an explicit timeout.

Route table unchanged: /v1/bots (runs), /v1/compute/bots (machines), /v1/bot/*
(runtime ops).
2026-07-15 22:52:24 -07:00
hanzo-dev c24b601825 feat(git): JSON read/browse surface for the console repo-browser
The console repo-browser (hanzoai/console products/git) needs machine-readable
refs/tree/blob/commits/readme, but git only served those as HTML (ui.go) + the
repo-CRUD control plane. Add the JSON twin, reusing ONE set of go-git read
helpers so the HTML + JSON surfaces can never drift:

  GET /v1/git/repos/:name/refs                   → { branches, tags, default }
  GET /v1/git/repos/:name/tree?ref&path          → { entries:[{name,path,type,size,mode}] }
  GET /v1/git/repos/:name/blob?ref&path          → { path,size,encoding,content,binary,truncated }
  GET /v1/git/repos/:name/commits?ref&path&limit → { commits:[{sha,shortSha,message,author*,date}] }
  GET /v1/git/repos/:name/readme?ref             → { path, content, encoding }

- browse.go: the handlers + DTOs (mirror the console GitApi normalizers verbatim).
  ref+path ride as ?ref=&path= query params (the UI's own convention) so a slashed
  branch is unambiguous. Reuses org()/findRepo()/openGit()/resolveRef()/
  cleanTreePath(). Org-scoped (X-Org-Id); a repo outside the caller's org 404s.
  Distinct trailing segments — never shadow the :org/:repo smart-HTTP routes.
- ui.go: findReadme → readmeAt (returns filename+content); the ONE readme scan now
  backs both the HTML repo home and the JSON /readme (DRY, no duplication).
- browse_test.go: seeds a nested tree + README + binary file, asserts every endpoint
  + org isolation + honest 404/403 + empty-repo refs.

CGO_ENABLED=0 go test ./clients/git/ green (full package); go vet + build clean.
Stays v1.x.x (Go module). Ships with console@main on the next cloud release.
2026-07-15 22:23:09 -07:00
hanzo-dev 3d2b7c87b5 fix(billing): read the prepaid balance from the finance ledger, not a self-call
GET /v1/billing/balance and /v1/finance/balance proxied to commerce at the SAME
path they are registered on. commerceinproc publishes the shared zip app and
re-dispatches by path, and commerce's own /v1/billing/* routes are never
registered in this binary (api.Route runs only from commerce's mount.go, which
is //go:build cloud; cloud ships -tags "libsqlite3 sqlite_fts5"). So the proxy
re-entered itself, hit its own sign-in gate with no principal, and reported
"billing upstream status 500".

Co-resident, the prepaid wallet is cloud's own finance ledger: wireFinance points
the ai gate's balance read at it, the edge meter debits it (metering.fetchAvailable
already resolves finance.Current() first), and an admin grant credits it
(core.grantDeposit already prefers it). Read it directly through that same seam.
The commerce S2S read stays as the split-deploy fallback.

The subject comes from ai/object.BillingSubject — the function the ai prepaid gate
itself resolves — instead of a re-implemented copy. cloud and ai each keeping their
own copy of that rule is what let them drift: the console scoped to the org while
the gate scoped to "org/user", so the view showed a funded org while the gate
refused the member.

Fail posture unchanged: a balance that cannot be read is UNKNOWN and surfaces as
502 — never rendered as a zero balance. The sign-in gate and org scoping are
untouched; the org still comes from the validated principal only.

Tests: self-dispatch pinned at depth 2 (the seam's real mechanics, which the
existing SetHandler-stub test cannot see); router semantics probed (byte-identical
patterns MERGE and silently shadow; equal-specificity param-name conflicts PANIC at
registration; most-specific wins over registration order); balance regression,
gate-subject parity, unreadable-is-not-zero, sign-in gate, and cross-org isolation.
2026-07-15 22:16:46 -07:00
antje 7dd7a9b4dd feat(insights): /v1/insights — the unified native surface on the ONE analytics engine
A wire adapter, not a second pipeline: POST /v1/insights/e accepts PostHog-
shaped payloads (single or {batch}) from @hanzo/insights and any compatible
SDK, maps $-properties onto the native CaptureEvent, and rides the SAME
capture path (tenant gate -> normalize -> scrub -> hanzo.events). GET
/v1/insights/events is the console's tenant-scoped recent-events read; GET
/v1/insights/health reports the surface. Flags deliberately stay at /v1/flags.

Scale path stays stateless: accept on any replica, pooled batch INSERT sink;
the queue-buffered (mq/pubsub -> Datastore consumer) upgrade swaps the exec
behind buildEventsInsert with no handler changes.
2026-07-15 21:35:30 -07:00
zandGitHub 8d719c9821 Merge pull request #309 from hanzoai/bump/ai-v1.809.3
chore(ai): bump embedded ai v1.809.2 -> v1.809.3 (comped preview balance fix)
2026-07-15 21:30:55 -07:00
hanzo-dev f58f0b7c2a chore(ai): bump embedded hanzoai/ai v1.809.2 -> v1.809.3 (comped preview bypasses enforceBalanceGate)
Claude-Session: https://claude.ai/code/session_015Z1iLf7QBrq1LhignJrzDw
2026-07-15 21:30:09 -07:00
hanzo-dev 94ec6ecbb9 Merge land/hybrid-connectors: injective tenant S3 key (close cross-tenant collision) + orgPathSafe guard 2026-07-15 21:21:56 -07:00
hanzo-dev f7fd44618c fix(projects): injective tenant S3 key via verbatim principal.Org (close cross-tenant collision) + orgPathSafe traversal guard 2026-07-15 21:21:25 -07:00
hanzo-dev 3ddbe91e63 bots: state the list cap explicitly
The published contract has no pagination, so the list is capped either way; set
it at the store maximum here rather than inherit the store's 100-row default.
2026-07-15 20:52:36 -07:00
hanzo-dev 8360315345 bots: give the three "bot" values one namespace each; make the run control plane native
GET /v1/bots was registered twice: clients/visor (bot machines) and clients/bots
(bot runs). The router resolves byte-identical patterns by first-registration
without panicking, and visor mounts first, so visor's machine list answered the
console's run list and clients/bots.list was unreachable. The console normalized
machine rows into run rows, yielding one blank-runId row per kind=bot machine
with a dead sessionUrl, and hid the org's real runs.

Name the values apart, one home and one route namespace each:
  - bot run     -> clients/bots  /v1/bots            (unchanged; the console + CLI contract)
  - bot machine -> clients/visor /v1/compute/bots    (moved; a machine is compute)
  - bot runtime -> clients/bot   /v1/bot/*           (passthrough for runtime-owned ops)

Make the run control plane native. clients/bots holds no store: a run is recorded
on the agents session plane under agent label "bot", so the run id is the session
id and one registry serves every kind of agent work. list reads it org-scoped;
stop resolves (org, runId) against that record and drives the runtime only after
ownership is proven, so a run of another tenant is a 404 the runtime never hears
about. An unreachable runtime is a 502 with the record left live.

Two seams (Runs, Runtime) are injected in adapters.go, the only file in
clients/bots importing agents/bot, so handlers unit-test against fakes.

agents: sessions carry a surface (the modality a session runs on, matching the
runtime's own origin.surface) via the established additive-column migration;
SessionFilter gains Agent so a product face reads only its own sessions;
OpenSession takes SessionOpen; StopSession is the single-session twin of
StopSessions and shares its one write path (stopOne).

Contract change: a run id is now the session id, not bot_<hex>. No bot_ id was
durable anywhere -- the old run handler minted an id and stored it nowhere -- so
there is nothing to migrate. Ids stay opaque to clients.
2026-07-15 20:38:54 -07:00
antje 6781eed94f cli/gpu: heartbeat the claimed activity + fleet presence DURING a render (goroutine in claimAndRun) — a >120s render no longer drops the machine offline mid-render; fixes BYO-GPU fix/compose/render hitting the deadline + going offline 2026-07-15 20:21:31 -07:00
zandGitHub cd082ca1cb Merge pull request #308 from hanzoai/bump/ai-v1.809.2
chore(ai): bump embedded hanzoai/ai v1.809.1 -> v1.809.2 (Enso limited-preview gating)
2026-07-15 19:19:07 -07:00
hanzo-dev 6c7a89f8d9 chore(ai): bump embedded hanzoai/ai v1.809.1 -> v1.809.2 (Enso limited-preview gating)
Lands the Enso limited-preview gating into the live api.hanzo.ai binary
(cloud embeds ai in-process via ai.Mount). v1.809.2 adds ai's Enso family
routing (ENSO_URL -> enso pod) + waitlist gating: ModelAccess visibility in
/v1/models, 403 request-access for ungated SKUs, comped bypass of the balance
gate for granted callers, POST /v1/models/:model/access, org `hanzo` seed.

Independent of the concurrent zen v1.3.0 -> v1.3.7 bump (#307), which this
branch is based on top of.

Claude-Session: https://claude.ai/code/session_015Z1iLf7QBrq1LhignJrzDw
2026-07-15 19:16:00 -07:00
hanzo-dev b5b1a09952 Merge land/chat-connectors: generalized ChatBridge + Discord/Teams/Telegram adapters for hanzo.chat 2026-07-15 19:11:41 -07:00
hanzo-dev 767e089410 land(chat): generalized ChatBridge + Discord/Teams/Telegram adapters for hanzo.chat 2026-07-15 19:09:52 -07:00
zandGitHub 5cbf1ed90e Merge pull request #307 from hanzoai/fix/zen-bump-clean
fix(zen): bump embedded hanzoai/zen v1.3.0 → v1.3.7 (1M ladder fix)
2026-07-15 19:04:13 -07:00
hanzo-dev 9d8f86d191 fix(zen): bump embedded hanzoai/zen v1.3.0 -> v1.3.7 (1M ladder need() fix)
The cloud binary serves the zen* family IN-PROCESS via zen.Mount, so the
embedded module version — not the zen pod image — is what serves zen5. v1.3.0
sized the ladder rung by the byte estimate alone: a ~230K-token prompt with a
32K max_tokens budget estimated ~258K (under glm-5.2's 262144 cap), stayed on
glm-5.2, which then saw prompt+output = 262145 and 400'd. v1.3.7 sizes the rung
by need() = messages + tool schema + max_tokens, overflowing a >262144 total to
the 1M deepseek-v4-pro rung. Brings the Enso family + gating in-binary too.

Claude-Session: https://claude.ai/code/session_015Z1iLf7QBrq1LhignJrzDw
2026-07-15 19:03:37 -07:00
antje f49a6ac2b2 fix(image): runtime needs libgcc — the flags staticlib references _Unwind_*
The hanzo-flags Rust staticlib compiles with unwinding (panic-guarded FFI);
its _Unwind_* references resolve from libgcc_s, which the alpine runtime did
not carry — /cloud failed relocation at exec ('Error relocating /cloud:
_Unwind_GetIP: symbol not found') and smoke red-gated the release. Add libgcc
to the runtime apk set.
2026-07-15 18:47:52 -07:00
hanzo-dev 92004893be Merge land/agent-deploy-sites: agent AI-brief site build+deploy to <slug>.hanzo.app, metered per deploy 2026-07-15 18:41:36 -07:00
hanzo-dev 3d294ce07c land(sites): agent AI-brief site build+deploy to <slug>.hanzo.app, metered per deploy 2026-07-15 18:40:05 -07:00
antje 1b552d890c feat(flags): native flag engine — stateless Rust FFI + SQLite per project, /v1/flags
native/flags: hanzo-flags, a stateless Rust staticlib with PostHog-compatible
evaluation — the exact Insights rollout hash (sha1 first-15-hex / LONG_SCALE,
pinned by test vectors), the full vendored property-operator set (exact/regex/
semver/date/relative-date...), condition groups (variant-override groups first),
multivariate cumulative selection, payloads. Pure (defs JSON, ctx JSON) ->
response JSON behind a panic-guarded C ABI: hanzo_flags_evaluate/_free.
65 tests green.

clients/featureflags becomes the NATIVE engine (no external evaluator, no KV,
no network): definitions in per-(org, project) SQLite via cloud.OrgDB
(encrypted at rest via cek), hot in-memory platform snapshot (TTL 15s),
evaluation over cgo. The INSIGHTS_FLAGS_URL HTTP proxy is gone. Bool/Int/
String and the admin Board keep their shapes; sources are flags -> env ->
default (first cockpit write creates the definition — env fallback intact
until then, zero regression). engine_stub (!cgo) degrades loudly, fail-safe.

/v1/flags (org-scoped via principal, project via X-Project-Id): POST /v1/flags
(+/decide alias) evaluate; GET/PUT/DELETE /v1/flags/defs[/:key]; GET
/v1/flags/activity; GET /v1/flags/health. PUT /v1/admin/flags/:key (SuperAdmin)
is the cockpit write path through SetPlatformSwitch — flips apply immediately
in-pod, peers converge within one TTL.

Build: Dockerfile flagslib stage (rust:1-alpine musl staticlib, --locked) copied
to the exact ${SRCDIR}-relative cgo link path; make native; hanzo.yml
native-flags step + clients/featureflags in the hermetic unit gate.
go test ./clients/featureflags ./apps green (FFI live).
2026-07-15 18:38:01 -07:00
zeekayandClaude Opus 4.8 23d9c4809a fix(console): white-label the embedded console <title> by request Host
The console SPA shipped in the unified cloud binary is a STATIC export of
hanzoai/console whose <title> is baked to the default (Hanzo) brand at build
time. The embed cannot read the request Host, so every host — including
console.lux.cloud and console.zoo.cloud — served "Hanzo Cloud Console" in the
browser tab, a white-label violation. (The standalone Next.js app is host-aware
via generateMetadata, but it is retired from this serving path: cloud serves
the console in-process from the go:embed static export.)

Rewrite the SPA shell's <title> at the serving layer (serveIndex/indexFor) to
the request host's brand, reusing the existing brands registry (BrandForHost):
console.lux.cloud -> "Lux Cloud Console", console.hanzo.ai -> "Hanzo Cloud
Console" (unchanged), console.zoo.cloud -> "Zoo Cloud Console". Matches
hanzoai/console's own `${brandName} Console` output — one source of truth. The
Hanzo/default host returns the embedded bytes unchanged (no regression); a shell
with no <title> is never altered.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 18:25:22 -07:00
hanzo-dev 9c2eb2cf64 Merge land/featuregate: per-service waitlist-mode control plane 2026-07-15 18:10:27 -07:00
antje 0414fe2975 deps: bump ai v1.808.1 -> v1.809.1
Brings live: get-cloud-usages Bearer + balance-exempt (usage panel works at
$0), the /v1/ai/connections usage-import endpoint. Console changes ride the
same release via CONSOLE_REF=main embed. Build green.
2026-07-15 17:38:07 -07:00
hanzo-dev 6529cf7d3e land(featuregate): per-service waitlist-mode control plane
Global SQLite registry {service, hosts, waitlistMode} + admin board/toggle
(/v1/admin/services*, /v1/featuregate/mode) + native Enforce middleware and
IAM-backed per-user approval resolver. Ported from recover/featuregate; dropped
the init()/cloud.RegisterWithShutdown self-registration in favor of the explicit
apps.Wire() MountSpec (order after admin, before tasks) + frozen wire_test row.

Distinct from clients/featureflags (a global read-only Insights waitlist_open
switch); this is the per-host launch lever with in-binary enforcement.

Claude-Session: https://claude.ai/code/session_01SpMZ69ur3tjAXCiwaa7Wv2
2026-07-15 13:20:25 -07:00
z 33072f27b3 platform: guard IAM object-store against a nil engine + commerce v1.48.2
Two co-residence fixes surfaced by the live founder-journey e2e:

1. /v1/platform/projects (authed) returned 500 'runtime error: invalid memory
   address' — iamobj.GetProjects/AddProject deref the package-global ormer.Engine,
   nil until the co-resident IAM subsystem initializes it. A nil store must fail
   cleanly: the iamStore() guard converts a nil-deref into a typed 503. (The store
   is initialized once iam co-mounts — v1.801.21 embedded-SQLite isolation.)
2. Bump commerce v1.48.1 -> v1.48.2: ErrorHandlerJSON preserves a downstream
   typed HTTP status, so a co-resident subsystem's 401/403/404 (e.g. platform's
   'X-Org-Id required') is no longer flattened to 500 by commerce's /v1 mount.

Regression tests: nil-store -> 503, success + real-error passthrough.
2026-07-15 13:11:54 -07:00
hanzo-dev ea3ccdbccd controlplane seam (c): R1 — first-writer-wins registry + self-authorized Rekey (RED R1)
The ValidatorRegistry is the trusted key source for the whole cert, so key binding must be rogue-key-resistant BEFORE the driver sources idVerifier into the cert verifier. Register is now first-writer-wins: a node already bound to one ML-DSA key may not be silently re-bound to a DIFFERENT key (ErrIdentityKeyConflict); idempotent re-register of the identical key stays allowed. The ONLY sanctioned rotation is Rekey(node, newPub, authSig) — self-authorized by a signature under the node's CURRENT key over rekeyTBS under a DISTINCT rekey context (not popContext/certContext); an attacker without the current secret key cannot rotate. This is the registry action a consensus-ordered OpRekeyValidator applies (op plumbing lands with the RSM rewire).

5 R1 tests green; existing OnePodOneShare + full suite unaffected.
2026-07-15 12:41:21 -07:00
hanzo-dev 8417cba676 controlplane seam (c): two-threshold self-guard + raw-craft verify tests (RED findings 1,3)
Finding 1: guardBFTFloor(len(keys), quorumWeight) fails closed unless the floor is the byzantine-safe BFT quorum (2n/3+1) for the validator-set size — so a mis-wired wallet-custody t (=3 for n=5) can neither compose nor verify a cert even if a future driver passes it. Called at the top of ComposeControlPlaneCert and VerifyControlPlaneCert. This is the exact two-threshold trap inc-1 hit (cluster.go wiring Pulsar threshold = quorum), now impossible in the cert core.

Finding 3: three raw-craft verify-side tests drive hand-built malicious certs (bypassing the honest composer) straight at VerifyControlPlaneCert — sub-quorum threshold-lie (ErrQCThresholdBelowFloor), attacker-key stuffing (ErrQCMerkleInclusion), weight inflation (ErrQCAggregateWeight) — plus a self-guard test (t=3 refused). 14 cert tests green.

Flag still false; ceremony untouched; existing byzantine suite green.
2026-07-15 12:41:21 -07:00
hanzo-dev 493d4ef01a controlplane inc-2 seam (c): real independent-sig ConsensusCert crypto core
The shipped Gen-3 weighted-quorum cert the design chose over the blocked threshold-Pulsar path. Each pod signs the canonical quorum message INDEPENDENTLY with its seam-a ML-DSA-65 key under a DISTINCT cert context (RED R4: certContext != popContext); the cert is a quasar.ConsensusCert carrying one EvidenceWeightedSigSet leg (a WeightedQuorumCert of N independent FIPS-204 sigs + weighted-Merkle quorum). Verification is quasar.VerifyConsensusCert under a control-plane ConsensusCertPolicy that requires the weighted-sig-set PQ leg — the shipped, audited verifier; NEVER the structural QuasarCert.Verify. No DKG, no threshold aggregate, no unshipped luxfi/pulsar core: soundness rests only on stock FIPS-204 verify + the weighted-validator-set Merkle commitment. Composes against luxfi/consensus v1.35.32 (BuildWeightedValidatorSet / BuildWeightedQuorumCert / VerifyConsensusCert).

VerifyControlPlaneCert binds the cert to the caller's expected position before the cryptographic verify (VerifyConsensusCert pins validator-set+policy but not the caller's height/round/block).

10 standalone crypto tests green: real cert verifies under policy; below-quorum / missing-leg / forged-sig / rogue-signer / wrong-position / wrong-validator-set all REJECTED with exact typed errors; R4 proven (a popContext sig is rejected as a cert sig); deterministic composition. Self-contained (does not touch the ceremony); existing suite stays green. Flag NOT yet flipped — ceremony rewiring + the ProductionBCCSigningReady() flip + stub deletion follow.
2026-07-15 12:41:21 -07:00
hanzo-dev fb5a9024db deploy: rename the CD surface gitops -> /v1/deploy
The ArgoCD fork now lives at hanzoai/deploy (hanzoai/gitops redirects to it), so
the cloud CD control plane takes the matching single-word name: clients/gitops ->
clients/deploy, /v1/gitops/* -> /v1/deploy/*, and the subsystem name (Wire entry,
enablement key, health surface) is 'deploy'.

Same plane, same ArgoCD-grade fleet view over our own App CRs — applications,
resource tree, live manifest + diff, logs, sync, rollback.
2026-07-15 12:30:57 -07:00
antjeandGitHub fbf347d851 cloud(cmd): generate standalone connectorruntime binary
Completes the per-app-binary invariant: connectorruntime (HIP-0126) is in apps.Wire() but its generated cmd stub was dropped by a stale reconciliation merge. go generate ./apps restores it. cicd green.
2026-07-15 11:18:51 -07:00
hanzo-dev 3322f7377f Merge fix/iam-coresidence-db-isolation: isolate embedded IAM SQLite (unblock iam+ai) + CLOUD_ENABLE_STAGED
# Conflicts:
#	clients/iam/iam.go
2026-07-15 10:40:00 -07:00
hanzo-dev a389074817 Merge chore/regen-cmd-link: generate cmd/link standalone binary 2026-07-15 10:33:42 -07:00
antje 83d7e65e8f cloud(ci): fix test gate path ./subsystems/ -> ./apps/ (main was red)
The subsystems/ -> apps/ rename merge (345264d2) missed hanzo.yml's test
gate, which still ran `go test ./subsystems/` — a directory that no longer
exists — so every main CI run failed "./subsystems [setup failed]". Point
it at ./apps/ (the renamed composition root). Unbreaks the release gate.
2026-07-15 10:21:30 -07:00
hanzo-dev cb579a8a79 Merge origin/main (agents oversize-target fix) into branch integration 2026-07-15 10:19:28 -07:00
hanzo-dev 3662f6982d Merge fix/402-credential-ordering: prefer fresh login JWT over stored hk- key in hanzo code 2026-07-15 10:18:18 -07:00
hanzo-dev 8813dd68c5 Merge feat/connector-runtime-land: native in-process connector runtime (goja) 2026-07-15 10:17:08 -07:00
hanzo-dev a5f94b0ef2 fix(agents): reject an oversize target GPU array at the handler
Red LOW-2: body.Spec.GPUs was only truncated post-decode by Sanitize
(cap 32). register + PATCH now return 400 for a GPU array larger than the
cap instead of silently truncating, so an absurd payload is refused up
front and the client learns the bound. The 4MiB body limit already caps
the transient decode allocation; a normal-sized list still registers.
2026-07-15 10:14:47 -07:00
antje 935974d230 cloud(cmd): generate missing standalone link binary (go generate ./apps)
The link app (unified AI login-manager registry, /v1/links) landed in
apps.Wire() without its generated cmd stub. Regenerating reconciles the
per-app-binary invariant: every Wire() app builds standalone via
apps.ServeSingle AND mounts into the unified binary. Idempotent.
2026-07-15 10:02:13 -07:00
hanzo-dev 4d42d6719e Merge origin/main (agents run-target) into apps/ rename integration 2026-07-15 09:49:42 -07:00
hanzo-dev c093a93d34 feat(agents): carry machine capability + live metrics on a run-target
A linked computer now reports what it IS (os/arch/cpus/memory/gpus, "spec")
and what it is DOING now (loadavg/memory/gpu-util, "metrics") so mission-
control can show which machine an agent runs on and whether it can take more
work — without copying the fact onto every session.

- targetspec.go: Spec/Metrics/GPU value types, JSON column codecs, and a
  total Sanitize that bounds strings/counts/sizes and coerces floats finite,
  so a hostile client can neither bloat the row nor smuggle a NaN/Inf.
- agent_targets gains spec/metrics/metrics_at (crashloop-safe addColumns,
  PRAGMA-guarded, no new index); register + PATCH accept them; a metrics
  PATCH is a heartbeat and the server owns the staleness clock (a client
  can't forge At).
- register upserts by (org, host) so re-linking the same machine refreshes
  one target instead of piling up duplicates.

Tests: sanitize bounds, store round-trip, GetTargetByHost org-scope, HTTP
capability+heartbeat, upsert-by-host. Existing target tests stay green.
2026-07-15 09:45:44 -07:00
hanzo-dev 68eebac230 fix(apps): add link mount to frozen wire-order fixture (main was red)
The feat/link-registry merge (669fee5) added the `link` mount to Wire() —
{Name:"link", after agents} — but never updated the frozen mount-order fixture,
so TestWireOrderMatchesFrozen failed on main (84 specs vs 83 frozen). This merge
inherited that break; adding the missing frozen entry (link, no health, has
shutdown) at its Wire() position makes apps green again.

Claude-Session: https://claude.ai/code/session_01SpMZ69ur3tjAXCiwaa7Wv2
2026-07-15 09:43:55 -07:00
hanzo-dev 345264d2f5 Merge feat/per-app-cmd-binaries: subsystems/ -> apps/ rename + per-app cmd binaries
# Conflicts:
#	apps/wire_seams.go
2026-07-15 09:39:39 -07:00
hanzo-dev 888d265469 fix(link): scope login-out session stop to the revoking user
A link revoke tore down live sessions by matching only {org, host,
provider, account}. Those fields come from a link row the caller sets at
upsert, so any org member could stop another member's sessions by
registering a wildcard link (e.g. provider-only) and revoking it.

Make Actor mandatory in agents.SessionMatch and AND it into the query.
The link adapter derives it from the revoking user's subject
(agents.BillingActor), the single place that binds a stop to the caller's
own sessions, so a stop/count can only ever reach that user's sessions.
A match with no actor stops nothing (fail-closed).

Tests: agents.TestStopSessions_ActorScoped (wildcard, host-forge,
no-actor fail-closed, count scope, own-account teardown) and
link.TestRevokeCannotStopCoTenantSessions (full stack through the real
adapter, co-tenant survives). TestRevokeStopsSessions stays green.
2026-07-15 09:31:20 -07:00
hanzo-dev e4a80c7de6 Merge branch 'feat/link-registry' into _mergetmp 2026-07-15 03:32:21 -07:00
hanzo-dev 669fee5cba feat(link): unified AI login-manager registry (/v1/links)
The org+user-scoped registry of which provider accounts (Claude Max, ChatGPT
Plus, a Hanzo/api key) a developer has signed into, on which machines, with each
account's latest usage snapshot — the cross-machine view console renders and the
source the redundancy route policy reads.

- clients/link: the Link atom (no secret — metadata + usage snapshot only),
  per-org SQLite store (org+subject leading-bound, upsert-on-identity, revoke),
  the /v1/links surface, and a pure RoutePolicy (Plan) that orders a user's linked
  accounts for redundancy (two Claude Max, then the metered API backstop) carrying
  the billing mode per candidate. The store holds no metering client — a
  subscription's usage is metered for visibility only and never charges commerce.
- clients/agents: a session now carries the linked account it ran under
  (Provider/Account tag), and StopSessions/CountActiveSessions expose the
  in-process action a link revoke takes to stop the sessions under a revoked
  account/device. Backward-compatible session migration (addColumns).
- subsystems: mount link after agents so a revoke can stop its sessions.

Org+user fail-closed isolation, subscription-vs-api-key billing distinction,
and revoke-stops-sessions are all tested (build/vet/gofmt clean; race+CGO green).
2026-07-15 03:31:37 -07:00
hanzo-dev 0b69af3e62 chore(cloud): bump hanzoai/ai v1.808.0 → v1.808.1 (relay reasoning normalization)
Pulls the DeepSeek <think></think> strip into api.hanzo.ai: reasoning-inlining
upstreams (zen5-pro/zen5-flash → deepseek-*) no longer leak the </think> template
token into the visible answer via the Anthropic-translation path that `hanzo code`
uses. Also carries the hk-key 402 tenant-gate fix. Builds clean (server + hanzo CLI).

Claude-Session: https://claude.ai/code/session_01SpMZ69ur3tjAXCiwaa7Wv2
2026-07-15 03:09:00 -07:00
zeekay 78d299e6b4 release: mirror via crane — IAM token realm rejects buildx's multi-scope request (crane single-scope proven E2E) 2026-07-15 02:59:17 -07:00
hanzo-dev f766c8aec1 feat(cloud): per-app standalone cmd binaries via apps.ServeSingle + generator
Each app now builds as its own standalone binary AND still mounts into the
unified cloud binary — one source of truth (apps.Wire()). Two pieces:

1. apps.ServeSingle(name) — the ONE way to run a single app standalone: validate
   the name against Wire(), then cloud.Serve(Wire(), []string{name}). It is the
   path cmd/hanzo's 'hanzo <name>' already uses; promoting it to apps (which
   already imports cloud, so no cycle) lets every cmd/<app> stub reuse it instead
   of re-implementing the dispatch. Adding an app in Wire() is still the one edit.

2. cmd/gen-app-cmds — a tiny generator (go:generate directive in apps/apps.go)
   that parses Wire()'s {Name: "..."} literals and writes one cmd/<app>/main.go
   stub per app (apps.ServeSingle("<app>")). Generated, not hand-maintained; a
   re-run is idempotent (writes only on content change). 80 unique apps today.

Verify: go build ./apps/... green; apps.ServeSingle added; go generate ./apps is
idempotent; go test ./apps/... green (TestWireOrderMatchesFrozen at 84 specs); a
sample of generated cmd stubs (kms/account/agents/platform/storage/audit/
analytics/ads/ingress/billing/o11y) build standalone green. The full ./cmd/...
build is heavy (80 binaries x the cloud tree) and needs CI parallelism; the
stubs themselves are sound.
2026-07-15 02:46:42 -07:00
hanzo-dev cabbfa297a refactor(cloud): rename subsystems/ -> apps/ (the composition root)
The cloud binary mounts ~84 'subsystems' into one unified binary; they read as
apps, so the package follows. Purely internal: dir subsystems/ -> apps/, package
'subsystems' -> 'apps', the one file subsystems.go -> apps.go. Three import sites
updated (cmd/cloud, cmd/hanzo). Broken path/filename references in comments
(subsystems.Wire, subsystems/commerce.go, subsystems.go) -> apps.*; the package
doc + LLM.md/docs paths follow.

No external breakage: the package is same-module (never in go.mod), no external
module imports it. The frozen-order test TestWireOrderMatchesFrozen compares
MountSpec.Name strings, not the package name — passes unchanged at 84 specs.

Standalone via 'hanzo <name>' and per-app cmd binaries are unchanged by this
rename (D2 adds the cmd binaries on top).

Verify: go build ./... green; go vet ./apps/... ./cmd/... clean; go test ./apps/...
green; TestWireOrderMatchesFrozen green. cmd/cloud TestMountAllAndServeHealth
fails identically on clean main (needs CLOUD_KMS_MASTER_KEY_REF), not this change.
2026-07-15 02:31:45 -07:00
hanzo-dev bb9bcbf307 fix(cloud/code): prefer the live login JWT over the hk- key to unblock 402
hanzo code 402s ('a billable tenant is required') on a deployment without
IAM_MINT_CLIENT_*: codeToken() preferred the hk- API key, but an hk- key only
mints a billing principal when the server can resolve it (iamKeys.resolve is a
no-op without the mint credential) — so the request arrives anonymous and zen's
/v1/messages billing gate refuses it. The /v1/models catalog call is loosely
gated, so the launcher banner still printed, masking the cause.

Reorder codeToken to prefer a FRESH hanzo login JWT, which carries the caller's
owner/project/sub claims verbatim and mints a billing principal on EVERY
deployment, then fall back to the hk- key chain (still works on mint-credentialed
servers). HANZO_API_KEY stays the deliberate operator override at the top.

freshAccessToken() is a new expiry-guarded accessor (decomplected from
accessToken, which whoami keeps expiry-agnostic): an EXPIRED jwt must not win —
it would 401 a session a valid hk- key would serve — so it falls through to the
key. No expiry record (a raw HANZO_TOKEN) is trusted as-is.

Pinned by TestCodeTokenPrecedence (fresh jwt beats hk-, expired jwt falls
through, HANZO_API_KEY overrides all). Structural hanzoai/jwt extraction is a
follow-up PR.
2026-07-15 02:19:47 -07:00
hanzo-dev a4cb8e9305 feat(cloud/connectorruntime): native in-process connector exec (HIP-0126)
Lands clients/connectorruntime — the native replacement for the standalone
ActivePieces Node engine: an ActivePieces JS connector action runs in goja
in-process (no auto pod, no in-cluster HTTP hop, no shared X-Piece-Run-Secret).
Same {action,auth,props} -> {ok,output,error} contract; org-gated via
principal.Org; the caller's credential travels in the request auth.

POST /v1/automations/connectors/:id/run  run one connector action in-process

Wired into the composition root (subsystems.Wire) the one way main does it —
an explicit MountSpec after automations (it pairs with the catalogue) — not the
old self-registering cloud.Register init (removed; main is explicit-Wire).
frozen order sequence updated to 84 specs.

Re-derived from the cloud-cr worktree's uncommitted work against current main:
the package's principal.Tenant -> principal.Org, the cloud.Register init -> the
explicit Wire() entry, and the go.mod esbuild dep flipped to direct. The
kb/sync_piece.go refactor that originally accompanied this is NOT carried —
clients/kb was removed on main since the branch forked, so there is nothing to
refactor; connectorruntime stands on its own (imports only cloud + principal).

Build green; go test ./clients/connectorruntime/... green (registry + runtime);
TestWireOrderMatchesFrozen green at 84 specs.
2026-07-15 02:13:38 -07:00
hanzo-dev f0d9f8cefb feat(config): additive CLOUD_ENABLE_STAGED lever — activate staged subsystems without an allowlist
Enabled() staged path is now orthogonal to the Enable allowlist: a staged
subsystem (iam/ingress) mounts when named in EITHER Enable (strict allowlist) OR
the new EnableStaged (additive). CLOUD_ENABLE_STAGED=iam + empty CLOUD_ENABLE =
all-non-staged prod default PLUS iam — the faithful iam-fold canary/cutover shape
with NO hand-enumerated allowlist that silently drops a newly-added subsystem.

Proven: TestEnabled_StagedActivatesAdditively (iam on, non-staged default intact,
unnamed staged sibling stays off).
2026-07-08 12:01:06 -07:00
hanzo-dev 798a85fddf fix(iam): isolate embedded IAM's SQLite via IAM_DATABASE_URL — unblock iam+ai co-residence
Both the iam and ai casdoor-derived forks resolve their SQLite handle from the
SAME env key (dataSourceName) + the one beego web.AppConfig global. A deployment
sets dataSourceName for ai; with iam enabled, IAM's bootstrap resolved that same
value and xorm-opened ai's DB (auto-migrating casdoor tables into it) -> the
documented boot crash that pinned every post-embed release and kept iam staged.

IAM's conf already honors an IAM-scoped IAM_DATABASE_URL above the shared
dataSourceName; pin it to IAM's own iam.db under DataDir so the two forks get
independent stores, order-independent, NO fork edit. Operator override respected.

Unit-proven: TestIsolateDatabase (iam-owned DSN, != ai dataSourceName, respects override).
2026-07-08 11:55:47 -07:00
371 changed files with 34766 additions and 4839 deletions
+14 -4
View File
@@ -89,15 +89,25 @@ jobs:
with:
go-version-file: go.mod
- name: go env for private modules (matches Dockerfile — zap-proto is direct+authenticated)
- name: go env for private modules
env:
GH_PAT: ${{ secrets.GH_PAT }}
# GOPRIVATE names exactly the namespace that is private. github.com/hanzoai/*
# is: ai, account, commerce, orm, xorm, beego, csqlite and ~30 more are
# private repos, so they must resolve direct+authenticated and skip a sumdb
# that cannot see them. Everything else stays on the public proxy + checksum
# db, which is what makes a module hash immutable: zap-proto (all 55 repos)
# and luxfi (all 37 deps here) are public and proxy-served.
#
# This previously named zap-proto — public, and never the reason anything
# here was direct — and then set GOSUMDB=off to compensate for hanzoai/*
# being absent, which disabled checksum verification for EVERY module in the
# build, public ones included. Naming the private namespace is what the off
# switch was standing in for.
run: |
git config --global url."https://x-access-token:${GH_PAT}@github.com/".insteadOf "https://github.com/"
{
echo "GOPRIVATE=github.com/zap-proto/*"
echo "GONOSUMDB=github.com/zap-proto/*"
echo "GOSUMDB=off"
echo "GOPRIVATE=github.com/hanzoai/*"
echo "GOPROXY=https://proxy.golang.org,direct"
} >> "$GITHUB_ENV"
+94 -35
View File
@@ -96,9 +96,9 @@ jobs:
# an image (even from a run that died before tagging) is never reused.
cont_max=""
if command -v gh >/dev/null 2>&1; then
cont_max="$(GH_TOKEN="$GH_PAT" gh api --paginate \
'/orgs/hanzoai/packages/container/cloud/versions' \
--jq '.[].metadata.container.tags[]' 2>/dev/null \
cont_max="$(GH_TOKEN="$GH_PAT" gh api \
'/orgs/hanzoai/packages/container/cloud/versions?per_page=100' \
--jq '.[].metadata.container.tags[]?' 2>/dev/null \
| sed 's/^v//' | grep -E '^[0-9]+\.[0-9]+\.[0-9]+$' | sort -V | tail -1 || true)"
fi
@@ -221,10 +221,10 @@ jobs:
# frozen snapshot the persistent BuildKit cache would otherwise serve forever.
build-args: |
CONSOLE_CACHEBUST=${{ steps.ver.outputs.cachebust }}
# gh_token: BuildKit secret the Dockerfile consumes to fetch private
# GIT_AUTH_TOKEN: BuildKit secret the Dockerfile consumes to fetch private
# cross-org Go modules (hanzoai/*, luxfi/*) over authenticated git.
secrets: |
gh_token=${{ secrets.GH_PAT }}
GIT_AUTH_TOKEN=${{ secrets.GH_PAT }}
- name: Smoke test — the binary MUST boot to "listening" with no crash signature
run: |
@@ -293,6 +293,67 @@ jobs:
echo "SMOKE PASS: cloud:smoke booted to \"listening\" with no crash signature"
# ── Functional smoke — authenticated per-subsystem probe (the REAL gate) ─────
# The boot check above proves the process REACHES "listening"; this proves the
# mounted HTTP surface actually WORKS. /smoke (cmd/smoke, baked into the image)
# hits ONE side-effect-free read per core subsystem and FAILS the release on any
# broken code — above all a 402 on a READ (the balance-gate-over-blocks-reads
# regression) or a 5xx (a crash, e.g. the /v1/billing/usage self-dispatch 500).
# So a release can never ship with chat/billing/projects/kms/... down.
- name: Functional smoke — per-subsystem probe (fails the release if a core endpoint is broken)
env:
KMS_CLIENT_ID: ${{ secrets.KMS_CLIENT_ID }}
KMS_CLIENT_SECRET: ${{ secrets.KMS_CLIENT_SECRET }}
KMS_ENDPOINT: ${{ vars.KMS_ENDPOINT || 'https://kms.hanzo.ai' }}
# A KMS-provisioned short-lived smoke bearer, injected as a secret (NEVER
# hardcoded). Absent → the anonymous matrix still gates public/authed and
# catches every 402-on-read / 5xx.
SMOKE_TOKEN: ${{ secrets.SMOKE_TOKEN }}
run: |
set -euo pipefail
IMAGE=cloud:smoke
CID=""
cleanup() { [ -n "$CID" ] && docker rm -f "$CID" >/dev/null 2>&1 || true; }
trap cleanup EXIT
KEY="$(head -c 32 /dev/urandom | base64 | tr -d '\n')"
CID="$(docker run -d \
--tmpfs /data:rw,size=64m \
-e CLOUD_DATA_DIR=/data -e CLOUD_ENV=smoke -e CLOUD_KMS_MASTER_KEY_REF="$KEY" \
"$IMAGE")"
# Wait for the HTTP listener to bind (or the process to die).
up=0
for _ in $(seq 1 60); do
lg="$(docker logs "$CID" 2>&1 || true)"
printf '%s' "$lg" | grep -q '"message":"listening"' && { up=1; break; }
[ "$(docker inspect -f '{{.State.Running}}' "$CID" 2>/dev/null || echo false)" != "true" ] && break
sleep 1
done
if [ "$up" != 1 ]; then
echo "::group::boot logs"; docker logs "$CID" 2>&1 || true; echo "::endgroup::"
echo "FUNCTIONAL SMOKE INFRA FAIL: image never reached \"listening\""
exit 1
fi
# Token: prefer the injected secret; else mint from KMS (a provisioned smoke
# identity); else run the anonymous matrix. Never hardcoded.
if [ -z "${SMOKE_TOKEN:-}" ] && [ -n "${KMS_CLIENT_ID:-}" ]; then
KT=$(curl -sf "$KMS_ENDPOINT/v1/kms/auth/login" -H 'Content-Type: application/json' \
-d "{\"clientId\":\"$KMS_CLIENT_ID\",\"clientSecret\":\"$KMS_CLIENT_SECRET\"}" | jq -r '.accessToken // empty' || true)
[ -n "$KT" ] && SMOKE_TOKEN=$(curl -sf "$KMS_ENDPOINT/v1/kms/orgs/hanzo/secrets/smoke/TOKEN?env=prod" \
-H "Authorization: Bearer $KT" | jq -r '.secret.value // empty' || true)
fi
if [ -n "${SMOKE_TOKEN:-}" ]; then echo "::add-mask::$SMOKE_TOKEN"; echo "smoke: AUTHENTICATED matrix"; else echo "smoke: ANONYMOUS matrix (no SMOKE_TOKEN wired)"; fi
# /smoke is baked into the image (Dockerfile) — exec it INSIDE the container,
# so it probes the real mounted surface at localhost:8080 with no port/network
# plumbing. A non-zero exit here fails the release BEFORE any image is pushed.
docker exec \
-e SMOKE_BASE_URL=http://127.0.0.1:8080 \
-e SMOKE_TOKEN="${SMOKE_TOKEN:-}" \
"$CID" /smoke
# ── Migration smoke — the gate the v1.800.1 crashloop would have tripped ─────
# The plain smoke above boots on a FRESH /data, so every subsystem's migrate()
# takes its CREATE-TABLE path and no forward-migration is exercised — which is
@@ -406,7 +467,7 @@ jobs:
build-args: |
CONSOLE_CACHEBUST=${{ steps.ver.outputs.cachebust }}
secrets: |
gh_token=${{ secrets.GH_PAT }}
GIT_AUTH_TOKEN=${{ secrets.GH_PAT }}
# THE RECEIPT + ATOMIC VERSION ASSIGNMENT (race-safe). Reached only because
# build + smoke + push all succeeded, so a proven image exists under the unique
@@ -434,11 +495,15 @@ jobs:
git fetch --tags --force --quiet
git_max="$(git tag -l 'v[0-9]*.[0-9]*.[0-9]*' \
| sed 's/^v//' | grep -E '^[0-9]+\.[0-9]+\.[0-9]+$' | sort -V | tail -1 || true)"
# Newest page only — NOT --paginate. Container versions are created
# newest-first and version tags are monotonic, so the highest version
# is always among the most-recent versions; paginating the WHOLE
# registry history is what livelocked this step as tags accumulated.
cont_max=""
if command -v gh >/dev/null 2>&1; then
cont_max="$(GH_TOKEN="$GH_PAT" gh api --paginate \
'/orgs/hanzoai/packages/container/cloud/versions' \
--jq '.[].metadata.container.tags[]' 2>/dev/null \
cont_max="$(GH_TOKEN="$GH_PAT" gh api \
'/orgs/hanzoai/packages/container/cloud/versions?per_page=100' \
--jq '.[].metadata.container.tags[]?' 2>/dev/null \
| sed 's/^v//' | grep -E '^[0-9]+\.[0-9]+\.[0-9]+$' | sort -V | tail -1 || true)"
fi
max="$(printf '%s\n%s\n%s\n' "1.786.0" "$git_max" "$cont_max" \
@@ -454,12 +519,22 @@ jobs:
-t "ghcr.io/hanzoai/cloud:${major}.${minor}" \
"$SHA_IMG"
# Dual-host: mirror the release tags to OUR fleet registry (server-
# side copy) so the cluster never depends on GHCR to deploy. Best-
# effort — a mirror hiccup must never block the tag-receipt.
# side copy) so the cluster never depends on GHCR to deploy. crane,
# not buildx imagetools: the IAM token realm doesn't answer buildx's
# multi-scope token request (spec gap, tracked), crane's single-scope
# flow works. Best-effort — a mirror hiccup never blocks the receipt.
if [ "${MIRROR_OK:-}" = "1" ]; then
command -v crane >/dev/null 2>&1 || {
curl -fsSL https://github.com/google/go-containerregistry/releases/download/v0.20.2/go-containerregistry_Linux_x86_64.tar.gz \
| tar -xz -C "$HOME/.local/bin" crane 2>/dev/null || {
mkdir -p "$HOME/.local/bin"
curl -fsSL https://github.com/google/go-containerregistry/releases/download/v0.20.2/go-containerregistry_Linux_x86_64.tar.gz \
| tar -xz -C "$HOME/.local/bin" crane
}
export PATH="$HOME/.local/bin:$PATH"
}
for MT in "${V}" "${VER}" "${major}.${minor}"; do
docker buildx imagetools create \
-t "registry.hanzo.ai/hanzoai/cloud:${MT}" "$SHA_IMG" \
crane copy "$SHA_IMG" "registry.hanzo.ai/hanzoai/cloud:${MT}" \
|| echo "::warning::mirror registry.hanzo.ai/hanzoai/cloud:${MT} failed"
done
fi
@@ -477,25 +552,9 @@ jobs:
echo "::error::could not acquire a free version tag after 8 attempts"
exit 1
# Notify universe so the GitOps pipeline rolls the new image to prod — same
# image-update contract every service uses (gateway, iam, …). Runs ONLY after
# build-amd64 succeeds, i.e. only for a version whose image is proven pushed and
# tagged. A failed release never reaches here, so universe is never asked to
# deploy a phantom tag.
notify-universe:
needs: build-amd64
runs-on: [hanzo-build-linux-amd64]
steps:
- name: Repository dispatch (image-update)
uses: peter-evans/repository-dispatch@v3
with:
token: ${{ secrets.UNIVERSE_DISPATCH_TOKEN }}
repository: hanzoai/universe
event-type: image-update
client-payload: |
{
"service": "cloud",
"image": "ghcr.io/hanzoai/cloud:${{ needs.build-amd64.outputs.version_v }}",
"sha": "${{ github.sha }}",
"env": "all"
}
# Deploy = a declared-tag bump in hanzoai/universe crs/cloud.yaml — Hanzo CD
# (the ArgoCD instance in ns hanzo-cd) syncs universe→cluster and the operator
# reconciles the CR. The old notify-universe repository_dispatch hub is retired
# (its flagged-sender dispatches were silently suppressed anyway); the native
# release path (release.go rolloutRelease) and deliberate promote commits own
# the bump.
+1
View File
@@ -34,3 +34,4 @@ Thumbs.db
.shots/
.claude/
native/flags/target
+73 -129
View File
@@ -2,122 +2,50 @@
#
# This image is a SINGLE artifact that serves BOTH the /v1 API AND the console
# UI from one process: the console is compiled into the Go binary via
# //go:embed (see webui.go). The pipeline is:
# //go:embed (see webui.go). The final `/cloud` binary already carries the UI —
# no separate console Service, no second origin; the embedded console calls /v1
# on its own host.
#
# 1. console stage → build the hanzoai/console static bundle
# 2. (copied) → into webui/dist/ of the Go build context
# 3. build stage → `go build` bakes webui/dist into the binary (go:embed)
#
# so the final `/cloud` binary already carries the UI. No separate console
# Service, no second origin — the embedded console calls /v1 on its own host.
#
# ── console UI stage ─────────────────────────────────────────────────────────
# Builds the console SPA and emits a STATIC bundle at /out. console is fetched
# at a pinned ref (CONSOLE_REF) using the same gh_token BuildKit secret the Go
# build uses for private modules.
#
# console exposes `npm run build:embed` (scripts/build-embed.mjs): it prunes the
# Next server route handlers (BFF proxies — they collapse to the cloud /v1/* the
# SPA calls same-origin), wraps the client catch-all pages for output:'export',
# and neutralizes the root layout's request-time headers() read (the per-host
# <title>, resolved client-side in the embed) so the STATIC export prerenders
# clean — emitting out/. This stage runs it and copies out/ into /out, which the
# Go build drops into webui/dist so //go:embed bakes the FULL @hanzo/gui console
# into the ONE binary. This stage FAILS HARD: the prod image MUST carry the real
# console — a missing/broken build:embed is a build ERROR, never a silent degrade
# to the placeholder shell. The one escape hatch is --build-arg ALLOW_PLACEHOLDER=1
# (pure-Go dev image with no Node console), which is NEVER set for prod.
FROM public.ecr.aws/docker/library/node:24-alpine@sha256:a0b9bf06e4e6193cf7a0f58816cc935ff8c2a908f81e6f1a95432d679c54fbfd AS console
ARG CONSOLE_REPO=https://github.com/hanzoai/console.git
ARG CONSOLE_REF=main
# CONSOLE_CACHEBUST busts this stage's BuildKit layer cache every build. WHY it must
# exist: the clone+build layer's cache key is derived from the RUN text + build args.
# With only a static `git clone --branch main`, the key NEVER changes, so on the
# persistent ARC dind BuildKit cache every cloud image re-embedded the SAME frozen
# console snapshot — new console work (the native Tracker, …) silently never shipped,
# even on a freshly-built+deployed image. release.yml feeds this the cloud commit sha
# (unique per push) so the clone RUN re-runs each build and re-fetches console
# ${CONSOLE_REF} (main HEAD) fresh. Correctness over cache reuse: the console stage
# rebuilds every time, but the embed is never stale.
ARG CONSOLE_CACHEBUST=none
RUN apk add --no-cache git
WORKDIR /console
# The static export prerenders every page (webpack compile + export prerender);
# give the heap headroom so a large @hanzo/gui build never OOMs into the stub.
ENV NEXT_TELEMETRY_DISABLED=1 NODE_OPTIONS=--max-old-space-size=8192
# Hanzo Analytics: the console's <HanzoAnalytics/> (env-gated) renders the one
# native analytics.hanzo.ai tag only when a website-id is baked in. Default to the
# console.hanzo.ai property (7dce54ee, public per-site) so console+team track on
# the next cloud build. GA4/Pixel stay off (unset). Public id, not a KMS secret.
ARG NEXT_PUBLIC_ANALYTICS_WEBSITE_ID=7dce54ee-41f6-4751-96bf-fe005067c7c7
ENV NEXT_PUBLIC_ANALYTICS_WEBSITE_ID=$NEXT_PUBLIC_ANALYTICS_WEBSITE_ID
RUN --mount=type=secret,id=gh_token \
if [ -s /run/secrets/gh_token ]; then \
git config --global url."https://x-access-token:$(cat /run/secrets/gh_token)@github.com/".insteadOf "https://github.com/"; \
fi && \
echo ">> embedding console ${CONSOLE_REF} (cachebust ${CONSOLE_CACHEBUST})" && \
git clone --depth 1 --branch "${CONSOLE_REF}" "${CONSOLE_REPO}" . && \
echo ">> console @ $(git rev-parse HEAD)" && \
npm install --no-audit --no-fund --fetch-retries=5 --fetch-retry-mintimeout=20000 --fetch-timeout=120000
# FAIL-HARD. build:embed MUST emit a REAL bundle — a non-empty out/index.html AND
# an out/_next/ chunk dir — and /out then carries it into the Go embed path. If the
# target is absent, the export fails, or the output is the placeholder shape, this
# is a build ERROR (exit 1): the prod image can NEVER silently ship the committed
# fallback shell. Escape hatch: --build-arg ALLOW_PLACEHOLDER=1 leaves /out empty
# (Go build keeps the committed shell) for a pure-Go dev image — NEVER set in prod.
ARG ALLOW_PLACEHOLDER=0
RUN mkdir -p /out; \
ok=0; \
if npm run 2>/dev/null | grep -q ' build:embed'; then \
if npm run build:embed && [ -s out/index.html ] && [ -d out/_next ]; then \
cp -r out/. /out/; \
echo ">> embedded REAL console static bundle: $(wc -c < out/index.html)-byte index.html, $(du -sh out/_next | cut -f1) _next/"; \
ok=1; \
else \
echo ">> console build:embed produced NO real bundle (missing/empty out/index.html or out/_next)"; \
fi; \
else \
echo ">> console exposes no build:embed target"; \
fi; \
if [ "$ok" != "1" ]; then \
if [ "$ALLOW_PLACEHOLDER" = "1" ]; then \
echo ">> ALLOW_PLACEHOLDER=1 — keeping committed fallback shell (DEV image only; NEVER prod)"; \
else \
echo ">> FATAL: refusing to ship the placeholder console. Fix the console build:embed, or pass --build-arg ALLOW_PLACEHOLDER=1 for a pure-Go dev image."; \
exit 1; \
fi; \
fi
# ── prebuilt decomplection artifacts (cloud compiles ONLY Go) ────────────────
# The console SPA, the agent-skills catalog, and the native flags staticlib are
# each built by THEIR OWN CI as a versioned immutable image and PULLED here,
# instead of rebuilding node + python + rust from scratch every cloud release.
# The heavy one (console: a cold `npm install` + full Next.js static export,
# force-cache-busted every build) used to dominate the ~20-min build; it is now
# a registry pull.
# console-embed (hanzoai/console Dockerfile.embed) → /dist → webui/dist (go:embed)
# agent-skills (hanzoai/openapi Dockerfile.skills) → /catalog → clients/agentskills/catalog (go:embed)
# cloud-flags (native/flags Dockerfile) → /libhanzo_flags.a → CGO link (clients/featureflags)
# Pinned to ghcr.io so BOTH buildx lanes (release.yml + platform arcbuild) pull
# it directly; the SAME tags are mirrored to registry.hanzo.ai (S3-backed) for
# GET-flow consumers (docker/kaniko/crane). Override any pin with
# --build-arg <NAME>_IMAGE=… — release.yml resolves CONSOLE_IMAGE to a fresh
# console-embed digest, exactly as CONSOLE_CACHEBUST re-fetched console before.
ARG CONSOLE_IMAGE=ghcr.io/hanzoai/console-embed:latest
ARG SKILLS_IMAGE=ghcr.io/hanzoai/agent-skills:latest
ARG FLAGS_IMAGE=ghcr.io/hanzoai/cloud-flags:latest
# ── Go build stage (CGO=1 + SQLCipher — REAL at-rest encryption) ─────────────
# The unified binary embeds IAM (clients/iam) whose per-org store is SQLCipher-
# encrypted (orgIsolation=sqlite), and commerce's per-tenant money DBs likewise.
# A CGO=0 modernc build SILENTLY SHIPS PLAINTEXT. So this builds CGO=1 against
# system libsqlcipher — hanzoai/iam's proven recipe: the `libsqlite3` tag + a
# libsqlcipher symlink + -DSQLITE_HAS_CODEC, with the modernc double-registration
# guard, TestEncryptionProof, and the cek.go golden-vector KAT baked in — so a
# build that fails to link REAL SQLCipher, or that would decrypt existing stores
# differently, produces NO image. alpine3.22 MATCHES the runtime base so the
# libsqlcipher soname the binary links is the SAME one present at runtime. ECR
# Public mirror avoids Docker Hub's 429 rate-limit on shared CI runners.
# ---- agent-skills stage: regenerate the FULL /.well-known/agent-skills catalog
# from the hanzoai/openapi SOT (skills.py) and carry it into the Go embed path
# BEFORE `go build`, the SAME way the console bundle is produced. The committed
# catalog is only the tiny `ai` fallback; prod must embed the full set. FAIL-HARD:
# if the clone/generation can't produce the master index, the image is not built.
FROM public.ecr.aws/docker/library/python:3.12-alpine AS skills
ARG OPENAPI_REPO=https://github.com/hanzoai/openapi.git
ARG OPENAPI_REF=main
RUN apk add --no-cache git && pip install --no-cache-dir pyyaml
WORKDIR /openapi
RUN --mount=type=secret,id=gh_token \
if [ -s /run/secrets/gh_token ]; then \
git config --global url."https://x-access-token:$(cat /run/secrets/gh_token)@github.com/".insteadOf "https://github.com/"; \
fi && \
git clone --depth 1 --branch "${OPENAPI_REF}" "${OPENAPI_REPO}" . && \
python3 skills.py --no-services --out /catalog && \
test -s /catalog/hanzo/index.json
# ── toolchain base images: the golang + alpine FROMs below pull from our own
# GHCR mirror (ghcr.io/hanzoai/mirror/*), pinned by digest. WHY: public.ecr.aws
# rate-limits anonymous pulls (HTTP 429) on shared CI runners and a 429 on ANY
# base pull aborts the release. The mirror packages are 1:1 amd64 copies of the
# upstream public images, digest-pinned for immutability; release.yml logs the
# build into ghcr.io (GH_PAT) before building so they resolve. REFRESH on a
# toolchain bump: crane/regctl copy the new upstream into
# ghcr.io/hanzoai/mirror/<name>:<tag> and repoint the digest below. Canonical
# long-term home is registry.hanzo.ai/hanzoai/mirror/* — repoint once the runners
# carry its IAM pull credentials (follow-up).
FROM public.ecr.aws/docker/library/golang:1.26-alpine3.22@sha256:727cfc3c40be55cd1bc9a4a059406b28a059857e3be752aa9d09531e12c20c56 AS build
# ── console SPA static export (prebuilt → /dist) ─────────────────────────────
FROM ${CONSOLE_IMAGE} AS console
# ── agent-skills catalog (prebuilt → /catalog) ──────────────────────────────
FROM ${SKILLS_IMAGE} AS skills
# ── native flags evaluator staticlib (prebuilt → /libhanzo_flags.a) ──────────
FROM ${FLAGS_IMAGE} AS flagslib
FROM ghcr.io/hanzoai/mirror/golang:1.26-alpine3.22@sha256:47d47cb5cc3c7dac409dcb6c3a98a6263571218046cd02d709527feef804a77c AS build
# CIPHER-FORMAT FREEZE (cek depends on this). The data-plane stores are
# SQLCipher pages in a fixed on-disk format (cipher_compatibility 4). An at-open
# compat pin is infeasible (mattn keys via URI before any pragma), so the format
@@ -138,18 +66,21 @@ RUN set -eux; \
ln -sf "$SC" /usr/lib/libsqlite3.so; \
ln -sf "$SC" /usr/lib/libsqlite3.so.0
WORKDIR /src
# hanzoai/* and luxfi/* are PUBLIC and resolve via the IMMUTABLE public proxy +
# sumdb — go.sum pins those canonical hashes, so a force-re-pointed tag can never
# break the build. GOSUMDB stays ON (a money image must not blanket-disable the
# checksum database); only zap-proto/* is exempt (first-party-direct via GOPRIVATE,
# authenticated git over gh_token). -mod=readonly means the committed go.sum is the
# SOLE source of truth: any drift (a needed hash not present) FAILS the build
# instead of being silently re-recorded. CGO_CFLAGS/LDFLAGS enable the SQLCipher
# codec + URI keying.
# zap-proto/* (all 55 repos) and luxfi/* (all 37 deps here) are PUBLIC and resolve
# via the IMMUTABLE public proxy + sumdb — go.sum pins those canonical hashes, so a
# force-re-pointed tag can never break the build. GOSUMDB stays ON (a money image
# must not blanket-disable the checksum database); github.com/hanzoai/* is the
# exempt namespace — ai, account, commerce, orm, xorm, beego, csqlite and ~30 more
# are PRIVATE repos, so they resolve direct+authenticated (git over gh_token) and
# skip a sumdb that cannot see them. GOPRIVATE named zap-proto until now, which is
# public and was never the reason anything was direct; the private namespace it
# stood for went unnamed and worked only on the GOPROXY `direct` fallback.
# -mod=readonly means the committed go.sum is the SOLE source of truth: any drift
# (a needed hash not present) FAILS the build instead of being silently
# re-recorded. CGO_CFLAGS/LDFLAGS enable the SQLCipher codec + URI keying.
ENV CGO_CFLAGS="-DSQLITE_HAS_CODEC -DSQLITE_USE_URI=1 -I/usr/include/sqlcipher" \
CGO_LDFLAGS="-lsqlcipher" \
GOPRIVATE=github.com/zap-proto/* \
GONOSUMDB=github.com/zap-proto/* \
GOPRIVATE=github.com/hanzoai/* \
GOPROXY=https://proxy.golang.org,direct \
GOFLAGS=-mod=readonly
COPY go.mod go.sum ./
@@ -160,19 +91,22 @@ COPY go.mod go.sum ./
# and resolves fine from a clean cache. That is exactly what wedged the release
# on otel-collector v0.144.10. BUMP THE SUFFIX (-v4 -> -v5) to force a cold
# module cache the next time a phantom pin poisons it.
RUN --mount=type=secret,id=gh_token \
RUN --mount=type=secret,id=GIT_AUTH_TOKEN \
--mount=type=cache,id=cloud-gomod-v4,target=/go/pkg/mod,sharing=locked \
if [ -s /run/secrets/gh_token ]; then \
git config --global url."https://x-access-token:$(cat /run/secrets/gh_token)@github.com/".insteadOf "https://github.com/"; \
if [ -s /run/secrets/GIT_AUTH_TOKEN ]; then \
git config --global url."https://x-access-token:$(cat /run/secrets/GIT_AUTH_TOKEN)@github.com/".insteadOf "https://github.com/"; \
fi && \
go mod download
COPY . .
# Drop the console static bundle into the embed path BEFORE `go build`, so
# //go:embed all:webui/dist bakes it into the binary (same-origin console).
COPY --from=console /out/ /src/webui/dist/
COPY --from=console /dist/ /src/webui/dist/
# Overlay the FULL agent-skills catalog before `go build` so //go:embed all:catalog
# bakes the complete set (all services × brands), not the committed `ai` fallback.
COPY --from=skills /catalog/ /src/clients/agentskills/catalog/
# The native flags staticlib at the exact ${SRCDIR}-relative path the cgo
# directive in clients/featureflags/engine.go links.
COPY --from=flagslib /libhanzo_flags.a /src/native/flags/target/release/libhanzo_flags.a
# RED gate — modernc double-registration guard: 0 modernc under CGO=1, else the
# "sqlite" driver is registered twice (mattn + modernc) → panic at init.
RUN --mount=type=cache,id=cloud-gomod-v4,target=/go/pkg/mod,sharing=locked \
@@ -201,12 +135,18 @@ RUN --mount=type=cache,id=cloud-gomod-v4,target=/go/pkg/mod,sharing=locked \
RUN --mount=type=cache,id=cloud-gomod-v4,target=/go/pkg/mod,sharing=locked \
--mount=type=cache,id=cloud-gobuild-v4,target=/root/.cache/go-build,sharing=locked \
CGO_ENABLED=1 go build -tags "libsqlite3 sqlite_fts5" -ldflags="-s -w" -o /cloud ./cmd/cloud
# The functional smoke prober (cmd/smoke) — a stdlib-only, static binary shipped
# alongside /cloud so the release gate can `docker exec` it against the freshly-built
# image (and any deployment can be smoked via `docker run --entrypoint /smoke ...`).
RUN --mount=type=cache,id=cloud-gomod-v4,target=/go/pkg/mod,sharing=locked \
--mount=type=cache,id=cloud-gobuild-v4,target=/root/.cache/go-build,sharing=locked \
CGO_ENABLED=0 go build -ldflags="-s -w" -o /smoke ./cmd/smoke
# Prove the SHIPPED binary binds sqlite3_* to libsqlcipher, not a plaintext libsqlite3.
RUN readelf -d /cloud | grep -qE 'NEEDED.*(sqlcipher|sqlite3)' || { echo "FATAL: /cloud links no sqlite/sqlcipher .so"; exit 1; }; \
! ldd /cloud 2>/dev/null | grep -E 'libsqlite3' | grep -vq 'libsqlcipher' || { echo "FATAL: /cloud resolves a NON-sqlcipher libsqlite3 (plaintext risk)"; exit 1; }
# ── final image (alpine, NOT scratch — CGO needs libc + libsqlcipher) ─────────
FROM public.ecr.aws/docker/library/alpine:3.22@sha256:14358309a308569c32bdc37e2e0e9694be33a9d99e68afb0f5ff33cc1f695dce
FROM ghcr.io/hanzoai/mirror/alpine:3.22@sha256:7c8cb692ae09657cbc4a3f3cbd0e8d5a2690ba38386aaaf252dbb060bf5eb2e6
ARG REVISION=unknown
LABEL org.opencontainers.image.revision="${REVISION}" \
org.opencontainers.image.source="https://github.com/hanzoai/cloud"
@@ -220,7 +160,10 @@ LABEL org.opencontainers.image.revision="${REVISION}" \
# (upload-pack / receive-pack --stateless-rpc / fetch) so multi-GB packs stream
# to and from disk with bounded memory instead of buffering whole packs in RAM.
# The `git` apk package carries upload-pack/receive-pack/http-backend/git-remote-https.
RUN apk add --no-cache ca-certificates tzdata sqlcipher-libs git \
# libgcc: the hanzo-flags Rust staticlib (clients/featureflags FFI) references the
# _Unwind_* unwinder symbols; musl needs libgcc_s at load time or the binary fails
# relocation ("Error relocating /cloud: _Unwind_GetIP: symbol not found").
RUN apk add --no-cache ca-certificates tzdata sqlcipher-libs git libgcc \
&& SC="$(find /usr/lib /lib -name 'libsqlcipher.so*' 2>/dev/null | sort | head -1)" \
&& test -n "$SC" \
&& ln -sf "$SC" /usr/lib/libsqlite3.so.0
@@ -229,6 +172,7 @@ COPY --from=build /usr/share/zoneinfo /usr/share/zoneinfo
COPY --from=build /etc/passwd /etc/passwd
COPY --from=build /etc/group /etc/group
COPY --from=build /cloud /cloud
COPY --from=build /smoke /smoke
EXPOSE 8080 9090 9653
USER 65532:65532
ENTRYPOINT ["/cloud"]
+98 -14
View File
@@ -12,25 +12,34 @@ One way to do everything. Composable, orthogonal, DRY. A new subsystem is a
package under `clients/<name>` that obeys these seams — nothing more.
- **Subsystem shape.** A subsystem exposes `func Mount(app *zip.App, deps cloud.Deps) error`
and self-registers at init with `cloud.Register("<name>", <order>, cloud.Typed(Mount))`
(or `RegisterWithShutdown`). `Mount` wires that subsystem's `/v1/<name>/*` routes
onto the shared `*zip.App`; `cloud.Deps` carries the process-wide handles
(Logger, DataDir, the subsystem `Client` seams). No subsystem reaches into
another's internals.
and is listed in `apps.Wire()` as a `cloud.MountSpec{Name, Mount: cloud.Typed(Mount)}`
(plus `Shutdown`/`OwnsHealth` where it owns them). `Mount` wires that subsystem's
`/v1/<name>/*` routes onto the shared `*zip.App`; `cloud.Deps` carries the
process-wide handles (Logger, DataDir, the subsystem `Client` seams). No
subsystem reaches into another's internals. There is no init()-registry and no
`cloud.Register` — subsystems do NOT self-register.
- **Client seams.** Cross-subsystem calls go through a narrow in-process interface
published in `types` and aliased at the provider, e.g. `commerce.Client =
types.CommerceClient` (`GetOrgConfig` + `CheckEntitlement`). Consumers depend on
the interface, never the implementation; the seam rides zap-proto/zip. Keep each
interface minimal — add a method only when a consumer needs it.
- **Composition root.** `subsystems/subsystems.go` blank-imports every subsystem
(its init runs `cloud.Register`), populating `cloud.Registry`. `MountAll`
(build.go) sorts the registry by `Order` and calls `Mount` on each ENABLED
subsystem (`cfg.Enabled`). That ordered blank-import set IS the wiring — there
is no separate `Wire()` function; to add a subsystem you add one import line.
- **Route precedence is a framework guarantee.** The router is zap-proto/fiber
(zip v1.3.0). Most-specific route wins regardless of mount order; a genuine
route CONFLICT panics at mount rather than resolving ambiguously. Subsystems may
therefore mount in any order and still compose deterministically.
- **Composition root.** `apps/apps.go:Wire()` returns `[]cloud.MountSpec` — every
linked subsystem, in mount order, as ONE explicit slice read top-to-bottom.
Slice position IS the order: there is no `Order` field and `MountAll`
(build.go) does NOT sort; it iterates as-given and mounts each ENABLED spec
(`cfg.Enabled`). To add a subsystem you add one line to `Wire()`.
`apps/wire_test.go` freezes the sequence, so a reorder/drop/add fails there.
- **Route precedence.** The router is zap-proto/fiber (zip v1.8.3). Most-specific
route wins regardless of mount order, so subsystems may mount in any order and
still compose deterministically. But precedence is NOT a conflict guard: two
registrations of a byte-identical pattern do NOT panic — fiber MERGES them into
ONE route with both handlers chained, resolving by first-registration. That is
invisible to a `GetRoutes()` entry count (see the bots note below), and it is
NOT distinguishable from a legitimate middleware chain: `app.Post(path, mw1,
mw2, mw3, handler)` is one registration with four handlers (apps/commerce.go:151),
and the whole `/v1/store/*` surface is that shape. A high handler count is
therefore evidence of nothing on its own; only a subsystem that never chains
middleware (bots/visor/runtime) can read `len(Handlers) > 1` as a collision.
- **Per-org data.** The ONE way any subsystem opens a per-org SQLite file is
`cloud.OrgDB(dataDir, org, project, sub)` — or the cached `cloud.OrgStore[T]`
(`NewOrgStore` + `For(org, project)`). Path convention:
@@ -42,6 +51,51 @@ package under `clients/<name>` that obeys these seams — nothing more.
the SOLE driver (blank-imported once, in orgdb.go); subsystems never import a
SQLite driver themselves. The caller owns its schema/migration and Close.
## The route table has three projections, and the router is the source
`serve.go` composes ONE route table and projects it three ways, all after
`MountAll` so each sees a complete table: `/zap` REPLAYS the /v1 handlers
(zapface), the console RENDERS them, and `GET /v1/openapi.json` DESCRIBES them
(`openapi.Mount`). None holds a second copy of anything; none can drift.
- **The spec IS the router.** `openapi.Live(app)` reads
`app.Fiber().GetRoutes(true)` — fiber's own filter drops `Use()` middleware —
and every other function in `openapi/` is a pure function of that `[]Route`.
There is NO checked-in spec file to hand-maintain and no second registry. The
drift guard is `cmd/cloud/openapi_test.go`: a BIJECTION over the fully-mounted
`apps.Wire()` (983 operations / 692 paths / 109 products) — every live route
appears as an operation, every operation is backed by a live route. It is the
only test whose failure means the document lies.
- **Reading the LIVE router is the only total source.** `POST /v1/kms/auth/login`
is registered as `Group("/v1/kms/auth").Post("/login")` — no grep can find that
path; only the assembled router knows it. And the route set is a function of
deployment config (`cfg.Enabled`, plus internal gates like kms's `if kc != nil`),
so **the spec VARIES PER DEPLOYMENT** — correctly: a deployment that does not
mount admin does not advertise it. That is why the document is generated
per-process at request time, not built once in CI.
- **The product axis is mechanical.** The first path segment after `/v1/` IS the
product (`openapi.Product`), tagged onto each operation so a CLI can build
`hanzo <product> <resource> <verb>` with no judgment. It is deliberately NOT the
subsystem name: `clients/billing` also serves `/v1/finance/*`.
- **What the router CANNOT tell you — do not try to fix this in the generator.**
Method, path, path params, and product are derivable; request/response schemas,
query/header params, status codes, and auth are NOT. The router holds a
`func(*zip.Ctx) error`; the request type is a LOCAL inside the handler
(`var req secretPutRequest; json.Unmarshal(ctx.Body(), &req)`), and Go cannot
reflect from a func value into its body. `cloud.Handle[S]` does not help — `S`
is the SERVICE (service.go:90), not the payload; `cloud.Typed` is an
`any→*zip.App` mount adapter. The ONE path to schemas is zip's typed ops
(`zip.Get[In,Out]`), which carry the In/Out types and also yield an MCP tool
from the same registry (zip/openapi.go, zip/mcp.go — today `len(a.ops) == 0`,
so zip's own generator emits nothing here). `GetRoutes()` is a superset of
`app.ops`, so migrating a handler to a typed op adds schema without changing
this pipeline.
- **Catch-alls are opaque, by construction.** `app.Post("/v1/billing/*")` proxies
to another service, so `POST /v1/billing/deposit` is NOT a route in this process
and cannot appear. Measured on the live table: 3 products are wholly opaque
(`bot`, `licensing`, `sentry` — the catch-all IS the product) and 12 more mix
concrete ops with a catch-all hiding an unknown remainder.
## Cross-subsystem seams that are values, not places
- **The per-principal MCP plane is callable in-process.** `clients/automations`
@@ -53,6 +107,36 @@ package under `clients/<name>` that obeys these seams — nothing more.
calls `InvokeTool` with `principal.Org(c)` — same 403 gate, per-org concurrency
bound, one metered unit, one audit record as the HTTP door — so it can never
exceed the caller's authority. Use this seam; never re-implement tool dispatch.
- **"Bot" is three values; each has one home and one namespace.** Do not merge
them and do not let them share a route prefix — they did once, and the router
resolves byte-identical patterns by first-registration with no panic (it MERGES
the handlers, so counting `GetRoutes()` entries cannot see it), and visor's
machine list silently answered the console's run list.
(1) A bot RUN — a task the runtime executes on a surface — is `clients/bots` at
`/v1/bots`. (2) A bot MACHINE — visor-provisioned compute of kind=bot plus its
agent binding — is `clients/visor` at `/v1/compute/bots`; what it rents you is
compute, so it nests in visor's domain. (3) The runtime SERVICE — the TS bot
(channels/skills), never reimplemented in Go — is reached through
`clients/runtime`, which is a TRANSPORT, not a domain: base address, identity,
framing, cleartext policy, and the `/v1/bot/*` ops face. It is named for what it
does, not for the host it dials, and it must never import `bots`/`coding` — each
of those owns its own wire stub (`bots/wire.go`, `coding/task.go`) and speaks
through the seam. That isolation is what makes the HIP-0106/HIP-0120 ZAP swap a
seam swap instead of a rewrite.
- **Cloud owns policy; the runtime owns the run. Do not copy state you do not
own.** `clients/bots` holds NO store. The sandbox lives in the bot runtime,
keyed in the runtime's own tenant store, which is the only thing that knows
whether a run is alive — so list and stop PROXY it, gated by cloud's
principal/org. A cloud-side registry was tried and was wrong: it minted an id
the runtime had never heard of, so it listed runs that did not exist and
"stopped" runs that were never started. Isolation holds because the org is the
validated one cloud sends, never a client's, and the runtime keys every run
under `tenants/{org}/`.
- **Absence is only meaningful from a callee that could have said otherwise.**
`runtime.ErrNotFound` (the operation ANSWERED "no such target") is separate from
`runtime.ErrNotServed` (the operation does not exist). Conflating them makes a
stop that cannot fail: a runtime without the route reports absent for EVERY run,
so "already gone" becomes permanently true. A bare 404 is 502, never success.
- **The Business AI Guide (`clients/guide`, `/v1/guide/*`)** is the on-site launch
checklist: a pure engine (`curriculum.go` — parse/validate/next-step/dependency
gating over plain data) + per-org progress (`cloud.OrgStore[*Store]`) + an
+4 -1
View File
@@ -24,7 +24,7 @@ OPENAPI_DIR ?= ../openapi
# forces the fork to modernc too so the whole binary registers "sqlite" once.
CGO_ENABLED ?= 0
.PHONY: help webui agentskills build build-standalone hanzo run smoke test test-cgo vet tidy docker docker-push clean
.PHONY: help native webui agentskills build build-standalone hanzo run smoke test test-cgo vet tidy docker docker-push clean
help: ## Show this help.
@awk 'BEGIN{FS=":.*##";printf "\nUsage: make <target>\n\nTargets:\n"} /^[a-zA-Z_-]+:.*##/{printf " \033[36m%-16s\033[0m %s\n", $$1, $$2}' $(MAKEFILE_LIST)
@@ -84,3 +84,6 @@ docker-push: docker ## Push the Docker image to ghcr.io. Requires docker login.
clean: ## Remove built artifacts.
rm -rf bin
native: ## Build the native flags evaluator staticlib (required for CGO=1 builds/tests).
cargo build --release --manifest-path native/flags/Cargo.toml
+169 -101
View File
@@ -1,4 +1,4 @@
// Package subsystems is the composition root: the single, explicit list of which
// Package apps is the composition root: the single, explicit list of which
// Hanzo cloud subsystems are linked into the binary AND the order they mount in.
//
// Wire() returns []cloud.MountSpec in mount order (slice position == order). There
@@ -27,11 +27,14 @@
// composition root fixes). They are wired back at their order-int slots — o11y-ext
// kept adjacent to the in-repo o11y read-plane (@69); ai as the last /v1/* catch-all
// before plugins (@900). Do not re-sort; edit positions deliberately.
package subsystems
//
//go:generate go run ../cmd/gen-app-cmds
package apps
import (
"context"
"fmt"
"os"
"github.com/hanzoai/cloud"
"github.com/zap-proto/zip"
@@ -43,7 +46,6 @@ import (
"github.com/hanzoai/authz"
"github.com/hanzoai/licensing"
"github.com/hanzoai/metrics"
o11ymod "github.com/hanzoai/o11y"
// In-repo subsystem packages (clients/*). Each exports a Mount (and, where it
// owns process-lifetime resources, a Shutdown); Wire references them directly.
@@ -51,6 +53,7 @@ import (
"github.com/hanzoai/cloud/clients/admin"
"github.com/hanzoai/cloud/clients/ads"
"github.com/hanzoai/cloud/clients/affiliates"
"github.com/hanzoai/cloud/clients/agent"
"github.com/hanzoai/cloud/clients/agents"
"github.com/hanzoai/cloud/clients/agentskills"
"github.com/hanzoai/cloud/clients/analytics"
@@ -59,34 +62,36 @@ import (
"github.com/hanzoai/cloud/clients/automations"
"github.com/hanzoai/cloud/clients/base"
"github.com/hanzoai/cloud/clients/billing"
"github.com/hanzoai/cloud/clients/bot"
"github.com/hanzoai/cloud/clients/bots"
"github.com/hanzoai/cloud/clients/captable"
"github.com/hanzoai/cloud/clients/catalogsync"
"github.com/hanzoai/cloud/clients/code"
"github.com/hanzoai/cloud/clients/company"
"github.com/hanzoai/cloud/clients/connectorruntime"
"github.com/hanzoai/cloud/clients/content"
"github.com/hanzoai/cloud/clients/crm"
"github.com/hanzoai/cloud/clients/cron"
"github.com/hanzoai/cloud/clients/dataroom"
"github.com/hanzoai/cloud/clients/deploy"
"github.com/hanzoai/cloud/clients/do"
"github.com/hanzoai/cloud/clients/entitlements"
"github.com/hanzoai/cloud/clients/eval"
"github.com/hanzoai/cloud/clients/exec"
"github.com/hanzoai/cloud/clients/featureflags"
"github.com/hanzoai/cloud/clients/flags"
"github.com/hanzoai/cloud/clients/framework"
"github.com/hanzoai/cloud/clients/functions"
"github.com/hanzoai/cloud/clients/gateway"
"github.com/hanzoai/cloud/clients/git"
"github.com/hanzoai/cloud/clients/gitops"
"github.com/hanzoai/cloud/clients/graph"
"github.com/hanzoai/cloud/clients/guide"
"github.com/hanzoai/cloud/clients/iam"
"github.com/hanzoai/cloud/clients/iam2"
"github.com/hanzoai/cloud/clients/ingress"
"github.com/hanzoai/cloud/clients/integrations"
"github.com/hanzoai/cloud/clients/kafka"
"github.com/hanzoai/cloud/clients/kms"
"github.com/hanzoai/cloud/clients/knowledge"
"github.com/hanzoai/cloud/clients/link"
"github.com/hanzoai/cloud/clients/marketing"
"github.com/hanzoai/cloud/clients/marketplace"
"github.com/hanzoai/cloud/clients/ml"
@@ -103,12 +108,14 @@ import (
"github.com/hanzoai/cloud/clients/provisioning"
"github.com/hanzoai/cloud/clients/pubsub"
"github.com/hanzoai/cloud/clients/referrals"
"github.com/hanzoai/cloud/clients/runtime"
"github.com/hanzoai/cloud/clients/sbom"
"github.com/hanzoai/cloud/clients/security"
"github.com/hanzoai/cloud/clients/settings"
"github.com/hanzoai/cloud/clients/sign"
"github.com/hanzoai/cloud/clients/social"
"github.com/hanzoai/cloud/clients/storage"
"github.com/hanzoai/cloud/clients/sync"
"github.com/hanzoai/cloud/clients/tasks"
"github.com/hanzoai/cloud/clients/team"
"github.com/hanzoai/cloud/clients/templates"
@@ -156,6 +163,22 @@ func init() {
})
}
// identitySpec selects the ONE identity backend that owns /v1/iam/* (+ /login/oauth/*)
// for this boot. CLOUD_IAM_IMPL=iam2 picks the clean-room iam2 (zip+orm, beego-free);
// anything else — including unset, the production default — keeps the legacy beego
// Casdoor embed, byte-for-byte today's behavior. The two impls register the SAME
// absolute prefixes and therefore cannot co-mount, so selection (this func) stays
// separate from activation (cfg.Enabled): exactly one spec occupies the identity slot
// in Wire, preserving mount order either way. os.Getenv (not the unexported
// cloud.getenv, which is unreachable from package apps) is the read — CLOUD_IAM_IMPL is
// the deliberate, off-by-default opt-in that keeps iam2 inert until a canary flips it.
func identitySpec() cloud.MountSpec {
if os.Getenv("CLOUD_IAM_IMPL") == "iam2" {
return cloud.MountSpec{Name: "iam2", Mount: iam2.Mount}
}
return cloud.MountSpec{Name: "iam", Mount: iam.Mount}
}
// Wire returns every linked subsystem as a cloud.MountSpec, in mount order. The
// slice position IS the order: cloud.MountAll iterates it as-given, registering each
// subsystem's teardown as a zip shutdown hook so teardown runs in reverse (LIFO).
@@ -164,170 +187,200 @@ func init() {
func Wire() []cloud.MountSpec {
return []cloud.MountSpec{
// embedded NATS :4222 + JetStream.
{Name: "pubsub", Mount: cloud.Typed(pubsub.Mount), Shutdown: pubsub.Shutdown},
{Name: "pubsub", Mount: pubsub.Mount, Shutdown: pubsub.Shutdown},
// embedded Kafka adaptor :9092.
{Name: "kafka", Mount: cloud.Typed(kafka.Mount), Shutdown: kafka.Shutdown},
{Name: "kafka", Mount: kafka.Mount, Shutdown: kafka.Shutdown},
// /.well-known/agent-skills/* — before IAM's /.well-known/* wildcard (50).
{Name: "agentskills", Mount: cloud.Typed(agentskills.Mount)},
{Name: "agentskills", Mount: agentskills.Mount},
// Insights feature-flag evaluation seam (no routes; a hot value plane).
{Name: "featureflags", Mount: cloud.Typed(featureflags.Mount)},
{Name: "flags", Mount: flags.Mount, Shutdown: flags.Shutdown, OwnsHealth: true},
// Embedded KMS secrets plane /v1/kms/*. OwnsHealth: serves its own fail-closed
// /v1/kms/health (the generic always-ok route must not shadow it). Fails closed
// until the operator injects CLOUD_KMS_MASTER_KEY_REF. (Its in-process client
// factory is registered separately via cloud.RegisterKMSClientFactory.)
{Name: "kms", Mount: cloud.Typed(kms.Mount), OwnsHealth: true},
{Name: "kms", Mount: kms.Mount, OwnsHealth: true},
// hanzoai/metrics — native o11y. It declares its OWN narrow metrics.Deps (no
// hanzoai/cloud import), so Typed cannot adapt it; mountMetrics builds that Deps
// from cloud.Deps and calls metrics.Mount explicitly.
{Name: "metrics", Mount: mountMetrics},
// Embedded runtime edge (/v1/ingress/*). STAGED — edge listeners stay off unless
// the operator names "ingress" in CLOUD_ENABLE.
{Name: "ingress", Mount: cloud.Typed(ingress.Mount), Shutdown: ingress.Shutdown},
{Name: "ingress", Mount: ingress.Mount, Shutdown: ingress.Shutdown},
// SPECIFIC self-service routes (/v1/iam/{keys,onboard}, /v1/csrf, /v1/embed-status,
// /v1/commerce/topup/wallet). MUST mount before the IAM /v1/iam/* wildcard (50) so
// they win Fiber's first-match scan (framework-guaranteed since zip v1.3.0).
{Name: "account", Mount: cloud.Typed(account.MountAccount)},
{Name: "account", Mount: account.MountAccount},
// Embedded IAM identity plane (/v1/iam/*, /.well-known/*, /login/oauth/*, /_/iam/*,
// /cas/*, /scim/*) — the identity authority, mounts before its dependents. STAGED:
// the operator adds "iam" to --enable only after IAM config + the fold are verified.
{Name: "iam", Mount: cloud.Typed(iam.Mount)},
// Which IMPLEMENTATION owns these prefixes is selected by CLOUD_IAM_IMPL
// (identitySpec): the clean-room iam2 (zip+orm, beego-free) when =="iam2", else the
// legacy beego Casdoor embed — the default (unset = today's behavior, byte-for-byte).
// Both register the SAME absolute paths and cannot co-mount, so this is an either/or
// switch at this ONE slot, never a shadow prefix.
identitySpec(),
// Embedded Base app engine + viral waitlist (/v1/waitlist/*). STAGED behind
// CLOUD_BASE_EMBED. OwnsHealth: native /v1/base/health.
{Name: "base", Mount: cloud.Typed(base.Mount), Shutdown: base.Shutdown, OwnsHealth: true},
// In-repo o11y READ plane + the runtime-handler install (o11y.SetHandler). Every
// specific /v1/o11y/* route registers INSIDE this one mount, hence BEFORE the
// hanzoai/o11y module wildcard (70) — Fiber's in-order match gives them precedence.
// OwnsHealth: the module's order-70 co-entry below owns the single /v1/o11y/health.
{Name: "o11y", Mount: o11y.MountO11y, Shutdown: o11y.ShutdownO11y, OwnsHealth: true},
// hanzoai/o11y module wildcard /v1/o11y/* — co-owner of the ONE o11y concept with
// the in-repo entry above (same name), delegated to via o11y.SetHandler.
{Name: "o11y", Mount: cloud.Typed(o11ymod.Mount)},
{Name: "authz", Mount: cloud.Typed(authz.Mount)},
{Name: "base", Mount: base.Mount, Shutdown: base.Shutdown, OwnsHealth: true},
// The ONE observability subsystem: the in-repo o11y READ plane + runtime-handler
// install (o11y.SetHandler), with the hanzoai/o11y module wildcard /v1/o11y/*
// folded in as the TERMINAL sub-mount INSIDE o11y.MountO11y. Every specific
// /v1/o11y/* route registers before that wildcard, so Fiber's in-order match gives
// them precedence. NOT OwnsHealth: /v1/o11y/health stays the generic always-ok
// route (registered before MountAll), exactly as when the former module co-entry —
// which also set OwnsHealth=false — triggered it.
{Name: "o11y", Mount: o11y.MountO11y, Shutdown: o11y.ShutdownO11y},
{Name: "authz", Mount: authz.Mount},
// Embedded commerce plane /v1/commerce/*, /_/commerce/* — the hanzoai/commerce
// MODULE via the adapter in commerce.go (un-forked; the in-process
// CommerceClient is wired directly in pickCommerceClient).
{Name: "commerce", Mount: cloud.Typed(mountCommerce)},
// hanzoai/licensing. Its Mount is func(any, cloud.Deps) error — a MountFunc
// already — so Wire references it DIRECTLY, not through Typed.
{Name: "commerce", Mount: mountCommerce},
{Name: "licensing", Mount: licensing.Mount},
{Name: "plans", Mount: cloud.Typed(plan.Mount), OwnsHealth: true},
{Name: "pricing", Mount: cloud.Typed(pricing.Mount), OwnsHealth: true},
{Name: "plans", Mount: plan.Mount, OwnsHealth: true},
{Name: "pricing", Mount: pricing.Mount, OwnsHealth: true},
// /v1/s3/buckets/* + /v1/s3/health. Mounts BEFORE provisioning (120) so its static
// routes win over provisioning's /v1/s3/:name. OwnsHealth (real fail-closed probe).
{Name: "storage", Mount: cloud.Typed(storage.Mount), OwnsHealth: true},
{Name: "storage", Mount: storage.Mount, OwnsHealth: true},
// Provisioning control plane: /v1/sql,/v1/vector,/v1/datastore,/v1/kv,/v1/search,/v1/s3,/v1/docdb.
{Name: "provisioning", Mount: cloud.Typed(provisioning.Mount)},
{Name: "billing", Mount: cloud.Typed(billing.Mount)},
{Name: "provisioning", Mount: provisioning.Mount},
{Name: "billing", Mount: billing.Mount},
// CATCH-ALL /v1/billing/* + /v1/commerce/* data bridges — AFTER clients/billing
// (121) + the commerce embed (100). Same clients/account package as "account" (48).
{Name: "account-bridge", Mount: cloud.Typed(account.MountBridge)},
{Name: "do", Mount: cloud.Typed(do.Mount)},
{Name: "platform", Mount: cloud.Typed(platform.Mount), OwnsHealth: true},
{Name: "projects", Mount: cloud.Typed(projects.Mount)},
{Name: "prompts", Mount: cloud.Typed(prompts.Mount)},
{Name: "agents", Mount: cloud.Typed(agents.Mount), Shutdown: agents.Shutdown},
{Name: "wallets", Mount: cloud.Typed(wallets.Mount), Shutdown: ctxShutdown(wallets.Shutdown)},
{Name: "account-bridge", Mount: account.MountBridge},
{Name: "do", Mount: do.Mount},
{Name: "platform", Mount: platform.Mount, OwnsHealth: true},
{Name: "projects", Mount: projects.Mount},
{Name: "prompts", Mount: prompts.Mount},
{Name: "agents", Mount: agents.Mount, Shutdown: agents.Shutdown},
// The unified AI login manager registry (/v1/links). Mounts AFTER agents so
// a link revoke can stop the affected agent sessions in-process.
{Name: "link", Mount: link.Mount, Shutdown: link.Shutdown},
{Name: "wallets", Mount: wallets.Mount, Shutdown: ctxShutdown(wallets.Shutdown)},
// x402 pay-per-use: settles a signed ERC-3009 authorization to a recipient
// wallet through the metering spine. Mounts AFTER wallets (it resolves the
// recipient via wallets.ResolvePaymentTarget) and provides the Enforce
// middleware a marketplace applies to its priced routes.
{Name: "x402", Mount: cloud.Typed(x402.Mount), Shutdown: ctxShutdown(x402.Shutdown)},
{Name: "paas", Mount: cloud.Typed(paas.Mount), OwnsHealth: true},
// GitOps deploy dashboard /v1/gitops/* (the ArgoCD-grade fleet view over the
{Name: "x402", Mount: x402.Mount, Shutdown: ctxShutdown(x402.Shutdown)},
{Name: "paas", Mount: paas.Mount, OwnsHealth: true},
// GitOps deploy dashboard /v1/deploy/* (the ArgoCD-grade fleet view over the
// operator App CRs). After paas so the release seam paas installs is registered
// before a gitops rollback delegates to it; owns its own /v1/gitops/health.
{Name: "gitops", Mount: cloud.Typed(gitops.Mount), OwnsHealth: true},
{Name: "functions", Mount: cloud.Typed(functions.Mount)},
{Name: "tracker", Mount: cloud.Typed(tracker.Mount)},
{Name: "templates", Mount: cloud.Typed(templates.Mount)},
{Name: "framework", Mount: cloud.Typed(framework.Mount), Shutdown: ctxShutdown(framework.Shutdown)},
{Name: "knowledge", Mount: cloud.Typed(knowledge.Mount)},
// before a gitops rollback delegates to it; owns its own /v1/deploy/health.
{Name: "deploy", Mount: deploy.Mount, OwnsHealth: true},
{Name: "functions", Mount: functions.Mount},
{Name: "tracker", Mount: tracker.Mount},
{Name: "templates", Mount: templates.Mount},
{Name: "framework", Mount: framework.Mount, Shutdown: ctxShutdown(framework.Shutdown)},
{Name: "knowledge", Mount: knowledge.Mount},
// Marketing content loop /v1/content/* (generate → CMS → transition → publish).
// After framework (its DocType store the ops read/write) + knowledge (the sibling
// framework lane); before the AI /v1/* catch-all so /v1/content/* resolves here.
// CRUD/tenancy/install are framework's; this adds the board, lifecycle transition,
// and the generate/publish orchestration over the zen5 + studio + social edges.
{Name: "content", Mount: cloud.Typed(content.Mount), Shutdown: ctxShutdown(content.Shutdown)},
{Name: "content", Mount: content.Mount, Shutdown: ctxShutdown(content.Shutdown)},
// Reverse storefront loop: consume the commerce COMMERCE stream (product.created)
// → content.EnsureCatalogAsset (render the new product's ecom asset, design==slug).
// After content (whose EnsureCatalogAsset it drives). Inert until CLOUD_COMMERCE_NATS_URL
// names the NATS carrying commerce catalog events — the reverse of the forward edge.
{Name: "catalogsync", Mount: cloud.Typed(catalogsync.Mount), Shutdown: catalogsync.Shutdown},
{Name: "ml", Mount: cloud.Typed(ml.Mount), OwnsHealth: true},
{Name: "usage", Mount: cloud.Typed(usage.Mount)},
{Name: "crm", Mount: cloud.Typed(crm.Mount)},
{Name: "catalogsync", Mount: catalogsync.Mount, Shutdown: catalogsync.Shutdown},
{Name: "ml", Mount: ml.Mount, OwnsHealth: true},
{Name: "usage", Mount: usage.Mount},
{Name: "crm", Mount: crm.Mount},
// Native /v1/marketing/* — the in-process fold of github.com/hanzoai/marketing
// (per-org campaign store on Base/SQLite), twin of crm. Owns a DB handle, so
// its Shutdown closes it cleanly on SIGTERM (ctxShutdown adapts func() error).
{Name: "marketing", Mount: cloud.Typed(marketing.Mount), Shutdown: ctxShutdown(marketing.Shutdown)},
{Name: "marketing", Mount: marketing.Mount, Shutdown: ctxShutdown(marketing.Shutdown)},
// Native /v1/ads/* — the net-new per-org ad-campaign store on Base/SQLite,
// twin of crm/marketing. Owns a DB handle, so its Shutdown closes it cleanly
// on SIGTERM (ctxShutdown adapts func() error).
{Name: "ads", Mount: cloud.Typed(ads.Mount), Shutdown: ctxShutdown(ads.Shutdown)},
{Name: "ads", Mount: ads.Mount, Shutdown: ctxShutdown(ads.Shutdown)},
// Native /v1/social/* — the in-process fold of the live social stack
// (github.com/hanzoai/social: social-backend/frontend/orchestrator, a Postiz-style
// scheduler), a per-org accounts+posts store on Base/SQLite, twin of crm. Owns a DB
// handle, so its Shutdown closes it cleanly on SIGTERM (ctxShutdown adapts func() error).
{Name: "social", Mount: cloud.Typed(social.Mount), Shutdown: ctxShutdown(social.Shutdown)},
{Name: "analytics", Mount: cloud.Typed(analytics.Mount), OwnsHealth: true},
{Name: "git", Mount: cloud.Typed(git.Mount)},
{Name: "visor", Mount: cloud.Typed(visor.Mount)},
{Name: "social", Mount: social.Mount, Shutdown: ctxShutdown(social.Shutdown)},
{Name: "analytics", Mount: analytics.Mount, OwnsHealth: true},
{Name: "git", Mount: git.Mount},
// Universal sync (/v1/sync/links + engine). Registers the cloud.SyncEngine the
// GitHub/Gitea webhooks enqueue to; git is its first provider. Owns per-org
// DB handles, so its Shutdown closes them on SIGTERM.
{Name: "sync", Mount: sync.Mount, Shutdown: ctxShutdown(sync.Shutdown)},
{Name: "visor", Mount: visor.Mount},
// Cap table on Base via goja. STAGED behind CLOUD_ENABLE.
{Name: "captable", Mount: cloud.Typed(captable.Mount), Shutdown: captable.Shutdown},
{Name: "code", Mount: cloud.Typed(code.Mount), Shutdown: code.Shutdown},
{Name: "zero-trust", Mount: cloud.Typed(zt.Mount)},
{Name: "captable", Mount: captable.Mount, Shutdown: captable.Shutdown},
{Name: "code", Mount: code.Mount, Shutdown: code.Shutdown},
{Name: "zero-trust", Mount: zt.Mount},
// Data rooms via goja + per-tenant Base. STAGED behind CLOUD_ENABLE. OwnsHealth.
{Name: "dataroom", Mount: cloud.Typed(dataroom.Mount), Shutdown: dataroom.Shutdown, OwnsHealth: true},
{Name: "graph", Mount: cloud.Typed(graph.Mount)},
{Name: "security", Mount: cloud.Typed(security.Mount), Shutdown: ctxShutdown(security.Shutdown), OwnsHealth: true},
{Name: "integrations", Mount: cloud.Typed(integrations.Mount), Shutdown: integrations.Shutdown},
{Name: "sbom", Mount: cloud.Typed(sbom.Mount), OwnsHealth: true},
{Name: "team", Mount: cloud.Typed(team.Mount), Shutdown: ctxShutdown(team.Shutdown)},
{Name: "settings", Mount: cloud.Typed(settings.Mount), Shutdown: settings.Shutdown},
{Name: "notify", Mount: cloud.Typed(notify.Mount), OwnsHealth: true},
{Name: "gateway", Mount: cloud.Typed(gateway.Mount)},
{Name: "entitlements", Mount: cloud.Typed(entitlements.Mount), Shutdown: entitlements.Shutdown},
{Name: "exec", Mount: cloud.Typed(exec.Mount)},
{Name: "websearch", Mount: cloud.Typed(websearch.Mount)},
{Name: "world", Mount: cloud.Typed(world.Mount), Shutdown: ctxShutdown(world.Shutdown)},
{Name: "bot", Mount: cloud.Typed(bot.Mount)},
{Name: "authors", Mount: cloud.Typed(authors.Mount), Shutdown: ctxShutdown(authors.Shutdown)},
{Name: "bots", Mount: cloud.Typed(bots.Mount)},
{Name: "audit", Mount: cloud.Typed(auditlog.Mount)},
{Name: "affiliates", Mount: cloud.Typed(affiliates.Mount)},
{Name: "dataroom", Mount: dataroom.Mount, Shutdown: dataroom.Shutdown, OwnsHealth: true},
{Name: "graph", Mount: graph.Mount},
{Name: "security", Mount: security.Mount, Shutdown: ctxShutdown(security.Shutdown), OwnsHealth: true},
{Name: "integrations", Mount: integrations.Mount, Shutdown: integrations.Shutdown},
{Name: "sbom", Mount: sbom.Mount, OwnsHealth: true},
{Name: "team", Mount: team.Mount, Shutdown: ctxShutdown(team.Shutdown)},
{Name: "settings", Mount: settings.Mount, Shutdown: settings.Shutdown},
{Name: "notify", Mount: notify.Mount, OwnsHealth: true},
{Name: "gateway", Mount: gateway.Mount},
{Name: "entitlements", Mount: entitlements.Mount, Shutdown: entitlements.Shutdown},
{Name: "exec", Mount: exec.Mount},
{Name: "websearch", Mount: websearch.Mount},
{Name: "world", Mount: world.Mount, Shutdown: ctxShutdown(world.Shutdown)},
// The bot runtime's ops face (/v1/bot/*). The transport itself is domain-free;
// the run control plane is "bots" below.
{Name: "runtime", Mount: runtime.Mount},
{Name: "authors", Mount: authors.Mount, Shutdown: ctxShutdown(authors.Shutdown)},
{Name: "bots", Mount: bots.Mount},
{Name: "audit", Mount: auditlog.Mount},
{Name: "affiliates", Mount: affiliates.Mount},
// Hanzo Sign (e-signature) via goja + per-tenant Base. STAGED behind CLOUD_ENABLE. OwnsHealth.
{Name: "sign", Mount: cloud.Typed(sign.Mount), Shutdown: sign.Shutdown, OwnsHealth: true},
{Name: "product", Mount: cloud.Typed(product.Mount)},
{Name: "evals", Mount: cloud.Typed(eval.Mount)},
{Name: "treasury", Mount: cloud.Typed(treasury.Mount), Shutdown: ctxShutdown(treasury.Shutdown)},
{Name: "admin", Mount: cloud.Typed(admin.Mount)},
{Name: "tasks", Mount: cloud.Typed(tasks.Mount)},
{Name: "sign", Mount: sign.Mount, Shutdown: sign.Shutdown, OwnsHealth: true},
{Name: "product", Mount: product.Mount},
{Name: "evals", Mount: eval.Mount},
{Name: "treasury", Mount: treasury.Mount, Shutdown: ctxShutdown(treasury.Shutdown)},
{Name: "admin", Mount: admin.Mount},
// Launch-control (per-service waitlist mode) folded into the flags engine: the
// mode IS the switch waitlist.<svc>, the board is the /v1/admin/services lens,
// and /v1/featuregate/mode is served by flags. featuregate is no longer a mounted
// subsystem — it exposes only the native Enforce middleware (wired in serve.go),
// a consumer of flags.WaitlistModeForHost.
{Name: "tasks", Mount: tasks.Mount},
// Platform cron: durable schedules on the shared tasks engine replacing
// every k8s CronJob — entries are cron.hanzo.ai ConfigMaps (universe git),
// runs visible in the Tasks console. Mounts no routes; starts after the
// engine is wired.
{Name: "cron", Mount: cloud.Typed(cron.Mount)},
{Name: "automations", Mount: cloud.Typed(automations.Mount), Shutdown: automations.Shutdown},
{Name: "cron", Mount: cron.Mount},
{Name: "automations", Mount: automations.Mount, Shutdown: automations.Shutdown},
// Native single-connector execution (HIP-0126): runs an ActivePieces JS
// connector action in-process via goja (clients/connectorruntime), retiring
// the standalone auto Node engine. Mounts POST /v1/automations/connectors/:id/run,
// paired with the automations catalogue above; STAGED like the rest.
{Name: "connectorruntime", Mount: connectorruntime.Mount},
// Unified tool plane: /v1/tools/* — the ONE registry (connectors, functions,
// agents, skills, external MCP servers, full-cloud-control /v1 routes), per-org
// activation, and the unified MCP endpoint. Sources register into it from their
// own Mounts, so mount position is not load-bearing (List/Dispatch run at request
// time); placed after automations, before the zen/ai catch-all so /v1/tools wins.
{Name: "tools", Mount: cloud.Typed(tools.Mount), Shutdown: tools.Shutdown},
{Name: "tools", Mount: tools.Mount, Shutdown: tools.Shutdown},
// Marketplace: /v1/marketplace/* — listing/discovery/install over the tool plane,
// with x402-priced monetized listings. Mounts after tools (it fills the price seam).
{Name: "marketplace", Mount: cloud.Typed(marketplace.Mount), Shutdown: marketplace.Shutdown},
{Name: "referrals", Mount: cloud.Typed(referrals.Mount)},
{Name: "marketplace", Mount: marketplace.Mount, Shutdown: marketplace.Shutdown},
{Name: "referrals", Mount: referrals.Mount},
// Business AI Guide /v1/guide/* — the interactive launch checklist engine +
// the agent that executes a step through the per-principal MCP plane. After
// automations (whose InvokeTool it drives) and referrals; before the ai
// catch-all. Owns per-org SQLite, so its Shutdown closes the stores.
{Name: "guide", Mount: cloud.Typed(guide.Mount), Shutdown: ctxShutdown(guide.Shutdown)},
{Name: "guide", Mount: guide.Mount, Shutdown: ctxShutdown(guide.Shutdown)},
// Hanzo Company — the incorporation + fundraising state machine
// (/v1/company/*). Mounts after the seams it composes (integrations for the
// google token custody; captable/dataroom facades) and before the /v1/* AI
// catch-all so its routes resolve here.
{Name: "company", Mount: cloud.Typed(company.Mount), Shutdown: company.Shutdown},
{Name: "company", Mount: company.Mount, Shutdown: company.Shutdown},
// Chat orchestrator — POST /v1/chat: ONE LLM tool-calling round over the tool
// plane. It COMPOSES the ai completion path (in-process, so per-org billing
// runs) + the unified tool registry, and splits the model's tool calls into
// server-executed actions and client-applied ops. Mounts BEFORE the zen/ai
// catch-all so /v1/chat resolves here (Fiber first-match); the ai module's
// beego /v1/chat alias behind its /v1/* glob is thereby shadowed, while ai
// keeps /v1/chat/completions + /v1/completions.
{Name: "agent", Mount: agent.Mount},
// The bare /v1/* AI catch-all — the LAST route position. Every owning subsystem above
// wins its own namespace (Fiber first-match); AI is the fallback for the rest of /v1/*.
// zen mounts as a /v1-scoped Claim middleware BEFORE ai: it routes zen* models
@@ -336,21 +389,36 @@ func Wire() []cloud.MountSpec {
// other model and the /v1/models list. Order is load-bearing — Claim must
// run before ai's catch-all. (See hip-00NN.)
{Name: "zen", Mount: mountZen},
{Name: "ai", Mount: cloud.Typed(ai.Mount)},
{Name: "ai", Mount: ai.Mount},
// Runtime wasm/proxy plugins — mounts dead last.
{Name: "plugins", Mount: cloud.Typed(plugin.Mount)},
{Name: "plugins", Mount: plugin.Mount},
}
}
// ServeSingle is the ONE way to run a single app standalone: validate `name`
// against Wire(), then serve exactly it (cloud.Serve with a one-name enable
// list — MountAll mounts only it). It is the path `hanzo <name>` already uses;
// promoting it here lets each cmd/<app>/main.go stub reuse it instead of
// re-implementing the dispatch, so adding an app in Wire() is still the one edit
// and its standalone binary comes for free (generated). Returns an error for an
// unknown name rather than booting a no-op.
func ServeSingle(name string) error {
if name == "" {
return fmt.Errorf("ServeSingle: empty app name")
}
for _, spec := range Wire() {
if spec.Name == name {
return cloud.Serve(Wire(), []string{name})
}
}
return fmt.Errorf("ServeSingle: unknown app %q — run `hanzo code ls`/`hanzo` for the list", name)
}
// mountMetrics adapts hanzoai/metrics into a cloud.MountFunc. Unlike the other
// externals, metrics declares its OWN narrow Deps (Logger, DataDir, Brand) and does
// not import hanzoai/cloud, so cloud.Typed cannot bridge it: the composition root
// builds metrics.Deps from cloud.Deps and calls metrics.Mount explicitly here.
func mountMetrics(app any, deps cloud.Deps) error {
a, ok := app.(*zip.App)
if !ok {
return fmt.Errorf("metrics.Mount: app is %T, want *zip.App", app)
}
func mountMetrics(a *zip.App, deps cloud.Deps) error {
return metrics.Mount(a, metrics.Deps{Logger: deps.Logger, DataDir: deps.DataDir, Brand: deps.Brand})
}
+41 -2
View File
@@ -13,13 +13,14 @@
//
// FAIL-SOFT. A broken Embed does NOT crash the binary: commerce degrades to a 503
// on its own prefixes while every co-resident subsystem stays up.
package subsystems
package apps
import (
"context"
"fmt"
"net/http"
"path/filepath"
"strings"
"github.com/hanzoai/cloud"
"github.com/hanzoai/cloud/clients/commerceclient"
@@ -115,6 +116,13 @@ func mountCommerce(app *zip.App, deps cloud.Deps) error {
// THE native co-residence contract: commerce registers its routes on
// cloud's own app — no second engine, no net/http adaptation.
App: app,
// ONE LEDGER: commerce's POST /v1/billing/credit mints into cloud's native
// finance ledger (the SAME per-org account the AI spend-gate reads), so a
// granted credit is immediately spendable. commerce.Embed calls
// creditledger.Set(this) before routes register; nil would leave commerce on
// its own datastore (standalone), but in this unified binary finance is
// co-resident, so we inject the finance-backed ledger adapter.
Ledger: ledger{},
})
if err != nil {
lg.Error("commerce embed failed — serving fail-closed 503 (cloud stays up)", "err", err)
@@ -126,7 +134,7 @@ func mountCommerce(app *zip.App, deps cloud.Deps) error {
// mirrors the standalone /v1 bundle: gated request context, host, IAM
// resolution; store.Route's own tokenRequired arg gates the CRUD.
storeV1 := app.Group("/v1")
storeV1.Use(commercemid.AddHost(), commercemid.RequestContext(), commercemid.ErrorHandlerJSON())
storeV1.Use(commercemid.AddHost(), commercemid.RequestContext(), commerceErrorScope())
// Unconditional, exactly like the standalone bundle: IAMTokenRequired
// no-ops gracefully when IAM is not initialized.
storeV1.Use(iammiddleware.IAMTokenRequired())
@@ -166,6 +174,37 @@ func mountCommerce(app *zip.App, deps cloud.Deps) error {
// mountCommerceFailClosed serves an honest JSON 503 on every commerce prefix when
// the embed cannot boot, so /v1/commerce/* answers "commerce unavailable" instead
// commerceErrorScope confines commerce's JSON error envelope to commerce's OWN
// routes. commercemid.ErrorHandlerJSON is a `/v1` GROUP middleware, and fiber
// matches group middleware by PREFIX, not by the handle a route registered on —
// so on the shared `/v1` it wraps every subsystem mounted AFTER commerce and
// flattens their typed zip.HTTPError (403/400/…) into a blanket 500 (the store
// envelope always renders 500). Guarded by commercePrefixes, the envelope stays on
// commerce and every other subsystem renders its own status via zip's default
// handler — the pre-commerce subsystems (kms, o11y, …) already do; this makes the
// post-commerce ones (projects, agents, wallets, …) match.
func commerceErrorScope() zip.Handler {
envelope := commercemid.ErrorHandlerJSON()
return func(c *zip.Ctx) error {
if hasCommercePrefix(c.Path()) {
return envelope(c)
}
return c.Next()
}
}
// hasCommercePrefix reports whether path is a commerce-owned root (an exact prefix
// or a child of one), the SAME ownership commercePrefixes encodes for the
// fail-closed mount.
func hasCommercePrefix(path string) bool {
for _, p := range commercePrefixes {
if path == p || strings.HasPrefix(path, p+"/") {
return true
}
}
return false
}
// of falling through to another subsystem's catch-all.
func mountCommerceFailClosed(app *zip.App) {
failed := func(c *zip.Ctx) error {
+82
View File
@@ -0,0 +1,82 @@
package apps
import (
"io"
"net/http/httptest"
"strings"
"testing"
commercemid "github.com/hanzoai/commerce/middleware"
"github.com/zap-proto/zip"
)
// TestCommerceErrorScope proves commerceErrorScope() confines commerce's always-500
// JSON envelope to commerce-owned prefixes: a post-commerce subsystem route
// (/v1/projects) that returns a typed 403 renders 403 (zip default), while a
// commerce route (/v1/store/...) still gets commerce's envelope. Mirrors the frozen
// order: kms (before commerce) → commerce /v1 group chain → projects + store (after).
// Regression for the release-smoke failure where 14 post-commerce endpoints 500'd.
func TestCommerceErrorScope(t *testing.T) {
app := zip.New(zip.Config{})
// kms (before commerce) — a clean 403 baseline (never wrapped by commerce).
app.Get("/v1/kms/health", func(c *zip.Ctx) error { return zip.ErrForbidden("kms says no") })
// commerce (position 39): the REAL group chain, now with the scoped envelope.
sv1 := app.Group("/v1")
sv1.Use(commercemid.AddHost(), commercemid.RequestContext(), commerceErrorScope())
// projects (after commerce) — typed 403; must NOT be clobbered to 500.
app.Get("/v1/projects", func(c *zip.Ctx) error { return zip.ErrForbidden("X-Org-Id required") })
// a commerce store route (after commerce) — typed 403; commerce envelope applies.
app.Get("/v1/store/current", func(c *zip.Ctx) error { return zip.ErrForbidden("store needs org") })
probe := func(path string) (int, string) {
req := httptest.NewRequest("GET", path, nil)
resp, err := app.Fiber().Test(req)
if err != nil {
t.Fatalf("%s: %v", path, err)
}
defer resp.Body.Close()
b, _ := io.ReadAll(resp.Body)
return resp.StatusCode, string(b)
}
// The invariant the scope guards: a typed zip.HTTPError (403) a subsystem returns
// is NEVER flattened to a 500 — not before commerce, not after it. (commerce
// >=1.48.10 honors the status itself; the scope keeps commerce's error handler off
// other subsystems' routes regardless, so a future commerce regression can't
// re-clobber them.)
for _, tc := range []struct {
path string
wantCode int
}{
{"/v1/kms/health", 403}, // before commerce
{"/v1/projects", 403}, // after commerce — must NOT be clobbered to 500
{"/v1/store/current", 403}, // commerce's own route — its handler still honors 403
} {
code, body := probe(tc.path)
t.Logf("%-20s -> %d %s", tc.path, code, body)
if code != tc.wantCode {
t.Errorf("%s: got %d, want %d (%s)", tc.path, code, tc.wantCode, body)
}
if strings.Contains(body, "\"status\":5") || code >= 500 {
t.Errorf("%s: a typed 403 was flattened to a 5xx (%s)", tc.path, body)
}
}
for _, p := range []struct {
path string
own bool
}{
{"/v1/store/current", true},
{"/v1/commerce/checkout", true},
{"/v1/projects", false},
{"/v1/agent/presets", false},
{"/v1/agents", false},
} {
if got := hasCommercePrefix(p.path); got != p.own {
t.Errorf("hasCommercePrefix(%q) = %v, want %v", p.path, got, p.own)
}
}
}
@@ -1,6 +1,6 @@
// Copyright © 2026 Hanzo AI. MIT License.
package subsystems
package apps
import (
"io"
@@ -1,4 +1,4 @@
package subsystems
package apps
import (
"testing"
@@ -10,7 +10,7 @@ import (
// (cms/erp/help) against silent removal. They are not mount subsystems, so they
// never appear in Wire(); they register their DocTypes and — for erp — the
// ledger-posting lifecycle hooks into the framework engine from a package
// init(), reached ONLY via the blank imports in subsystems.go. #248 dropped
// init(), reached ONLY via the blank imports in apps.go. #248 dropped
// those imports, which stripped the erp ledger hooks from the binary with no
// mount change and no failing mount test. This asserts the engine's module
// registry carries each lane, so that money-adjacent regression cannot recur.
@@ -21,7 +21,7 @@ func TestFrameworkContentModulesLinked(t *testing.T) {
}
for _, want := range []string{"cms", "erp", "help"} {
if !got[want] {
t.Errorf("framework content module %q not registered — a blank import in subsystems.go is missing (erp drop = ledger hooks gone from the binary)", want)
t.Errorf("framework content module %q not registered — a blank import in apps.go is missing (erp drop = ledger hooks gone from the binary)", want)
}
}
// The module registry proves DocTypes are linked, but erp's ledger-posting
+81
View File
@@ -0,0 +1,81 @@
// Copyright © 2026 Hanzo AI. MIT License.
package apps
import (
"context"
"fmt"
"github.com/hanzoai/cloud/clients/finance"
"github.com/hanzoai/cloud/clients/money"
"github.com/hanzoai/cloud/types"
"github.com/hanzoai/commerce/billing/creditledger"
)
// ledger implements commerce's creditledger.CreditLedger over cloud's native
// finance ledger — the SAME per-org account (finance.Current()) the AI spend-gate
// reads and the edge meter debits. Injected at mountCommerce (EmbedConfig.Ledger),
// it makes commerce's POST /v1/billing/credit mint into the ONE ledger: a granted
// credit is immediately visible to the gate (one ledger, no split). This is the
// cloud half of the one-ledger seam — commerce defines the interface, cloud
// implements it once here, the compiler enforces the match.
//
// Fails closed when no finance ledger is co-resident; in the unified cloud binary
// finance is always published, so Get() != nil ⇒ credit routes here.
type ledger struct{}
// compile-time proof the adapter satisfies commerce's exported seam.
var _ creditledger.CreditLedger = ledger{}
// Credit posts a balanced deposit (funding:platform → wallet) to the org's POOL
// account (Subject == Org, the wallet the gate reads) and returns the ledger entry
// id + the org's new available balance in cents. Idempotent on IdempotencyKey:
// finance dedups on Ref, so the same key credits AT MOST once.
func (ledger) Credit(ctx context.Context, in creditledger.CreditInput) (string, int64, error) {
fin := finance.Current()
if fin == nil {
return "", 0, fmt.Errorf("commerce credit: no finance ledger co-resident")
}
cur := in.Currency
if cur == "" {
cur = "usd"
}
tag := in.Tag
if tag == "" {
tag = "grant:admin" // non-cash grant bucket (finance is a single wallet; Tags is a memo)
}
id, err := fin.Deposit(ctx, types.DepositInput{
Org: in.Org,
Subject: in.Org, // org-pool wallet == the account the AI gate reads
Amount: money.FromCents(in.AmountCents),
Currency: cur,
Notes: in.Reason,
Tags: tag,
Ref: in.IdempotencyKey,
})
if err != nil {
return "", 0, err
}
bal, berr := fin.Balance(ctx, in.Org, in.Org, cur, false)
if berr != nil {
return id, 0, berr
}
return id, bal.Cents(), nil
}
// Balance returns the org pool's available balance in cents for currency — the
// same read the AI gate performs, so GET /v1/billing/balance and the gate agree.
func (ledger) Balance(ctx context.Context, org, currency string) (int64, error) {
fin := finance.Current()
if fin == nil {
return 0, fmt.Errorf("commerce balance: no finance ledger co-resident")
}
if currency == "" {
currency = "usd"
}
bal, err := fin.Balance(ctx, org, org, currency, false)
if err != nil {
return 0, err
}
return bal.Cents(), nil
}
@@ -1,4 +1,4 @@
package subsystems
package apps
import (
"github.com/hanzoai/cloud/clients/coding"
+152
View File
@@ -0,0 +1,152 @@
package apps
import "testing"
// frozen is the EXACT subsystem mount sequence of the PRE-refactor binary —
// name, OwnsHealth, and whether it has a Shutdown — one row per mounted spec, in
// mount order. It was captured empirically from the pre-refactor cloud binary by
// cmd/dumpregistry (a throwaway tool that blank-imported the old init()-registry
// and replayed the legacy MountAll bubble-sort: ascending order-int, ties broken by
// the non-stable sort over init-registration order). The "was order N" trailing
// comment records each spec's deleted order-int for provenance.
//
// This is the SOLE guardian of mount order now that the order-ints are gone:
// Wire() must reproduce this sequence byte-for-byte (the func pointers aside), so a
// reorder, drop, add, or flag change in the composition root fails HERE. Captured on
// origin/main @c504d2b: 68 specs from the live init()-registry, PLUS the two the
// wave-2 external bumps (ai v1.805.2, o11y v1.5.12) stopped self-registering — ai
// (@150 catch-all) and the hanzoai/o11y module wildcard (@70), which main currently
// DROPS and this PR restores. The module wildcard (order 70) is now folded in as the
// terminal sub-mount of the in-repo o11y read plane (order 69), so "o11y" is ONE spec.
var frozen = []struct {
name string
ownsHealth bool
hasShutdown bool
}{
{"pubsub", false, true}, // was order 5
{"kafka", false, true}, // was order 6
{"agentskills", false, false}, // was order 8
{"flags", true, true}, // was order 9; native engine: /v1/flags health + store shutdown
{"kms", true, false}, // was order 10
{"metrics", false, false}, // was order 40
{"ingress", false, true}, // was order 42
{"account", false, false}, // was order 48
{"iam", false, false}, // was order 50
{"base", true, true}, // was order 60; per-org embed added Shutdown (#298)
{"o11y", false, true}, // ONE observability subsystem (was co-owned orders 69+70): read plane + the hanzoai/o11y module wildcard folded in as MountO11y's terminal sub-mount. OwnsHealth=false keeps /v1/o11y/health the generic always-ok route the module co-entry used to trigger.
{"authz", false, false}, // was order 70
{"commerce", false, false}, // was order 100
{"licensing", false, false}, // was order 110
{"plans", true, false}, // was order 111
{"pricing", true, false}, // was order 112
{"storage", true, false}, // was order 118
{"provisioning", false, false}, // was order 120
{"billing", false, false}, // was order 121
{"account-bridge", false, false}, // was order 122
{"do", false, false}, // was order 123
{"platform", true, false}, // was order 124
{"projects", false, false}, // was order 125
{"prompts", false, false}, // was order 126
{"agents", false, true}, // was order 127
{"link", false, true}, // new: unified AI login manager (/v1/links), after agents
{"wallets", false, true}, // was order 127
{"x402", false, true}, // new: x402 pay-per-use settlement (after wallets)
{"paas", true, false}, // was order 128
{"deploy", true, false}, // after paas (release seam), before functions
{"functions", false, false}, // was order 128
{"tracker", false, false}, // was order 129
{"templates", false, false}, // was order 129
{"framework", false, true}, // was order 129
{"knowledge", false, false}, // was order 130
{"content", false, true}, // new: marketing content loop (after knowledge)
{"catalogsync", false, true}, // new: reverse loop (product.created → render) after content
{"ml", true, false}, // was order 130
{"usage", false, false}, // was order 131
{"crm", false, false}, // was order 131
{"marketing", false, true}, // new: marketing domain fold (after crm)
{"ads", false, true}, // new: ads domain fold (after crm)
{"social", false, true}, // new: /v1/social fold (after crm)
{"analytics", true, false}, // was order 132
{"git", false, false}, // was order 132
{"sync", false, true}, // /v1/sync engine (owns per-org DB handles → Shutdown)
{"visor", false, false}, // was order 133
{"captable", false, true}, // was order 133
{"code", false, true}, // was order 134
{"zero-trust", false, false}, // was order 134
{"dataroom", true, true}, // was order 134
{"graph", false, false}, // was order 135
{"security", true, true}, // was order 136
{"integrations", false, true}, // was order 137
{"sbom", true, false}, // was order 137
{"team", false, true}, // was order 138
{"settings", false, true}, // was order 138
{"notify", true, false}, // was order 139
{"gateway", false, false}, // was order 139
{"entitlements", false, true}, // was order 139
{"exec", false, false}, // was order 140
{"websearch", false, false}, // was order 141
{"world", false, true}, // was order 142
{"runtime", false, false}, // was order 143; was "bot" until the transport was named for what it is
{"authors", false, true}, // was order 143
{"bots", false, false}, // was order 143
{"audit", false, false}, // was order 144
{"affiliates", false, false}, // was order 144
{"sign", true, true}, // was order 145
{"product", false, false}, // was order 145
{"evals", false, false}, // was order 145
{"treasury", false, true}, // was order 146
{"admin", false, false}, // was order 146
{"tasks", false, false}, // was order 147
{"cron", false, false}, // durable platform cron on the shared engine (post-freeze add)
{"automations", false, true}, // was order 148
{"connectorruntime", false, false}, // new: native single-connector exec via goja (after automations, HIP-0126)
{"tools", false, true}, // new: unified tool plane (after automations)
{"marketplace", false, true}, // new: marketplace over the tool plane (after tools)
{"referrals", false, false}, // was order 149
{"guide", false, true}, // new: Business AI Guide (after referrals, before ai)
{"company", false, true}, // new: Hanzo Company formation state machine (after guide)
{"agent", false, false}, // new: /v1/agent tool-calling round (before zen/ai catch-all)
{"zen", false, false}, // zen* claim middleware before ai's catch-all (hip-00NN)
{"ai", false, false}, // was order 150
{"plugins", false, false}, // was order 900
}
// TestWireOrderMatchesFrozen proves the composition root's mount order is
// byte-identical to the legacy init()-registry's, position by position.
func TestWireOrderMatchesFrozen(t *testing.T) {
wire := Wire()
if len(wire) != len(frozen) {
t.Fatalf("Wire() has %d specs, frozen sequence has %d", len(wire), len(frozen))
}
for i, s := range wire {
w := frozen[i]
if s.Name != w.name {
t.Errorf("position %d: Wire() = %q, frozen = %q (mount ORDER changed)", i, s.Name, w.name)
}
if s.OwnsHealth != w.ownsHealth {
t.Errorf("position %d (%s): OwnsHealth = %v, frozen = %v", i, s.Name, s.OwnsHealth, w.ownsHealth)
}
if (s.Shutdown != nil) != w.hasShutdown {
t.Errorf("position %d (%s): hasShutdown = %v, frozen = %v", i, s.Name, s.Shutdown != nil, w.hasShutdown)
}
if s.Mount == nil {
t.Errorf("position %d (%s): Mount is nil", i, s.Name)
}
}
}
// TestWireNoDuplicateEnablement guards that every subsystem name is unique: a name
// maps 1:1 to an enable id, so a duplicate would mount two specs under one id. (The
// former o11y co-ownership was collapsed — the module wildcard is now a sub-mount of
// the in-repo o11y read plane — so there is no longer any exempt duplicate.)
func TestWireNoDuplicateEnablement(t *testing.T) {
seen := map[string]int{}
for _, s := range Wire() {
seen[s.Name]++
}
for name, n := range seen {
if n > 1 {
t.Errorf("subsystem %q wired %d times (each enable id must be unique)", name, n)
}
}
}
+112 -26
View File
@@ -1,13 +1,16 @@
// Copyright 2026 Hanzo AI Inc. All Rights Reserved.
package subsystems
package apps
import (
"context"
"fmt"
"math/big"
"os"
"strings"
aicontrollers "github.com/hanzoai/ai/controllers"
aiobject "github.com/hanzoai/ai/object"
"github.com/hanzoai/cloud"
"github.com/hanzoai/cloud/clients/metering"
cloudmoney "github.com/hanzoai/cloud/clients/money"
@@ -53,11 +56,7 @@ import (
//
// It is wired BEFORE ai in Wire() so Claim's c.Next() falls through to ai's
// catch-all. zen's catalog reads its upstream keys from KMS via the Key resolver.
func mountZen(app any, deps cloud.Deps) error {
a, ok := app.(*zip.App)
if !ok {
return fmt.Errorf("zen.Mount: app is %T, want *zip.App", app)
}
func mountZen(a *zip.App, deps cloud.Deps) error {
z, err := zen.New(zen.Config{
Logger: deps.Logger,
Key: zenKeyResolver(deps.KMS),
@@ -110,11 +109,17 @@ func commerceGate(m *metering.Client) zen.Gate {
if t.BillingOrg == "" {
return fmt.Errorf("a billable tenant is required (no anonymous usage)")
}
// zen's estimate is exact 18-dp atto-USD (hanzoai/money). Fold to whole
// cents for the balance check via cloud's typed money.Amount.Cents() — a
// sub-cent estimate gates as 0 (any-positive-balance), matching the edge
// gate's AmountCents contract. The post-serve Meter debits the exact 18-dp.
cents := cloudmoney.FromInt(est.Minor()).Cents()
// zen's estimate is an exact 18-dp USD value. Fold it to whole cents for
// the balance check via cloud's typed money.Amount.Cents() — a sub-cent
// estimate gates as 0 (any-positive-balance), matching the edge gate's
// AmountCents contract. The post-serve Meter debits the exact 18-dp.
//
// The fold is Cents() on the CREDIT amount, never Minor() on the zen one:
// Minor() renders money.USD's 2 decimals, so it returned cents that FromInt
// then read as atto — every estimate came back 0, and AuthorizeVerdict skips
// its `available >= AmountCents` check when AmountCents is 0, admitting a
// request of ANY size against any positive balance.
cents := credit(est).Cents()
v, err := m.AuthorizeVerdict(ctx, metering.AuthInput{
User: t.BillingOrg,
Org: t.BillingOrg,
@@ -158,25 +163,106 @@ func (g commerceMeterImpl) Record(ctx context.Context, u zen.Usage) {
if u.Tenant.BillingOrg == "" {
return // never debit an unattributable request
}
usage := metering.Usage{
User: u.Tenant.BillingOrg,
Org: u.Tenant.BillingOrg,
Actor: u.Tenant.User,
Model: u.Model,
Provider: zenProvider,
Service: zenService,
Project: u.Tenant.Project,
PromptTokens: u.PromptTokens,
// Beside the commerce debit, land the SAME warehouse row + gen_ai span every
// native ai path writes (TraceServedUsage = recordTrace WITHOUT recordUsage —
// the debit below is the one billing source, never doubled). zen knows its
// EXACT per-tier retail (Charge) and upstream COGS (Cost), so the row carries
// true margin (credit → nano). Without this, zen* traffic is
// warehouse/o11y-blind exactly where prod runs (the unified binary).
aicontrollers.TraceServedUsage(context.Background(), aicontrollers.ServedUsage{
Owner: u.Tenant.BillingOrg,
User: u.Tenant.User,
Model: u.Model,
Provider: zenProvider,
RequestID: u.RequestID,
Status: "success",
PromptTokens: u.PromptTokens,
CompletionTokens: u.CompletionTokens,
TotalTokens: u.PromptTokens + u.CompletionTokens,
Amount: cloudmoney.FromInt(u.Cost.Minor()), // exact 18-dp USD, no floor
RequestID: u.RequestID,
Currency: "usd",
Status: "success",
}
BilledNano: nano(credit(u.Charge)),
CostNano: nano(credit(u.Cost)),
})
// Detached: the request context is recycled once the handler returns, so a
// background context carries the debit to commerce without racing the reply.
usage := meterUsage(u)
go func() { _, _ = g.m.Record(context.Background(), usage) }()
// Enso learning ledger: the embedded zen mount serves the zen catalog in-process
// and never reaches ai's pipeToFamily, so ai's family-event writer never runs for
// zen traffic. Write the SAME RoutingEvent here (source="family") through the ONE
// shared writer, keyed on the client-visible response id (zen.Usage.ResponseID), so
// zen* calls land in the same ledger — stats, world, spark retrain, and /v1/feedback
// all read these rows. No prompt text; no shadow (zen.Usage carries no request
// text — that stays the auto/enso-proxy path's job). Fire-and-forget.
owner := u.Tenant.Org
if owner == "" {
owner = u.Tenant.BillingOrg
}
go aiobject.RecordFamilyRouting(aiobject.FamilyRoutingInput{
Owner: owner,
User: u.Tenant.User,
RequestedModel: u.Model,
RoutedModel: u.Upstream,
ResponseId: u.ResponseID,
PromptTokens: u.PromptTokens,
CompletionTokens: u.CompletionTokens,
CostCents: credit(u.Charge).Cents(),
RouterEndpoint: os.Getenv("ROUTER_ENDPOINT"),
})
}
// meterUsage projects a served zen.Usage onto the commerce debit. It is the ONE
// place the debit's amount is chosen, and it is pure — no ledger, no warehouse —
// so the money property is a unit test rather than an integration.
//
// The amount is the RETAIL Charge: what the caller pays. Cost is the upstream
// COGS we pay to serve the call; it is never the debit. It rides only the
// warehouse row (CostNano), where margin = Charge Cost stays exact. Debiting
// Cost would collect our own COGS and book zero margin on every zen call — and
// because the affiliate and OSS payout bases read this debit, it would fund
// their shares out of principal. This mirrors ai, whose debit is likewise the
// customer price (usageBilledCents), never its CostIn/CostOut COGS.
func meterUsage(u zen.Usage) metering.Usage {
return metering.Usage{
User: u.Tenant.BillingOrg,
Org: u.Tenant.BillingOrg,
Actor: u.Tenant.User,
Model: u.Model,
Provider: zenProvider,
Service: zenService,
Project: u.Tenant.Project,
PromptTokens: u.PromptTokens,
CompletionTokens: u.CompletionTokens,
TotalTokens: u.PromptTokens + u.CompletionTokens,
Amount: credit(u.Charge), // exact 18-dp USD, no floor
RequestID: u.RequestID,
Currency: "usd",
Status: "success",
}
}
// credit re-denominates a zen price into cloud's credit unit. It is the ONE
// conversion at this seam — every site below goes through it, so the unit is
// decided once rather than re-derived per call site.
//
// zen prices every SKU as an exact 18-dp value tagged money.USD (meter.go:
// money.New(<18-dp decimal>, money.USD)), and cloud's credit unit is the SAME USD
// value at 18-dp storage scale. So the conversion carries the exact decimal across
// and changes only the minor-unit convention: no rescale, no rounding, no factor.
// It is right by construction because the decimal is the value — the currency's
// Decimals is a rendering convention, not part of it.
//
// It must NEVER go through Amount.Minor(). money.USD declares 2 decimals, so
// Minor() rescales zen's 18-dp value to CENTS; feeding cents to the 18-dp
// FromInt understates the debit by 10^16 (a $17.376 charge debits $0.0000000000000017),
// and folds every sub-cent charge to a zero the ledger drops entirely.
func credit(a hmoney.Amount) cloudmoney.Amount { return cloudmoney.FromDecimal(a.Decimal()) }
// nano folds an exact credit Amount to nano-USD (1e-9) for the warehouse margin
// columns. It takes the typed Amount rather than a bare *big.Int so the unit is
// carried by the type: a cents integer is not a cloudmoney.Amount and can no
// longer be passed here. A single request's cost always fits int64 at nano.
func nano(a cloudmoney.Amount) int64 {
return new(big.Int).Div(a.Atto(), big.NewInt(1_000_000_000)).Int64()
}
// zenService is the commerce service axis zen* spend attributes to. zen serves
+305
View File
@@ -0,0 +1,305 @@
// Copyright 2026 Hanzo AI Inc. All Rights Reserved.
package apps
import (
"context"
"io"
"net/http"
"net/http/httptest"
"strconv"
"sync/atomic"
"testing"
"github.com/hanzoai/cloud/clients/metering"
cloudmoney "github.com/hanzoai/cloud/clients/money"
"github.com/hanzoai/decimal"
hmoney "github.com/hanzoai/money"
"github.com/hanzoai/zen"
)
// usd builds an exact USD amount from a decimal string, the way zen prices a
// call (18-dp native, never through float).
//
// The decimal here must be hanzoai/decimal, the one hmoney.New takes and the one
// zen prices with — not shopspring's identically-named type. Money has exactly one
// decimal; a second one that merely LOOKS like it is how a price silently becomes
// a different number.
func usd(t *testing.T, s string) hmoney.Amount {
t.Helper()
d, err := decimal.Parse(s)
if err != nil {
t.Fatalf("parse %q: %v", s, err)
}
return hmoney.New(d, hmoney.USD)
}
// zen5Usage is one served call at zen5's live rates: 1M in + 1M out, priced at
// the family's 3× margin (retail = cost × margin).
//
// in : cost 1.392 → retail 4.176
// out: cost 4.40 → retail 13.20
func zen5Usage(t *testing.T) zen.Usage {
t.Helper()
return zen.Usage{
Tenant: zen.Tenant{BillingOrg: "acme", User: "acme/alice", Project: "p1"},
Model: "zen5",
PromptTokens: 1_000_000,
CompletionTokens: 1_000_000,
Charge: usd(t, "17.376"), // 4.176 + 13.20 — what the caller pays
Cost: usd(t, "5.792"), // 1.392 + 4.40 — what we pay upstream
RequestID: "req-1",
}
}
// dollars is the EXPECTED money, built from a plain dollar literal through cloud's
// OWN ParseUSD — deliberately a DIFFERENT constructor than the code under test uses.
//
// That is what pins the UNIT. These tests once asserted meterUsage(u).Amount.Int()
// against u.Charge.Minor(): both sides re-derived the number through the same
// conversion, so the assertion only proved an integer round-tripped and was blind
// to what the integer MEANT. It passed while every zen debit was 10^16 too small.
// Comparing money to a known dollar amount cannot be blind that way: if the debit
// is off by any factor, it is not $17.376 and the test fails.
func dollars(t *testing.T, s string) cloudmoney.Amount {
t.Helper()
a, err := cloudmoney.ParseUSD(s)
if err != nil {
t.Fatalf("parse %q: %v", s, err)
}
return a
}
// The debit is the RETAIL Charge — what the caller pays — never the upstream
// COGS. This is the money property: it fails if the Amount is built from
// u.Cost. The tier is margin-bearing (retail != cost), so the two values are
// distinguishable and the assertion cannot pass by coincidence.
func TestMeterUsageDebitsRetailNotCost(t *testing.T) {
u := zen5Usage(t)
if u.Charge.Cmp(u.Cost) == 0 {
t.Fatal("fixture is not margin-bearing: retail == cost, so the test could not tell them apart")
}
got := meterUsage(u).Amount
// The known dollar value the fixture charges — a $17.376 call debits $17.376.
if want := dollars(t, "17.376"); got.Cmp(want) != 0 {
t.Errorf("debit = $%s, want the retail Charge $%s", got, want)
}
if cogs := dollars(t, "5.792"); got.Cmp(cogs) == 0 {
t.Errorf("debit = $%s, which is the upstream COGS — the caller must be billed retail, not our cost", got)
}
}
// At the family's 3× margin the debit is exactly 3× the COGS: we collect the
// full retail price, not the wholesale one. Debiting Cost would collect 1/3 —
// our own COGS — and book zero margin.
func TestMeterUsageCollectsTheFullMargin(t *testing.T) {
u := zen5Usage(t)
debit := meterUsage(u).Amount
// 3x the COGS in the SAME unit as the debit — $5.792 + $5.792 + $5.792.
// Summing the credit Amount keeps the comparison in exact dollars; the old
// version multiplied Cost.Minor() (cents, 579 after rounding away 5.792's
// third decimal) and compared it to a debit that was not cents at all.
cogs := dollars(t, "5.792")
thriceCOGS := cogs.Add(cogs).Add(cogs)
if debit.Cmp(thriceCOGS) != 0 {
t.Errorf("debit = $%s, want 3x COGS = $%s (retail = cost x margin, margin 3.0)", debit, thriceCOGS)
}
if want := dollars(t, "17.376"); debit.Cmp(want) != 0 {
t.Errorf("debit = $%s, want $%s", debit, want)
}
}
// The debit carries zen's exact 18-dp value with no floor: a sub-cent call must
// not round to zero on the way to the ledger.
func TestMeterUsageKeepsExactSubCentCharge(t *testing.T) {
u := zen5Usage(t)
u.Charge = usd(t, "0.004176") // 1k input tokens at 4.176/MTok — well under a cent
u.Cost = usd(t, "0.001392")
got := meterUsage(u).Amount
if want := dollars(t, "0.004176"); got.Cmp(want) != 0 {
t.Errorf("debit = $%s, want the exact sub-cent charge $%s", got, want)
}
// metering.Record drops a zero Amount before it ever reaches the ledger
// (`if !c.Enabled() || amt.IsZero() ... return nil`), so a floored sub-cent
// charge is not a small debit — it is NO DEBIT ROW, and the call is free.
if got.IsZero() {
t.Error("sub-cent charge floored to zero — Record drops a zero Amount, so the call is served free with no debit row")
}
}
// The unit trap this seam shipped with, pinned so it cannot come back. zen prices
// an exact 18-dp value but tags it money.USD, whose Currency declares 2 decimals —
// so Charge.Minor() renders CENTS, while cloudmoney.FromInt reads its argument as
// 18-dp. Composing them silently divides every debit by 10^16.
//
// The tests above already fail if credit() regresses to that composition; this one
// names WHY, and proves the two conversions are still distinguishable — an
// assertion that cannot tell right from wrong is worse than no assertion.
func TestCreditIsNotTheMinorUnit(t *testing.T) {
charge := usd(t, "17.376")
if got, want := credit(charge), dollars(t, "17.376"); got.Cmp(want) != 0 {
t.Fatalf("credit($17.376) = $%s, want $%s", got, want)
}
// The conversion that shipped, spelled out.
old := cloudmoney.FromAtto(charge.Minor())
if old.Cmp(credit(charge)) == 0 {
t.Fatal("FromInt(Minor()) agrees with credit() — the fixture can no longer tell the units apart, so these tests prove nothing")
}
if old.Cents() != 0 {
t.Errorf("FromInt(Minor()).Cents() = %d, want 0 — that this was ALWAYS 0 is what made the spend gate admit every request", old.Cents())
}
}
// The identity fields ride with the debit unchanged: the debit lands on the org
// that PAYS (BillingOrg), scoped to its project, with the actor for the audit
// trail.
func TestMeterUsageAttribution(t *testing.T) {
u := zen5Usage(t)
m := meterUsage(u)
for _, c := range []struct{ name, got, want string }{
{"User", m.User, "acme"},
{"Org", m.Org, "acme"},
{"Actor", m.Actor, "acme/alice"},
{"Project", m.Project, "p1"},
{"Model", m.Model, "zen5"},
{"Provider", m.Provider, zenProvider},
{"Service", m.Service, zenService},
{"Currency", m.Currency, "usd"},
} {
if c.got != c.want {
t.Errorf("%s = %q, want %q", c.name, c.got, c.want)
}
}
if m.TotalTokens != 2_000_000 {
t.Errorf("TotalTokens = %d, want 2000000", m.TotalTokens)
}
}
// The spend gate must REFUSE a request whose estimate exceeds the balance.
//
// This is the sharpest edge of the unit bug and the reason it is a security
// finding, not only a revenue one. AuthorizeVerdict gates size like this:
//
// funded := available > 0
// if in.AmountCents > 0 { funded = available >= in.AmountCents }
//
// The estimate reached it as FromInt(est.Minor()).Cents(), which is ALWAYS 0 —
// so the size branch was DEAD and every request rode `available > 0`. Any org
// with a single cent of balance could draw an unbounded call. The debits were
// dust too, so the balance never fell and the cap could never trip.
func TestCommerceGateRefusesAnOverCapRequest(t *testing.T) {
const availableCents = 500 // the org holds $5.00
var authorized atomic.Bool
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/v1/billing/balance":
_, _ = io.WriteString(w, `{"available":`+strconv.Itoa(availableCents)+`}`)
case "/v1/billing/spend-alerts/authorize":
authorized.Store(true)
_, _ = io.WriteString(w, `{"allow":true}`)
default:
_, _ = io.WriteString(w, `[]`)
}
}))
defer srv.Close()
m, err := metering.New(metering.Config{BaseURL: srv.URL, Token: "t", Org: "acme"})
if err != nil {
t.Fatal(err)
}
gate := commerceGate(m)
if gate == nil {
t.Fatal("gate is nil — the client must be enabled for this proof to mean anything")
}
tenant := zen.Tenant{BillingOrg: "acme", User: "acme/alice", Project: "p1"}
// $17.376 against a $5.00 balance: over cap, must be refused.
if err := gate(context.Background(), tenant, "zen5", usd(t, "17.376")); err == nil {
t.Error("gate ADMITTED a $17.376 request against a $5.00 balance — the estimate is reaching AuthorizeVerdict as 0 cents, so the size check never runs")
}
// The same balance must still admit a request it can actually cover, or the
// test would pass by refusing everything.
if err := gate(context.Background(), tenant, "zen5", usd(t, "1.00")); err != nil {
t.Errorf("gate refused an affordable $1.00 request against a $5.00 balance: %v", err)
}
if !authorized.Load() {
t.Error("the affordable request never reached the spend-cap authorize step")
}
}
// The estimate must reach the balance check as the RIGHT number of cents. The
// refusal test above proves the gate says no; this proves it says no for the
// right reason — that $17.376 is folded to 1738 cents, not to 0.
func TestCommerceGateFoldsTheEstimateToRealCents(t *testing.T) {
for _, c := range []struct {
charge string
want int64
}{
{"17.376", 1738}, // rounds half-away-from-zero at the cent
{"1000.00", 100000},
{"1.00", 100},
{"0.004176", 0}, // sub-cent gates as "any positive balance"
} {
if got := credit(usd(t, c.charge)).Cents(); got != c.want {
t.Errorf("$%s folds to %d cents, want %d", c.charge, got, c.want)
}
}
}
// A zero-value zen price must convert and fold without panicking. zen leaves
// Charge/Cost as the zero Amount for a free SKU, and that value carries the
// EMPTY currency code, not "USD" — so this also pins that credit() reads the
// decimal rather than dispatching on the currency. The old attoToNano guarded a
// nil *big.Int here; nano() needs no guard because decimal.Coef() returns a real
// zero big.Int, never nil, but the property is worth holding.
func TestCreditAndNanoHandleTheZeroPrice(t *testing.T) {
var free hmoney.Amount // zero value: no currency, no coefficient
got := credit(free)
if !got.IsZero() {
t.Errorf("credit(zero) = $%s, want $0", got)
}
if n := nano(got); n != 0 {
t.Errorf("nano(credit(zero)) = %d, want 0", n)
}
if c := got.Cents(); c != 0 {
t.Errorf("credit(zero).Cents() = %d, want 0", c)
}
// And a free call books no debit row, which is correct — nothing is owed.
u := zen5Usage(t)
u.Charge, u.Cost = free, free
if amt := meterUsage(u).Amount; !amt.IsZero() {
t.Errorf("free call debits $%s, want $0", amt)
}
}
// nano carries real money to the warehouse margin columns. It is the last place
// the unit could silently collapse: attoToNano(cents) divided a cents integer by
// 1e9 and produced 0 for every charge under $10,000,000.
func TestNanoFoldsCreditToRealNano(t *testing.T) {
for _, c := range []struct {
charge string
want int64
}{
{"17.376", 17_376_000_000},
{"5.792", 5_792_000_000},
{"0.004176", 4_176_000},
{"1000.00", 1_000_000_000_000},
} {
if got := nano(credit(usd(t, c.charge))); got != c.want {
t.Errorf("nano($%s) = %d, want %d", c.charge, got, c.want)
}
}
}
+15 -2
View File
@@ -15,6 +15,7 @@ import (
"fmt"
"os"
"path/filepath"
"strings"
"time"
"github.com/hanzoai/cloud/audit"
@@ -57,6 +58,18 @@ func buildAuditRecorder(cfg *Config, logger luxlog.Logger) (*audit.Recorder, err
return nil, fmt.Errorf("open audit store: %w", err)
}
// PER-SHARD audit under horizontal scale. The trail lives at {DataDir}/audit.db on
// THIS pod's own RWO PVC, so under shard routing each pod's chain covers ONLY the
// tenants routed to it (its shard) — and org-scoped audit queries route to the
// owning shard where those records live. Soundness: the chain is a per-FILE hash
// chain whose head is recovered at open; because no two pods share the file, there
// is no cross-pod head to fork (the very failure that pinned cloud to replicas:1 was
// two pods on ONE audit file). Integrity is preserved WITHIN each partition; a
// deployment-wide view is the union of the N per-shard chains. The shard id is
// stamped on the AU-9 checkpoint stream below so the external tail-truncation monitor
// tracks N heads (one per shard) rather than expecting a single global head.
shard := strings.TrimSpace(cfg.ShardSelf) // "" when single-pod — a harmless empty tag
// AU-9 tail-truncation anchor: emit a periodic head-digest checkpoint to the
// append-only observability log (and, when a mirror supports it, an
// independent digest store). An external o11y monitor compares consecutive
@@ -67,7 +80,7 @@ func buildAuditRecorder(cfg *Config, logger luxlog.Logger) (*audit.Recorder, err
if logger != nil {
rec.StartCheckpoints(interval, func(cp audit.Checkpoint) {
logger.Info("audit_head_checkpoint",
"count", cp.Count, "head", cp.Head, "ts", cp.Time.Format(time.RFC3339Nano))
"shard", shard, "count", cp.Count, "head", cp.Head, "ts", cp.Time.Format(time.RFC3339Nano))
})
} else {
rec.StartCheckpoints(interval, nil)
@@ -76,7 +89,7 @@ func buildAuditRecorder(cfg *Config, logger luxlog.Logger) (*audit.Recorder, err
if logger != nil {
count, head := rec.Head()
logger.Info("audit trail ready (tamper-evident, append-only)",
"store", dbPath, "records", count, "head", head,
"store", dbPath, "shard", shard, "records", count, "head", head,
"mirror", mirror != nil, "checkpoint_interval", interval.String())
}
return rec, nil
+18 -9
View File
@@ -47,21 +47,30 @@ type iamKeys struct {
// credential it returns a resolver that resolves nothing (keys stay anonymous —
// never a fabricated principal), so a deployment lacking the credential is safe.
func newIAMKeys() *iamKeys {
base := strings.TrimRight(env("IAM_URL", "IAM_INTERNAL_URL"), "/")
id := strings.TrimSpace(os.Getenv("IAM_MINT_CLIENT_ID"))
secret := strings.TrimSpace(os.Getenv("IAM_MINT_CLIENT_SECRET"))
var auth string
if id != "" && secret != "" {
auth = "Basic " + base64.StdEncoding.EncodeToString([]byte(id+":"+secret))
}
return &iamKeys{
base: base,
auth: auth,
base: iamHost(),
auth: iamCred(),
http: &http.Client{Timeout: 5 * time.Second},
cache: newCache[string, *idClaims](60 * time.Second),
}
}
// iamHost is the standalone IAM origin cloud talks to; iamCred is the service
// credential (client_secret_basic) it presents — the ONE IAM identity, shared by
// the API-key resolver here and the /v1/iam edge (iam_edge.go), so both
// authenticate to IAM the same way. Empty cred → a deployment lacking the
// credential stays safe (the caller treats "" as unconfigured).
func iamHost() string { return strings.TrimRight(env("IAM_URL", "IAM_INTERNAL_URL"), "/") }
func iamCred() string {
id := strings.TrimSpace(os.Getenv("IAM_MINT_CLIENT_ID"))
secret := strings.TrimSpace(os.Getenv("IAM_MINT_CLIENT_SECRET"))
if id == "" || secret == "" {
return ""
}
return "Basic " + base64.StdEncoding.EncodeToString([]byte(id+":"+secret))
}
func env(names ...string) string {
for _, n := range names {
if v := strings.TrimSpace(os.Getenv(n)); v != "" {
+11
View File
@@ -113,6 +113,17 @@ func BrandForHost(host string) string {
return DefaultBrand
}
// brandDisplay is a brand id's human display name: the id with an upper-cased
// first letter (lux → "Lux", hanzo → "Hanzo"). Derived from the id — one source
// of truth with the brands registry, no hand-maintained display list. Used to
// build the white-label console <title> (webui.go).
func brandDisplay(id string) string {
if id == "" {
id = DefaultBrand
}
return strings.ToUpper(id[:1]) + id[1:]
}
// BrandIssuers returns the OIDC issuer of every configured white-label brand. The
// in-binary identity validator (auth_identity.go) trusts a token whose `iss` is
// any of these, so ONE cloud binary validates hanzo AND lux/zoo/pars tokens. One
+17 -31
View File
@@ -504,7 +504,7 @@ func EmitLifecycle(ctx context.Context, ev LifecycleEvent) {
// pickCommerceClient resolves deps.Commerce — the typed inter-subsystem client the
// entitlements/licensing tier calls (GetOrgConfig, CheckEntitlement). When the
// commerce subsystem is co-resident (Enabled("commerce")) it returns the IN-PROCESS
// client via the factory subsystems/commerce.go registers in init() — a direct Go
// client via the factory apps/commerce.go registers in init() — a direct Go
// call that reads the embedded commerce datastore (hanzoai/commerce MODULE, since
// the un-fork) + the @hanzo/plans vocabulary, no network hop (the HIP-0106
// co-resident default). The factory inversion stays because the concrete client
@@ -533,14 +533,14 @@ func pickCommerceClient(cfg *Config, log luxlog.Logger) CommerceClient {
}
// commerceClientFactory constructs the embedded in-process Commerce client.
// subsystems/commerce.go registers it in init(); pickCommerceClient calls it so
// apps/commerce.go registers it in init(); pickCommerceClient calls it so
// package cloud depends on the CommerceClient interface + this hook, never the
// concrete commerceinproc package (whose entitlement client pulls clients/plan,
// which imports cloud — the hook is what keeps the package graph acyclic).
var commerceClientFactory func(cfg *Config, log luxlog.Logger) CommerceClient
// RegisterCommerceClientFactory installs the embedded-Commerce client constructor.
// subsystems/commerce.go calls this from its init(); exactly one registration.
// apps/commerce.go calls this from its init(); exactly one registration.
func RegisterCommerceClientFactory(f func(cfg *Config, log luxlog.Logger) CommerceClient) {
commerceClientFactory = f
}
@@ -656,30 +656,17 @@ func pickVaultClient(cfg *Config, log luxlog.Logger) VaultClient {
return clients.DisabledVault()
}
// MountFunc is a subsystem's mount contract. app is `any`, not *zip.App, and that
// is load-bearing: some external modules expose Mount as func(any, Deps) error
// (e.g. hanzoai/licensing), which subsystems.Wire references DIRECTLY — a
// func(any,…) value is not assignable to a func(*zip.App,…) parameter, so
// narrowing the type would break them at compile time. The concrete value is
// always a *zip.App; strongly-typed Mounts (func(*zip.App, Deps) error, what every
// in-repo subsystem exports) are adapted by Typed, which recovers it in ONE place.
type MountFunc func(app any, deps Deps) error
// Typed adapts a strongly-typed subsystem Mount — func(*zip.App, Deps) error,
// the signature every in-repo subsystem already exports — into the registry's
// MountFunc. It performs the *zip.App recovery in ONE place, fail-closed with a
// clear error, so no subsystem repeats the `a, ok := app.(*zip.App)` boilerplate.
// The concrete value MountAll passes is always a *zip.App, so the assertion is
// total in practice; it stays as a defensive, self-documenting guard.
func Typed(mount func(*zip.App, Deps) error) MountFunc {
return func(app any, deps Deps) error {
a, ok := app.(*zip.App)
if !ok {
return fmt.Errorf("cloud.Mount: app is %T, want *zip.App", app)
}
return mount(a, deps)
}
}
// MountFunc is a subsystem's mount contract: register your routes on app, using
// deps for everything shared. Every subsystem in the fleet exports exactly this
// signature, so Wire references each one directly and the compiler checks it.
//
// app was once `any`, on the stated grounds that an external module (licensing)
// exposed func(any, Deps) error and narrowing would break it — while licensing
// said it used `any` to avoid an import cycle in pkg/cloud. Each cited the other,
// and the cycle could not exist: this package already imports zip, and zip does
// not import cloud. The `any` bought nothing and cost every subsystem a Typed()
// wrapper plus a runtime type assertion whose failure branch was unreachable.
type MountFunc func(app *zip.App, deps Deps) error
// ShutdownFunc releases a subsystem's process-lifetime resources (background
// goroutines, open DB handles) on graceful shutdown. It must be idempotent and
@@ -688,7 +675,7 @@ func Typed(mount func(*zip.App, Deps) error) MountFunc {
type ShutdownFunc func(ctx context.Context) error
// MountSpec describes one subsystem to mount. There is NO Order field: the slice
// position in subsystems.Wire() IS the mount order — the composition root lists
// position in apps.Wire() IS the mount order — the composition root lists
// subsystems in the exact sequence they mount (and, reversed, tear down), so order
// is data read top-to-bottom in one file, not ints scattered across the tree.
type MountSpec struct {
@@ -703,9 +690,8 @@ type MountSpec struct {
}
// MountAll mounts every ENABLED subsystem in specs, in slice order — the order is
// the composition root's (subsystems.Wire()); MountAll does NOT sort. app is the
// concrete *zip.App from Serve; the MountFunc accepts it as `any` and in-repo
// subsystems recover it via Typed.
// the composition root's (apps.Wire()); MountAll does NOT sort. app is the
// concrete *zip.App from Serve, handed to each MountFunc as itself.
//
// Teardown is wired HERE, at mount time: right after a subsystem mounts, its
// ShutdownFunc (if any) is registered via app.OnShutdown. zip drains those hooks
+1 -1
View File
@@ -16,7 +16,7 @@ import (
// noopMount mounts nothing: the fake specs below carry the behavior under test in
// their Shutdown, not their Mount.
func noopMount(any, cloud.Deps) error { return nil }
func noopMount(*zip.App, cloud.Deps) error { return nil }
// freeAddr reserves an ephemeral loopback port and hands back its address; the
// listener is closed so the app under test can bind it.
+18 -39
View File
@@ -1,49 +1,28 @@
package cloud_test
import (
"strings"
"testing"
"github.com/hanzoai/cloud"
"github.com/zap-proto/zip"
)
// TestTyped_RecoversZipApp verifies cloud.Typed adapts a strongly-typed
// func(*zip.App, Deps) into the registry MountFunc: it hands the concrete
// *zip.App straight through to the wrapped mount.
func TestTyped_RecoversZipApp(t *testing.T) {
app := zip.New(zip.Config{})
var got *zip.App
mf := cloud.Typed(func(a *zip.App, _ cloud.Deps) error {
got = a
return nil
})
if err := mf(app, cloud.Deps{}); err != nil {
t.Fatalf("Typed mount returned error: %v", err)
}
if got != app {
t.Fatalf("Typed did not pass the concrete *zip.App through (got %p, want %p)", got, app)
}
}
// TestTyped_WrongTypeFailsClosed verifies cloud.Typed fails closed with a clear
// error — never a panic — when the registry passes a value that is not a
// *zip.App. This is the single, central replacement for the per-subsystem
// assertion boilerplate.
func TestTyped_WrongTypeFailsClosed(t *testing.T) {
called := false
mf := cloud.Typed(func(*zip.App, cloud.Deps) error {
called = true
return nil
})
err := mf("not-a-zip-app", cloud.Deps{})
if err == nil {
t.Fatal("Typed must return an error on a non-*zip.App value")
}
if called {
t.Fatal("Typed must NOT invoke the wrapped mount on a type mismatch")
}
if !strings.Contains(err.Error(), "*zip.App") {
t.Errorf("error should name the wanted type *zip.App, got: %v", err)
}
// TestMountFunc_IsTheSubsystemSignature pins the registry's mount contract: the
// signature every subsystem exports IS a cloud.MountFunc, checked by the compiler.
//
// This file used to test cloud.Typed, the adapter that took a MountFunc's `any`
// app and asserted it back to *zip.App. Both of its tests went with it, and
// neither is a loss:
//
// - "Typed recovers the *zip.App" only ever proved the adapter handed through
// the value it was given. MountFunc now names *zip.App, so there is no
// recovery step left to get wrong.
// - "Typed fails closed on a wrong type" can no longer be written: passing
// "not-a-zip-app" to a MountFunc is a compile error, so the runtime branch it
// exercised does not exist. A test asserting a wrong type is rejected is
// precisely what a type already is.
//
// What remains is the only claim worth making, and the build enforces it.
func TestMountFunc_IsTheSubsystemSignature(t *testing.T) {
var _ cloud.MountFunc = func(*zip.App, cloud.Deps) error { return nil }
}
+128 -22
View File
@@ -160,24 +160,21 @@ type loginFlags struct {
}
func runLogin(env *Env, lf *loginFlags, cmd *cobra.Command) error {
creds, err := LoadCredentials()
if err != nil {
return err
}
var creds *Credentials
switch {
case lf.token != "":
// Paste an externally-minted token. Decode claims for identity.
tr := &tokenResp{AccessToken: lf.token, TokenType: "Bearer"}
creds = credsFromToken(tr)
creds = credsFromToken(&tokenResp{AccessToken: lf.token, TokenType: "Bearer"})
case lf.username != "" || lf.passwordStdin:
// Password grant — kept for automation (--username/--password-stdin).
username := lf.username
if username == "" {
username, err = prompt(cmd, "Email: ")
u, err := prompt(cmd, "Email: ")
if err != nil {
return err
}
username = u
}
password, err := readPassword(cmd, lf.passwordStdin)
if err != nil {
@@ -192,14 +189,15 @@ func runLogin(env *Env, lf *loginFlags, cmd *cobra.Command) error {
default:
// The ONE interactive way: RFC 8628 device flow — link + QR + code,
// approve from any signed-in browser or phone. Headless-safe.
creds, err = runDeviceLogin(cmd, env, lf.scope)
c, err := runDeviceLogin(cmd, env, lf.scope)
if err != nil {
return err
}
creds = c
}
// Optional machine-to-machine tokens for the platform control plane,
// stored alongside the identity so apps/deploy work post-login.
// stored with this identity so apps/deploy work post-login.
if lf.platformToken != "" {
creds.PlatformToken = lf.platformToken
}
@@ -207,14 +205,23 @@ func runLogin(env *Env, lf *loginFlags, cmd *cobra.Command) error {
creds.BuildToken = lf.buildToken
}
if err := creds.Save(); err != nil {
// Persist under this identity's stable key and make it active. A second
// login as a different owner (e.g. admin vs hanzo for the same email, via a
// different --client-id) is stored beside the first, never over it;
// credentials.json mirrors whichever is active for legacy readers.
store, err := LoadIdentities()
if err != nil {
return err
}
key := store.Put(creds)
if err := store.Save(); err != nil {
return err
}
who := firstNonEmpty(creds.Subject, "(unknown)")
if creds.Owner != "" {
who += " @ " + creds.Owner
}
fmt.Fprintf(cmd.OutOrStdout(), "Logged in as %s (token expires %s)\n", who, shortTime(creds.Expiry))
fmt.Fprintf(cmd.OutOrStdout(), "Logged in as %s [%s] (token expires %s)\n", who, key, shortTime(creds.Expiry))
return nil
}
@@ -249,28 +256,92 @@ func bindLoginFlags(cmd *cobra.Command, lf *loginFlags) {
func newLogoutCmd() *cobra.Command {
return &cobra.Command{
Use: "logout",
Short: "Remove stored credentials",
Args: cobra.NoArgs,
Use: "logout [<owner>]",
Short: "Remove a stored identity (the active one, or the named owner)",
Args: cobra.MaximumNArgs(1),
PersistentPreRunE: func(*cobra.Command, []string) error { return nil },
RunE: func(cmd *cobra.Command, _ []string) error {
if err := DeleteCredentials(); err != nil {
RunE: func(cmd *cobra.Command, args []string) error {
store, err := LoadIdentities()
if err != nil {
return err
}
fmt.Fprintln(cmd.OutOrStdout(), "Logged out.")
if len(store.Identities) == 0 {
fmt.Fprintln(cmd.OutOrStdout(), "Not logged in.")
return nil
}
target := store.Active
if len(args) == 1 {
if target, err = store.resolve(args[0]); err != nil {
return err
}
}
store.Remove(target)
if err := store.Save(); err != nil {
return err
}
msg := "Logged out of " + target + "."
if store.Active != "" {
msg += " Active is now " + store.Active + "."
}
fmt.Fprintln(cmd.OutOrStdout(), msg)
return nil
},
}
}
// identityRow is the JSON/table projection of one stored identity.
type identityRow struct {
Key string `json:"key"`
Owner string `json:"owner"`
Subject string `json:"subject"`
Expiry int64 `json:"expiry,omitempty"`
Active bool `json:"active"`
}
// listIdentities renders every stored identity (active marked with *) — the
// shared body of `hanzo auth list` and `hanzo whoami --all`.
func listIdentities(env *Env, _ *cobra.Command) error {
store, err := LoadIdentities()
if err != nil {
return err
}
rows := make([]identityRow, 0, len(store.Identities))
for _, k := range store.keys() {
c := store.Identities[k]
rows = append(rows, identityRow{
Key: k, Owner: c.Owner, Subject: c.Subject, Expiry: c.Expiry,
Active: k == store.Active,
})
}
return env.emit(rows, func(w io.Writer) {
if len(rows) == 0 {
fmt.Fprintln(w, "No stored identities. Run `hanzo login`.")
return
}
tw := newTab(w)
fmt.Fprintln(tw, "ACTIVE\tKEY\tOWNER\tSUBJECT\tEXPIRES")
for _, r := range rows {
active := ""
if r.Active {
active = "*"
}
fmt.Fprintf(tw, "%s\t%s\t%s\t%s\t%s\n", active, r.Key, r.Owner, r.Subject, shortTime(r.Expiry))
}
tw.Flush()
})
}
func newWhoamiCmd(envOf func() *Env) *cobra.Command {
var verify bool
var verify, all bool
cmd := &cobra.Command{
Use: "whoami",
Short: "Show the current identity from the stored token",
Short: "Show the active identity from the stored token (--all lists every stored identity)",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, _ []string) error {
env := envOf()
if all {
return listIdentities(env, cmd)
}
tok := env.accessToken()
if tok == "" {
return fmt.Errorf("not logged in: run `hanzo login`")
@@ -300,6 +371,7 @@ func newWhoamiCmd(envOf func() *Env) *cobra.Command {
},
}
cmd.Flags().BoolVar(&verify, "verify", false, "verify the token against the IAM userinfo endpoint")
cmd.Flags().BoolVar(&all, "all", false, "list every stored identity (active marked with *)")
return cmd
}
@@ -329,11 +401,11 @@ func verifyUserInfo(ctx context.Context, env *Env, token string) error {
func newAuthCmd(envOf func() *Env, gf *globalFlags) *cobra.Command {
cmd := &cobra.Command{
Use: "auth",
Short: "Manage authentication",
Short: "Manage authentication and stored identities",
}
tokenCmd := &cobra.Command{
Use: "token",
Short: "Print the stored access token",
Short: "Print the active access token",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, _ []string) error {
tok := envOf().accessToken()
@@ -344,7 +416,41 @@ func newAuthCmd(envOf func() *Env, gf *globalFlags) *cobra.Command {
return nil
},
}
cmd.AddCommand(newLoginCmd(envOf, gf), newLogoutCmd(), newWhoamiCmd(envOf), tokenCmd)
listCmd := &cobra.Command{
Use: "list",
Aliases: []string{"ls", "identities"},
Short: "List stored identities (active marked with *)",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, _ []string) error { return listIdentities(envOf(), cmd) },
}
switchCmd := &cobra.Command{
Use: "switch <owner>",
Aliases: []string{"use"},
Short: "Make a stored identity active (accepts an owner, or a full owner/name key)",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
store, err := LoadIdentities()
if err != nil {
return err
}
key, err := store.resolve(args[0])
if err != nil {
return err
}
store.Active = key
if err := store.Save(); err != nil {
return err
}
c := store.Identities[key]
who := firstNonEmpty(c.Subject, "(unknown)")
if c.Owner != "" {
who += " @ " + c.Owner
}
fmt.Fprintf(cmd.OutOrStdout(), "Switched to %s [%s] (token expires %s)\n", who, key, shortTime(c.Expiry))
return nil
},
}
cmd.AddCommand(newLoginCmd(envOf, gf), newLogoutCmd(), newWhoamiCmd(envOf), tokenCmd, listCmd, switchCmd)
return cmd
}
+175
View File
@@ -208,3 +208,178 @@ func TestAuthTokenCommand(t *testing.T) {
t.Fatalf("auth token output: %q", out)
}
}
// TestMultiIdentityLoginSwitch is the full multi-identity story: two logins for
// the same email under different owners (admin vs hanzo — the privilege-
// separation case) coexist, `auth list` shows both, `switch` flips the active
// pointer and rewrites credentials.json, and legacy single-file readers always
// see the active identity.
func TestMultiIdentityLoginSwitch(t *testing.T) {
sandbox(t)
adminTok := makeJWT(map[string]any{"email": "z@hanzo.ai", "owner": "admin", "sub": "u-admin", "exp": float64(2000000000)})
hanzoTok := makeJWT(map[string]any{"email": "z@hanzo.ai", "owner": "hanzo", "sub": "u-hanzo", "exp": float64(2000000001)})
// First login → admin/z is stored and active.
out, err := runRoot(t, "", "login", "--token", adminTok)
if err != nil {
t.Fatalf("login admin: %v", err)
}
if !strings.Contains(out, "admin/z") {
t.Fatalf("login should report the key: %q", out)
}
if c, _ := LoadCredentials(); c.Owner != "admin" || c.Subject != "z@hanzo.ai" {
t.Fatalf("active not admin after first login: %+v", c)
}
// Second login (different owner) → added beside admin/z, becomes active,
// does NOT clobber the first.
if _, err := runRoot(t, "", "login", "--token", hanzoTok); err != nil {
t.Fatalf("login hanzo: %v", err)
}
store, err := LoadIdentities()
if err != nil {
t.Fatalf("load identities: %v", err)
}
if len(store.Identities) != 2 {
t.Fatalf("want 2 identities, got %d: %v", len(store.Identities), store.keys())
}
if store.Identities["admin/z"] == nil || store.Identities["hanzo/z"] == nil {
t.Fatalf("both identities must persist, got %v", store.keys())
}
if store.Active != "hanzo/z" {
t.Fatalf("active = %q, want hanzo/z (last login)", store.Active)
}
// Legacy reader sees the active (hanzo) identity.
if c, _ := LoadCredentials(); c.Owner != "hanzo" {
t.Fatalf("credentials.json not mirroring active: %+v", c)
}
// auth list shows both, with the active row marked.
out, err = runRoot(t, "", "auth", "list")
if err != nil {
t.Fatalf("auth list: %v", err)
}
for _, want := range []string{"admin/z", "hanzo/z", "z@hanzo.ai", "*"} {
if !strings.Contains(out, want) {
t.Fatalf("auth list missing %q in:\n%s", want, out)
}
}
// switch admin → active flips + credentials.json is rewritten to admin.
if _, err := runRoot(t, "", "auth", "switch", "admin"); err != nil {
t.Fatalf("auth switch admin: %v", err)
}
if st, _ := LoadIdentities(); st.Active != "admin/z" {
t.Fatalf("active after switch = %q, want admin/z", st.Active)
}
if c, _ := LoadCredentials(); c.Owner != "admin" || c.Subject != "z@hanzo.ai" {
t.Fatalf("switch did not rewrite credentials.json: %+v", c)
}
// whoami (top-level, reads the active token) reflects admin.
out, err = runRoot(t, "", "whoami")
if err != nil {
t.Fatalf("whoami: %v", err)
}
if !strings.Contains(out, "admin") || !strings.Contains(out, "z@hanzo.ai") {
t.Fatalf("whoami not reflecting the switched-to identity: %q", out)
}
// switch by the full owner/name key works too.
if _, err := runRoot(t, "", "auth", "switch", "hanzo/z"); err != nil {
t.Fatalf("auth switch hanzo/z: %v", err)
}
if c, _ := LoadCredentials(); c.Owner != "hanzo" {
t.Fatalf("switch by full key failed: %+v", c)
}
}
// TestAuthListJSON checks the machine-readable projection.
func TestAuthListJSON(t *testing.T) {
sandbox(t)
tok := makeJWT(map[string]any{"email": "z@hanzo.ai", "owner": "admin", "sub": "a"})
if _, err := runRoot(t, "", "login", "--token", tok); err != nil {
t.Fatalf("login: %v", err)
}
out, err := runRoot(t, "", "auth", "list", "-o", "json")
if err != nil {
t.Fatalf("auth list json: %v", err)
}
var rows []identityRow
if err := json.Unmarshal([]byte(out), &rows); err != nil {
t.Fatalf("json unmarshal: %v\n%s", err, out)
}
if len(rows) != 1 || rows[0].Key != "admin/z" || rows[0].Owner != "admin" || !rows[0].Active {
t.Fatalf("json rows wrong: %+v", rows)
}
}
// TestLogoutOneOfMany removes a single identity and, only when the last one is
// gone, clears the store entirely.
func TestLogoutOneOfMany(t *testing.T) {
sandbox(t)
admin := makeJWT(map[string]any{"email": "z@hanzo.ai", "owner": "admin", "sub": "a"})
hanzo := makeJWT(map[string]any{"email": "z@hanzo.ai", "owner": "hanzo", "sub": "h"})
if _, err := runRoot(t, "", "login", "--token", admin); err != nil {
t.Fatal(err)
}
if _, err := runRoot(t, "", "login", "--token", hanzo); err != nil { // active = hanzo/z
t.Fatal(err)
}
// logout of the named owner (admin) leaves hanzo/z active.
if _, err := runRoot(t, "", "logout", "admin"); err != nil {
t.Fatalf("logout admin: %v", err)
}
store, _ := LoadIdentities()
if store.Identities["admin/z"] != nil {
t.Fatalf("admin/z not removed: %v", store.keys())
}
if store.Active != "hanzo/z" {
t.Fatalf("active = %q, want hanzo/z", store.Active)
}
if c, _ := LoadCredentials(); c.Owner != "hanzo" {
t.Fatalf("credentials.json not mirroring survivor: %+v", c)
}
// logout of the active (no arg) removes the last identity → both files gone.
if _, err := runRoot(t, "", "logout"); err != nil {
t.Fatalf("logout active: %v", err)
}
if c, _ := LoadCredentials(); c.AccessToken != "" {
t.Fatalf("credentials.json not cleared: %+v", c)
}
if st, _ := LoadIdentities(); len(st.Identities) != 0 {
t.Fatalf("identity store not cleared: %v", st.keys())
}
}
// TestMigrateLegacyCredentials proves a pre-multi-identity credentials.json is
// adopted into the store and preserved when a new identity is added.
func TestMigrateLegacyCredentials(t *testing.T) {
sandbox(t)
// Simulate an old single-file login: only credentials.json exists.
legacy := credsFromToken(&tokenResp{AccessToken: makeJWT(map[string]any{
"email": "z@hanzo.ai", "owner": "hanzo", "sub": "h",
})})
if err := legacy.Save(); err != nil {
t.Fatal(err)
}
store, err := LoadIdentities()
if err != nil {
t.Fatalf("load identities: %v", err)
}
if store.Identities["hanzo/z"] == nil || store.Active != "hanzo/z" {
t.Fatalf("legacy credentials not migrated: active=%q keys=%v", store.Active, store.keys())
}
// A fresh login as a different owner preserves the migrated identity.
if _, err := runRoot(t, "", "login", "--token", makeJWT(map[string]any{
"email": "z@hanzo.ai", "owner": "admin", "sub": "a",
})); err != nil {
t.Fatal(err)
}
st2, _ := LoadIdentities()
if len(st2.Identities) != 2 || st2.Identities["hanzo/z"] == nil {
t.Fatalf("migrated identity lost after new login: %v", st2.keys())
}
}
+181 -1
View File
@@ -55,7 +55,7 @@ var controlCommands = map[string]string{
"login": "authenticate against Hanzo IAM (hanzo.id) and store a token",
"logout": "remove stored credentials",
"whoami": "show the current identity from the stored token",
"auth": "manage authentication (login, logout, whoami, token)",
"auth": "manage authentication + stored identities (login, logout, whoami, list, switch, token)",
"apps": "list/get the platform apps board (declared/running/drift)",
"deploy": "drive a platform redeploy (rolling restart, zero-downtime)",
"clusters": "provision/list/select dedicated DOKS clusters",
@@ -236,6 +236,170 @@ func DeleteCredentials() error {
return nil
}
// ---------------------------------------------------------------------------
// Identity store — ~/.hanzo/identities.json. Holds EVERY logged-in identity
// keyed by its stable "<owner>/<name>" key, with an Active pointer. On every
// write the active identity is mirrored into credentials.json (above), so every
// legacy single-file reader keeps seeing the current identity unchanged. This
// is the ONE credential store; credentials.json is its active-view mirror.
// ---------------------------------------------------------------------------
// IdentityStore is the on-disk shape of ~/.hanzo/identities.json.
type IdentityStore struct {
Active string `json:"active,omitempty"`
Identities map[string]*Credentials `json:"identities,omitempty"`
}
// key is the stable per-identity store key "<owner>/<name>", where name is the
// email local-part (else the raw subject). The same identity yields the same
// key every login, so re-login updates in place; the same email under a
// different org (privilege separation) yields a distinct key (admin/z vs
// hanzo/z) and is stored side by side rather than clobbering.
func (c *Credentials) key() string {
name := c.Subject
if i := strings.IndexByte(name, '@'); i > 0 {
name = name[:i]
}
return firstNonEmpty(c.Owner, "-") + "/" + firstNonEmpty(name, "-")
}
func identitiesPath() (string, error) {
dir, err := hanzoDir()
if err != nil {
return "", err
}
return filepath.Join(dir, "identities.json"), nil
}
// LoadIdentities reads the store. A pre-existing single-file credentials.json
// with no store yet is migrated in (read-only) as the sole, active identity, so
// upgrades are seamless — the first write persists it into the store.
func LoadIdentities() (*IdentityStore, error) {
p, err := identitiesPath()
if err != nil {
return nil, err
}
s := &IdentityStore{Identities: map[string]*Credentials{}}
if err := loadJSON(p, s); err != nil {
return nil, err
}
if s.Identities == nil {
s.Identities = map[string]*Credentials{}
}
if len(s.Identities) == 0 {
if c, err := LoadCredentials(); err == nil && c.AccessToken != "" {
k := c.key()
s.Identities[k] = c
s.Active = k
}
}
return s, nil
}
// keys returns the identity keys, sorted, for deterministic output.
func (s *IdentityStore) keys() []string {
ks := make([]string, 0, len(s.Identities))
for k := range s.Identities {
ks = append(ks, k)
}
sort.Strings(ks)
return ks
}
// Put stores c under its key and makes it active, returning the key.
func (s *IdentityStore) Put(c *Credentials) string {
if s.Identities == nil {
s.Identities = map[string]*Credentials{}
}
k := c.key()
s.Identities[k] = c
s.Active = k
return k
}
// Remove deletes an identity; Save re-points Active if it was the one removed.
func (s *IdentityStore) Remove(key string) { delete(s.Identities, key) }
// resolve turns a user selector into a stored key: an exact key wins; otherwise
// a bare owner matches iff exactly one identity carries it.
func (s *IdentityStore) resolve(sel string) (string, error) {
if _, ok := s.Identities[sel]; ok {
return sel, nil
}
var match []string
for _, k := range s.keys() {
if s.Identities[k].Owner == sel {
match = append(match, k)
}
}
switch len(match) {
case 1:
return match[0], nil
case 0:
return "", fmt.Errorf("no stored identity for %q (see `hanzo auth list`)", sel)
default:
return "", fmt.Errorf("%q is ambiguous across %s — pass the full owner/name key", sel, strings.Join(match, ", "))
}
}
// Save persists the store (0600) and mirrors the active identity into
// credentials.json for legacy single-file readers. When the store is empty it
// removes both files. Active is normalized to a real key first.
func (s *IdentityStore) Save() error {
if _, ok := s.Identities[s.Active]; !ok {
s.Active = ""
if ks := s.keys(); len(ks) > 0 {
s.Active = ks[0]
}
}
if len(s.Identities) == 0 {
return clearCredentialStore()
}
p, err := identitiesPath()
if err != nil {
return err
}
if err := writeJSON(p, s, 0o600); err != nil {
return err
}
return s.Identities[s.Active].Save() // mirror active → credentials.json (0600)
}
// SaveActive writes c back as the active identity (store + mirror), keeping the
// two consistent after an in-place token refresh. With no store yet it falls
// back to the single-file write.
func SaveActive(c *Credentials) error {
s, err := LoadIdentities()
if err != nil {
return err
}
if len(s.Identities) == 0 {
return c.Save()
}
k := s.Active
if k == "" || s.Identities[k] == nil {
k = c.key()
}
s.Identities[k] = c
s.Active = k
return s.Save()
}
// clearCredentialStore removes the identity store and its credentials.json
// mirror (used by logout when the last identity is removed).
func clearCredentialStore() error {
for _, pathOf := range []func() (string, error){credentialsPath, identitiesPath} {
p, err := pathOf()
if err != nil {
return err
}
if err := os.Remove(p); err != nil && !os.IsNotExist(err) {
return err
}
}
return nil
}
// ---------------------------------------------------------------------------
// Env — the effective, resolved settings a command operates with.
// ---------------------------------------------------------------------------
@@ -294,6 +458,22 @@ func (e *Env) accessToken() string {
return firstNonEmpty(os.Getenv("HANZO_TOKEN"), e.creds.AccessToken)
}
// freshAccessToken returns the IAM user token only while it is not yet expired.
// accessToken() stays expiry-agnostic so `hanzo whoami` can report a dead token
// rather than masking it as logged-out; freshAccessToken is the code-agent path,
// where an expired token would 401 a session a still-valid hk- key could serve.
// No expiry recorded (a raw HANZO_TOKEN with no claims) ⟹ trust it as-is.
func (e *Env) freshAccessToken() string {
tok := e.accessToken()
if tok == "" {
return ""
}
if exp := e.creds.Expiry; exp > 0 && time.Now().Unix() >= exp {
return ""
}
return tok
}
// platformToken resolves the platform control-plane service token. The
// platform REST surface is machine-to-machine (it cannot validate IAM user
// tokens), so apps/clusters/redeploy authenticate with this, sourced from
+14 -4
View File
@@ -535,11 +535,21 @@ func writeIfAbsent(path, content string) error {
return os.WriteFile(path, []byte(content), 0o600)
}
// codeToken resolves the credential the agents authenticate with: an explicit
// API key wins, then the stored key, then the key the rest of the Hanzo
// toolchain already keeps in ~/.hanzo/config.json, then the `hanzo login` token.
// codeToken resolves the credential the agents authenticate with. Precedence:
// an explicit HANZO_API_KEY always wins (the operator's deliberate override),
// then the live `hanzo login` JWT, then the hk- API key chain.
//
// Why the JWT beats the hk- key: the JWT carries the caller's owner/project/sub
// claims verbatim, so the identity boundary mints a billing principal on EVERY
// deployment. The hk- key only mints a principal when the server can resolve it
// (iamKeys.resolve, which is a no-op without IAM_MINT_CLIENT_ID/SECRET) — on a
// deployment lacking that credential an hk- request arrives anonymous and zen's
// billing gate 402s ("a billable tenant is required"). Preferring a FRESH JWT
// keeps `hanzo code` working everywhere; the hk- key stays the fallback for
// mint-credentialed servers and the explicit-override case. freshAccessToken
// skips an expired token so it can't 401 a session a valid hk- key would serve.
func codeToken(env *Env) string {
return firstNonEmpty(os.Getenv("HANZO_API_KEY"), env.cfg.APIKey, storedAPIKey(), env.accessToken())
return firstNonEmpty(os.Getenv("HANZO_API_KEY"), env.freshAccessToken(), env.cfg.APIKey, storedAPIKey())
}
// storedAPIKey reads (never writes) the hk- key that hanzo-mcp and the rest of
+68
View File
@@ -20,6 +20,7 @@ import (
"path/filepath"
"slices"
"testing"
"time"
)
func TestCodeAgentsBypassPermissionsByDefault(t *testing.T) {
@@ -47,6 +48,73 @@ func TestCodeAgentsBypassPermissionsByDefault(t *testing.T) {
}
}
// TestCodeTokenPrecedence locks in the 402 unblock: a fresh `hanzo login` JWT
// (which carries owner/project/sub on EVERY deployment) beats the hk- API key
// (which only mints a billing principal where the server has IAM_MINT_CLIENT_*).
// On a mint-less deployment an hk- request arrives anonymous and zen 402s; the
// JWT must win when it is live. An EXPIRED JWT must NOT win — it would 401 a
// session a valid hk- key would still serve — so it falls through to the key.
// HANZO_API_KEY stays the deliberate operator override at the top.
func TestCodeTokenPrecedence(t *testing.T) {
// freshExpiry is comfortably in the future without a literal unix timestamp.
freshExpiry := time.Now().Add(1 * time.Hour).Unix()
cases := []struct {
name string
envKey string // HANZO_API_KEY override
creds Credentials
want string
}{
{
name: "fresh JWT beats hk- key",
creds: Credentials{AccessToken: "jwt-live", Expiry: freshExpiry},
want: "jwt-live",
},
{
name: "expired JWT falls through to stored hk- key",
creds: Credentials{AccessToken: "jwt-dead", Expiry: time.Now().Add(-1 * time.Hour).Unix()},
want: "hk-stored",
},
{
name: "no JWT, no expiry record ⟹ hk- key (mint-credentialed servers)",
creds: Credentials{},
want: "hk-stored",
},
{
name: "HANZO_API_KEY overrides everything (deliberate operator override)",
envKey: "hk-explicit",
creds: Credentials{AccessToken: "jwt-live", Expiry: freshExpiry},
want: "hk-explicit",
},
{
name: "HANZO_API_KEY overrides even an expired JWT",
envKey: "hk-explicit",
creds: Credentials{AccessToken: "jwt-dead", Expiry: time.Now().Add(-1 * time.Hour).Unix()},
want: "hk-explicit",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
sandbox(t) // isolates HANZO_HOME + clears HANZO_TOKEN/HANZO_API_KEY
if tc.envKey != "" {
t.Setenv("HANZO_API_KEY", tc.envKey)
}
// Put the hk- key where storedAPIKey() reads it (~/.hanzo/config.json),
// the same path the rest of the toolchain shares it from.
cfgDir, _ := hanzoDir()
if err := os.WriteFile(filepath.Join(cfgDir, "config.json"),
[]byte(`{"apiKey":"hk-stored"}`), 0o600); err != nil {
t.Fatalf("write config.json: %v", err)
}
env := resolve(&Config{}, &tc.creds, globalFlags{})
if got := codeToken(env); got != tc.want {
t.Fatalf("codeToken = %q, want %q", got, tc.want)
}
})
}
}
func TestDefaultCodeModelIsToolCapableAlias(t *testing.T) {
// zen5 is the flagship GLM-5.2-class alias — tool-capable (verified live: the
// glm-5.2 upstream returns tool_use / stop_reason:tool_use) and the model the
+172 -2
View File
@@ -54,6 +54,10 @@ const (
heartbeatEvery = 30 * time.Second
claimPoll = 2 * time.Second
claimLeaseSecs = 120
// renderWindow matches the dispatch cap (studio gpu_dispatch sets
// startToCloseTimeout 14400s). The old 10m local poll undercut it and
// marked live renders failed while they kept sampling (observed 8-70m).
renderWindow = 4 * time.Hour
// localComfyUI is the studio render backend the studio.render handler drives.
localComfyUI = "http://127.0.0.1:8188"
// defaultStudioUploadURL is where finished render outputs are POSTed so they
@@ -95,6 +99,7 @@ func newGPUCmd(envOf func() *Env, _ *globalFlags) *cobra.Command {
var engineEndpoint string
var registerProvider bool
var studioDir string
var studioURL string
connect := &cobra.Command{
Use: "connect",
Short: "Register this GPU and run the outbound worker loop",
@@ -107,6 +112,7 @@ func newGPUCmd(envOf func() *Env, _ *globalFlags) *cobra.Command {
engineEndpoint: engineEndpoint,
registerProvider: registerProvider,
studioDir: studioDir,
studioURL: studioURL,
}
if daemon {
return installDaemon(cmd, opts)
@@ -121,6 +127,7 @@ func newGPUCmd(envOf func() *Env, _ *globalFlags) *cobra.Command {
connect.Flags().StringVar(&engineEndpoint, "engine-endpoint", "", "public URL to advertise for gateway routing (defaults to --engine-url; a BYO node needs a reachable URL/tunnel)")
connect.Flags().BoolVar(&registerProvider, "register-provider", false, "auto-register the engine endpoint as an org model provider (POST /v1/add-provider)")
connect.Flags().StringVar(&studioDir, "studio-dir", os.Getenv("HANZO_STUDIO_DIR"), "local Hanzo Studio checkout; when set, connect launches and supervises the render backend on 127.0.0.1:8188")
connect.Flags().StringVar(&studioURL, "studio-url", firstNonEmpty(os.Getenv("HANZO_STUDIO_UPLOAD_URL"), defaultStudioUploadURL), "studio base URL the render mirror uploads finished images to (POST /v1/library/upload)")
status := &cobra.Command{
Use: "status",
@@ -370,6 +377,7 @@ type connectOpts struct {
engineEndpoint string // public URL to advertise (defaults to engineURL)
registerProvider bool // auto POST /v1/add-provider for the engine
studioDir string // local Studio checkout to launch + supervise on :8188
studioURL string // studio base the render mirror uploads finished images to
}
func runConnect(cmd *cobra.Command, env *Env, opts connectOpts) error {
@@ -422,6 +430,25 @@ func runConnect(cmd *cobra.Command, env *Env, opts connectOpts) error {
poll := time.NewTicker(claimPoll)
defer poll.Stop()
// Render mirror — independent of claims by design. It scans the local studio
// output tree every heartbeatEvery and uploads every image to the org's library
// (POST /v1/library/upload), so EVERY render lands in studio.hanzo.ai even when
// it was produced outside the job path — a graph hand-run on this node, or a
// render that finished after its activity was reaped (the stranded-late-render
// class). Active only when a studio checkout is named (there is local output to
// mirror); a nil channel case never fires when it is not.
w.studioUploadURL = firstNonEmpty(opts.studioURL, w.studioUploadURL)
mirrorBase := w.studioUploadURL
mirrorDir := ""
seen := map[string]int64{}
var mirC <-chan time.Time
if opts.studioDir != "" {
mirrorDir = filepath.Join(opts.studioDir, "output")
mir := time.NewTicker(heartbeatEvery)
defer mir.Stop()
mirC = mir.C
}
// Heartbeat once immediately so the machine reports online without waiting a
// full interval.
_ = w.heartbeat(ctx)
@@ -449,6 +476,8 @@ func runConnect(cmd *cobra.Command, env *Env, opts connectOpts) error {
if err := w.claimAndRun(ctx, out); err != nil {
fmt.Fprintf(cmd.ErrOrStderr(), "claim: %v\n", err)
}
case <-mirC:
w.mirrorRenders(ctx, out, mirrorDir, mirrorBase, seen)
}
}
}
@@ -542,7 +571,29 @@ func (w *worker) claimAndRun(ctx context.Context, out io.Writer) error {
fmt.Fprintf(out, " → failed: %s\n", cause)
return nil
}
// Keep BOTH the claimed activity and this machine's fleet presence alive while
// the handler runs. A render blocks this call for minutes (a cold GB10 reloads
// ~40GB before sampling); without heartbeats the studio.render activity hits its
// heartbeatTimeout AND the fleet presence (120s) goes stale, so the machine
// drops offline mid-render and the next dispatch sees no online GPU. A ticker in
// a child context heartbeats both every heartbeatEvery until the handler returns.
hbCtx, stopHB := context.WithCancel(ctx)
go func() {
t := time.NewTicker(heartbeatEvery)
defer t.Stop()
for {
select {
case <-hbCtx.Done():
return
case <-t.C:
_, _ = w.call(ctx, http.MethodPost, w.actPath(wf, run, "heartbeat"),
map[string]any{"identity": w.identity}, nil)
_ = w.heartbeat(ctx) // fleet presence — stays online through the render
}
}
}()
result, herr := h(ctx, act.Input)
stopHB()
if herr != nil {
_, _ = w.call(ctx, http.MethodPost, w.actPath(wf, run, "fail"), map[string]any{"cause": herr.Error(), "identity": w.identity}, nil)
fmt.Fprintf(out, " → failed: %v\n", herr)
@@ -710,7 +761,7 @@ func (e *Env) ensureToken(ctx context.Context) (string, error) {
nc.RefreshToken = e.creds.RefreshToken
}
*e.creds = *nc
_ = e.creds.Save()
_ = SaveActive(e.creds) // refresh the active identity in the store + mirror
}
// On refresh failure fall through: the current token may still be valid
// (clock skew) and the server is the authority.
@@ -801,7 +852,7 @@ func (w *worker) studioRenderHandler(ctx context.Context, input json.RawMessage)
return nil, fmt.Errorf("studio.render: no prompt_id in /prompt response")
}
// Poll history until the prompt shows up (completed).
deadline := time.Now().Add(10 * time.Minute)
deadline := time.Now().Add(renderWindow)
for time.Now().Before(deadline) {
select {
case <-ctx.Done():
@@ -825,6 +876,11 @@ func (w *worker) studioRenderHandler(ctx context.Context, input json.RawMessage)
if uerr != nil {
return nil, fmt.Errorf("studio.render: prompt %s rendered but gallery upload failed: %w", pr.PromptID, uerr)
}
// The engine leaks ~58GB per render; recycling after each completed
// render caps it at one render's worth. Boot (~20s) is noise next to
// 8-70m renders. Never recycle on the timeout path — the engine may
// still be sampling and the mirror rescues late finishes.
requestStudioRecycle()
return map[string]any{"promptId": pr.PromptID, "outputs": outputs, "gallery": gallery}, nil
}
}
@@ -939,6 +995,120 @@ func (w *worker) postGalleryOutput(ctx context.Context, base, tok, org, name, su
return filepath.Join(out.Subfolder, out.Name), nil
}
// isImageFile reports whether name carries a render image extension the library accepts.
func isImageFile(name string) bool {
switch strings.ToLower(filepath.Ext(name)) {
case ".png", ".jpg", ".jpeg", ".webp":
return true
}
return false
}
// mirrorRenders scans dir (the local studio output tree) for image files new or
// changed since the last scan and POSTs each to base/v1/library/upload with the
// worker's bearer, tagged with this node's identity, so EVERY render lands in the
// org's studio library — including ones produced OUTSIDE the job path. seen (rel
// path -> size) skips unchanged files; the endpoint dedupes, so a re-scan after a
// restart is cheap and harmless. One log line per newly stored file; upload
// failures are summarized once per scan and retried next tick (no 5xx log spam).
func (w *worker) mirrorRenders(ctx context.Context, out io.Writer, dir, base string, seen map[string]int64) {
tok, err := w.env.ensureToken(ctx)
if err != nil {
return
}
base = strings.TrimRight(base, "/")
failed := 0
var firstErr error
_ = filepath.Walk(dir, func(p string, info os.FileInfo, werr error) error {
if werr != nil || info == nil || info.IsDir() || !isImageFile(p) {
return nil
}
// Hidden files and AppleDouble forks (`._*`, `.DS_Store`) ride along with
// mac scp and are not renders — `._foo.png` passes the extension check
// but is a 4KB resource fork that poisons the library.
if strings.HasPrefix(filepath.Base(p), ".") {
return nil
}
rel, rerr := filepath.Rel(dir, p)
if rerr != nil {
return nil
}
rel = filepath.ToSlash(rel)
if seen[rel] == info.Size() {
return nil
}
data, derr := os.ReadFile(p)
if derr != nil || len(data) == 0 {
return nil
}
sub, name := "", rel
if i := strings.LastIndex(rel, "/"); i >= 0 {
sub, name = rel[:i], rel[i+1:]
}
existed, perr := w.postLibraryUpload(ctx, base, tok, sub, name, data)
if perr != nil {
failed++
if firstErr == nil {
firstErr = perr
}
return nil
}
seen[rel] = info.Size()
if !existed {
fmt.Fprintf(out, "mirrored %s (%d bytes) -> %s\n", rel, len(data), base)
}
return nil
})
if failed > 0 {
fmt.Fprintf(out, "mirror: %d file(s) failed to upload, will retry: %v\n", failed, firstErr)
}
}
// postLibraryUpload multipart-POSTs one image to base/v1/library/upload with the
// worker's IAM bearer, landing it in the org's library (orgs/{org}/output). The
// file's subfolder rides as ?subpath and this node's identity as ?node so the
// render is filterable by its source in Queue & History. Returns whether the
// endpoint already had a byte-identical copy (dedup).
func (w *worker) postLibraryUpload(ctx context.Context, base, tok, sub, name string, data []byte) (bool, error) {
var buf bytes.Buffer
mw := multipart.NewWriter(&buf)
part, err := mw.CreateFormFile("image", name)
if err != nil {
return false, err
}
if _, err := part.Write(data); err != nil {
return false, err
}
if err := mw.Close(); err != nil {
return false, err
}
q := url.Values{"node": {w.identity}}
if sub != "" {
q.Set("subpath", sub)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, base+"/v1/library/upload?"+q.Encode(), &buf)
if err != nil {
return false, err
}
req.Header.Set("Authorization", "Bearer "+tok)
req.Header.Set("Content-Type", mw.FormDataContentType())
req.Header.Set("Accept", "application/json")
resp, err := w.http.Do(req)
if err != nil {
return false, err
}
defer resp.Body.Close()
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if resp.StatusCode/100 != 2 {
return false, fmt.Errorf("POST /v1/library/upload HTTP %d: %s", resp.StatusCode, serverMessage(raw))
}
var out struct {
Existed bool `json:"existed"`
}
_ = json.Unmarshal(raw, &out)
return out.Existed, nil
}
// inputImage is one uploaded input shipped with the job: a base64 blob plus the
// input-dir-relative location it must occupy on this worker so LoadImage finds it.
type inputImage struct {
+92
View File
@@ -0,0 +1,92 @@
package cli
import (
"bytes"
"context"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"time"
)
// TestMirrorRenders verifies the render mirror: it scans the local studio output
// tree and POSTs every image (new or changed) to /v1/library/upload with the node's
// identity + subfolder + bearer, skips unchanged files across scans, and re-uploads
// a changed file. This is the path that lands EVERY render in studio.hanzo.ai even
// when it was produced outside the job/claim path.
func TestMirrorRenders(t *testing.T) {
t.Setenv("HANZO_TOKEN", "test-bearer")
dir := t.TempDir()
if err := os.MkdirAll(filepath.Join(dir, "renders"), 0o755); err != nil {
t.Fatal(err)
}
write := func(rel, body string) {
if err := os.WriteFile(filepath.Join(dir, filepath.FromSlash(rel)), []byte(body), 0o644); err != nil {
t.Fatal(err)
}
}
write("renders/a.png", "\x89PNG-a")
write("top.jpg", "jpg-top")
write("notes.txt", "not an image")
type up struct{ name, node, subpath, auth string }
var got []up
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/v1/library/upload" {
http.Error(w, "not found", http.StatusNotFound)
return
}
_ = r.ParseMultipartForm(1 << 20)
name := ""
if r.MultipartForm != nil {
for _, fh := range r.MultipartForm.File["image"] {
name = fh.Filename
}
}
got = append(got, up{
name: name,
node: r.URL.Query().Get("node"),
subpath: r.URL.Query().Get("subpath"),
auth: r.Header.Get("Authorization"),
})
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"ok":true,"path":"x"}`))
}))
defer srv.Close()
w := &worker{env: &Env{}, http: &http.Client{Timeout: 10 * time.Second}, identity: "spark"}
seen := map[string]int64{}
var buf bytes.Buffer
w.mirrorRenders(context.Background(), &buf, dir, srv.URL, seen)
if len(got) != 2 {
t.Fatalf("uploaded %d files, want 2 images (the .txt is skipped): %+v", len(got), got)
}
var a *up
for i := range got {
if got[i].name == "a.png" {
a = &got[i]
}
}
if a == nil || a.subpath != "renders" || a.node != "spark" || a.auth != "Bearer test-bearer" {
t.Fatalf("renders/a.png upload = %+v, want subpath=renders node=spark bearer set", a)
}
// A second scan re-uploads nothing (seen matches every size).
got = nil
w.mirrorRenders(context.Background(), &buf, dir, srv.URL, seen)
if len(got) != 0 {
t.Fatalf("second scan uploaded %d files, want 0 (all unchanged): %+v", len(got), got)
}
// A changed file is re-uploaded on the next scan.
write("renders/a.png", "\x89PNG-a-grew")
got = nil
w.mirrorRenders(context.Background(), &buf, dir, srv.URL, seen)
if len(got) != 1 || got[0].name != "a.png" {
t.Fatalf("after change, uploaded %+v, want just renders/a.png", got)
}
}
+13
View File
@@ -107,6 +107,17 @@ func stopStudio(cmd *exec.Cmd) {
}
}
// studioRecycle carries at most one pending recycle request; the render
// handler signals it after each completed render (see gpu.go).
var studioRecycle = make(chan struct{}, 1)
func requestStudioRecycle() {
select {
case studioRecycle <- struct{}{}:
default:
}
}
// superviseStudio keeps the local render backend on :8188 alive until ctx
// ends. Quiet by design: one line per restart event, not a probe firehose.
func superviseStudio(ctx context.Context, dir string, out io.Writer) {
@@ -145,6 +156,8 @@ func superviseStudio(ctx context.Context, dir string, out io.Writer) {
_ = syscall.Kill(-cmd.Process.Pid, syscall.SIGTERM)
}
return
case <-studioRecycle:
restart("recycle")
case <-tick.C:
if studioHealthy(ctx) {
continue
+4
View File
@@ -161,6 +161,10 @@ func routesBridge(s *cloud.Service[state], app *zip.App) {
// console calls, forwarded to commerce with the admin service token and SCOPED to the
// validated caller's own subject (billing.go). Registered AFTER clients/billing's
// specific routes (121 < 122) so those win and this catches the rest. GET+POST only.
// The wildcard is what the ROUTER matches; it is NOT the forwardable set — billing.go's
// billingForwardable allowlist decides that, per method, and 404s everything else
// BEFORE the admin service token is attached. Widening this pattern grants nothing on
// its own; adding a line to that table is the only way to expose an endpoint.
app.Get("/v1/billing/*", cloud.Handle(s, billingData))
app.Post("/v1/billing/*", requireCSRF(s, cloud.Handle(s, billingData)))
// Per-tenant STORE DATA bridge — the canonical /v1/commerce/* the console calls,
+126 -13
View File
@@ -7,6 +7,14 @@
// read/act on its OWN ledger (balance / usage / invoices / subscriptions /
// payment-methods / spend-alerts / …), never another's.
//
// TWO INDEPENDENT BOUNDS, because the token makes this a privileged forwarder:
// 1. WHICH ENDPOINT — billingForwardable, the per-method allowlist below. It is the
// authorization gate: an unlisted path is 404'd before the token is ever attached, so
// no money-MINT route (deposit/credit/refund/…) can be reached through this bridge.
// 2. WHOSE DATA — the subject-pinning below. It aims a permitted call at the caller's own
// ledger. It is an IDOR control and NOT an authority control: on a mint route it would
// have pinned the CREDIT to the attacker's own account. (1) is what stops that.
//
// WHY A SERVER HANDLER (not a same-origin passthrough). Commerce's billing surface is
// service-token-gated and filters DIFFERENT endpoints on DIFFERENT subject params —
// subscriptions on ?userId, payment-methods on ?customerId, usage on ?user. Pinning
@@ -28,10 +36,115 @@ import (
"strings"
"unicode"
"github.com/hanzoai/account"
"github.com/hanzoai/cloud"
"github.com/zap-proto/zip"
)
// billingForwardable — THE allowlist of billing endpoints this bridge may forward, keyed
// by method. It is the whole authorization story of the bridge, because forwarding IS
// authorization here: every forwarded request carries the admin COMMERCE_SERVICE_TOKEN,
// and commerce's money gate is MayMintMoney(c) = IsServiceToken(c) || IsSuperAdmin(c)
// (middleware/platformonly.go). The token satisfies IsServiceToken, so ANY subpath that
// reaches commerce is executed with PLATFORM authority — not the caller's. Commerce 403s
// an org admin who calls POST /v1/billing/deposit directly; without this table the bridge
// handed that same person the platform's own credential and minted it for them, scoped —
// by the subject-pinning below — to their OWN account. That is the escalation, and
// subject-pinning is what AIMS it, not what stops it. Only a path gate stops it.
//
// It is an ALLOWLIST, never a denylist: a denylist must enumerate every mint route
// (deposit/credit/refund/credit-grants/payouts/husd/allotment…) and stays correct only
// until commerce adds the next one — a route this file has never heard of is then
// forwarded by default. Here the default is REFUSE, so a new commerce mint route is
// unreachable the day it lands, with no change on this side. One table, one place; a path
// not in it cannot reach commerce, by construction.
//
// GET and POST are SEPARATE sets because a read bridge and a write bridge are different
// concerns: `payouts` is a legitimate read and a money-MINT write (api/billing/handlers.go
// `api.Get("/payouts", ListPayouts)` vs `api.Post("/payouts", mintRequired, CreatePayout)`),
// so one method-blind set would hand the mint to every reader. The POST set is therefore
// deliberately tiny and holds NOTHING that creates spendable balance from a client-named
// amount: cancel/reactivate a subscription, vault a card, create a budget, and a top-up
// that CHARGES a real card (money in, not minted). Every entry is a call the console
// actually makes; `{}` matches exactly one opaque id segment.
//
// EVIDENCE — each entry is a live console call (repo hanzoai/console):
//
// GET balance src/lib/api/billing.ts:397 sidebar wallet + billing overview
// GET usage src/lib/api/billing.ts:415 cost reports / AI metrics
// GET invoices src/lib/api/billing.ts:419 invoice history table
// GET invoices/{}/pdf src/components/products/billing/BillingInvoices.tsx:31
// GET subscriptions src/lib/api/billing.ts:423 subscriptions list
// GET payment-methods src/lib/api/billing.ts:450 saved cards (masked)
// GET spend-alerts src/lib/api/billing.ts:482 budgets / spend caps
// GET payment-config src/lib/api/billing.ts:552 public Square app/location id
// GET plans src/lib/api/plans.ts:126 published tiers
// GET payouts src/components/products/SettlementModule.tsx:61 settlement view
// POST subscriptions/{}/cancel src/lib/api/billing.ts:434
// POST subscriptions/{}/reactivate src/lib/api/billing.ts:444
// POST payment-methods src/lib/api/billing.ts:461 vault a Square nonce (no PAN)
// POST spend-alerts src/lib/api/billing.ts:500 create a budget
// POST topup/token src/lib/api/billing.ts:565 charge a card → credit
//
// balance/usage/payment-methods are ALSO served natively by clients/billing (order 121),
// which wins over this catch-all (122), so those entries are reached only on a deploy
// where that subsystem is disabled. They are listed because they are legitimate reads of
// the caller's own ledger, not because this bridge is their primary route.
//
// NOT LISTED, deliberately: `me/welcome` and `grant-starter` (console calls the first at
// billing.ts:407 and the second server-side at src/lib/server/billing-grant.ts:35) exist
// in NEITHER the pinned commerce (v1.48.5) route table — both 404 today whether or not
// this bridge forwards them, and grant-starter is mint-gated and browser-unreachable by
// design. The console's PATCH/DELETE calls (spend-alerts/{}, payment-methods/{}) are absent
// because routesBridge mounts GET+POST only, so they never reached this handler.
var billingForwardable = map[string][]string{
http.MethodGet: {
"balance",
"usage",
"invoices",
"invoices/{}/pdf",
"subscriptions",
"payment-methods",
"spend-alerts",
"payment-config",
"plans",
"payouts",
},
http.MethodPost: {
"subscriptions/{}/cancel",
"subscriptions/{}/reactivate",
"payment-methods",
"spend-alerts",
"topup/token",
},
}
// isForwardableBilling reports whether method+sub is in billingForwardable. sub has
// already passed isSafeSegment, so no segment can contain a slash, a percent-escape, or a
// traversal — a pattern segment therefore matches exactly one real segment and `{}` cannot
// swallow a path. Fail-closed: an unknown method or an unlisted path is false.
func isForwardableBilling(method, sub string) bool {
got := strings.Split(sub, "/")
for _, pattern := range billingForwardable[method] {
want := strings.Split(pattern, "/")
if len(want) != len(got) {
continue
}
match := true
for i, seg := range want {
if seg != "{}" && seg != got[i] {
match = false
break
}
}
if match {
return true
}
}
return false
}
// billingSubjectKeys — every query/body param through which a commerce billing endpoint
// identifies its subject. Kept identical to commerce's edge-auth billingSubjectKeys
// {user,userId,customerId} AND console's billing-scope.ts BILLING_SUBJECT_KEYS. Change
@@ -48,17 +161,6 @@ func isSubjectKey(k string) bool {
return false
}
// billingSubject — the commerce billing subject for an org+user: ALWAYS the org
// (`org`), lowercased. Every member of an org reads/scopes to the ONE org billing
// account — the same subject the gateway gate reads and debits. `name` is recorded
// for metrics, never for the billing key. This is the ONE rule; the former
// PERSONAL_BILLING_ORGS / ORG_BILLING_ORGS allowlists are gone. Keep in lockstep
// with ai/object.BillingSubject so the console view and the gate never disagree.
func billingSubject(org, name string) string {
_ = name
return strings.ToLower(strings.TrimSpace(org))
}
// scopedBillingSearch — pin every billingSubjectKey to subject (OVERWRITING any client
// value — the browser cannot widen scope) and DROP org. Every OTHER param (currency,
// status, date range) passes through untouched. Mirrors billing-scope.ts.
@@ -165,10 +267,21 @@ func billingData(s *cloud.Service[state], c *zip.Ctx) error {
return zip.ErrBadRequest("invalid billing path")
}
}
// THE authorization gate. Forwarding is authorization: the request below carries the
// admin service token, which satisfies commerce's MayMintMoney. So refuse anything the
// console does not actually call — BEFORE the token is attached. Fail closed (404, the
// same answer an unrouted path gives, so this leaks no map of the money surface).
if !isForwardableBilling(method, sub) {
return zip.Errorf(http.StatusNotFound, "not a forwardable billing endpoint")
}
// Scope EVERY request to the caller's OWN subject — query AND write body — so
// commerce's per-tenant isolation can never be crossed from the browser.
subject := billingSubject(cr.owner, cr.name)
// commerce's per-tenant isolation can never be crossed from the browser. The
// subject comes from the ONE rule (ai/object.Payer), keyed on the IAM username
// (cr.username = X-User-Name) the gate also keys on — so a top-up credits the
// SAME account the gate debits. Keying on cr.name (X-User-Id, a UUID on the
// direct-bearer path) would fund an account the gate never reads: the split.
subject := account.Payer(account.Credential{Owner: cr.owner, Name: cr.username}).Subject()
inQuery, _ := url.ParseQuery(string(c.Fiber().Request().URI().QueryString()))
q := scopedBillingSearch(inQuery, subject)
+26 -18
View File
@@ -2,6 +2,7 @@ package account
import (
"encoding/json"
"github.com/hanzoai/account"
"io"
"net/http"
"net/http/httptest"
@@ -16,37 +17,44 @@ import (
// ── pure scoping ─────────────────────────────────────────────────────────────
// TestBillingSubject proves the top-up subject is resolved through the ONE rule
// (ai/object.Payer) — so a top-up credits the SAME account the ai gate debits and
// the console reads. The signup org bills per-person (matching the gate), which is
// the whole fix: money and gate land on one account.
func TestBillingSubject(t *testing.T) {
cases := []struct{ org, name, want string }{
{"acme", "alice", "acme"}, // any member bills the ONE org account
{"hanzo", "Dave", "hanzo"}, // no per-user wallet; org, lowercased
{"hanzo", "z", "hanzo"}, // another member — same org account
{"hanzo", "", "hanzo"}, // no name → org
{"Hanzo", "z", "hanzo"}, // lowercased
{"", "x", ""}, // no org → empty subject
{"acme", "alice", "acme"}, // real org: any member bills the ONE org account
{"hanzo", "Dave", "hanzo/dave"}, // signup org: each person bills their OWN account
{"hanzo", "z", "hanzo/z"}, // another signup person — their own account
{"hanzo", "", "hanzo"}, // no name (org-owned principal) → org pool
{"Hanzo", "Z", "hanzo/z"}, // folded
{"", "x", ""}, // no org → empty subject (cannot bill)
}
for _, c := range cases {
if got := billingSubject(c.org, c.name); got != c.want {
t.Fatalf("billingSubject(%q,%q): want %q, got %q", c.org, c.name, c.want, got)
got := account.Payer(account.Credential{Owner: c.org, Name: c.name}).Subject()
if got != c.want {
t.Fatalf("Payer(%q,%q).Subject(): want %q, got %q", c.org, c.name, c.want, got)
}
}
}
// TestBillingSubject_IgnoresLegacyEnv locks that the killed allowlist envs have NO
// effect: the subject is ALWAYS the org, whether or not the old PERSONAL_BILLING_ORGS
// / ORG_BILLING_ORGS knobs are set. This mirrors ai/object.BillingSubject (one rule,
// no config) so the console view and the gateway gate can never disagree.
// effect: nothing reads them. Set to values that WOULD have flipped every
// resolution — the subject is unchanged. This is the console/top-up half of the
// same proof ai carries (one rule, no config), so the view and the gate can never
// disagree, and the deleted CR env is a genuine no-op.
func TestBillingSubject_IgnoresLegacyEnv(t *testing.T) {
t.Setenv("PERSONAL_BILLING_ORGS", "hanzo,acme")
t.Setenv("ORG_BILLING_ORGS", "hanzo")
t.Setenv("PERSONAL_BILLING_ORGS", "hanzo,acme") // would have split acme per-user
t.Setenv("ORG_BILLING_ORGS", "hanzo") // would have pooled the signup org
cases := []struct{ org, name, want string }{
{"hanzo", "z", "hanzo"},
{"acme", "alice", "acme"},
{"maxpower", "dave", "maxpower"},
{"hanzo", "z", "hanzo/z"}, // env cannot pool the signup org
{"acme", "alice", "acme"}, // env cannot split a real org per-user
{"maxpower", "dave", "maxpower"}, // untouched
}
for _, c := range cases {
if got := billingSubject(c.org, c.name); got != c.want {
t.Fatalf("legacy env must be ignored: billingSubject(%q,%q) want %q, got %q", c.org, c.name, c.want, got)
got := account.Payer(account.Credential{Owner: c.org, Name: c.name}).Subject()
if got != c.want {
t.Fatalf("legacy env must be ignored: Payer(%q,%q).Subject() want %q, got %q", c.org, c.name, c.want, got)
}
}
}
+257
View File
@@ -0,0 +1,257 @@
package account
import (
"net/http"
"strings"
"testing"
commercebilling "github.com/hanzoai/commerce/api/billing"
commercemid "github.com/hanzoai/commerce/middleware"
"github.com/zap-proto/zip"
)
// bridge_mint_test.go — the privilege-escalation boundary of the /v1/billing/*
// bridge: an ordinary signed-in ORG user must never reach commerce's money-MINT
// surface.
//
// THE ESCALATION THIS LOCKS OUT. The bridge forwards to commerce with the admin
// COMMERCE_SERVICE_TOKEN. Commerce gates every mint on
// MayMintMoney(c) = IsServiceToken(c) || IsSuperAdmin(c) (middleware/platformonly.go)
// — and the bridge's service token satisfies IsServiceToken. So ANY subpath the
// bridge forwards is executed by commerce as the PLATFORM, not as the caller.
// billingData scopes the SUBJECT to the caller's own account, which is exactly the
// attack rather than a defense: an org user mints to THEMSELVES. Commerce's own
// gate comment names this: "let ANY org owner self-credit unlimited balance (POST
// /v1/billing/deposit &c.) → unlimited free inference."
//
// Commerce 403s that same org admin when they call it DIRECTLY
// (TestC1_OrgAdminDeniedOnEveryMintRoute) and mints 201 for the service token
// (TestC1_ServiceTokenMintsDeposit). The bridge is what converts the former into
// the latter. The gate therefore has to live HERE, at the point that hands out the
// token: forwardable subpaths are an ALLOWLIST, and a mint path is not on it.
//
// alice is an ordinary org user — X-Org-Id "acme", owner != "admin", NOT a
// SuperAdmin — i.e. precisely the principal commerce refuses at the front door.
// TestBridge_OrgUserCannotReachMint is the reproduction. Each of these commerce
// subpaths is PlatformOnly-gated (api/billing/handlers.go: `mintRequired`), meaning
// possession of the service token IS authority to create spendable balance. None
// may leave cloud. A request that never reaches commerce cannot mint, so the
// assertion is twofold: the caller is refused AND upstream saw nothing.
// mintSurface asks COMMERCE which routes it gates, rather than keeping a copy.
//
// The list used to live here by hand under "kept in lockstep with
// api/billing/handlers.go" — and it had already drifted: 10 paths here against
// 16 commerce actually gates. A comment cannot hold two lists together. Now
// commerce DECLARES its gated surface (middleware.Mint records what it gates)
// and we read that declaration, so a mint route added there is covered here with
// nobody remembering to do anything.
//
// Registration is what populates the registry, so register first, then read.
func mintSurface(t *testing.T) []commercemid.MintRoute {
t.Helper()
commercebilling.Route(zip.New(zip.Config{DisableStartupMessage: true}).Group("/v1"))
var out []commercemid.MintRoute
for _, r := range commercemid.MintRoutes() {
// Only what THIS bridge can address: it forwards /v1/billing/* alone.
if !strings.HasPrefix(r.Path, "/v1/billing/") {
continue
}
// A wildcard segment needs some concrete value to be requestable; which
// one is irrelevant, since a refused call never reaches an id.
parts := strings.Split(r.Path, "/")
for i, seg := range parts {
if strings.HasPrefix(seg, ":") || seg == "{}" {
parts[i] = "probe"
}
}
r.Path = strings.Join(parts, "/")
out = append(out, r)
}
if len(out) == 0 {
t.Fatal("commerce declared no /v1/billing mint routes — the registry is not being populated")
}
return out
}
func TestBridge_OrgUserCannotReachMint(t *testing.T) {
mintPaths := mintSurface(t)
t.Logf("commerce declares %d gated /v1/billing mint routes", len(mintPaths))
for _, m := range mintPaths {
t.Run(m.Method+" "+m.Path, func(t *testing.T) {
f := &fakeBilling{}
t.Setenv("COMMERCE_URL", f.server(t).URL)
t.Setenv("COMMERCE_SERVICE_TOKEN", "svc-tok")
app := mountApp(t, "http://iam.invalid", "", "")
code, body := callH(t, app, m.Method, m.Path, alice, `{}`)
// The mint request must NEVER reach commerce: arriving there at all means
// it arrived bearing the admin service token, which IS the authority to
// mint (MayMintMoney → AuthorizeMint → the ledger write).
if f.path != "" {
t.Fatalf("ESCALATION: an ordinary org user's %s %s reached commerce at %q "+
"carrying %q — the service token that satisfies MayMintMoney. "+
"Minted subject=%v amount=%v in org=%q.",
m.Method, m.Path, f.path, f.auth, f.body["user"], f.body["amount"], f.org)
}
if code != http.StatusNotFound {
t.Fatalf("%s %s: want 404 (not a forwardable billing endpoint), got %d (%s)",
m.Method, m.Path, code, body)
}
})
}
}
// TestBridge_ConsoleCallsStillForward is the other half of the allowlist: the calls the
// console ACTUALLY makes must still reach commerce. An allowlist that blocks the product
// is not a fix, so each entry here is a live console call (cited in billing.go), and this
// test fails if a future edit narrows the table below the console's real needs.
func TestBridge_ConsoleCallsStillForward(t *testing.T) {
calls := []struct{ method, path, want string }{
{http.MethodGet, "/v1/billing/invoices", "/v1/billing/invoices"},
{http.MethodGet, "/v1/billing/invoices/inv_123/pdf", "/v1/billing/invoices/inv_123/pdf"},
{http.MethodGet, "/v1/billing/subscriptions", "/v1/billing/subscriptions"},
{http.MethodGet, "/v1/billing/spend-alerts", "/v1/billing/spend-alerts"},
{http.MethodGet, "/v1/billing/payment-config", "/v1/billing/payment-config"},
{http.MethodGet, "/v1/billing/plans", "/v1/billing/plans"},
{http.MethodGet, "/v1/billing/payouts", "/v1/billing/payouts"},
{http.MethodPost, "/v1/billing/subscriptions/sub_1/cancel", "/v1/billing/subscriptions/sub_1/cancel"},
{http.MethodPost, "/v1/billing/subscriptions/sub_1/reactivate", "/v1/billing/subscriptions/sub_1/reactivate"},
{http.MethodPost, "/v1/billing/payment-methods", "/v1/billing/payment-methods"},
{http.MethodPost, "/v1/billing/spend-alerts", "/v1/billing/spend-alerts"},
{http.MethodPost, "/v1/billing/topup/token", "/v1/billing/topup/token"},
}
for _, call := range calls {
t.Run(call.method+" "+call.path, func(t *testing.T) {
f := &fakeBilling{}
t.Setenv("COMMERCE_URL", f.server(t).URL)
t.Setenv("COMMERCE_SERVICE_TOKEN", "svc-tok")
app := mountApp(t, "http://iam.invalid", "", "")
code, body := callH(t, app, call.method, call.path, alice, "{}")
if code != http.StatusOK {
t.Fatalf("%s %s: want 200 (the console needs this), got %d (%s)",
call.method, call.path, code, body)
}
if f.path != call.want {
t.Fatalf("%s %s must forward to %q, got %q", call.method, call.path, call.want, f.path)
}
})
}
}
// TestBridge_UnlistedPathsAreRefused covers the rest of the money surface — routes that
// are NOT mint-gated but that the console never calls. The bridge is not a general
// commerce proxy; least privilege means "only what the product needs", so these 404
// even though commerce would have served them to a service token.
func TestBridge_UnlistedPathsAreRefused(t *testing.T) {
unlisted := []struct{ method, path string }{
{http.MethodPost, "/v1/billing/invoices"}, // CreateInvoice (admin group)
{http.MethodPost, "/v1/billing/invoices/i1/pay"}, // PayInvoice
{http.MethodPost, "/v1/billing/invoices/i1/void"}, // VoidInvoice
{http.MethodPost, "/v1/billing/meters"}, // CreateMeter
{http.MethodPost, "/v1/billing/pricing-rules"}, // CreatePricingRule
{http.MethodPost, "/v1/billing/withdraw"}, // money OUT
{http.MethodPost, "/v1/billing/usage"}, // RecordUsage — the meter itself
{http.MethodGet, "/v1/billing/balance/all"}, // every subject's balance
{http.MethodGet, "/v1/billing/sbom"}, // OSS payout surface
{http.MethodGet, "/v1/billing/oss-payout/summary"}, // OSS payout rollup
{http.MethodPost, "/v1/billing/subscriptions"}, // CreateBillingSubscription
}
for _, u := range unlisted {
t.Run(u.method+" "+u.path, func(t *testing.T) {
f := &fakeBilling{}
t.Setenv("COMMERCE_URL", f.server(t).URL)
t.Setenv("COMMERCE_SERVICE_TOKEN", "svc-tok")
app := mountApp(t, "http://iam.invalid", "", "")
code, _ := callH(t, app, u.method, u.path, alice, "{}")
if f.path != "" {
t.Fatalf("%s %s is not a console call and must not reach commerce, but upstream saw %q",
u.method, u.path, f.path)
}
if code != http.StatusNotFound {
t.Fatalf("%s %s: want 404, got %d", u.method, u.path, code)
}
})
}
}
// TestBridge_ReadAllowlistIsNotAWriteAllowlist pins the method split. `payouts` is the
// proof that one method-blind set would be a hole: GET /payouts is a plain read, POST
// /payouts is `mintRequired` (api/billing/handlers.go). The same string must resolve
// differently by method, or reading the settlement view would grant minting a payout.
func TestBridge_ReadAllowlistIsNotAWriteAllowlist(t *testing.T) {
if !isForwardableBilling(http.MethodGet, "payouts") {
t.Fatal("GET payouts is a live console read and must be forwardable")
}
if isForwardableBilling(http.MethodPost, "payouts") {
t.Fatal("POST payouts is mint-gated in commerce and must NEVER be forwardable")
}
// A GET-only entry must not leak into POST, and vice-versa.
if isForwardableBilling(http.MethodPost, "invoices") {
t.Fatal("POST invoices must not inherit the GET entry")
}
if isForwardableBilling(http.MethodGet, "topup/token") {
t.Fatal("GET topup/token must not inherit the POST entry")
}
// An unknown method fails closed (the router mounts GET+POST only; defense in depth).
for _, m := range []string{http.MethodPut, http.MethodPatch, http.MethodDelete, ""} {
if isForwardableBilling(m, "balance") {
t.Fatalf("method %q must fail closed", m)
}
}
// `{}` matches exactly ONE segment — it can never swallow a path into a mint route.
if isForwardableBilling(http.MethodPost, "subscriptions/a/b/cancel") {
t.Fatal("{} must match exactly one segment")
}
}
// TestBridge_StoreBridgeCannotReachBilling is the sibling lock. /v1/commerce/* carries the
// SAME admin token with FULL CRUD, and its own allowlist (commerceStoreHeads) is what keeps
// it a store proxy. Prove it cannot tunnel into the money surface — a store head that
// resolved to `billing` would reopen this hole from the other bridge.
func TestBridge_StoreBridgeCannotReachBilling(t *testing.T) {
for _, p := range []string{
"/v1/commerce/billing/deposit",
"/v1/commerce/billing",
"/v1/commerce/checkout",
"/v1/commerce/_/commerce/tenants",
} {
f := &fakeBilling{}
t.Setenv("COMMERCE_URL", f.server(t).URL)
t.Setenv("COMMERCE_SERVICE_TOKEN", "svc-tok")
app := mountApp(t, "http://iam.invalid", "", "")
code, _ := callH(t, app, http.MethodPost, p, alice, `{"amount":100000000}`)
if f.path != "" {
t.Fatalf("store bridge %q must never reach commerce, but upstream saw %q", p, f.path)
}
if code != http.StatusNotFound {
t.Fatalf("store bridge %q: want 404, got %d", p, code)
}
}
}
// TestBridge_MintIsRefusedEvenWithForgedSubject proves the refusal does not depend
// on the subject-pinning. Pinning is an IDOR control, not an authority control: it
// makes the mint land on the CALLER's own account, which is the attack, not a
// defense. The path gate must refuse before any of that logic runs.
func TestBridge_MintIsRefusedEvenWithForgedSubject(t *testing.T) {
f := &fakeBilling{}
t.Setenv("COMMERCE_URL", f.server(t).URL)
t.Setenv("COMMERCE_SERVICE_TOKEN", "svc-tok")
app := mountApp(t, "http://iam.invalid", "", "")
code, _ := callH(t, app, http.MethodPost, "/v1/billing/deposit", alice,
`{"user":"victim","userId":"victim","amount":100000000}`)
if f.path != "" {
t.Fatalf("ESCALATION: deposit reached commerce at %q with the service token", f.path)
}
if code != http.StatusNotFound {
t.Fatalf("forged-subject deposit: want 404, got %d", code)
}
}
+7 -1
View File
@@ -108,7 +108,13 @@ func routes(app *zip.App, s *cloud.Service[core.State]) {
app.Get("/v1/admin/bases", core.GuardScoped(s, bases))
// ── Platform control plane — SuperAdmin ONLY (launch/release/flags + access). ──
app.Get("/v1/admin/flags", core.Guard(s, flags))
app.Get("/v1/admin/flags", core.Guard(s, flagsBoard))
app.Put("/v1/admin/flags/:key", core.Guard(s, setFlag))
// Launch-control services board — the waitlist-mode lens on the flag engine (twin
// of /v1/admin/flags), folded in from the former featuregate control plane.
app.Get("/v1/admin/services", core.Guard(s, services))
app.Post("/v1/admin/services", core.Guard(s, upsertService))
app.Post("/v1/admin/services/:service/mode", core.Guard(s, setServiceMode))
app.Get("/v1/admin/waitlist", core.Guard(s, waitlist))
app.Post("/v1/admin/waitlist/boost", core.Guard(s, waitlistBoost))
+28 -12
View File
@@ -18,9 +18,8 @@ import (
"github.com/hanzoai/cloud/audit"
"github.com/hanzoai/cloud/clients/admin/money"
"github.com/hanzoai/cloud/clients/finance"
finmoney "github.com/hanzoai/cloud/clients/money"
"github.com/hanzoai/cloud/clients/principal"
"github.com/hanzoai/cloud/types"
"github.com/hanzoai/commerce/billing/creditledger"
"github.com/zap-proto/zip"
)
@@ -183,22 +182,39 @@ func ApplyGrant(s *cloud.Service[State], c *zip.Ctx, org string, req CreditReque
// one shape regardless of which path moved the money.
func grantDeposit(s *cloud.Service[State], c *zip.Ctx, org, currency, notes, tag, source string, amountCents int64) (before int64, txID string, after int64, afterExact string, err error) {
ctx := c.Context()
if fin := finance.Current(); fin != nil {
if bal, berr := fin.Balance(ctx, org, org, currency, false); berr == nil {
before = bal.Cents()
// ONE credit path: prefer the in-proc commerce credit ledger (creditledger) — the
// SAME injected ledger adapter commerce's POST /v1/billing/credit mints through
// and the ai prepaid gate reads. An admin grant and a self-serve credit thus move
// money the ONE way, into the ONE ledger; the admin path no longer carries its own
// parallel finance.Deposit. The operator-nonce idempotency key rides through so a
// retried grant dedupes (finance dedups on Ref). Before/after balances are read from
// the SAME co-resident finance ledger for the audit trail (exact, sub-cent visible).
if led := creditledger.Get(); led != nil {
if fin := finance.Current(); fin != nil {
if bal, berr := fin.Balance(ctx, org, org, currency, false); berr == nil {
before = bal.Cents()
}
}
id, derr := fin.Deposit(ctx, types.DepositInput{
Org: org, Subject: org, Amount: finmoney.FromCents(amountCents), Currency: currency, Notes: notes, Tags: tag,
id, balCents, cerr := led.Credit(ctx, creditledger.CreditInput{
Org: org,
Currency: currency,
Reason: notes,
Tag: tag,
IdempotencyKey: grantIdempotencyKey(c, org, currency, source, amountCents),
AmountCents: amountCents,
})
if derr != nil {
return before, "", before, "", derr
if cerr != nil {
return before, "", before, "", cerr
}
if bal, berr := fin.Balance(ctx, org, org, currency, false); berr == nil {
after, afterExact = bal.Cents(), bal.IntString() // afterExact = the EXACT balance (sub-cent visible)
after = balCents
if fin := finance.Current(); fin != nil {
if bal, berr := fin.Balance(ctx, org, org, currency, false); berr == nil {
afterExact = bal.AttoString() // afterExact = the EXACT balance (sub-cent visible)
}
}
return before, id, after, afterExact, nil
}
// Split deploy: no co-resident finance ledger → the commerce billing HTTP deposit, with
// Split deploy: no co-resident credit ledger → the commerce billing HTTP deposit, with
// its operator-nonce idempotency key so a retried grant dedupes at commerce.
beforeC, _ := s.State.Commerce.Credits(ctx, org)
idem := grantIdempotencyKey(c, org, currency, source, amountCents)
+36 -14
View File
@@ -2,26 +2,48 @@ package admin
// The PLATFORM CONTROL PLANE board (/v1/admin/flags) — every runtime LAUNCH / RELEASE
// switch (waitlist, public signup, subsystem activation, gateway limits, network ids)
// with its LIVE value, evaluated through the Hanzo Insights feature-flag engine
// (clients/featureflags → insights rust/feature-flags). SuperAdmin only (mounted
// behind s.guard, like every /v1/admin/* route).
// with its LIVE value, evaluated through the embedded native flag engine
// (clients/flags → native/flags, SQLite-per-project definitions + Rust FFI
// evaluation). SuperAdmin only (mounted behind core.Guard, like every /v1/admin/*).
//
// ONE flag engine, not two. Insights OWNS the flag definitions, targeting, percentage
// rollout, and the change/activity log. This endpoint READS the switches for the
// cockpit and hands the operator the deep-links to the Insights flag MANAGER (where a
// switch is toggled / rolled out / cohort-targeted) and its ACTIVITY LOG (the native
// change audit). A flip there is hot — the consuming subsystems re-read within one
// evaluation TTL, no redeploy. The board is read-only here on purpose: management is
// the native Insights UI (the one-and-one-way flag surface), surfaced in the cockpit.
// ONE flag engine, TWO verbs. GET reads the board; PUT writes a switch's definition
// through flags.SetPlatformSwitch — the ONE write path, audited in the store's
// activity log. A flip is hot: this pod applies immediately, peers converge within one
// evaluation TTL (default 15s), no redeploy. Org/project product flags are managed on
// /v1/flags (org-scoped); this surface is the platform's own switchboard.
import (
"encoding/json"
"strings"
"github.com/hanzoai/cloud"
"github.com/hanzoai/cloud/clients/admin/core"
"github.com/hanzoai/cloud/clients/featureflags"
"github.com/hanzoai/cloud/clients/flags"
"github.com/zap-proto/zip"
)
// flags answers GET /v1/admin/flags — the platform control-plane read board.
func flags(s *cloud.Service[core.State], c *zip.Ctx) error {
return core.OK(c, featureflags.Board())
// flagsBoard answers GET /v1/admin/flags — the platform control-plane read board.
func flagsBoard(s *cloud.Service[core.State], c *zip.Ctx) error {
return core.OK(c, flags.Board())
}
// setFlag answers PUT /v1/admin/flags/:key — store/overwrite one platform switch's
// definition. The body is the flag definition JSON; the two common shapes:
//
// {"active": true} — boolean switch on/off
// {"active": true, "filters": {"groups": [{"properties": [], "rollout_percentage": 100}],
// "payloads": {"true": 250}}} — valued switch (int/string payload)
func setFlag(s *cloud.Service[core.State], c *zip.Ctx) error {
key := strings.TrimSpace(c.Param("key"))
if key == "" {
return zip.ErrBadRequest("key is required")
}
body := c.Body()
if len(body) == 0 || !json.Valid(body) {
return zip.ErrBadRequest("body must be the flag definition JSON")
}
if err := flags.SetPlatformSwitch(key, json.RawMessage(body), c.UserEmail()); err != nil {
return zip.ErrBadRequest(err.Error())
}
return core.OK(c, flags.Board())
}
+73
View File
@@ -0,0 +1,73 @@
package admin
// The /v1/admin/services board — the launch-control LENS on the ONE flag engine, twin
// of /v1/admin/flags. Every hosted service (studio/chat/console/app/api/team + runtime
// onboards) with its LIVE waitlist mode — the switch waitlist.<svc> evaluated through
// clients/flags. This is the "remove the waitlist one service at a time" toggle.
// SuperAdmin only (core.Guard), like every platform /v1/admin/*.
//
// Formerly clients/featuregate owned its OWN SQLite mode store + this control plane;
// both folded onto the flag engine so the platform has ONE decision plane. featuregate
// now owns only the native Enforce middleware — a consumer of flags.WaitlistModeForHost.
// Per-user approval (the second, orthogonal axis) stays IAM's, reached via the existing
// admin IAM proxy — not re-served here.
import (
"errors"
"net/http"
"strings"
"github.com/hanzoai/cloud"
"github.com/hanzoai/cloud/clients/admin/core"
"github.com/hanzoai/cloud/clients/flags"
"github.com/zap-proto/zip"
)
// services answers GET /v1/admin/services — the launch board (every service + live mode).
func services(s *cloud.Service[core.State], c *zip.Ctx) error {
rows, err := flags.ListWaitlistServices(c.Context())
if err != nil {
return zip.Errorf(http.StatusInternalServerError, "list services: %v", err)
}
return core.OK(c, map[string]any{"services": rows})
}
// upsertService answers POST /v1/admin/services — onboard or edit a hosted service so a
// new host is governed WITHOUT a redeploy. A re-register PRESERVES the live switch.
func upsertService(s *cloud.Service[core.State], c *zip.Ctx) error {
var in flags.ServiceInput
if err := c.Bind(&in); err != nil {
return err
}
if strings.TrimSpace(in.Service) == "" {
return zip.ErrBadRequest("service slug is required")
}
view, err := flags.UpsertWaitlistService(c.Context(), in, c.UserEmail())
if err != nil {
return zip.ErrBadRequest(err.Error())
}
return core.OK(c, map[string]any{"service": view})
}
// setServiceMode answers POST /v1/admin/services/:service/mode — flip one service's
// waitlist switch {waitlistMode:bool}. The launch lever; hot, no redeploy.
func setServiceMode(s *cloud.Service[core.State], c *zip.Ctx) error {
service := strings.TrimSpace(c.Param("service"))
if service == "" {
return zip.ErrBadRequest("service is required")
}
var body struct {
WaitlistMode bool `json:"waitlistMode"`
}
if err := c.Bind(&body); err != nil {
return err
}
view, err := flags.SetWaitlistMode(c.Context(), service, body.WaitlistMode, c.UserEmail())
if err != nil {
if errors.Is(err, flags.ErrServiceNotFound) {
return zip.ErrNotFound("service not found: " + service)
}
return zip.Errorf(http.StatusInternalServerError, "set mode: %v", err)
}
return core.OK(c, map[string]any{"service": view})
}
+141
View File
@@ -0,0 +1,141 @@
// Package agent mounts the hanzoai/agent orchestrator into cloud: POST /v1/agent
// (+ /v1/agent/presets, /v1/agent/conversations). The orchestrator logic and its
// per-org conversation history live in github.com/hanzoai/agent, which imports
// NEITHER cloud NOR ai. Cloud is the composition root: it injects the two seams —
// - Completer: the ai subsystem's /v1/chat/completions, replayed in-process (the
// one path that returns tool_calls AND carries per-org reserve/settle billing);
// - ToolPlane: the unified tool registry (tools.Default()), so /v1/agent's
// server-executed tools are the org's activated MCP/registry tools.
// /v1/agent is a DISTINCT path (not /v1/chat, which ai owns as completions), so a
// specific route wins over ai's /v1/* glob — no collision.
package agent
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/httptest"
hz "github.com/hanzoai/agent"
"github.com/hanzoai/cloud"
"github.com/hanzoai/cloud/clients/tools"
openai "github.com/sashabaranov/go-openai"
fiber "github.com/zap-proto/fiber/v3"
"github.com/zap-proto/zip"
)
// maxCompletionResponse bounds the in-process completion body read so a hostile or
// broken upstream cannot balloon memory.
const maxCompletionResponse = 8 << 20
// Mount wires POST /v1/agent (+ reads) into cloud, injecting the ai completion and
// the tool plane. The caller identity comes from cloud's validated principal.
func Mount(app *zip.App, deps cloud.Deps) error {
if app == nil {
return fmt.Errorf("agent.Mount: nil zip.App")
}
_, err := hz.Mount(app, hz.Deps{
Logger: deps.Logger,
DataDir: deps.DataDir,
Brand: deps.Brand,
Model: deps.AIDefaultModel,
Principal: func(c *zip.Ctx) (hz.Principal, bool) {
p, ok := tools.PrincipalFrom(c)
if !ok {
return hz.Principal{}, false
}
return hz.Principal{Org: p.Org, Project: p.Project, User: p.User, Cred: credential(c)}, true
},
}, aiCompleter{app: app}, toolPlane{})
return err
}
// ── Completer: replay /v1/chat/completions in-process ─────────────────────────────
type aiCompleter struct{ app *zip.App }
// Complete replays the request against the SAME app at /v1/chat/completions, so it
// flows the whole middleware chain (per-org reserve/settle billing) and returns
// tool_calls. Non-streaming. Mirrors the tool plane's in-process dispatch contract:
// the caller's OWN credential headers are replayed; no minted authority header.
func (a aiCompleter) Complete(ctx context.Context, cred map[string]string, req openai.ChatCompletionRequest) (openai.ChatCompletionResponse, error) {
req.Stream = false
b, err := json.Marshal(req)
if err != nil {
return openai.ChatCompletionResponse{}, err
}
hreq := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", bytes.NewReader(b)).WithContext(ctx)
hreq.Header.Set("Content-Type", "application/json")
for k, v := range cred {
hreq.Header.Set(k, v)
}
resp, err := a.app.Fiber().Test(hreq, fiber.TestConfig{Timeout: 0})
if err != nil {
return openai.ChatCompletionResponse{}, err
}
defer func() { _ = resp.Body.Close() }()
raw, _ := io.ReadAll(io.LimitReader(resp.Body, maxCompletionResponse))
if resp.StatusCode/100 != 2 {
// Carry the completion's OWN status + body so the round can pass a
// caller-facing refusal (402 insufficient_balance, 429, 403) straight
// through instead of masking it as a gateway 502. hz.UpstreamError is the
// agent's typed seam for exactly this.
return openai.ChatCompletionResponse{}, &hz.UpstreamError{Status: resp.StatusCode, Body: raw}
}
var out openai.ChatCompletionResponse
if err := json.Unmarshal(raw, &out); err != nil {
return openai.ChatCompletionResponse{}, fmt.Errorf("decode completion: %w", err)
}
return out, nil
}
// ── ToolPlane: adapter over the unified registry ──────────────────────────────────
type toolPlane struct{}
func (toolPlane) List(ctx context.Context, scope hz.Scope) []hz.Tool {
ts := tools.Default().List(ctx, tools.Scope{Org: scope.Org, Project: scope.Project})
out := make([]hz.Tool, 0, len(ts))
for _, t := range ts {
out = append(out, hz.Tool{
Name: t.Name,
Description: t.Description,
Schema: t.Schema,
Activated: t.Activated,
Dispatchable: t.Dispatchable,
})
}
return out
}
func (toolPlane) Exists(ctx context.Context, scope hz.Scope, name string) bool {
return tools.Default().Exists(ctx, tools.Scope{Org: scope.Org, Project: scope.Project}, name)
}
// Dispatch resolves the caller the ONE canonical way — tools.PrincipalFrom(c) — so
// the tool runs under the SAME validated identity + credential as a direct call.
// No reconstruction: the credential is only ever read from the live request.
func (toolPlane) Dispatch(c *zip.Ctx, name string, args map[string]any) (any, error) {
p, ok := tools.PrincipalFrom(c)
if !ok {
return nil, zip.ErrForbidden("a validated principal is required")
}
return tools.Default().Dispatch(c.Context(), p, name, args)
}
// ── helpers ───────────────────────────────────────────────────────────────────────
// credential extracts the caller's replayable credential headers (the same set the
// tool plane replays) so the in-process completion runs as the caller.
func credential(c *zip.Ctx) map[string]string {
cred := map[string]string{}
for _, h := range []string{"Authorization", "X-Authorization", "Cookie", "Accept-Language", "X-Forwarded-For"} {
if v := c.Header(h); v != "" {
cred[h] = v
}
}
return cred
}
+15 -9
View File
@@ -278,19 +278,18 @@ func Mount(app *zip.App, deps cloud.Deps) error {
app.Get("/v1/agents", cloud.Handle(s, list))
app.Post("/v1/agents", cloud.Handle(s, create))
// Static org-wide surfaces MUST register before the :ref wildcard: Fiber
// matches routes in registration order, so a bare `/v1/agents/:ref` would
// otherwise capture "metrics"/"activity"/"sessions" as a ref and 404 them
// (Red route audit). Registering the literals first makes them win.
// The static org-wide surfaces are listed before the :ref wildcard for reading
// order, not for matching: the router resolves by SPECIFICITY, so a literal
// beats a param whatever order they register in ("metrics" is never captured as
// a ref). Registration order decides nothing here — it only decides which
// handler silently wins when two patterns are byte-identical, which is a
// collision, not a precedence.
app.Get("/v1/agents/metrics", cloud.Handle(s, metrics))
app.Get("/v1/agents/activity", cloud.Handle(s, activity))
// Live agent-session control plane: /v1/agents/sessions[/...]. Registered
// before :name for the same registration-order reason (and internally the
// static /stream precedes /:id).
// Live agent-session control plane: /v1/agents/sessions[/...].
mountSessions(s, app)
// Agent targets: /v1/agents/targets[/...] — the #48 dispatch destinations a
// session runs on. Registered before :ref for the same registration-order reason
// (and internally /targets precedes /targets/:id).
// session runs on.
mountTargets(s, app)
app.Get("/v1/agents/:ref", cloud.Handle(s, get))
app.Patch("/v1/agents/:ref", cloud.Handle(s, update))
@@ -992,6 +991,13 @@ func billingActor(org, sub string) string {
return org
}
// BillingActor is the exported form of the actor identity a session is recorded
// under. The login-manager adapter (the only external caller) uses it to scope a
// session stop/count to the REVOKING user's own actor, so a revoke can never reach a
// co-tenant's sessions. It mirrors what sessions.go stamps on Session.Actor, so a
// stop's actor predicate matches exactly the sessions that user created.
func BillingActor(org, sub string) string { return billingActor(org, sub) }
func cleanList(xs []string) []string {
seen := map[string]bool{}
var out []string
+21 -4
View File
@@ -61,6 +61,8 @@ const (
maxHost = 256
maxCwd = 1024
maxRepo = 512
maxProvider = 64
maxAccount = 256
)
func validKind(k string) bool {
@@ -85,10 +87,12 @@ type sessionView struct {
TaskRunID string `json:"taskRunId,omitempty"`
// Execution context (mission-control): the machine/repo/cwd a card shows and
// the run-target a session is dispatched to. Omitted when a surface didn't report it.
Host string `json:"host,omitempty"`
Cwd string `json:"cwd,omitempty"`
Repo string `json:"repo,omitempty"`
Target string `json:"target,omitempty"`
Host string `json:"host,omitempty"`
Cwd string `json:"cwd,omitempty"`
Repo string `json:"repo,omitempty"`
Target string `json:"target,omitempty"`
Provider string `json:"provider,omitempty"`
Account string `json:"account,omitempty"`
Events int `json:"events"`
Children int `json:"children"`
@@ -155,6 +159,7 @@ func toSessionView(x Session, events, children int) sessionView {
ParentSessionID: x.ParentID, RootSessionID: x.RootID, Title: x.Title,
TaskWorkflowID: x.TaskWorkflowID, TaskRunID: x.TaskRunID,
Host: x.Host, Cwd: x.Cwd, Repo: x.Repo, Target: x.Target,
Provider: x.Provider, Account: x.Account,
Events: events, Children: children,
StartedAt: rfc3339(x.StartedAt), EndedAt: rfc3339(x.EndedAt),
CreatedAt: rfc3339(x.CreatedAt), UpdatedAt: rfc3339(x.UpdatedAt),
@@ -207,6 +212,9 @@ type registerReq struct {
Cwd string `json:"cwd"`
Repo string `json:"repo"`
Target string `json:"target"`
// Account tag — the linked AI account this session ran under (login manager).
Provider string `json:"provider"`
Account string `json:"account"`
}
func registerSession(s *cloud.Service[state], c *zip.Ctx) error {
@@ -249,6 +257,14 @@ func registerSession(s *cloud.Service[state], c *zip.Ctx) error {
if cerr != nil {
return cerr
}
provider := strings.TrimSpace(body.Provider)
account := strings.TrimSpace(body.Account)
if len(provider) > maxProvider {
return zip.ErrBadRequest("provider too long")
}
if len(account) > maxAccount {
return zip.ErrBadRequest("account too long")
}
id, err := genID("sess")
if err != nil {
@@ -261,6 +277,7 @@ func registerSession(s *cloud.Service[state], c *zip.Ctx) error {
TaskWorkflowID: strings.TrimSpace(body.TaskWorkflowID),
TaskRunID: strings.TrimSpace(body.TaskRunID),
Host: host, Cwd: cwd, Repo: repo, Target: target,
Provider: provider, Account: account,
StartedAt: now, CreatedAt: now, UpdatedAt: now,
}
if isTerminalStatus(status) {
+166
View File
@@ -0,0 +1,166 @@
package agents
import (
"context"
"encoding/json"
"fmt"
"strings"
"time"
)
// sessions_stop.go is the login-manager tie-in: the in-process action a link
// revoke takes to tear down the live sessions that ran under a revoked account or
// device, plus the active-session count the device view shows. Both are org-scoped
// (org is the ONLY tenant key) and nil-safe (no agents mounted → 0), and neither
// can fan out to another tenant or to an org's every session by accident.
// SessionMatch selects live (running|paused) sessions to stop or count. Actor (the
// owning subject, org/user) is MANDATORY and always ANDed, so a match can only ever
// affect the caller's OWN sessions — never a co-tenant's. Host/Provider/Account are
// optional narrowing WITHIN the actor's sessions (empty = any of the actor's). A
// match with no actor selects NOTHING (fail-closed), so a login-out can never sweep
// another user's — or an org's every — session, even when Host/Provider/Account are
// attacker-set at link upsert.
type SessionMatch struct {
Actor string
Host string
Provider string
Account string
}
// empty reports whether the match lacks its mandatory actor scope. Without an actor
// the match selects nothing — the fail-closed direction (an under-stop, never a
// cross-user over-stop).
func (m SessionMatch) empty() bool {
return strings.TrimSpace(m.Actor) == ""
}
// where builds the ANDed predicate + args for a live-session match under org. Actor
// is always in the base predicate (the guard rejects an empty actor before this runs),
// so a stop/count is bounded to the caller's own sessions before any optional narrowing.
func (m SessionMatch) where(org string) (string, []any) {
where := "org=? AND actor=? AND status IN (?,?)"
args := []any{org, strings.TrimSpace(m.Actor), StatusRunning, StatusPaused}
if h := strings.TrimSpace(m.Host); h != "" {
where += " AND host=?"
args = append(args, h)
}
if p := strings.TrimSpace(m.Provider); p != "" {
where += " AND provider=?"
args = append(args, p)
}
if a := strings.TrimSpace(m.Account); a != "" {
where += " AND account=?"
args = append(args, a)
}
return where, args
}
// listActiveMatch returns org's live sessions matching m, oldest first.
func (s *Store) listActiveMatch(ctx context.Context, org string, m SessionMatch) ([]Session, error) {
where, args := m.where(org)
rows, err := s.db.QueryContext(ctx,
`SELECT `+sessionCols+` FROM agent_sessions WHERE `+where+` ORDER BY created_at ASC`, args...)
if err != nil {
return nil, fmt.Errorf("list active match: %w", err)
}
defer func() { _ = rows.Close() }()
var out []Session
for rows.Next() {
x, err := scanSession(rows)
if err != nil {
return nil, fmt.Errorf("scan session: %w", err)
}
out = append(out, x)
}
return out, rows.Err()
}
// countActiveMatch counts org's live sessions matching m.
func (s *Store) countActiveMatch(ctx context.Context, org string, m SessionMatch) (int, error) {
where, args := m.where(org)
var n int
if err := s.db.QueryRowContext(ctx,
`SELECT COUNT(*) FROM agent_sessions WHERE `+where, args...).Scan(&n); err != nil {
return 0, fmt.Errorf("count active match: %w", err)
}
return n, nil
}
// StopSessions closes every RUNNING|PAUSED session of org matching m — recording a
// control "stop" event on each and transitioning it to a terminal state — and
// returns how many it stopped. It is the action a login-out (link revoke) takes so
// the sessions that ran under a revoked account/device are torn down. Org AND Actor
// scope it: the caller passes their own actor (org/user), so a revoke can only ever
// stop the caller's OWN sessions — never a co-tenant's, never an org's every session
// — even though m's Host/Provider/Account come from an attacker-controllable link
// row. A match with no actor stops nothing (fail-closed). Not-mounted → (0, nil), so
// a revoke tolerates a deployment with no session plane.
func StopSessions(ctx context.Context, org string, m SessionMatch) (int, error) {
if mounted == nil {
return 0, nil
}
org = strings.TrimSpace(org)
if org == "" || m.empty() {
return 0, nil
}
live, err := mounted.State.store.listActiveMatch(ctx, org, m)
if err != nil {
return 0, err
}
stopped := 0
for _, x := range live {
if err := stopOne(ctx, x); err != nil {
// Best-effort per session: a failure on one does not abort the rest, so a
// revoke tears down as many as it can and reports the true count.
mounted.Log.Warn("agents: stop session", "org", org, "session", x.ID, "err", err)
continue
}
stopped++
}
return stopped, nil
}
// stopOne records a stop control event on a live session and moves it to a
// terminal (error) state — the forced-teardown transition. A session already
// terminal is skipped (monotonic terminal rule).
func stopOne(ctx context.Context, x Session) error {
if isTerminalStatus(x.Status) {
return nil
}
now := time.Now().Unix()
if evID, err := genID("evt"); err == nil {
payload, _ := json.Marshal(controlPayload{Command: CmdStop, Message: "account logged out via login manager"})
e, aerr := mounted.State.store.AppendEvent(ctx, Event{
ID: evID, SessionID: x.ID, Org: x.Org, Kind: KindControl,
Actor: billingActor(x.Org, ""), Payload: string(payload), CreatedAt: now,
})
if aerr == nil {
publishEvent(mounted, x.Org, x.RootID, e)
}
}
x.Status = StatusError
x.EndedAt = now
x.UpdatedAt = now
if err := mounted.State.store.UpdateSession(ctx, x); err != nil {
return err
}
ev, _ := mounted.State.store.CountEvents(ctx, x.Org, x.ID)
ch, _ := mounted.State.store.CountChildren(ctx, x.Org, x.ID)
publishSession(mounted, x, ev, ch)
return nil
}
// CountActiveSessions returns how many of org's sessions matching m are live
// (running|paused) — the device view's "active sessions". Org-scoped; 0 when not
// mounted or the match is empty.
func CountActiveSessions(ctx context.Context, org string, m SessionMatch) (int, error) {
if mounted == nil {
return 0, nil
}
org = strings.TrimSpace(org)
if org == "" || m.empty() {
return 0, nil
}
return mounted.State.store.countActiveMatch(ctx, org, m)
}
+146
View File
@@ -0,0 +1,146 @@
package agents
import (
"context"
"testing"
)
// mkLive inserts a running session under a given actor/host/provider/account so the
// stop-scope tests can craft the exact overlap a hostile revoke would try to exploit.
func mkLive(t *testing.T, s *Store, id, org, actor, host, provider, account string) {
t.Helper()
if err := s.CreateSession(context.Background(), Session{
ID: id, Org: org, Agent: "hanzo", Actor: actor, Status: StatusRunning,
RootID: id, Title: "t", StartedAt: 1, CreatedAt: 1, UpdatedAt: 1,
Host: host, Provider: provider, Account: account,
}); err != nil {
t.Fatalf("create %s: %v", id, err)
}
}
func statusOf(t *testing.T, s *Store, org, id string) string {
t.Helper()
x, err := s.GetSession(context.Background(), org, id)
if err != nil {
t.Fatalf("get %s: %v", id, err)
}
return string(x.Status)
}
// TestStopSessions_ActorScoped is the HIGH-1 regression. A login-out stop is bounded
// to the REVOKING user's own actor, so no org member can terminate another member's
// live sessions — even though the match's Host/Provider/Account come from a link row
// the caller fully controls (attacker-set at upsert). It also proves the fail-closed
// direction: a match with no actor stops NOTHING (an under-stop, never a cross-user
// over-stop).
func TestStopSessions_ActorScoped(t *testing.T) {
ctx := context.Background()
t.Run("provider wildcard stops only the caller's own sessions", func(t *testing.T) {
mountInproc(t)
st := mounted.State.store
// One org, three users; Alice + Bob overlap on host AND provider so an
// org-only match (the pre-fix behavior) would sweep every claude session.
mkLive(t, st, "alice", "acme", "acme/alice", "box1", "claude", "alice@x")
mkLive(t, st, "bob", "acme", "acme/bob", "box1", "claude", "bob@x")
mkLive(t, st, "carol", "acme", "acme/carol", "box2", "openai", "carol@x")
n, err := StopSessions(ctx, "acme", SessionMatch{Actor: "acme/alice", Provider: "claude"})
if err != nil {
t.Fatalf("stop: %v", err)
}
if n != 1 {
t.Fatalf("wildcard-claude stop must hit only Alice's 1 session, got %d", n)
}
if s := statusOf(t, st, "acme", "alice"); s != string(StatusError) {
t.Fatalf("Alice's own session must be stopped, got %q", s)
}
if s := statusOf(t, st, "acme", "bob"); s != string(StatusRunning) {
t.Fatalf("co-tenant Bob must survive, got %q", s)
}
if s := statusOf(t, st, "acme", "carol"); s != string(StatusRunning) {
t.Fatalf("co-tenant Carol must survive, got %q", s)
}
})
t.Run("host forge cannot reach a co-tenant on the same device", func(t *testing.T) {
mountInproc(t)
st := mounted.State.store
// Alice and Bob both have a live session on the SAME host. Alice forges her
// link Host to that shared box and revokes it.
mkLive(t, st, "alice", "acme", "acme/alice", "shared", "claude", "alice@x")
mkLive(t, st, "bob", "acme", "acme/bob", "shared", "claude", "bob@x")
n, err := StopSessions(ctx, "acme", SessionMatch{Actor: "acme/alice", Host: "shared"})
if err != nil {
t.Fatalf("stop: %v", err)
}
if n != 1 {
t.Fatalf("host-forge stop must hit only Alice's session on the host, got %d", n)
}
if s := statusOf(t, st, "acme", "bob"); s != string(StatusRunning) {
t.Fatalf("co-tenant Bob on the same host must survive, got %q", s)
}
})
t.Run("a match with no actor fails closed (stops nothing)", func(t *testing.T) {
mountInproc(t)
st := mounted.State.store
mkLive(t, st, "alice", "acme", "acme/alice", "box1", "claude", "alice@x")
mkLive(t, st, "bob", "acme", "acme/bob", "box1", "claude", "bob@x")
// An org+provider match that lost its caller identity must NOT sweep the org.
n, err := StopSessions(ctx, "acme", SessionMatch{Provider: "claude", Host: "box1"})
if err != nil {
t.Fatalf("stop: %v", err)
}
if n != 0 {
t.Fatalf("no-actor match must stop nothing (fail-closed), got %d", n)
}
if s := statusOf(t, st, "acme", "alice"); s != string(StatusRunning) {
t.Fatalf("no session may be stopped without an actor, Alice got %q", s)
}
if s := statusOf(t, st, "acme", "bob"); s != string(StatusRunning) {
t.Fatalf("no session may be stopped without an actor, Bob got %q", s)
}
})
t.Run("count is actor scoped too", func(t *testing.T) {
mountInproc(t)
st := mounted.State.store
mkLive(t, st, "alice", "acme", "acme/alice", "box1", "claude", "alice@x")
mkLive(t, st, "bob", "acme", "acme/bob", "box1", "claude", "bob@x")
// Alice counting her device's active sessions sees only HER own, not Bob's.
n, err := CountActiveSessions(ctx, "acme", SessionMatch{Actor: "acme/alice", Host: "box1"})
if err != nil {
t.Fatalf("count: %v", err)
}
if n != 1 {
t.Fatalf("count must be scoped to Alice's own sessions on the host, got %d", n)
}
// A count with no actor is 0, never the whole host.
if n, _ := CountActiveSessions(ctx, "acme", SessionMatch{Host: "box1"}); n != 0 {
t.Fatalf("no-actor count must be 0 (fail-closed), got %d", n)
}
})
t.Run("caller still stops their OWN matching session", func(t *testing.T) {
mountInproc(t)
st := mounted.State.store
// The fix must not over-restrict: Alice logging out her claude account DOES
// tear down her matching session (the intended teardown).
mkLive(t, st, "alice", "acme", "acme/alice", "box1", "claude", "alice@x")
n, err := StopSessions(ctx, "acme", SessionMatch{Actor: "acme/alice", Provider: "claude", Account: "alice@x"})
if err != nil {
t.Fatalf("stop: %v", err)
}
if n != 1 {
t.Fatalf("Alice's own-account logout must stop her session, got %d", n)
}
if s := statusOf(t, st, "acme", "alice"); s != string(StatusError) {
t.Fatalf("Alice's session must be terminal after her own logout, got %q", s)
}
})
}
+24 -10
View File
@@ -52,6 +52,14 @@ type Session struct {
Cwd string
Repo string
Target string
// Provider/Account tag a session with the linked AI account it ran under (the
// login-manager tie-in): which provider (claude|codex|hanzo|…) and which
// subscription/api account served this run. Optional (a surface that doesn't
// know sets ""), surfaced so the cockpit shows "this ran on your Claude Max
// acct" and so a login-out (link revoke) can stop the sessions that used it.
Provider string
Account string
}
// Event is one entry in a session's ordered log: a model message, a tool call, a
@@ -116,7 +124,9 @@ CREATE TABLE IF NOT EXISTS agent_sessions (
host TEXT NOT NULL DEFAULT '',
cwd TEXT NOT NULL DEFAULT '',
repo TEXT NOT NULL DEFAULT '',
target TEXT NOT NULL DEFAULT ''
target TEXT NOT NULL DEFAULT '',
provider TEXT NOT NULL DEFAULT '',
account TEXT NOT NULL DEFAULT ''
);
CREATE INDEX IF NOT EXISTS ix_sessions_org_root ON agent_sessions(org, root_id, created_at);
CREATE INDEX IF NOT EXISTS ix_sessions_org_parent ON agent_sessions(org, parent_id, created_at);
@@ -141,10 +151,12 @@ CREATE INDEX IF NOT EXISTS ix_events_org_session_seq ON agent_session_events(org
// Forward, idempotent: a sessions table created before the execution-context
// columns existed gains them here (the CREATE above only runs on a fresh DB).
if err := s.addColumns("agent_sessions", map[string]string{
"host": "TEXT NOT NULL DEFAULT ''",
"cwd": "TEXT NOT NULL DEFAULT ''",
"repo": "TEXT NOT NULL DEFAULT ''",
"target": "TEXT NOT NULL DEFAULT ''",
"host": "TEXT NOT NULL DEFAULT ''",
"cwd": "TEXT NOT NULL DEFAULT ''",
"repo": "TEXT NOT NULL DEFAULT ''",
"target": "TEXT NOT NULL DEFAULT ''",
"provider": "TEXT NOT NULL DEFAULT ''",
"account": "TEXT NOT NULL DEFAULT ''",
}); err != nil {
return err
}
@@ -154,19 +166,21 @@ CREATE INDEX IF NOT EXISTS ix_events_org_session_seq ON agent_session_events(org
// column and fail on the old schema ("no such column: target").
if _, err := s.db.Exec(`
CREATE INDEX IF NOT EXISTS ix_sessions_org_target ON agent_sessions(org, target);
CREATE INDEX IF NOT EXISTS ix_sessions_org_host ON agent_sessions(org, host);`); err != nil {
CREATE INDEX IF NOT EXISTS ix_sessions_org_host ON agent_sessions(org, host);
CREATE INDEX IF NOT EXISTS ix_sessions_org_account ON agent_sessions(org, provider, account);`); err != nil {
return fmt.Errorf("migrate sessions indexes: %w", err)
}
return nil
}
const sessionCols = `id,org,agent,actor,status,parent_id,root_id,title,started_at,ended_at,created_at,updated_at,task_workflow_id,task_run_id,host,cwd,repo,target`
const sessionCols = `id,org,agent,actor,status,parent_id,root_id,title,started_at,ended_at,created_at,updated_at,task_workflow_id,task_run_id,host,cwd,repo,target,provider,account`
func scanSession(sc interface{ Scan(...any) error }) (Session, error) {
var x Session
err := sc.Scan(&x.ID, &x.Org, &x.Agent, &x.Actor, &x.Status, &x.ParentID, &x.RootID,
&x.Title, &x.StartedAt, &x.EndedAt, &x.CreatedAt, &x.UpdatedAt,
&x.TaskWorkflowID, &x.TaskRunID, &x.Host, &x.Cwd, &x.Repo, &x.Target)
&x.TaskWorkflowID, &x.TaskRunID, &x.Host, &x.Cwd, &x.Repo, &x.Target,
&x.Provider, &x.Account)
return x, err
}
@@ -191,10 +205,10 @@ func (s *Store) CreateSession(ctx context.Context, x Session) error {
}
}
_, err := s.db.ExecContext(ctx,
`INSERT INTO agent_sessions (`+sessionCols+`) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
`INSERT INTO agent_sessions (`+sessionCols+`) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
x.ID, x.Org, x.Agent, x.Actor, x.Status, x.ParentID, x.RootID, x.Title,
x.StartedAt, x.EndedAt, x.CreatedAt, x.UpdatedAt, x.TaskWorkflowID, x.TaskRunID,
x.Host, x.Cwd, x.Repo, x.Target)
x.Host, x.Cwd, x.Repo, x.Target, x.Provider, x.Account)
if err != nil {
return fmt.Errorf("insert session: %w", err)
}
+147 -33
View File
@@ -71,15 +71,21 @@ func validTargetStatus(s string) bool {
var errTargetNotFound = errors.New("agents: target not found")
// Target is a registered agent run-target (metadata only). Owned by one org.
// Target is a registered agent run-target. Owned by one org. Spec is its static
// capability (os/arch/cpus/memory/gpus) and Metrics its last live heartbeat
// (loadavg/memory/gpu-util); MetricsAt is the unix second that heartbeat was recorded
// (0 = never). See targetspec.go for the value plane.
type Target struct {
ID string
Org string
Label string
Kind string // laptop | cloud | gpu | cluster | machine
Status string // online | offline | draining
Capacity string // free-form ("8 vCPU / 32G", "1× GB10")
Capacity string // free-form ("8 vCPU / 32G", "1× GB10") — human summary
Host string // hostname sessions on this machine report (maps sessions -> target)
Spec Spec // static capability
Metrics Metrics
MetricsAt int64
CreatedAt int64
UpdatedAt int64
}
@@ -102,6 +108,9 @@ CREATE TABLE IF NOT EXISTS agent_targets (
status TEXT NOT NULL DEFAULT 'online',
capacity TEXT NOT NULL DEFAULT '',
host TEXT NOT NULL DEFAULT '',
spec TEXT NOT NULL DEFAULT '',
metrics TEXT NOT NULL DEFAULT '',
metrics_at INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
@@ -110,23 +119,40 @@ CREATE INDEX IF NOT EXISTS ix_targets_org_created ON agent_targets(org, created_
if _, err := s.db.Exec(ddl); err != nil {
return fmt.Errorf("migrate targets: %w", err)
}
// Forward, idempotent upgrade for target rows created before the capability +
// metrics columns existed. PRAGMA-guarded, so re-running on an upgraded DB is a
// no-op — the DDL above covers fresh installs, this covers pre-existing ones.
if err := s.addColumns("agent_targets", map[string]string{
"spec": "TEXT NOT NULL DEFAULT ''",
"metrics": "TEXT NOT NULL DEFAULT ''",
"metrics_at": "INTEGER NOT NULL DEFAULT 0",
}); err != nil {
return err
}
return nil
}
const targetCols = `id,org,label,kind,status,capacity,host,created_at,updated_at`
const targetCols = `id,org,label,kind,status,capacity,host,spec,metrics,metrics_at,created_at,updated_at`
func scanTarget(sc interface{ Scan(...any) error }) (Target, error) {
var t Target
var spec, metrics string
err := sc.Scan(&t.ID, &t.Org, &t.Label, &t.Kind, &t.Status, &t.Capacity, &t.Host,
&t.CreatedAt, &t.UpdatedAt)
return t, err
&spec, &metrics, &t.MetricsAt, &t.CreatedAt, &t.UpdatedAt)
if err != nil {
return t, err
}
t.Spec = decodeSpec(spec)
t.Metrics = decodeMetrics(metrics)
return t, nil
}
// CreateTarget inserts one target. The id is caller-generated (genID("tgt")).
func (s *Store) CreateTarget(ctx context.Context, t Target) error {
_, err := s.db.ExecContext(ctx,
`INSERT INTO agent_targets (`+targetCols+`) VALUES (?,?,?,?,?,?,?,?,?)`,
t.ID, t.Org, t.Label, t.Kind, t.Status, t.Capacity, t.Host, t.CreatedAt, t.UpdatedAt)
`INSERT INTO agent_targets (`+targetCols+`) VALUES (?,?,?,?,?,?,?,?,?,?,?,?)`,
t.ID, t.Org, t.Label, t.Kind, t.Status, t.Capacity, t.Host,
encodeSpec(t.Spec), encodeMetrics(t.Metrics), t.MetricsAt, t.CreatedAt, t.UpdatedAt)
if err != nil {
return fmt.Errorf("insert target: %w", err)
}
@@ -171,9 +197,10 @@ func (s *Store) ListTargets(ctx context.Context, org string) ([]Target, error) {
// so a cross-tenant id can never mutate another's target.
func (s *Store) UpdateTarget(ctx context.Context, t Target) error {
res, err := s.db.ExecContext(ctx,
`UPDATE agent_targets SET label=?, kind=?, status=?, capacity=?, host=?, updated_at=?
`UPDATE agent_targets SET label=?, kind=?, status=?, capacity=?, host=?, spec=?, metrics=?, metrics_at=?, updated_at=?
WHERE org=? AND id=?`,
t.Label, t.Kind, t.Status, t.Capacity, t.Host, t.UpdatedAt, t.Org, t.ID)
t.Label, t.Kind, t.Status, t.Capacity, t.Host,
encodeSpec(t.Spec), encodeMetrics(t.Metrics), t.MetricsAt, t.UpdatedAt, t.Org, t.ID)
if err != nil {
return fmt.Errorf("update target: %w", err)
}
@@ -184,6 +211,28 @@ func (s *Store) UpdateTarget(ctx context.Context, t Target) error {
return nil
}
// GetTargetByHost returns an org's target reporting the given host, or
// errTargetNotFound. It is how a re-link of the SAME machine finds its existing target
// (idempotent register) instead of creating a duplicate. Org-scoped: a host string can
// never resolve another tenant's target. Newest wins if a host was ever double-listed.
func (s *Store) GetTargetByHost(ctx context.Context, org, host string) (Target, error) {
host = strings.TrimSpace(host)
if host == "" {
return Target{}, errTargetNotFound
}
row := s.db.QueryRowContext(ctx,
`SELECT `+targetCols+` FROM agent_targets WHERE org=? AND host=? ORDER BY created_at DESC, id ASC LIMIT 1`,
org, host)
t, err := scanTarget(row)
if errors.Is(err, sql.ErrNoRows) {
return Target{}, errTargetNotFound
}
if err != nil {
return Target{}, fmt.Errorf("get target by host: %w", err)
}
return t, nil
}
// DeleteTarget removes an org's target. Sessions keep their recorded target id (a
// historical fact); a detached target simply stops appearing in the registry.
func (s *Store) DeleteTarget(ctx context.Context, org, id string) (bool, error) {
@@ -216,25 +265,41 @@ func (s *Store) SessionLoad(ctx context.Context, org, id, host string) (TargetLo
// ---- HTTP shapes (the published contract) ----
type targetView struct {
ID string `json:"id"`
Label string `json:"label"`
Kind string `json:"kind"`
Status string `json:"status"`
Capacity string `json:"capacity,omitempty"`
Host string `json:"host,omitempty"`
Sessions int `json:"sessions"`
Running int `json:"running"`
CreatedAt string `json:"createdAt"`
UpdatedAt string `json:"updatedAt"`
ID string `json:"id"`
Label string `json:"label"`
Kind string `json:"kind"`
Status string `json:"status"`
Capacity string `json:"capacity,omitempty"`
Host string `json:"host,omitempty"`
Spec *Spec `json:"spec,omitempty"`
Metrics *Metrics `json:"metrics,omitempty"`
MetricsAt string `json:"metricsAt,omitempty"`
Sessions int `json:"sessions"`
Running int `json:"running"`
CreatedAt string `json:"createdAt"`
UpdatedAt string `json:"updatedAt"`
}
func toTargetView(t Target, load TargetLoad) targetView {
return targetView{
v := targetView{
ID: t.ID, Label: t.Label, Kind: t.Kind, Status: t.Status,
Capacity: t.Capacity, Host: t.Host,
Sessions: load.Sessions, Running: load.Running,
CreatedAt: rfc3339(t.CreatedAt), UpdatedAt: rfc3339(t.UpdatedAt),
}
if !t.Spec.IsZero() {
spec := t.Spec
v.Spec = &spec
}
if !t.Metrics.IsZero() {
m := t.Metrics
m.At = t.MetricsAt
v.Metrics = &m
}
if t.MetricsAt > 0 {
v.MetricsAt = rfc3339(t.MetricsAt)
}
return v
}
// mountTargets registers the target routes. Called from Mount BEFORE the
@@ -251,11 +316,13 @@ func mountTargets(s *cloud.Service[state], app *zip.App) {
// ---- register ----
type targetReq struct {
Label string `json:"label"`
Kind string `json:"kind"`
Status string `json:"status"`
Capacity string `json:"capacity"`
Host string `json:"host"`
Label string `json:"label"`
Kind string `json:"kind"`
Status string `json:"status"`
Capacity string `json:"capacity"`
Host string `json:"host"`
Spec Spec `json:"spec"`
Metrics Metrics `json:"metrics"`
}
func registerTarget(s *cloud.Service[state], c *zip.Ctx) error {
@@ -296,14 +363,42 @@ func registerTarget(s *cloud.Service[state], c *zip.Ctx) error {
if len(host) > maxHost {
return zip.ErrBadRequest("host too long")
}
if len(body.Spec.GPUs) > maxGPUs {
return zip.ErrBadRequest("too many gpus")
}
spec := body.Spec.Sanitize()
metrics := body.Metrics.Sanitize()
now := time.Now().Unix()
metricsAt := int64(0)
if !metrics.IsZero() {
metricsAt = now // the server owns the staleness clock; a client can't forge it
}
// Idempotent re-link: the SAME machine (org+host) refreshes its existing target
// rather than piling up duplicates, so mission-control shows one row per machine
// with live spec/metrics. Only an explicit host keys this — an anonymous target
// (no host) always creates.
if host != "" {
if existing, err := s.State.store.GetTargetByHost(c.Context(), org, host); err == nil {
existing.Label, existing.Kind, existing.Status, existing.Capacity = label, kind, status, capacity
existing.Spec, existing.Metrics, existing.MetricsAt = spec, metrics, metricsAt
existing.UpdatedAt = now
if err := s.State.store.UpdateTarget(c.Context(), existing); err != nil {
return zip.Errorf(http.StatusInternalServerError, "persist: %v", err)
}
load, _ := s.State.store.SessionLoad(c.Context(), org, existing.ID, existing.Host)
return c.JSON(http.StatusOK, toTargetView(existing, load))
}
}
id, err := genID("tgt")
if err != nil {
return zip.Errorf(http.StatusInternalServerError, "rng: %v", err)
}
now := time.Now().Unix()
t := Target{
ID: id, Org: org, Label: label, Kind: kind, Status: status,
Capacity: capacity, Host: host, CreatedAt: now, UpdatedAt: now,
Capacity: capacity, Host: host, Spec: spec, Metrics: metrics, MetricsAt: metricsAt,
CreatedAt: now, UpdatedAt: now,
}
if err := s.State.store.CreateTarget(c.Context(), t); err != nil {
return zip.Errorf(http.StatusInternalServerError, "persist: %v", err)
@@ -355,11 +450,13 @@ func getTarget(s *cloud.Service[state], c *zip.Ctx) error {
// ---- patch ----
type patchTargetReq struct {
Label *string `json:"label"`
Kind *string `json:"kind"`
Status *string `json:"status"`
Capacity *string `json:"capacity"`
Host *string `json:"host"`
Label *string `json:"label"`
Kind *string `json:"kind"`
Status *string `json:"status"`
Capacity *string `json:"capacity"`
Host *string `json:"host"`
Spec *Spec `json:"spec"`
Metrics *Metrics `json:"metrics"` // present => a heartbeat; the server stamps its time
}
func patchTarget(s *cloud.Service[state], c *zip.Ctx) error {
@@ -417,7 +514,24 @@ func patchTarget(s *cloud.Service[state], c *zip.Ctx) error {
}
t.Host = nh
}
t.UpdatedAt = time.Now().Unix()
now := time.Now().Unix()
if body.Spec != nil {
if len(body.Spec.GPUs) > maxGPUs {
return zip.ErrBadRequest("too many gpus")
}
t.Spec = body.Spec.Sanitize()
}
if body.Metrics != nil {
// A metrics patch IS a heartbeat: refresh the sample and stamp the server's
// own clock (a client can never forge or backdate the staleness time).
t.Metrics = body.Metrics.Sanitize()
if t.Metrics.IsZero() {
t.MetricsAt = 0
} else {
t.MetricsAt = now
}
}
t.UpdatedAt = now
if err := s.State.store.UpdateTarget(c.Context(), t); err != nil {
if err == errTargetNotFound {
return zip.ErrNotFound("target not found")
+194
View File
@@ -0,0 +1,194 @@
package agents
import (
"encoding/json"
"math"
"strings"
)
// targetspec.go is the machine-capability value plane for a run-target: two orthogonal
// values a linked computer carries so mission-control can answer "which machine, and
// can it run this?" without copying the fact onto every session.
//
// - Spec — what the machine IS (os/arch/cpus/memory/gpus): static, rarely changes.
// - Metrics — what the machine is DOING now (loadavg/memory/gpu-util): the last
// heartbeat, with At = the server second it was recorded (the staleness clock).
//
// `hanzo code --link` captures both from explicit system sources (never the process
// environment) and reports them on the target. They are stored as JSON on the target
// row (agent_targets.spec / .metrics) — one column per concept, extensible without
// schema churn — and every field is bounded on write (sanitize) so a hostile or buggy
// client can never bloat the row or smuggle a non-finite float that would break JSON.
// GPU is one accelerator on a machine.
type GPU struct {
Vendor string `json:"vendor,omitempty"` // nvidia | amd | apple | intel | ...
Model string `json:"model,omitempty"` // "GB10", "8060S", "RTX 4090"
Memory int64 `json:"memory,omitempty"` // VRAM bytes, 0 = unknown
}
// Spec is a machine's static capability.
type Spec struct {
OS string `json:"os,omitempty"` // linux | darwin | windows
Arch string `json:"arch,omitempty"` // amd64 | arm64 | ...
CPUs int `json:"cpus,omitempty"` // logical cores
Memory int64 `json:"memory,omitempty"` // total RAM, bytes
GPUs []GPU `json:"gpus,omitempty"`
}
// Metrics is a machine's live state from the last heartbeat.
type Metrics struct {
Load1 float64 `json:"load1,omitempty"`
Load5 float64 `json:"load5,omitempty"`
Load15 float64 `json:"load15,omitempty"`
MemUsed int64 `json:"memUsed,omitempty"` // bytes
MemFree int64 `json:"memFree,omitempty"` // bytes
GPUUtil float64 `json:"gpuUtil,omitempty"` // 0..1 aggregate utilization
At int64 `json:"at,omitempty"` // unix seconds, server-stamped
}
const (
maxGPUs = 32 // an absurd count is a bug or an attack, not a real host
maxSpecField = 64 // os/arch/gpu vendor/model
maxCPUs = 8192 // clamps a garbage core count
)
// IsZero reports an all-empty spec (nothing worth storing).
func (s Spec) IsZero() bool {
return s.OS == "" && s.Arch == "" && s.CPUs == 0 && s.Memory == 0 && len(s.GPUs) == 0
}
// IsZero reports an all-empty metrics sample.
func (m Metrics) IsZero() bool {
return m.Load1 == 0 && m.Load5 == 0 && m.Load15 == 0 &&
m.MemUsed == 0 && m.MemFree == 0 && m.GPUUtil == 0 && m.At == 0
}
// Sanitize bounds every field so a target row stays small and well-formed no matter
// what a client sends: strings trimmed + length-capped, counts/sizes non-negative and
// clamped, GPU list truncated, floats coerced finite. It is total (never errors) so
// the write path can always proceed with a safe value.
func (s Spec) Sanitize() Spec {
out := Spec{
OS: clampStr(s.OS, maxSpecField),
Arch: clampStr(s.Arch, maxSpecField),
CPUs: clampInt(s.CPUs, maxCPUs),
Memory: nonNegI64(s.Memory),
}
for i, g := range s.GPUs {
if i >= maxGPUs {
break
}
g = GPU{Vendor: clampStr(g.Vendor, maxSpecField), Model: clampStr(g.Model, maxSpecField), Memory: nonNegI64(g.Memory)}
if g == (GPU{}) {
continue
}
out.GPUs = append(out.GPUs, g)
}
return out
}
// Sanitize coerces a metrics sample into a safe, finite range. It does NOT set At —
// the server stamps that so a client can never backdate or forge the staleness clock.
func (m Metrics) Sanitize() Metrics {
return Metrics{
Load1: nonNegF(m.Load1),
Load5: nonNegF(m.Load5),
Load15: nonNegF(m.Load15),
MemUsed: nonNegI64(m.MemUsed),
MemFree: nonNegI64(m.MemFree),
GPUUtil: clampF01(m.GPUUtil),
}
}
func clampStr(s string, n int) string {
s = strings.TrimSpace(s)
if len(s) > n {
return strings.ToValidUTF8(s[:n], "")
}
return s
}
func clampInt(i, hi int) int {
if i < 0 {
return 0
}
if i > hi {
return hi
}
return i
}
func nonNegI64(i int64) int64 {
if i < 0 {
return 0
}
return i
}
// nonNegF returns a finite, non-negative float (NaN/Inf/negative → 0), so a hostile
// loadavg can never poison the JSON encode or the display.
func nonNegF(f float64) float64 {
if math.IsNaN(f) || math.IsInf(f, 0) || f < 0 {
return 0
}
return f
}
// clampF01 returns a finite float in [0,1] (utilization).
func clampF01(f float64) float64 {
if math.IsNaN(f) || math.IsInf(f, 0) || f < 0 {
return 0
}
if f > 1 {
return 1
}
return f
}
// encodeSpec/decodeSpec + encodeMetrics/decodeMetrics are the column codecs. An empty
// value encodes to "" (a NULL-equivalent the column defaults to), and a malformed
// stored blob decodes to the zero value rather than failing a whole target read.
func encodeSpec(s Spec) string {
if s.IsZero() {
return ""
}
b, err := json.Marshal(s)
if err != nil {
return ""
}
return string(b)
}
func decodeSpec(raw string) Spec {
if strings.TrimSpace(raw) == "" {
return Spec{}
}
var s Spec
if json.Unmarshal([]byte(raw), &s) != nil {
return Spec{}
}
return s
}
func encodeMetrics(m Metrics) string {
if m.IsZero() {
return ""
}
b, err := json.Marshal(m)
if err != nil {
return ""
}
return string(b)
}
func decodeMetrics(raw string) Metrics {
if strings.TrimSpace(raw) == "" {
return Metrics{}
}
var m Metrics
if json.Unmarshal([]byte(raw), &m) != nil {
return Metrics{}
}
return m
}
+224
View File
@@ -0,0 +1,224 @@
package agents
import (
"context"
"math"
"net/http"
"reflect"
"strings"
"testing"
)
// TestSpecMetricsSanitize proves the capability + live-metrics values are bounded on
// the way in: no unbounded strings, no absurd GPU counts, no negative sizes, and no
// non-finite float that would break the JSON encode or the display. Sanitize is total
// (never errors) so the write path always has a safe value.
func TestSpecMetricsSanitize(t *testing.T) {
longVendor := strings.Repeat("x", 500)
huge := make([]GPU, 100)
for i := range huge {
huge[i] = GPU{Vendor: "nvidia", Model: "GB10", Memory: 1 << 40}
}
spec := Spec{
OS: "linux", Arch: "arm64", CPUs: -5, Memory: -1,
GPUs: append([]GPU{{Vendor: longVendor, Model: "x", Memory: -9}}, huge...),
}.Sanitize()
if spec.CPUs != 0 || spec.Memory != 0 {
t.Fatalf("negative cpus/memory must clamp to 0, got cpus=%d mem=%d", spec.CPUs, spec.Memory)
}
if len(spec.GPUs) > maxGPUs {
t.Fatalf("gpu list must cap at %d, got %d", maxGPUs, len(spec.GPUs))
}
if len(spec.GPUs[0].Vendor) > maxSpecField {
t.Fatalf("gpu vendor must be length-capped, got %d", len(spec.GPUs[0].Vendor))
}
if spec.GPUs[0].Memory != 0 {
t.Fatalf("negative gpu memory must clamp to 0, got %d", spec.GPUs[0].Memory)
}
m := Metrics{
Load1: math.NaN(), Load5: math.Inf(1), Load15: -3,
MemUsed: -1, MemFree: 1 << 30, GPUUtil: 9.5, At: 999,
}.Sanitize()
if m.Load1 != 0 || m.Load5 != 0 || m.Load15 != 0 {
t.Fatalf("NaN/Inf/negative load must become 0, got %+v", m)
}
if m.MemUsed != 0 {
t.Fatalf("negative memUsed must clamp to 0, got %d", m.MemUsed)
}
if m.GPUUtil != 1 {
t.Fatalf("gpuUtil must clamp to [0,1], got %v", m.GPUUtil)
}
if m.At != 0 {
t.Fatalf("Sanitize must NOT carry a client-supplied At (server owns the clock), got %d", m.At)
}
// A JSON encode of the sanitized metrics must succeed (proves no residual NaN/Inf).
if encodeMetrics(m) == "" && !m.IsZero() {
t.Fatal("sanitized non-zero metrics must encode to a non-empty blob")
}
}
// TestTargetStoreSpecMetricsRoundTrip proves the capability + metrics survive a store
// write/read exactly (JSON column codec), and a malformed stored blob decodes to the
// zero value rather than failing the whole target read.
func TestTargetStoreSpecMetricsRoundTrip(t *testing.T) {
s := testSessionStore(t)
ctx := context.Background()
spec := Spec{OS: "linux", Arch: "arm64", CPUs: 20, Memory: 128 << 30,
GPUs: []GPU{{Vendor: "nvidia", Model: "GB10", Memory: 96 << 30}}}
metrics := Metrics{Load1: 2.5, MemFree: 64 << 30, GPUUtil: 0.8}
tg := Target{ID: "t1", Org: "acme", Label: "spark", Kind: TargetGPU, Status: TargetOnline,
Host: "spark", Spec: spec, Metrics: metrics, MetricsAt: 1234, CreatedAt: 1, UpdatedAt: 1}
if err := s.CreateTarget(ctx, tg); err != nil {
t.Fatalf("create: %v", err)
}
got, err := s.GetTarget(ctx, "acme", "t1")
if err != nil {
t.Fatalf("get: %v", err)
}
if !reflect.DeepEqual(got.Spec, spec) {
t.Fatalf("spec round-trip mismatch:\n got %+v\nwant %+v", got.Spec, spec)
}
if got.Metrics.Load1 != 2.5 || got.Metrics.MemFree != 64<<30 || got.Metrics.GPUUtil != 0.8 {
t.Fatalf("metrics round-trip mismatch: %+v", got.Metrics)
}
if got.MetricsAt != 1234 {
t.Fatalf("metricsAt column mismatch: %d", got.MetricsAt)
}
}
// TestGetTargetByHost proves the idempotent-relink lookup is org-scoped (fail-closed
// cross-tenant), empty-host is a miss, and the newest wins on a duplicate host.
func TestGetTargetByHost(t *testing.T) {
s := testSessionStore(t)
ctx := context.Background()
_ = s.CreateTarget(ctx, Target{ID: "a1", Org: "acme", Host: "box", Label: "old", CreatedAt: 10, UpdatedAt: 10, Kind: TargetMachine, Status: TargetOnline})
_ = s.CreateTarget(ctx, Target{ID: "a2", Org: "acme", Host: "box", Label: "new", CreatedAt: 20, UpdatedAt: 20, Kind: TargetMachine, Status: TargetOnline})
_ = s.CreateTarget(ctx, Target{ID: "e1", Org: "evil", Host: "box", Label: "evil", CreatedAt: 15, UpdatedAt: 15, Kind: TargetMachine, Status: TargetOnline})
got, err := s.GetTargetByHost(ctx, "acme", "box")
if err != nil || got.ID != "a2" {
t.Fatalf("newest of acme's host wins, got %+v %v", got, err)
}
if _, err := s.GetTargetByHost(ctx, "acme", " "); err != errTargetNotFound {
t.Fatalf("empty host must miss, got %v", err)
}
if _, err := s.GetTargetByHost(ctx, "nobody", "box"); err != errTargetNotFound {
t.Fatalf("a foreign org's host must fail-closed, got %v", err)
}
}
// TestHTTPTargetCapabilityAndHeartbeat proves register carries spec + metrics into the
// view, the server stamps the metrics clock (not the client), a metrics PATCH is a
// heartbeat that refreshes the sample + clock, and a spec PATCH updates capability.
func TestHTTPTargetCapabilityAndHeartbeat(t *testing.T) {
app := mountApp(t, &fakeAI{content: "x"})
code, b := do(t, app, http.MethodPost, "/v1/agents/targets", "acme", map[string]any{
"label": "spark", "kind": TargetGPU, "host": "spark",
"spec": map[string]any{"os": "linux", "arch": "arm64", "cpus": 20, "memory": 137438953472,
"gpus": []map[string]any{{"vendor": "nvidia", "model": "GB10", "memory": 103079215104}}},
// A client cannot forge the clock: it sends "at" but the server ignores it.
"metrics": map[string]any{"load1": 1.5, "memFree": 68719476736, "gpuUtil": 0.4, "at": 42},
})
if code != http.StatusCreated {
t.Fatalf("register want 201, got %d (%s)", code, b)
}
var tv targetView
mustJSON(t, b, &tv)
if tv.Spec == nil || tv.Spec.Arch != "arm64" || tv.Spec.CPUs != 20 || len(tv.Spec.GPUs) != 1 || tv.Spec.GPUs[0].Model != "GB10" {
t.Fatalf("spec not carried into view: %+v", tv.Spec)
}
if tv.Metrics == nil || tv.Metrics.Load1 != 1.5 || tv.Metrics.GPUUtil != 0.4 {
t.Fatalf("metrics not carried into view: %+v", tv.Metrics)
}
if tv.Metrics.At == 42 || tv.Metrics.At <= 0 {
t.Fatalf("metrics clock must be server-stamped (not client 42), got %d", tv.Metrics.At)
}
if tv.MetricsAt == "" {
t.Fatalf("metricsAt (rfc3339) must be set when metrics present")
}
// A metrics PATCH is a heartbeat: refresh the sample, keep the clock owned by us.
code, b = do(t, app, http.MethodPatch, "/v1/agents/targets/"+tv.ID, "acme", map[string]any{
"metrics": map[string]any{"load1": 3.0, "memFree": 1000, "at": 99},
})
if code != http.StatusOK {
t.Fatalf("heartbeat patch want 200, got %d (%s)", code, b)
}
var hb targetView
mustJSON(t, b, &hb)
if hb.Metrics == nil || hb.Metrics.Load1 != 3.0 {
t.Fatalf("heartbeat did not refresh metrics: %+v", hb.Metrics)
}
if hb.Metrics.At <= 0 || hb.Metrics.At == 99 {
t.Fatalf("heartbeat clock must be server-stamped, got %d", hb.Metrics.At)
}
// Spec is untouched by a metrics-only heartbeat.
if hb.Spec == nil || hb.Spec.Arch != "arm64" {
t.Fatalf("metrics heartbeat must not drop spec: %+v", hb.Spec)
}
}
// TestHTTPTargetUpsertByHost proves re-linking the SAME machine (org+host) refreshes
// ONE target (200, not a duplicate), while a different host makes a second target.
func TestHTTPTargetUpsertByHost(t *testing.T) {
app := mountApp(t, &fakeAI{content: "x"})
// First link on host "evo" -> created (201).
code, b := do(t, app, http.MethodPost, "/v1/agents/targets", "acme", map[string]any{
"label": "evo", "host": "evo", "capacity": "old", "metrics": map[string]any{"load1": 1},
})
if code != http.StatusCreated {
t.Fatalf("first link want 201, got %d (%s)", code, b)
}
var first targetView
mustJSON(t, b, &first)
// Re-link the SAME host -> updated in place (200), same id, refreshed fields.
code, b = do(t, app, http.MethodPost, "/v1/agents/targets", "acme", map[string]any{
"label": "evo", "host": "evo", "capacity": "new", "metrics": map[string]any{"load1": 5},
})
if code != http.StatusOK {
t.Fatalf("re-link same host want 200 (upsert), got %d (%s)", code, b)
}
var second targetView
mustJSON(t, b, &second)
if second.ID != first.ID {
t.Fatalf("re-link must reuse the machine's target id: %s != %s", second.ID, first.ID)
}
if second.Capacity != "new" || second.Metrics == nil || second.Metrics.Load1 != 5 {
t.Fatalf("re-link must refresh capacity+metrics: %+v", second)
}
// A different host is a distinct machine -> a second target.
_, _ = do(t, app, http.MethodPost, "/v1/agents/targets", "acme", map[string]any{"label": "dbc", "host": "dbc"})
_, lb := do(t, app, http.MethodGet, "/v1/agents/targets", "acme", nil)
var list targetsResp
mustJSON(t, lb, &list)
if len(list.Targets) != 2 {
t.Fatalf("upsert must leave 2 machines (evo, dbc), got %d: %+v", len(list.Targets), list.Targets)
}
}
// TestHTTPTargetRejectsOversizeGPUList proves an absurd GPU array is rejected at the
// handler (a clean 400) rather than silently truncated after a large allocation — the
// bound is communicated to the client, and a normal list still registers.
func TestHTTPTargetRejectsOversizeGPUList(t *testing.T) {
app := mountApp(t, &fakeAI{content: "x"})
huge := make([]map[string]any, maxGPUs+50)
for i := range huge {
huge[i] = map[string]any{"vendor": "nvidia", "model": "x"}
}
if code, b := do(t, app, http.MethodPost, "/v1/agents/targets", "acme", map[string]any{
"label": "box", "host": "box", "spec": map[string]any{"gpus": huge},
}); code != http.StatusBadRequest {
t.Fatalf("oversize gpu list must be rejected 400, got %d (%s)", code, b)
}
// A normal-sized list is accepted.
ok := []map[string]any{{"vendor": "nvidia", "model": "GB10"}, {"vendor": "amd", "model": "8060S"}}
if code, b := do(t, app, http.MethodPost, "/v1/agents/targets", "acme", map[string]any{
"label": "box", "host": "box", "spec": map[string]any{"gpus": ok},
}); code != http.StatusCreated {
t.Fatalf("normal gpu list must be accepted, got %d (%s)", code, b)
}
}
+6
View File
@@ -106,6 +106,12 @@ func routes(app *zip.App, s *cloud.Service[state]) {
app.Post("/v1/analytics", cloud.Handle(s, capture))
app.Post("/v1/analytics/batch", cloud.Handle(s, capture))
app.Post("/v1/tracker", cloud.Handle(s, capture))
// /v1/insights — the unified native surface (insights.go): PostHog-wire
// ingest + console reads over the SAME engine. Flags live at /v1/flags.
app.Get("/v1/insights/health", cloud.Handle(s, insightsHealth))
app.Post("/v1/insights/e", cloud.Handle(s, insightsIngest))
app.Get("/v1/insights/events", cloud.Handle(s, insightsEvents))
}
// ── shared helpers ──────────────────────────────────────────────────────────
+204
View File
@@ -0,0 +1,204 @@
package analytics
// /v1/insights — the UNIFIED native insights surface on the SAME engine.
//
// This file is a WIRE ADAPTER, not a second pipeline: PostHog-shaped payloads
// (what @hanzo/insights and every PostHog-compatible SDK emit) are mapped onto
// the native CaptureEvent and flow through the ONE capture path (normalize →
// scrub → hanzo.events), and the console reads recent events back through the
// ONE datastore client. Flags stay at /v1/flags (the native flags engine) —
// this namespace deliberately does not duplicate them.
//
// Routes (org resolved SERVER-SIDE — same tenant gates as the rest):
//
// POST /v1/insights/e PostHog-compatible ingest: one event or {batch:[...]}
// GET /v1/insights/events recent events for the org (console read; limit<=200)
// GET /v1/insights/health liveness of the unified surface
//
// SCALE PATH: accept is stateless (any replica) and the sink is the pooled
// batch INSERT into Datastore. When ingest volume outgrows direct sink, the
// seam is buildEventsInsert — swap the exec for a queue producer (mq/pubsub)
// with a Datastore consumer, no handler changes.
import (
"encoding/json"
"net/http"
"strconv"
"strings"
"time"
aiobject "github.com/hanzoai/ai/object"
"github.com/hanzoai/cloud"
"github.com/zap-proto/zip"
)
// insightsEvent is the PostHog wire shape (subset that matters for ingest).
type insightsEvent struct {
Event string `json:"event"`
DistinctID string `json:"distinct_id"`
Timestamp string `json:"timestamp"`
Properties map[string]any `json:"properties"`
}
// insightsBody accepts both the single-event and batch PostHog shapes.
type insightsBody struct {
insightsEvent
Batch []insightsEvent `json:"batch"`
}
// toCapture maps one PostHog event onto the native CaptureEvent. Well-known
// $-properties become first-class columns; the rest stay in properties (the
// scrubber runs downstream in normalizeEvent, same as every native event).
func (e insightsEvent) toCapture() CaptureEvent {
props := e.Properties
str := func(key string) string {
if props == nil {
return ""
}
if v, ok := props[key].(string); ok {
return v
}
return ""
}
typ := "event"
if e.Event == "$pageview" {
typ = "pageview"
}
return CaptureEvent{
Type: typ,
Event: e.Event,
Timestamp: e.Timestamp,
DistinctID: e.DistinctID,
SessionID: str("$session_id"),
URL: str("$current_url"),
Path: str("$pathname"),
Referrer: str("$referrer"),
Product: str("product"),
Library: str("$lib"),
LibraryVer: str("$lib_version"),
Properties: props,
}
}
// insightsIngest answers POST /v1/insights/e — the PostHog-compatible front
// door. Same tenant gate, same normalize/scrub, same warehouse as /v1/analytics.
func insightsIngest(s *cloud.Service[state], c *zip.Ctx) error {
org, ok := captureTenant(c)
if !ok {
return zip.ErrForbidden("valid bearer or a recognized brand host required")
}
var body insightsBody
if err := c.Bind(&body); err != nil {
return zip.ErrBadRequest("malformed insights payload")
}
events := body.Batch
if len(events) == 0 && body.Event != "" {
events = []insightsEvent{body.insightsEvent}
}
if len(events) == 0 {
return c.JSON(http.StatusOK, CaptureResult{})
}
if len(events) > maxBatch {
return zip.ErrBadRequest("batch too large")
}
if err := requireDatastore(); err != nil {
return err
}
ctx := c.Context()
if err := EnsureEventsTable(ctx); err != nil {
return zip.Errorf(http.StatusServiceUnavailable, "analytics warehouse unavailable: %v", err)
}
now := time.Now().UTC()
rows := make([]eventRow, 0, len(events))
dropped := 0
for _, e := range events {
row, ok := normalizeEvent(org, now, e.toCapture())
if !ok {
dropped++
continue
}
rows = append(rows, row)
}
if len(rows) == 0 {
return c.JSON(http.StatusOK, CaptureResult{Dropped: dropped})
}
stmt, args := buildEventsInsert(rows)
if err := aiobject.DatastoreExec(ctx, stmt, args...); err != nil {
return zip.Errorf(http.StatusServiceUnavailable, "analytics warehouse write failed: %v", err)
}
return c.JSON(http.StatusOK, CaptureResult{Accepted: len(rows), Dropped: dropped})
}
// insightsEvents answers GET /v1/insights/events — the console's recent-events
// read (newest first). Tenant-scoped server-side; limit defaults 50, caps 200.
func insightsEvents(s *cloud.Service[state], c *zip.Ctx) error {
org, ok := tenant(c)
if !ok {
return zip.ErrForbidden("valid bearer required")
}
limit, _ := strconv.Atoi(strings.TrimSpace(c.Query("limit")))
if limit <= 0 {
limit = 50
}
if limit > 200 {
limit = 200
}
rows, err := aiobject.DatastoreQuery(c.Context(), `
SELECT id, timestamp, event, event_type, distinct_id, session_id,
product, url, path, properties
FROM hanzo.events
WHERE tenant_id = ?
ORDER BY timestamp DESC
LIMIT ?`, org, limit)
if err != nil {
return zip.Errorf(http.StatusServiceUnavailable, "analytics warehouse unavailable: %v", err)
}
type ev struct {
ID string `json:"id"`
Timestamp string `json:"timestamp"`
Event string `json:"event"`
Type string `json:"type"`
DistinctID string `json:"distinctId"`
SessionID string `json:"sessionId,omitempty"`
Product string `json:"product,omitempty"`
URL string `json:"url,omitempty"`
Path string `json:"path,omitempty"`
Properties json.RawMessage `json:"properties,omitempty"`
}
out := make([]ev, 0, len(rows))
for _, r := range rows {
e := ev{
ID: asStr(r["id"]), Timestamp: asStr(r["timestamp"]), Event: asStr(r["event"]),
Type: asStr(r["event_type"]), DistinctID: asStr(r["distinct_id"]),
SessionID: asStr(r["session_id"]), Product: asStr(r["product"]),
URL: asStr(r["url"]), Path: asStr(r["path"]),
}
if p := asStr(r["properties"]); p != "" && json.Valid([]byte(p)) {
e.Properties = json.RawMessage(p)
}
out = append(out, e)
}
return c.JSON(http.StatusOK, map[string]any{"data": out})
}
func insightsHealth(s *cloud.Service[state], c *zip.Ctx) error {
return c.JSON(http.StatusOK, map[string]any{"ok": true, "engine": "hanzo-analytics", "surface": "/v1/insights"})
}
func asStr(v any) string {
switch t := v.(type) {
case string:
return t
case time.Time:
return t.UTC().Format(time.RFC3339)
case nil:
return ""
default:
b, err := json.Marshal(t)
if err != nil {
return ""
}
return strings.Trim(string(b), `"`)
}
}
+1 -1
View File
@@ -676,7 +676,7 @@ func recordRunEnd(s *cloud.Service[state], ctx context.Context, in RunEndInput)
// meterUnit records one metered unit for an HTTP caller's org. Nil/disabled meter → no-op.
func meterUnit(s *cloud.Service[state], org string, c *zip.Ctx) {
s.Bill.Meter(principal.Payer(c), principal.Project(c), meterKind, cloud.ResourceFeeCents(feeEnvPrefix, meterKind), c.RequestID(), cloud.ClientIP(c))
s.Bill.Meter(principal.HomeOrg(c), principal.Project(c), meterKind, cloud.ResourceFeeCents(feeEnvPrefix, meterKind), c.RequestID(), cloud.ClientIP(c))
}
// meterRun records one metered unit for a flow run from the durable path (no HTTP
+80
View File
@@ -0,0 +1,80 @@
package billing
import (
"context"
"strings"
"github.com/hanzoai/account"
"github.com/hanzoai/cloud/clients/finance"
"github.com/zap-proto/zip"
)
// The ONE prepaid-balance read for the customer surface (/v1/billing/balance and the
// /v1/finance/balance projection).
//
// WHY IT IS NOT A COMMERCE PROXY. Co-resident, commerce registers its routes on the
// HOST's zip app (apps/commerce.go mountCommerce → commerce.Embed with EmbedConfig.App),
// and commerceinproc publishes that SAME shared app as the S2S "commerce" transport,
// which re-dispatches BY PATH. commerce's own billing routes are NOT registered in this
// binary — api.Route(), which registers GET /v1/billing/balance, is called only from
// commerce's mount.go, which is behind `//go:build cloud` and never compiled (cloud ships
// -tags "libsqlite3 sqlite_fts5"). So a GET of "/v1/billing/balance" through the S2S seam
// matches the ONLY registration of that path — the handler below — and re-enters it with
// no principal, which answers "sign in to view billing". The proxy was calling itself.
//
// WHERE THE MONEY IS. Co-resident, the prepaid wallet lives in cloud's OWN finance ledger
// (clients/finance, per-org double-entry SQLite): wireFinance (build.go) points the ai
// prepaid gate's balance read at it, the edge meter debits it, and an admin grant credits
// it (clients/admin/core.grantDeposit prefers finance.Current() for exactly this reason).
// So the customer's balance is read from that ledger DIRECTLY — no HTTP hop, nothing to
// self-dispatch, and the number shown is the number that admits or refuses a request.
// The commerce S2S read stays as the split-deploy fallback, unchanged.
// subjectFor resolves the billing subject for the caller by CALLING the ONE rule,
// ai/object.Payer — the same function the ai prepaid gate (routers/filter_balance.go
// resolveBillingKey) and the usage debit resolve. It is the SHARED resolver for every
// commerce-projected read in this package (the balance read AND the finance reads), so
// there is exactly one copy of the rule. cloud and ai each keeping their own copy is
// what let them drift apart (cloud's console view scoping to the org while the gate
// scoped to "org/user"), so the view showed a funded org while the gate refused the
// member. One function, one rule, one wallet.
//
// org is the VALIDATED principal org. The name half uses the SAME precedence as
// clients/account.resolveCaller: X-User-Name (the IAM username the identity boundary mints
// from the validated `name` claim — expressly "the `name` half of <owner>/<name>"), then
// X-User-Id. Both are authorityHeaders — stripped on ingress and re-injected only from
// verified claims — so neither is a client value.
//
// The X-User-Id fallback goes through the shared account.PayerOf because that header's shape is
// path-dependent: the gateway historically minted X-User-Id == the username, while the
// in-binary direct-Bearer path mints the UUID subject, and callers hold it as an
// "<owner>/<name>" key. PayerOf is the parse ai already uses to fold that key form back
// to the payer, so the two agree by construction instead of by a re-implemented split.
//
// KNOWN RESIDUAL: a validated principal carrying NEITHER X-User-Name NOR an "<owner>/<name>"
// X-User-Id (i.e. a bare username id) folds to the org pool. That requires a JWT with no
// `name` and no `preferred_username`, since the boundary mints X-User-Name from either;
// production tokens carry one. Called out for review rather than papered over.
func subjectFor(c *zip.Ctx, org string) string {
if name := strings.TrimSpace(c.Header("X-User-Name")); name != "" {
return account.Payer(account.Credential{Owner: org, Name: name}).Subject()
}
return account.PayerOf(org, strings.TrimSpace(c.User())).Subject()
}
// availableCents returns the caller's spendable prepaid balance from the co-resident
// finance ledger. ok is false when no finance ledger is published (split deploy) and the
// caller must fall back to the commerce S2S read; a non-nil err is a REAL read failure and
// must be surfaced, never rendered as a zero balance — a balance that cannot be read is
// unknown, and unknown is not "broke".
func availableCents(ctx context.Context, org, subject string) (cents int64, ok bool, err error) {
fin := finance.Current()
if fin == nil {
return 0, false, nil
}
bal, err := fin.Balance(ctx, org, subject, "usd", false)
if err != nil {
return 0, true, err
}
return bal.Cents(), true, nil
}
+219
View File
@@ -0,0 +1,219 @@
package billing
import (
"context"
"encoding/json"
"fmt"
"github.com/hanzoai/account"
"net/http"
"net/http/httptest"
"testing"
"github.com/hanzoai/cloud/clients/finance"
"github.com/hanzoai/cloud/clients/money"
"github.com/hanzoai/cloud/types"
)
// fakeFinance is the co-resident finance ledger seam (types.FinanceClient). It records
// the exact (org, subject) the handler read so a test can prove WHICH wallet the console
// shows, and can be made to fail so a test can prove an unreadable balance is never
// rendered as zero.
type fakeFinance struct {
wallets map[string]int64 // "org|subject" -> cents
usageRows []finance.UsageRow
err error
gotOrg string
gotSubj string
calls int
}
// ListUsage satisfies the optional co-resident usage-read capability coResidentUsage
// resolves; returns the seeded rows so a test can prove the usage view answers from the
// ledger instead of the self-dispatching commerce hop.
func (f *fakeFinance) ListUsage(context.Context, string, int) ([]finance.UsageRow, error) {
return f.usageRows, f.err
}
func (f *fakeFinance) Balance(_ context.Context, org, subject, _ string, _ bool) (money.Amount, error) {
f.calls++
f.gotOrg, f.gotSubj = org, subject
if f.err != nil {
return money.Zero(), f.err
}
return money.FromCents(f.wallets[org+"|"+subject]), nil
}
func (f *fakeFinance) Deposit(context.Context, types.DepositInput) (string, error) { return "", nil }
func (f *fakeFinance) RecordUsage(context.Context, types.UsageInput) error { return nil }
func publishFinance(t *testing.T, f *fakeFinance) {
t.Helper()
finance.Publish(f)
t.Cleanup(func() { finance.Publish(nil) })
}
// TestBalance_ReadsFinanceLedgerNotCommerce is the regression for the live incident:
// GET /v1/billing/balance proxied to commerce at the SAME path, which re-entered THIS
// handler (commerceinproc dispatches the shared app by path; commerce's own billing
// routes are never registered in the co-resident binary), answered "sign in to view
// billing", and surfaced as "billing upstream status 500".
//
// Co-resident the balance now comes from the finance ledger — the wallet the ai gate
// actually reads — and commerce is NOT called at all, so there is nothing to self-dispatch.
func TestBalance_ReadsFinanceLedgerNotCommerce(t *testing.T) {
fin := &fakeFinance{wallets: map[string]int64{"maxpower|maxpower": 6875}}
publishFinance(t, fin)
f := &fakeCommerce{status: 200, body: `{"balance":1,"holds":0,"available":1}`}
app := mountApp(t, f.server(t).URL, "svc-token")
code, body := call(t, app, http.MethodGet, "/v1/billing/balance", "maxpower/dave", "maxpower")
if code != 200 {
t.Fatalf("balance: want 200, got %d (%s)", code, body)
}
var got commerceBalance
if err := json.Unmarshal(body, &got); err != nil {
t.Fatalf("decode: %v (%s)", err, body)
}
if got.Available != 6875 || got.Balance != 6875 {
t.Fatalf("balance not read from the finance ledger: %s", body)
}
// The proof there is no self-dispatch: the commerce hop never happened.
if f.gotPath != "" {
t.Fatalf("commerce was called at %q — the self-dispatching proxy is back", f.gotPath)
}
if fin.gotOrg != "maxpower" {
t.Fatalf("read org %q, want the validated principal's org", fin.gotOrg)
}
}
// TestBalance_SubjectIsTheGateSubject pins the invariant this incident broke: the wallet
// the console SHOWS must be the wallet the ai prepaid gate READS. Both derive it from the
// one function, ai/object.Payer, so they cannot drift apart again — cloud keeping its own
// copy of this rule is exactly what let the console show a funded org while the gate
// refused the member.
func TestBalance_SubjectIsTheGateSubject(t *testing.T) {
// The allowlists are gone; set them to values that WOULD have flipped the resolution
// to prove they are inert — nothing reads them, the signup org still bills per-person.
t.Setenv("PERSONAL_BILLING_ORGS", "")
t.Setenv("ORG_BILLING_ORGS", "hanzo")
// The gate's subject for this principal, from ai itself — not a value this test invents.
// This is what routers/filter_balance.go resolveBillingKey computes from the JWT claims:
// a person in the signup org bills their OWN account, hanzo/z.
want := account.Payer(account.Credential{Owner: "hanzo", Name: "z"}).Subject()
if want != "hanzo/z" {
t.Fatalf("precondition: ai resolves a signup-org person to %q, want hanzo/z", want)
}
// Every identity shape a validated principal can arrive in must land on that ONE wallet.
for _, tc := range []struct{ name, userName, userID string }{
// Production: the identity boundary mints X-User-Name from the `name` claim.
{"gateway mints X-User-Name", "z", "8f14e45f-ea1b-4c2a-9f3d-000000000001"},
// In-binary direct-Bearer: X-User-Id is the UUID subject, X-User-Name carries the name.
{"in-binary direct bearer", "z", "hanzo/z"},
// No X-User-Name: the "<owner>/<name>" key form folds back via PayerOf.
{"owner/name id, no X-User-Name", "", "hanzo/z"},
} {
t.Run(tc.name, func(t *testing.T) {
fin := &fakeFinance{wallets: map[string]int64{}}
publishFinance(t, fin)
app := mountApp(t, "", "")
req := httptest.NewRequest(http.MethodGet, "/v1/billing/balance", nil)
req.Header.Set("X-User-Id", tc.userID)
req.Header.Set("X-Org-Id", "hanzo")
if tc.userName != "" {
req.Header.Set("X-User-Name", tc.userName)
}
resp, err := app.Fiber().Test(req)
if err != nil {
t.Fatalf("Test: %v", err)
}
_ = resp.Body.Close()
if fin.calls == 0 {
t.Fatal("finance ledger was never read")
}
if fin.gotSubj != want {
t.Fatalf("console read wallet %q but the ai gate reads %q — the view and the gate disagree", fin.gotSubj, want)
}
})
}
}
// TestBalance_UnreadableIsNotZero is the fail-closed guard. A balance that cannot be read
// is UNKNOWN, and unknown must never render as a zero balance (a fabricated "you're broke")
// nor as a fabricated positive. It surfaces as an upstream failure.
func TestBalance_UnreadableIsNotZero(t *testing.T) {
publishFinance(t, &fakeFinance{err: fmt.Errorf("ledger open failed")})
app := mountApp(t, "", "")
code, body := call(t, app, http.MethodGet, "/v1/billing/balance", "maxpower/dave", "maxpower")
if code != http.StatusBadGateway {
t.Fatalf("unreadable balance: want 502, got %d (%s)", code, body)
}
// The body must be an honest error, never a balance object — a caller must not be able
// to mistake "unknown" for "zero".
var got map[string]any
if err := json.Unmarshal(body, &got); err != nil {
t.Fatalf("decode: %v (%s)", err, body)
}
if _, isBalance := got["available"]; isBalance {
t.Fatalf("an unreadable balance was rendered as a balance object: %s", body)
}
if got["error"] == nil {
t.Fatalf("want an honest error body, got: %s", body)
}
}
// TestBalance_RequiresSignIn proves the browser-facing sign-in gate is intact: no
// validated principal ⇒ 401, and the ledger is never touched. A forged service-token
// header cannot reach a wallet, because the org comes from the validated principal only.
func TestBalance_RequiresSignIn(t *testing.T) {
fin := &fakeFinance{wallets: map[string]int64{"hanzo|hanzo": 14953300}}
publishFinance(t, fin)
app := mountApp(t, "", "")
// No principal at all.
if code, _ := call(t, app, http.MethodGet, "/v1/billing/balance", "", ""); code != http.StatusUnauthorized {
t.Fatalf("anonymous: want 401, got %d", code)
}
// A client-supplied X-Org-Id with NO validated user is not a principal either — this
// is the off-cluster forge Red will try.
if code, _ := call(t, app, http.MethodGet, "/v1/billing/balance", "", "hanzo"); code != http.StatusUnauthorized {
t.Fatalf("forged org, no principal: want 401, got %d", code)
}
if fin.calls != 0 {
t.Fatalf("the ledger was read %d times without a validated principal", fin.calls)
}
}
// TestBalance_ScopedToCallerOrg proves tenant isolation: the org is taken from the
// VALIDATED principal, so a caller reads only its OWN org's ledger no matter what the
// request says. There is no client-supplied input that can widen it.
func TestBalance_ScopedToCallerOrg(t *testing.T) {
fin := &fakeFinance{wallets: map[string]int64{
"victim|victim": 999999,
"attacker|attacker": 5,
}}
publishFinance(t, fin)
app := mountApp(t, "", "")
// Attacker is validated in its OWN org and tries to name the victim's org/subject
// through every client-controlled channel the old proxy forwarded.
code, body := call(t, app, http.MethodGet,
"/v1/billing/balance?user=victim&userId=victim&customerId=victim&org=victim&currency=usd",
"attacker/mallory", "attacker")
if code != 200 {
t.Fatalf("want 200, got %d (%s)", code, body)
}
if fin.gotOrg != "attacker" {
t.Fatalf("cross-org read: ledger org = %q, want attacker", fin.gotOrg)
}
var got commerceBalance
_ = json.Unmarshal(body, &got)
if got.Available == 999999 {
t.Fatalf("attacker read the victim's balance: %s", body)
}
}
+50 -3
View File
@@ -246,6 +246,24 @@ func usage(s *cloud.Service[state], c *zip.Ctx) error {
if !ok {
return zip.ErrUnauthorized("sign in to view billing")
}
// Co-resident, read the usage ledger DIRECTLY from cloud's own finance ledger
// (usage_coresident.go explains why this is NOT a commerce proxy: proxying
// "/v1/billing/usage" re-enters THIS handler — commerce's own /v1/billing/usage
// route is behind //go:build cloud and never compiled here, so the only
// registration of that path is this handler — and the in-proc S2S hop carries no
// validated principal, so usage() self-answered "sign in to view billing"; that
// self-dispatch is the 500 a valid caller saw). This is the exact move balance()
// already makes. Off the co-resident path the commerce S2S proxy is unchanged.
if body, coResident, err := coResidentUsage(c.Context(), org, strings.TrimSpace(c.Query("product")), strings.TrimSpace(c.Query("groupBy"))); err != nil {
s.Log.Warn("finance usage read failed", "org", org, "err", err)
return zip.Errorf(http.StatusBadGateway, "billing upstream unreachable")
} else if coResident {
c.SetHeader("Content-Type", "application/json")
c.SetHeader("Cache-Control", "no-store")
return c.Bytes(http.StatusOK, body)
}
if !s.State.commerce.configured() {
return zip.Errorf(http.StatusNotImplemented, "billing is not configured")
}
@@ -267,10 +285,39 @@ func usage(s *cloud.Service[state], c *zip.Ctx) error {
return c.Bytes(status, body)
}
// balance → commerce GET /v1/billing/balance: the org's prepaid credit balance
// ({balance,holds,available} in USD cents), the SAME wallet the gateway debits.
// balance answers the caller's prepaid credit balance ({balance,holds,available} in USD
// cents) the SAME wallet the ai prepaid gate reads, the edge meter debits, and an admin
// grant credits.
//
// Co-resident it reads cloud's own finance ledger DIRECTLY (balance.go explains why this
// is NOT a commerce proxy: proxying "/v1/billing/balance" re-enters THIS handler, because
// commerceinproc dispatches the shared app by path and commerce's own billing routes are
// never registered in this binary — the proxy called itself and answered "sign in to view
// billing"). Off the co-resident path the commerce S2S proxy is unchanged.
//
// Holds are the ai gate's in-pod reservations, not a persisted ledger position (see
// types.FinanceClient.Balance), so the settled balance IS the available balance here.
func balance(s *cloud.Service[state], c *zip.Ctx) error {
return proxy(s, c, "/v1/billing/balance", "currency")
org, ok := principal.Org(c)
if !ok {
// A customer's OWN billing — never admin-gate it; an absent identity is a
// true "not signed in" (401), matching usage/gpuCharge.
return zip.ErrUnauthorized("sign in to view billing")
}
cents, coResident, err := availableCents(c.Context(), org, subjectFor(c, org))
if err != nil {
// A balance that cannot be READ is unknown — surface it as an upstream failure.
// It must never render as a zero balance: unknown is not "broke".
s.Log.Warn("finance balance read failed", "org", org, "err", err)
return zip.Errorf(http.StatusBadGateway, "billing upstream unreachable")
}
if !coResident {
return proxy(s, c, "/v1/billing/balance", "currency") // split deploy
}
c.SetHeader("Content-Type", "application/json")
// Per-tenant money must never be cached by the browser or an intermediary.
c.SetHeader("Cache-Control", "no-store")
return c.JSON(http.StatusOK, commerceBalance{Balance: cents, Holds: 0, Available: cents})
}
// gpuEligibility → commerce GET /v1/billing/gpu-eligibility: the read-only launch gate
+22 -7
View File
@@ -199,6 +199,21 @@ func financeBalance(s *cloud.Service[state], c *zip.Ctx) error {
if !ok {
return zip.ErrUnauthorized("sign in to view finance")
}
// The ONE balance read (balance.go) — the same wallet /v1/billing/balance answers, so
// the two surfaces can never disagree. Co-resident this is the finance ledger; only a
// split deploy falls through to the commerce S2S read below.
if cents, coResident, err := availableCents(c.Context(), org, subjectFor(c, org)); err != nil {
s.Log.Warn("finance balance read failed", "org", org, "err", err)
return zip.Errorf(http.StatusBadGateway, "billing upstream unreachable")
} else if coResident {
return financeJSON(s, c, financeBalanceView{
Currency: "usd",
AvailableCents: cents,
PendingCents: 0,
DueCents: 0,
AsOf: time.Now().UTC().Format(time.RFC3339),
})
}
if !s.State.commerce.configured() {
return zip.Errorf(http.StatusNotImplemented, "billing is not configured")
}
@@ -339,7 +354,7 @@ func financePaymentMethods(s *cloud.Service[state], c *zip.Ctx) error {
return zip.Errorf(http.StatusNotImplemented, "billing is not configured")
}
// Portal read filters on customerId; the subject is pinned to the caller's own org.
body, status, err := s.State.commerce.get(c.Context(), "/v1/billing/portal/payment-methods", org, financeSubject(org, nil))
body, status, err := s.State.commerce.get(c.Context(), "/v1/billing/portal/payment-methods", org, financeSubject(subjectFor(c, org), nil))
if err != nil {
s.Log.Warn("commerce payment-methods read failed", "org", org, "err", err)
return zip.Errorf(http.StatusBadGateway, "billing upstream unreachable")
@@ -421,12 +436,12 @@ func financeCaller(s *cloud.Service[state], c *zip.Ctx) (string, bool) {
return principal.Org(c)
}
// financeSubject builds the commerce query with every billing-subject key PINNED to org
// (the client can never widen scope), plus any extra passthrough params.
func financeSubject(org string, extra url.Values) url.Values {
// financeSubject builds the commerce query with every billing-subject key PINNED to
// subject (the client can never widen scope), plus any extra passthrough params.
func financeSubject(subject string, extra url.Values) url.Values {
q := url.Values{}
for _, k := range billingSubjectKeys {
q.Set(k, org)
q.Set(k, subject)
}
for k, vs := range extra {
for _, v := range vs {
@@ -439,7 +454,7 @@ func financeSubject(org string, extra url.Values) url.Values {
// financeGet does one org-scoped commerce GET and decodes the 2xx body into out. A
// non-2xx or unreachable upstream is surfaced honestly (never masked as empty data).
func financeGet(s *cloud.Service[state], c *zip.Ctx, path, org string, extra url.Values, out any) error {
body, status, err := s.State.commerce.get(c.Context(), path, org, financeSubject(org, extra))
body, status, err := s.State.commerce.get(c.Context(), path, org, financeSubject(subjectFor(c, org), extra))
if err != nil {
s.Log.Warn("commerce finance read failed", "org", org, "path", path, "err", err)
return zip.Errorf(http.StatusBadGateway, "billing upstream unreachable")
@@ -457,7 +472,7 @@ func financeGet(s *cloud.Service[state], c *zip.Ctx, path, org string, extra url
// credits/usage/ledger projections share). Tolerates the wrapped {transactions:[…]}
// shape and a bare array.
func financeTxns(s *cloud.Service[state], c *zip.Ctx, org string) ([]commerceTxn, error) {
body, status, err := s.State.commerce.get(c.Context(), "/v1/billing/transactions", org, financeSubject(org, url.Values{"limit": {"2000"}}))
body, status, err := s.State.commerce.get(c.Context(), "/v1/billing/transactions", org, financeSubject(subjectFor(c, org), url.Values{"limit": {"2000"}}))
if err != nil {
s.Log.Warn("commerce transactions read failed", "org", org, "err", err)
return nil, zip.Errorf(http.StatusBadGateway, "billing upstream unreachable")
+84
View File
@@ -0,0 +1,84 @@
package billing
// usage_coresident.go — the co-resident source for GET /v1/billing/usage.
//
// WHY IT EXISTS. balance() already reads cloud's OWN finance ledger directly rather
// than proxying "/v1/billing/balance" through commerceinproc, because co-resident the
// ONLY registration of that path is balance() itself (commerce's api.Route() is behind
// //go:build cloud and never compiled here), so the S2S proxy re-dispatches BY PATH
// straight back into the same handler, which self-answers "sign in to view billing"
// (the in-proc hop carries no validated principal). usage() had the SAME defect on
// "/v1/billing/usage" — a valid caller's usage read re-entered usage() and failed. This
// file gives usage() the co-resident answer balance() already has: the usage ledger read
// straight from finance (the wallet→revenue debits RecordUsage wrote), off the
// self-dispatching hop. Split deploy (no co-resident finance) falls back to the commerce
// S2S read, unchanged.
import (
"context"
"encoding/json"
"time"
"github.com/hanzoai/cloud/clients/finance"
)
// coResidentUsage builds the customer usage envelope from cloud's OWN finance ledger
// when the money plane is co-resident (finance.Current() published AND exposes the
// usage read). It returns (body, true, nil) with the commerce-shaped
// {user,count,usage:[...]} envelope — enriched + optionally ?product=filtered /
// ?groupBy=product-reduced exactly like the proxied path — or (nil, false, nil) when
// finance is not co-resident (split deploy), so the caller falls back to the commerce
// S2S read. This is what keeps /v1/billing/usage off the self-dispatching commerceinproc
// hop; a real read failure surfaces as a non-nil error (never a masked-empty ledger).
func coResidentUsage(ctx context.Context, org, product, groupBy string) ([]byte, bool, error) {
fin := finance.Current()
if fin == nil {
return nil, false, nil // split deploy → commerce S2S read
}
// The usage read is an OPTIONAL capability (the base FinanceClient is
// Balance+Deposit+RecordUsage); a finance impl without it falls back to the proxy.
lister, ok := fin.(interface {
ListUsage(context.Context, string, int) ([]finance.UsageRow, error)
})
if !ok {
return nil, false, nil
}
rows, err := lister.ListUsage(ctx, org, 2000)
if err != nil {
return nil, false, err
}
env := usageEnvelope(org, rows)
if out, ok := enrichUsageLedger(env, product, groupBy); ok {
return out, true, nil
}
return env, true, nil
}
// usageEnvelope renders finance usage rows as commerce's GetUsage envelope
// ({user,count,usage:[{transactionId,amount,metadata,createdAt}]}) — the exact shape the
// console's normalizeUsageRecords + this package's enrichUsageLedger already parse.
// amount is USD cents; metadata carries the metered unit (model) the debit recorded, so
// enrichUsageLedger can still attribute a product where the unit implies one.
func usageEnvelope(org string, rows []finance.UsageRow) []byte {
type usageRow struct {
TransactionID string `json:"transactionId"`
Amount int64 `json:"amount"`
Metadata map[string]any `json:"metadata"`
CreatedAt string `json:"createdAt"`
}
out := make([]usageRow, 0, len(rows))
for _, r := range rows {
md := map[string]any{}
if r.Model != "" {
md["model"] = r.Model
}
out = append(out, usageRow{
TransactionID: r.ID,
Amount: r.Cents,
Metadata: md,
CreatedAt: time.Unix(r.CreatedAt, 0).UTC().Format(time.RFC3339),
})
}
body, _ := json.Marshal(map[string]any{"user": org, "count": len(out), "usage": out})
return body
}
+89
View File
@@ -0,0 +1,89 @@
package billing
import (
"context"
"encoding/json"
"testing"
"github.com/hanzoai/cloud/clients/finance"
)
// TestCoResidentUsage proves usage() answers from the finance ledger (never the
// self-dispatching commerce hop) and shapes the commerce GetUsage envelope the
// console parses. fakeFinance + publishFinance live in balance_test.go.
func TestCoResidentUsage(t *testing.T) {
publishFinance(t, &fakeFinance{usageRows: []finance.UsageRow{
{ID: "u1", Cents: 150, Model: "gpt-x", CreatedAt: 1_700_000_000},
{ID: "u2", Cents: 75, Model: "embed-y", CreatedAt: 1_700_000_100},
}})
body, coResident, err := coResidentUsage(context.Background(), "acme", "", "")
if err != nil {
t.Fatalf("coResidentUsage: %v", err)
}
if !coResident {
t.Fatal("want coResident=true when finance is published")
}
var env struct {
User string `json:"user"`
Count int `json:"count"`
Usage []struct {
TransactionID string `json:"transactionId"`
Amount int64 `json:"amount"`
Metadata map[string]any `json:"metadata"`
CreatedAt string `json:"createdAt"`
} `json:"usage"`
}
if err := json.Unmarshal(body, &env); err != nil {
t.Fatalf("envelope not valid JSON: %v\n%s", err, body)
}
if env.User != "acme" || env.Count != 2 || len(env.Usage) != 2 {
t.Fatalf("bad envelope: user=%q count=%d rows=%d", env.User, env.Count, len(env.Usage))
}
if env.Usage[0].TransactionID != "u1" || env.Usage[0].Amount != 150 {
t.Errorf("row0 = %+v", env.Usage[0])
}
if env.Usage[0].Metadata["model"] != "gpt-x" {
t.Errorf("row0 metadata missing model: %+v", env.Usage[0].Metadata)
}
if env.Usage[0].CreatedAt == "" {
t.Error("row0 createdAt should be RFC3339, got empty")
}
}
// TestCoResidentUsageSplitDeploy proves that with no co-resident finance, usage()
// falls through to the commerce S2S proxy (coResident=false), unchanged.
func TestCoResidentUsageSplitDeploy(t *testing.T) {
finance.Publish(nil)
body, coResident, err := coResidentUsage(context.Background(), "acme", "", "")
if err != nil {
t.Fatalf("coResidentUsage: %v", err)
}
if coResident || body != nil {
t.Fatalf("want fall-through (coResident=false, nil body); got coResident=%v body=%s", coResident, body)
}
}
// TestCoResidentUsageGroupBy proves the ?groupBy=product reduction still runs on the
// co-resident path (a token-metered row attributes to product "inference").
func TestCoResidentUsageGroupBy(t *testing.T) {
publishFinance(t, &fakeFinance{usageRows: []finance.UsageRow{{ID: "u1", Cents: 150, Model: "gpt-x", CreatedAt: 1_700_000_000}}})
body, ok, err := coResidentUsage(context.Background(), "acme", "", "product")
if err != nil || !ok {
t.Fatalf("coResidentUsage groupBy: ok=%v err=%v", ok, err)
}
var grouped struct {
GroupBy string `json:"groupBy"`
Groups []struct {
Product string `json:"product"`
Requests int `json:"requests"`
AmountCents int64 `json:"amountCents"`
} `json:"groups"`
}
if err := json.Unmarshal(body, &grouped); err != nil {
t.Fatalf("grouped not valid JSON: %v\n%s", err, body)
}
if grouped.GroupBy != "product" || len(grouped.Groups) != 1 {
t.Fatalf("bad grouped envelope: %s", body)
}
}
-209
View File
@@ -1,209 +0,0 @@
package bot
import (
"bufio"
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strings"
)
// coding.go is the IN-PROCESS client for bot-gateway's native coding-task runner
// (POST /v1/coding-tasks). It is the ONE wire seam cloud uses to hand a coding job
// to the sandbox runtime — distinct from the /v1/bot/* reverse proxy (bot.go),
// which relays an inbound browser/API request. Here cloud ORIGINATES the call
// server-side (from a Slack coding trigger), so it mints the identity headers
// itself (X-Org-Id / X-User-Id) and presents the shared gateway service token as
// the pod-boundary bearer.
//
// The transport is a line-delimited JSON (NDJSON) stream: bot-gateway emits one
// JSON object per line as the run progresses (clone → dev exec → commit → push),
// each a `step`/`log`, and a final `result` (or `error`) line. Cloud mirrors every
// step into the agent session as it arrives, so `GET /v1/agents/sessions/:id/stream`
// carries the run live, and returns the terminal result.
//
// CREDENTIAL CUSTODY: the per-org agent git credential travels in the request
// BODY (never on a URL, never on argv, never logged). bot-gateway injects it into
// git via an env-fed http.extraHeader inside the sandbox; cloud never logs the
// Credential field and this client never places it in an error string.
// Credential is the per-org agent git credential the sandbox presents to native
// git. Token is the secret (an hk- key); Username is the basic-auth user label.
// Marshaled into the request body only — never logged.
type Credential struct {
Username string `json:"username"`
Token string `json:"token"`
}
// CodingTaskRequest is the cloud→bot contract for one coding run.
type CodingTaskRequest struct {
CloneURL string `json:"cloneUrl"` // https://<domain>/v1/git/<org>/<repo>.git
BaseBranch string `json:"baseBranch"` // branch to start from (default repo default)
Branch string `json:"branch"` // branch to create + push (e.g. agent/<sessionid>)
Prompt string `json:"prompt"` // the engineering task
SessionID string `json:"sessionId"` // cloud session id (correlation)
RunTimeoutSeconds int `json:"runTimeoutSeconds"` // sandbox run budget
Credential Credential `json:"credential"` // agent git credential (write-only)
}
// CodingStep is one progress event mirrored into the session. Type is "step" or
// "log"; Step names the phase (clone|plan|edit|commit|push); Status is ok|error
// for a completed phase. All fields are safe to surface (no credential).
type CodingStep struct {
Type string `json:"type"`
Step string `json:"step,omitempty"`
Message string `json:"message,omitempty"`
Status string `json:"status,omitempty"`
}
// CodingTaskResult is the terminal outcome bot-gateway reports.
type CodingTaskResult struct {
Branch string `json:"branch"`
CommitSha string `json:"commitSha"`
Diffstat string `json:"diffstat"`
Changed bool `json:"changed"`
OK bool `json:"ok"`
LogTail string `json:"logTail"`
Error string `json:"error,omitempty"`
}
// codingEnvelope is the discriminated line shape: one of step/log/result/error.
type codingEnvelope struct {
Type string `json:"type"` // step | log | result | error
Step string `json:"step,omitempty"`
Message string `json:"message,omitempty"`
Status string `json:"status,omitempty"`
Branch string `json:"branch,omitempty"`
CommitSha string `json:"commitSha,omitempty"`
Diffstat string `json:"diffstat,omitempty"`
Changed bool `json:"changed,omitempty"`
OK bool `json:"ok,omitempty"`
LogTail string `json:"logTail,omitempty"`
}
const (
// codingLineCap bounds one NDJSON line (a diffstat/log line can be large but
// never unbounded) so a hostile/huge line can't exhaust cloud memory.
codingLineCap = 1 << 20 // 1 MiB per line
// codingErrBodyCap bounds the non-2xx error body we read for a message.
codingErrBodyCap = 64 << 10
)
// codingHTTP has NO client-side timeout: a coding run legitimately streams for
// minutes. The deadline is the caller's ctx (bounded by the coding job), applied
// to the request; a stuck stream is cut when ctx expires.
var codingHTTP = &http.Client{}
// RunCodingTask POSTs a coding job to bot-gateway and streams the NDJSON result,
// invoking onStep for each progress line as it arrives. It returns the terminal
// result. A non-2xx status, a transport error, or a stream that ends without a
// terminal line is an error (no partial success is fabricated). org/userID are
// the gateway-minted tenant context bot-gateway trusts AFTER its own bearer gate.
func RunCodingTask(ctx context.Context, org, userID string, req CodingTaskRequest, onStep func(CodingStep)) (CodingTaskResult, error) {
payload, err := json.Marshal(req)
if err != nil {
return CodingTaskResult{}, fmt.Errorf("bot: marshal coding request: %w", err)
}
// This POST carries the org's hk- git credential AND the shared gateway bearer,
// so it must not travel cleartext. Require https by default; a plaintext in-cluster
// target is allowed ONLY when the operator asserts the hop is secured by mesh mTLS
// (BOT_GATEWAY_ALLOW_PLAINTEXT=1) — an explicit, documented trust decision, never a
// silent default.
target, terr := codingTaskTarget()
if terr != nil {
return CodingTaskResult{}, terr
}
hreq, err := http.NewRequestWithContext(ctx, http.MethodPost, target, bytes.NewReader(payload))
if err != nil {
return CodingTaskResult{}, fmt.Errorf("bot: build coding request: %w", err)
}
hreq.Header.Set("Content-Type", "application/json")
hreq.Header.Set("Accept", "application/x-ndjson")
// Server-originated identity: cloud has already resolved the tenant, so it mints
// the headers bot-gateway trusts (post pod-boundary auth). The service bearer is
// the shared gateway token (KMS-injected env); absent => bot-gateway fails the
// request closed at its auth gate.
hreq.Header.Set("X-Org-Id", org)
if userID != "" {
hreq.Header.Set("X-User-Id", userID)
}
if tok := getenv("BOT_GATEWAY_TOKEN"); tok != "" {
hreq.Header.Set("Authorization", "Bearer "+tok)
}
resp, err := codingHTTP.Do(hreq)
if err != nil {
return CodingTaskResult{}, fmt.Errorf("bot: coding gateway unreachable: %w", err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(io.LimitReader(resp.Body, codingErrBodyCap))
return CodingTaskResult{}, fmt.Errorf("bot: coding gateway status %d: %s",
resp.StatusCode, strings.TrimSpace(string(body)))
}
sc := bufio.NewScanner(resp.Body)
sc.Buffer(make([]byte, 0, 64<<10), codingLineCap)
var result CodingTaskResult
var gotTerminal bool
for sc.Scan() {
line := bytes.TrimSpace(sc.Bytes())
if len(line) == 0 {
continue
}
var env codingEnvelope
if err := json.Unmarshal(line, &env); err != nil {
continue // skip a malformed line rather than abort the whole run
}
switch env.Type {
case "result":
result = CodingTaskResult{
Branch: env.Branch, CommitSha: env.CommitSha, Diffstat: env.Diffstat,
Changed: env.Changed, OK: env.OK, LogTail: env.LogTail,
}
gotTerminal = true
case "error":
result = CodingTaskResult{OK: false, LogTail: env.LogTail, Error: nonEmpty(env.Message, "coding task failed")}
gotTerminal = true
default: // step | log — mirror live
if onStep != nil {
onStep(CodingStep{Type: env.Type, Step: env.Step, Message: env.Message, Status: env.Status})
}
}
}
if err := sc.Err(); err != nil {
return CodingTaskResult{}, fmt.Errorf("bot: coding stream read: %w", err)
}
if !gotTerminal {
return CodingTaskResult{}, fmt.Errorf("bot: coding stream ended without a result")
}
return result, nil
}
// codingTaskTarget resolves the /v1/coding-tasks endpoint and fails closed on a
// cleartext http:// target — the request carries the org's git credential and the
// gateway bearer. BOT_GATEWAY_ALLOW_PLAINTEXT=1 is the explicit, operator-set
// opt-out for a deployment whose bot-gateway hop is secured by mesh mTLS.
func codingTaskTarget() (string, error) {
base := botURL()
u, err := url.Parse(base)
if err != nil {
return "", fmt.Errorf("bot: invalid gateway url")
}
if u.Scheme != "https" && getenv("BOT_GATEWAY_ALLOW_PLAINTEXT") != "1" {
return "", fmt.Errorf("bot: refusing to send the coding credential over cleartext %q (set BOT_GATEWAY_URL to https, or BOT_GATEWAY_ALLOW_PLAINTEXT=1 if the hop is mesh-mTLS secured)", u.Scheme)
}
return base + "/v1/coding-tasks", nil
}
// nonEmpty returns a when non-empty, else b.
func nonEmpty(a, b string) string {
if strings.TrimSpace(a) != "" {
return a
}
return b
}
+135 -413
View File
@@ -1,166 +1,98 @@
// Package bots mounts the Hanzo Cloud POST /v1/bots/run surface: launch a
// computer-using agent (a "bot") — a booted desktop or terminal sandbox the
// operative computer-use runtime drives to do a task — and hand back a LIVE
// Package bots is the CONTROL PLANE for a bot run: a task the bot runtime
// executes on a surface — a desktop or terminal sandbox it drives — with a LIVE
// session (the URL the hanzo.app /vnc panel embeds to watch/attach).
//
// This handler is a THIN ORCHESTRATOR, deliberately. It does NOT boot machines,
// speak VNC, or reimplement visor: those live in the separate TS `bot` service
// (its gateway exposes the browser<->gateway<->node HMAC VNC tunnel at
// /vnc?nodeId=<id>) and in visor (machine provisioning). This handler owns the
// three things a cloud control-plane owns:
// A bot run is ONE value with ONE home. It is not the bot MACHINE that hosts a
// runtime (visor's /v1/compute/bots — a machine you rent), and it is not the
// runtime service itself (clients/runtime — the transport to the executor).
//
// 1. authenticate the caller (a run MOVES MONEY, so a VALIDATED principal is
// required — never a bare, forgeable org header);
// 2. gate + meter the run against the caller's OWN org ledger (a flat per-run
// fee, the same ResourceMeter path every non-LLM resource uses);
// 3. mint the run id and return the session descriptor whose sessionUrl points
// at the bot VNC gateway for that run.
// CLOUD OWNS POLICY, THE RUNTIME OWNS THE RUN. The sandbox lives in the runtime,
// keyed in the runtime's own store under the tenant that started it; that store is
// the only thing that knows whether a run is alive. So this package keeps no
// second copy of it. It owns what a control plane owns — who you are, which org
// you are, and whether you may — and then asks the runtime, which IS the registry.
// Copying that state into cloud would create a second id space agreeing with
// nothing: listing runs that do not exist and stopping runs never started.
//
// Tenant isolation is the gateway-minted X-Org-Id (HIP-0026), resolved via
// principal.Org and NEVER read from the request body — so one tenant can
// never launch, or bill, a bot against another's org.
// Isolation: the org is the gateway-minted X-Org-Id (HIP-0026) resolved via
// principal.Org, NEVER a request field, and it is what cloud sends the runtime,
// which keys every run under tenants/{org}/. A caller cannot name another tenant's
// org, so it cannot read or stop another tenant's runs; a foreign run id resolves
// under the CALLER's org, where it does not exist, and answers 404.
//
// Surface (org-scoped; the CLI `hanzo bot run` calls it):
// Surface (org-scoped; the console BotsApi and the CLI `hanzo bot run` call it):
//
// POST /v1/bots/run {task, surface, gpu, timeout} -> {runId, status, sessionUrl}
//
// The billed unit is a flat per-RUN fee — the honest, policy-set unit a bot
// launch bills. GB-seconds / GPU-hour metering is intentionally NOT fabricated
// here: this endpoint launches the run, it does not observe its runtime
// footprint (visor/bot-gateway do). The fee is ops-configurable per deployment
// via cloud.ResourceFeeCents(botFeeEnvPrefix, meterKind); set it to 0 to make
// bot launches free (and therefore un-gated), exactly like the agents run fee.
// POST /v1/bots/run -> 501: no runtime launch operation exists yet
// GET /v1/bots -> {bots:[{runId,task,surface,status,sessionUrl,startedAt}]}
// POST /v1/bots/:runId/stop -> {runId, status}
package bots
import (
"bytes"
"crypto/rand"
"encoding/hex"
"encoding/json"
"context"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"os"
"strings"
"time"
"github.com/hanzoai/cloud"
"github.com/hanzoai/cloud/clients/metering"
"github.com/hanzoai/cloud/clients/principal"
"github.com/hanzoai/cloud/clients/runtime"
"github.com/zap-proto/zip"
)
const (
// meterKind is the commerce "provider"/attribution label for bot spend — the
// product:"bot". One value so every bot launch is attributed identically on
// the ledger.
meterKind = "bot"
// botFeeEnvPrefix is the operator knob for the flat per-run launch fee. The
// effective fee is cloud.ResourceFeeCents(botFeeEnvPrefix, meterKind): a
// CLOUD_BOT_FEE_CENTS override wins over the $1.00 default; set it to 0 to
// make bot launches free (and therefore un-gated). A flat per-RUN fee — the
// policy-set unit a launch bills; NOT a fabricated GB-second/GPU-hour price.
botFeeEnvPrefix = "CLOUD_BOT_FEE_CENTS"
// gatewayURLEnv configures the browser-facing bot VNC gateway base — the
// public origin the TS bot service serves /vnc?nodeId=<id> from, which the
// hanzo.app /vnc panel embeds. It is DISTINCT from clients/bot's server-side
// BOT_GATEWAY_URL (the in-cluster reverse-proxy target http://bot-gateway.hanzo.svc,
// which a browser cannot reach): a session URL must be publicly embeddable, so
// it carries its own knob. Ops sets it per deployment/brand.
// gatewayURLEnv configures the browser-facing bot VNC gateway base — the public
// origin the TS bot service serves /vnc?nodeId=<id> from, which the hanzo.app
// /vnc panel embeds. It is DISTINCT from the runtime's in-cluster address
// (clients/runtime's BOT_GATEWAY_URL, a pod-internal DNS name a browser cannot
// reach): a session URL must be publicly embeddable, so it carries its own knob.
gatewayURLEnv = "CLOUD_BOT_GATEWAY_URL"
defaultGatewayURL = "https://bot.hanzo.ai"
// serverGatewayURLEnv is the IN-CLUSTER, server-side bot-gateway base the
// control plane calls to list/stop live runs. It is the SAME knob clients/bot's
// reverse proxy uses (BOT_GATEWAY_URL, default http://bot-gateway.hanzo.svc) —
// NOT the browser-facing gatewayURLEnv above (a pod-internal DNS name a browser
// can't reach). List/stop are server->server calls, so they ride the in-cluster
// target; only the returned sessionUrl carries the public origin.
serverGatewayURLEnv = "BOT_GATEWAY_URL"
defaultServerGatewayURL = "http://bot-gateway.hanzo.svc"
// maxRunID bounds the :runId path param before it is sent onward — an oversize
// id is not a run this org owns, so it is a 404 like any other miss.
maxRunID = 128
// gatewayCallTimeout bounds a single list/stop round-trip to the bot-gateway so
// a hung gateway can't stall the control-plane request; on timeout list is
// honest-empty and stop is a clean 502.
gatewayCallTimeout = 15 * time.Second
// maxTask bounds the launch task/prompt at the create boundary.
maxTask = 32 * 1024
// maxTimeout caps the requested wall-clock so a client can't ask for an
// unbounded run; the runtime enforces the real limit, this is the sane input
// bound.
maxTimeout = 24 * time.Hour
surfaceDesktop = "desktop"
surfaceTerminal = "terminal"
// statusRunning is the launch outcome this endpoint reports: the run was
// authorized, metered, and its live session URL is returned for the client to
// attach to. The bot's fine-grained runtime lifecycle (booting/ready/stopped)
// is visor/bot-gateway's concern and is observed there — this orchestration
// record carries ONE status: the launch succeeded.
// statusRunning is what a listed run reports when the runtime names no status of
// its own. statusStopped is the terminal outcome a stop reports.
statusRunning = "running"
// statusStopped is the terminal outcome POST /v1/bots/:runId/stop reports once
// the bot-gateway has torn the run's live session down.
statusStopped = "stopped"
)
// identityHeaders are the gateway-minted tenant-context headers forwarded on a
// server-side list/stop call so the bot-gateway scopes the operation to the SAME
// caller — the exact set clients/bot's reverse proxy forwards. X-Org-Id is set
// explicitly to the validated org (never the raw request header), so a forged
// org can never reach the gateway; the rest ride through for the audit trail.
var identityHeaders = []string{
"Authorization", "X-User-Id", "X-User-Email", "X-Project-Id", "X-Environment",
// Runtime is the seam onto the run registry — the bot runtime, which owns the
// sandboxes and is therefore the only truthful answer to "what is running". Bound
// to the real transport in wire.go; a fake in tests.
//
// Every method takes org FIRST and the runtime scopes by it. The seam carries no
// authority: cloud decides WHETHER a caller may ask, the runtime answers WHAT it
// holds for that org.
type Runtime interface {
List(ctx context.Context, org string) ([]Run, error)
Stop(ctx context.Context, org, runID string) error
}
// Run is one bot run as the runtime reports it.
type Run struct {
ID string
Task string
Surface string
Status string
StartedAt string // RFC3339, as the runtime stamps it
}
// state is bots' own data; shared deps live in the embedded cloud.Base.
type state struct {
// bill is the shared per-org gate+meter (reuses deps.Metering, the ONE
// commerce client the agents/provisioning/ml subsystems use). Nil/!Enabled()
// makes Gate allow and Meter a no-op, so an unconfigured deployment launches
// bots without billing rather than failing closed on a missing ledger. It stays
// here (not Base.Bill) because its provider label is meterKind ("bot"), which
// diverges from the subsystem name ("bots") — Base.Bill would attribute to the
// wrong product.
bill *cloud.ResourceMeter
// gateway is the browser-facing bot VNC gateway base (no trailing slash) that
// every returned sessionUrl is derived from.
gateway string
// serverGateway is the in-cluster bot-gateway base (no trailing slash) the
// control plane calls server-side to list + stop an org's live runs.
serverGateway string
// cc is the outbound client for those server->server calls; a bounded timeout
// keeps a hung gateway from stalling the request (list falls back to empty).
cc *http.Client
}
// runReq is the boot-a-computer-using-agent body — the exact shape the CLI
// (cli/bot.go BotRunReq) sends. Org is NEVER here: it is the gateway-minted
// X-Org-Id, resolved from the validated principal, never the body.
type runReq struct {
Task string `json:"task"`
Surface string `json:"surface"` // desktop | terminal
GPU bool `json:"gpu"`
Timeout string `json:"timeout"` // optional wall-clock, e.g. "30m"
}
// runView is the live-session descriptor — the exact shape the CLI
// (cli/bot.go BotRunResult) decodes: the run id, its status, and the URL the
// hanzo.app /vnc panel embeds.
type runView struct {
RunID string `json:"runId"`
Status string `json:"status"`
SessionURL string `json:"sessionUrl"`
// runtime is the run registry — what list reads and what stop drives.
runtime Runtime
}
// botView is one row of GET /v1/bots — the console list item. sessionUrl is
// derived control-plane side from runId (the ONE place a session URL is built,
// sessionURL below), so the gateway never has to know its own public origin.
// derived control-plane side from runId (the ONE place a session URL is built), so
// the runtime never has to know its own public origin.
type botView struct {
RunID string `json:"runId"`
Task string `json:"task"`
@@ -170,8 +102,8 @@ type botView struct {
StartedAt string `json:"startedAt"`
}
// botsView is the GET /v1/bots envelope; Bots is always non-nil so an empty org
// (or an unreachable gateway) serializes as {"bots":[]}, never {"bots":null}.
// botsView is the GET /v1/bots envelope; Bots is always non-nil so an org with no
// runs serializes as {"bots":[]}, never {"bots":null}.
type botsView struct {
Bots []botView `json:"bots"`
}
@@ -182,20 +114,7 @@ type stopView struct {
Status string `json:"status"`
}
// gatewayBot is the bot-gateway's session shape: the caller's run minus the
// sessionUrl (control-plane-derived). Its own /v1/bots emits exactly these
// fields; a shape drift that fails to decode collapses to honest-empty.
type gatewayBot struct {
RunID string `json:"runId"`
Task string `json:"task"`
Surface string `json:"surface"`
Status string `json:"status"`
StartedAt string `json:"startedAt"`
}
// Mount wires the bots surface onto app per HIP-0106. Constructs the value directly
// (cloud.NewBase) because the metered launch fee uses a meter keyed to meterKind
// ("bot"), not the subsystem name — so it lives in State, built from Deps here.
// Mount wires the bots surface onto app per HIP-0106.
func Mount(app *zip.App, deps cloud.Deps) error {
if app == nil {
return fmt.Errorf("bots.Mount: nil zip.App")
@@ -204,109 +123,45 @@ func Mount(app *zip.App, deps cloud.Deps) error {
return fmt.Errorf("bots.Mount: nil deps.Logger")
}
s := &cloud.Service[state]{
Base: cloud.NewBase(deps, "bots"),
State: state{
bill: cloud.NewResourceMeter(deps, meterKind),
gateway: gatewayBase(),
serverGateway: serverGatewayBase(),
cc: &http.Client{Timeout: gatewayCallTimeout},
},
Base: cloud.NewBase(deps, "bots"),
State: state{gateway: gatewayBase(), runtime: wire{}},
}
routes(app, s)
s.Log.Info("bots surface mounted", "gateway", s.State.gateway,
"serverGateway", s.State.serverGateway, "billing", s.State.bill.Enabled(),
"brand", deps.Brand)
s.Log.Info("bots surface mounted", "gateway", s.State.gateway, "brand", deps.Brand)
return nil
}
// routes registers the bots surface: launch, list, and stop. list/stop are the
// read/lifecycle half — org-scoped proxies onto the bot-gateway's live runs.
// routes registers the bots surface. The static /run literal and the :runId param
// are resolved by specificity, so /v1/bots/run can never bind as a run id.
func routes(app *zip.App, s *cloud.Service[state]) {
app.Post("/v1/bots/run", cloud.Handle(s, run))
app.Get("/v1/bots", cloud.Handle(s, list))
app.Post("/v1/bots/:runId/stop", cloud.Handle(s, stop))
}
// run launches a computer-using bot: it authenticates the caller, gates+meters a
// flat per-run fee against the caller's OWN org, mints the run id, and returns
// the live VNC session descriptor. Every 200 reflects an authorized, metered
// launch — an unfunded org gets 402 and no session, an unreachable commerce 503.
func run(s *cloud.Service[state], c *zip.Ctx) error {
org, ok := principal.Org(c)
if !ok {
return zip.ErrForbidden("X-Org-Id required")
}
// A launch MOVES MONEY (debits the org's commerce ledger), so it requires a
// VALIDATED principal — never a bare, forgeable X-Org-Id from the direct-to-pod
// path. Same money-path guard the agents run + s3 + provisioning surfaces ship.
if !principal.Validated(c) {
return zip.ErrForbidden("a validated principal is required to launch a bot")
}
var body runReq
if err := c.Bind(&body); err != nil {
return err
}
task := strings.TrimSpace(body.Task)
if task == "" {
return zip.ErrBadRequest("task is required")
}
if len(task) > maxTask {
return zip.ErrBadRequest("task too large")
}
surface, err := validateSurface(body.Surface)
if err != nil {
return err
}
if err := validateTimeout(body.Timeout); err != nil {
return err
}
runID, err := genID("bot")
if err != nil {
return zip.Errorf(http.StatusInternalServerError, "rng: %v", err)
}
// Pre-authorize the CALLER's org balance BEFORE returning a session (fail-
// closed): an unfunded org gets 402 and no bot, an unreachable commerce 503.
// project = the caller's validated org sub-scope, so a per-scope spend cap is
// enforced on a bot launch exactly as on the request edge. fee<=0 or
// unconfigured billing makes this a no-op (allow).
fee := cloud.ResourceFeeCents(botFeeEnvPrefix, meterKind)
project, projectValidated := principal.ValidatedProject(c)
if gateErr := s.State.bill.Gate(c.Context(), principal.Payer(c), project, projectValidated, meterKind, fee); gateErr != nil {
return cloud.DenyResource(c, gateErr)
}
// The launch is authorized. The durable, attributable record of this run is
// the commerce ledger debit (product=bot, the surface as the billed unit, the
// acting principal for the audit trail) — fire-and-forget, exactly like the
// agents run fee. GPU/surface ride the log line for operator visibility.
s.State.bill.MeterUsage(principal.Payer(c), meterKind, metering.Usage{
AmountCents: fee,
Model: surface,
Actor: billingActor(org, c.User()),
RequestID: c.RequestID(),
ClientIP: cloud.ClientIP(c),
})
s.Log.Info("bot launched", "org", org, "run", runID,
"surface", surface, "gpu", body.GPU)
return c.JSON(http.StatusOK, runView{
RunID: runID,
Status: statusRunning,
SessionURL: sessionURL(s, runID),
})
// run reports that launching is not implemented.
//
// There is no launch operation on the bot runtime, so nothing in cloud can start a
// sandbox. This endpoint used to mint a run id, charge a flat per-run fee, and hand
// back a sessionUrl for a bot that never booted — an id the runtime had never heard
// of, pointing at a VNC node that did not exist, for money that was really taken.
// 501 is the truth, and the truth is cheaper than a plausible lie.
//
// Restoring it needs a runtime-side launch operation first (TS, cross-repo); the
// gate and the meter belong in the same change that can prove a bot boots.
func run(_ *cloud.Service[state], _ *zip.Ctx) error {
return zip.Errorf(http.StatusNotImplemented,
"launching a bot is not implemented: the bot runtime exposes no launch operation, so cloud cannot start one")
}
// list returns the caller org's live bot runs. It proxies the bot-gateway's
// org-scoped session list (server-side, forwarding the caller's tenant context)
// and normalizes each row into the console contract, deriving sessionUrl here.
// list returns the caller org's live bot runs, read from the runtime and projected
// into the console contract with sessionUrl derived here.
//
// The org is ALWAYS the validated principal's org, NEVER a request param — one
// tenant can never enumerate another's runs. It is honest-empty by construction:
// an unconfigured or unreachable gateway, a non-2xx, or a shape it can't decode
// all yield {"bots":[]} (a 200), never a 5xx — the console renders "no bots"
// rather than an error when the runtime plane is simply down.
// The org is ALWAYS the validated principal's org, NEVER a request param, and it is
// what scopes the runtime's answer — so one tenant can never enumerate another's
// runs. A runtime that cannot answer is an error, not an empty list: [] would tell
// the caller "your org has no runs", which is a different claim from "we could not
// ask", and the difference is the whole reason this endpoint exists.
func list(s *cloud.Service[state], c *zip.Ctx) error {
org, ok := principal.Org(c)
if !ok {
@@ -314,18 +169,47 @@ func list(s *cloud.Service[state], c *zip.Ctx) error {
}
// Org-scoping is only trustworthy behind a validated principal: a bare,
// forgeable X-Org-Id (the direct-to-pod path) must not enumerate a victim
// tenant's runs. Same guard clients/bot's proxy applies before handing the
// gateway a tenant context.
// tenant's runs.
if !principal.Validated(c) {
return zip.ErrForbidden("a validated principal is required to list bots")
}
return c.JSON(http.StatusOK, botsView{Bots: fetchBots(s, c, org)})
runs, err := s.State.runtime.List(c.Context(), org)
if err != nil {
return zip.Errorf(http.StatusBadGateway, "bots: the runtime could not list this org's runs: %v", err)
}
out := make([]botView, 0, len(runs))
for _, r := range runs {
out = append(out, toBotView(s, r))
}
return c.JSON(http.StatusOK, botsView{Bots: out})
}
// stop terminates one of the caller org's live runs. It proxies the bot-gateway's
// org-scoped stop; a run the caller's org does not own is a 404 (never a 200,
// never another tenant's teardown). An unreachable gateway is a clean 502 — a
// stop that could not reach the runtime must not claim the run was stopped.
// toBotView projects a run into one list row, deriving sessionUrl from the run id.
func toBotView(s *cloud.Service[state], r Run) botView {
status := strings.TrimSpace(r.Status)
if status == "" {
status = statusRunning
}
return botView{
RunID: r.ID,
Task: r.Task,
Surface: r.Surface,
Status: status,
SessionURL: sessionURL(s, r.ID),
StartedAt: r.StartedAt,
}
}
// stop terminates one of the caller org's own runs.
//
// The own-key guard is the org: it is the caller's validated org, never theirs to
// choose, and the runtime resolves the run id UNDER it. A run belonging to another
// tenant is not among this org's runs, so it answers absent — the same 404 a
// nonexistent id gets, which is what keeps this from being an oracle.
//
// Absence is honoured ONLY when the runtime answers it. A runtime that does not
// serve stop reports nothing about the run, and reporting "stopped" on that basis
// would be a stop that cannot fail — so it is a 502.
func stop(s *cloud.Service[state], c *zip.Ctx) error {
org, ok := principal.Org(c)
if !ok {
@@ -338,175 +222,33 @@ func stop(s *cloud.Service[state], c *zip.Ctx) error {
if runID == "" {
return zip.ErrBadRequest("runId is required")
}
code, err := stopBot(s, c, org, runID)
if err != nil {
return zip.Errorf(http.StatusBadGateway, "bots: gateway unreachable: %v", err)
if len(runID) > maxRunID {
return zip.ErrNotFound("no such bot for this org")
}
switch {
case code >= 200 && code < 300:
switch err := s.State.runtime.Stop(c.Context(), org, runID); {
case err == nil:
s.Log.Info("bot stopped", "org", org, "run", runID)
return c.JSON(http.StatusOK, stopView{RunID: runID, Status: statusStopped})
case code == http.StatusNotFound:
case errors.Is(err, runtime.ErrNotFound):
return zip.ErrNotFound("no such bot for this org")
case errors.Is(err, runtime.ErrNotServed):
return zip.Errorf(http.StatusBadGateway,
"bots: the runtime does not serve stop, so this run's state is unknown — it was NOT stopped")
default:
return zip.Errorf(http.StatusBadGateway, "bots: gateway rejected stop (%d)", code)
return zip.Errorf(http.StatusBadGateway, "bots: the runtime could not stop this run: %v", err)
}
}
// fetchBots calls the bot-gateway's GET /v1/bots scoped to org and maps the
// result into the contract. Every failure path — no server gateway configured,
// build error, transport error, non-2xx, or an undecodable body — returns a
// non-nil empty slice so the caller serializes {"bots":[]} and never a 5xx.
func fetchBots(s *cloud.Service[state], c *zip.Ctx, org string) []botView {
out := []botView{}
if s.State.serverGateway == "" {
return out
}
req, err := gatewayRequest(c, http.MethodGet, s.State.serverGateway+"/v1/bots", org, nil)
if err != nil {
return out
}
resp, err := s.State.cc.Do(req)
if err != nil {
s.Log.Warn("bots list: gateway unreachable, returning empty", "org", org, "err", err)
return out
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
s.Log.Warn("bots list: gateway non-2xx, returning empty", "org", org, "status", resp.StatusCode)
return out
}
body, err := io.ReadAll(io.LimitReader(resp.Body, 8<<20))
if err != nil {
return out
}
var decoded struct {
Bots []gatewayBot `json:"bots"`
}
if err := json.Unmarshal(body, &decoded); err != nil {
s.Log.Warn("bots list: undecodable gateway body, returning empty", "org", org, "err", err)
return out
}
for _, b := range decoded.Bots {
runID := strings.TrimSpace(b.RunID)
if runID == "" {
continue
}
status := strings.TrimSpace(b.Status)
if status == "" {
status = statusRunning
}
out = append(out, botView{
RunID: runID,
Task: b.Task,
Surface: b.Surface,
Status: status,
SessionURL: sessionURL(s, runID),
StartedAt: b.StartedAt,
})
}
return out
}
// stopBot calls the bot-gateway's POST /v1/bots/{runId}/stop scoped to org and
// returns the gateway status code. A transport failure is a non-nil error the
// caller maps to 502; the status code drives 200-vs-404.
func stopBot(s *cloud.Service[state], c *zip.Ctx, org, runID string) (int, error) {
if s.State.serverGateway == "" {
return 0, fmt.Errorf("bot gateway not configured")
}
target := s.State.serverGateway + "/v1/bots/" + url.PathEscape(runID) + "/stop"
req, err := gatewayRequest(c, http.MethodPost, target, org, bytes.NewReader(nil))
if err != nil {
return 0, err
}
resp, err := s.State.cc.Do(req)
if err != nil {
return 0, err
}
defer func() { _ = resp.Body.Close() }()
_, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 1<<20))
return resp.StatusCode, nil
}
// gatewayRequest builds a server-side call to the bot-gateway carrying the
// caller's tenant context: X-Org-Id is pinned to the validated org, the other
// identity headers ride through, so the gateway scopes to exactly this caller.
func gatewayRequest(c *zip.Ctx, method, target, org string, body io.Reader) (*http.Request, error) {
req, err := http.NewRequestWithContext(c.Context(), method, target, body)
if err != nil {
return nil, err
}
for _, h := range identityHeaders {
if v := c.Header(h); v != "" {
req.Header.Set(h, v)
}
}
// Pin the validated org last so a forged X-Org-Id in the incoming headers can
// never override the tenant the gateway scopes to.
req.Header.Set("X-Org-Id", org)
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
return req, nil
}
// sessionURL derives the live VNC session URL for a run: the browser-facing bot
// gateway base + the node's VNC path. One id per run — the run id IS the node id
// the bot machine for this run registers under, so the tunnel is addressable by
// exactly the id the client holds.
// gateway base + the node's VNC path. The run id IS the node id the runtime
// registers the session under, so the tunnel is addressable by exactly the id the
// client holds.
func sessionURL(s *cloud.Service[state], runID string) string {
return s.State.gateway + "/vnc?" + url.Values{"nodeId": {runID}}.Encode()
}
// validateSurface normalizes the requested sandbox. Empty defaults to desktop
// (the noVNC GUI); an unknown surface is a clean 400 rather than a launch the
// runtime can't honor.
func validateSurface(raw string) (string, error) {
switch strings.TrimSpace(strings.ToLower(raw)) {
case "", surfaceDesktop:
return surfaceDesktop, nil
case surfaceTerminal:
return surfaceTerminal, nil
default:
return "", zip.ErrBadRequest("surface must be 'desktop' or 'terminal'")
}
}
// validateTimeout bounds the optional wall-clock at the boundary: it must parse
// as a Go duration and be within (0, maxTimeout]. Empty means "runtime default".
func validateTimeout(raw string) error {
raw = strings.TrimSpace(raw)
if raw == "" {
return nil
}
d, err := time.ParseDuration(raw)
if err != nil {
return zip.ErrBadRequest("invalid 'timeout': " + err.Error())
}
if d <= 0 || d > maxTimeout {
return zip.ErrBadRequest("timeout must be > 0 and <= 24h")
}
return nil
}
// billingActor is the "org/sub" identity recorded on a debit for the audit
// trail. It never selects which balance is gated — that is always the org — but
// attributes the spend to the acting principal. Falls back to the bare org when
// no validated subject is present.
func billingActor(org, sub string) string {
sub = strings.TrimSpace(sub)
if org != "" && sub != "" {
return org + "/" + sub
}
if sub != "" {
return sub
}
return org
}
// gatewayBase resolves the browser-facing bot VNC gateway base (no trailing
// slash) from CLOUD_BOT_GATEWAY_URL, falling back to the public default.
// gatewayBase resolves the browser-facing bot VNC gateway base (no trailing slash)
// from CLOUD_BOT_GATEWAY_URL, falling back to the public default.
func gatewayBase() string {
base := strings.TrimSpace(os.Getenv(gatewayURLEnv))
if base == "" {
@@ -514,23 +256,3 @@ func gatewayBase() string {
}
return strings.TrimRight(base, "/")
}
// serverGatewayBase resolves the in-cluster bot-gateway base (no trailing slash)
// the control plane calls server-side for list/stop, from BOT_GATEWAY_URL — the
// SAME knob clients/bot's reverse proxy uses — falling back to the in-cluster
// service DNS default.
func serverGatewayBase() string {
base := strings.TrimSpace(os.Getenv(serverGatewayURLEnv))
if base == "" {
base = defaultServerGatewayURL
}
return strings.TrimRight(base, "/")
}
func genID(prefix string) (string, error) {
var b [16]byte
if _, err := rand.Read(b[:]); err != nil {
return "", err
}
return prefix + "_" + hex.EncodeToString(b[:]), nil
}
+315 -187
View File
@@ -1,94 +1,124 @@
package bots
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
"github.com/hanzoai/cloud"
"github.com/hanzoai/cloud/clients/runtime"
luxlog "github.com/luxfi/log"
"github.com/zap-proto/zip"
)
// gwServer is a minimal bot-gateway double for the list/stop path. It records
// the X-Org-Id it was called with (a wrong tenant here would prove a cross-tenant
// leak) and serves configurable list/stop responses so the cloud proxy's
// normalization + honest-empty behavior can be asserted hermetically.
type gwServer struct {
bots []map[string]any // rows GET /v1/bots returns
listCode int // status for GET /v1/bots (0 => 200)
stopCode int // status for POST /v1/bots/{id}/stop (0 => 200)
// The control plane against a fake runtime. The fake is GENUINELY org-scoped (a
// run lives under exactly one org key), so a test that reads another tenant's run
// has to get past the same boundary the real runtime enforces by tenant path — a
// handler that forgot to pass the validated org, or passed a client-supplied one,
// fails here.
mu sync.Mutex
listOrg string // X-Org-Id seen on the last list
stopOrg string // X-Org-Id seen on the last stop
stopID string // runId parsed from the last stop path
type runKey struct{ org, id string }
type listCall struct{ org string }
type fakeRuntime struct {
mu sync.Mutex
rows map[runKey]Run
lists []listCall
stops []runKey
// Injected outcomes for the honest-failure paths.
listErr, stopErr error
}
func (g *gwServer) start(t *testing.T) string {
t.Helper()
mux := http.NewServeMux()
mux.HandleFunc("/v1/bots", func(w http.ResponseWriter, r *http.Request) {
g.mu.Lock()
g.listOrg = r.Header.Get("X-Org-Id")
g.mu.Unlock()
if g.listCode != 0 {
w.WriteHeader(g.listCode)
return
func newFake() *fakeRuntime { return &fakeRuntime{rows: map[runKey]Run{}} }
func (f *fakeRuntime) seed(org string, r Run) {
f.mu.Lock()
defer f.mu.Unlock()
f.rows[runKey{org, r.ID}] = r
}
func (f *fakeRuntime) List(_ context.Context, org string) ([]Run, error) {
f.mu.Lock()
defer f.mu.Unlock()
f.lists = append(f.lists, listCall{org})
if f.listErr != nil {
return nil, f.listErr
}
var out []Run
for k, r := range f.rows {
if k.org == org {
out = append(out, r)
}
_ = json.NewEncoder(w).Encode(map[string]any{"bots": g.bots})
})
mux.HandleFunc("/v1/bots/", func(w http.ResponseWriter, r *http.Request) {
// path is /v1/bots/{id}/stop
id := strings.TrimSuffix(strings.TrimPrefix(r.URL.Path, "/v1/bots/"), "/stop")
g.mu.Lock()
g.stopOrg, g.stopID = r.Header.Get("X-Org-Id"), id
g.mu.Unlock()
if g.stopCode != 0 {
w.WriteHeader(g.stopCode)
return
}
w.WriteHeader(http.StatusOK)
})
srv := httptest.NewServer(mux)
t.Cleanup(srv.Close)
return srv.URL
}
return out, nil
}
func (g *gwServer) seenListOrg() string {
g.mu.Lock()
defer g.mu.Unlock()
return g.listOrg
func (f *fakeRuntime) Stop(_ context.Context, org, runID string) error {
f.mu.Lock()
defer f.mu.Unlock()
f.stops = append(f.stops, runKey{org, runID})
if f.stopErr != nil {
return f.stopErr
}
k := runKey{org, runID}
if _, ok := f.rows[k]; !ok {
// The real runtime resolves under tenants/{org}/ and ANSWERS absent.
return runtime.ErrNotFound
}
delete(f.rows, k)
return nil
}
func (g *gwServer) seenStop() (string, string) {
g.mu.Lock()
defer g.mu.Unlock()
return g.stopOrg, g.stopID
func (f *fakeRuntime) stopCalls() []runKey {
f.mu.Lock()
defer f.mu.Unlock()
return append([]runKey(nil), f.stops...)
}
// mountGW mounts the bots surface with the server-side bot-gateway pinned to
// serverURL (empty => the in-cluster default, i.e. unreachable in tests). It
// reuses mount(t, "") so the browser-facing gateway base stays the deterministic
// https://bot.example.test that sessionUrl assertions depend on.
func mountGW(t *testing.T, serverURL string) *zip.App {
func (f *fakeRuntime) listCalls() []listCall {
f.mu.Lock()
defer f.mu.Unlock()
return append([]listCall(nil), f.lists...)
}
func (f *fakeRuntime) has(org, id string) bool {
f.mu.Lock()
defer f.mu.Unlock()
_, ok := f.rows[runKey{org, id}]
return ok
}
// mountWith builds the surface over an injected runtime, exactly as Mount does over
// the real one — routes() is the shared registration path, so what a test drives is
// the code that ships.
func mountWith(t *testing.T, rt Runtime) *zip.App {
t.Helper()
t.Setenv(serverGatewayURLEnv, serverURL)
return mount(t, "")
t.Setenv(gatewayURLEnv, "https://bot.example.test")
s := &cloud.Service[state]{
Base: cloud.NewBase(cloud.Deps{Logger: luxlog.New("test")}, "bots"),
State: state{gateway: gatewayBase(), runtime: rt},
}
app := zip.New(zip.Config{Logger: luxlog.New("test"), DisableStartupMessage: true})
routes(app, s)
return app
}
// getBots issues GET /v1/bots with the gateway-minted identity headers (org +
// its validated X-User-Id). An empty org sends neither (the anonymous path).
func getBots(t *testing.T, app *zip.App, org string) (int, []byte) {
// call sends a request with the gateway-minted identity headers. A non-empty org
// also sets X-User-Id (the validated principal); an empty org sends neither.
func call(t *testing.T, app *zip.App, method, path, org string) (int, []byte) {
t.Helper()
req := httptest.NewRequest(http.MethodGet, "/v1/bots", nil)
req := httptest.NewRequest(method, path, nil)
if org != "" {
req.Header.Set("X-Org-Id", org)
req.Header.Set("X-User-Id", "u-"+org)
}
resp, err := app.Fiber().Test(req)
resp, err := app.Fiber().Test(req, testCfg)
if err != nil {
t.Fatalf("Test: %v", err)
}
@@ -97,176 +127,274 @@ func getBots(t *testing.T, app *zip.App, org string) (int, []byte) {
return resp.StatusCode, b
}
// stopBotReq issues POST /v1/bots/{runID}/stop with identity headers.
func stopBotReq(t *testing.T, app *zip.App, org, runID string) (int, []byte) {
func listRunIDs(t *testing.T, body []byte) []string {
t.Helper()
req := httptest.NewRequest(http.MethodPost, "/v1/bots/"+runID+"/stop", nil)
if org != "" {
req.Header.Set("X-Org-Id", org)
req.Header.Set("X-User-Id", "u-"+org)
var v botsView
if err := json.Unmarshal(body, &v); err != nil {
t.Fatalf("decode list: %v (%s)", err, body)
}
resp, err := app.Fiber().Test(req)
if err != nil {
t.Fatalf("Test: %v", err)
ids := make([]string, 0, len(v.Bots))
for _, b := range v.Bots {
ids = append(ids, b.RunID)
}
defer func() { _ = resp.Body.Close() }()
b, _ := io.ReadAll(resp.Body)
return resp.StatusCode, b
return ids
}
// TestListRequiresOrg: no X-Org-Id is 403 — no tenant, no list.
func TestListRequiresOrg(t *testing.T) {
app := mountGW(t, "http://127.0.0.1:1")
if code, _ := getBots(t, app, ""); code != http.StatusForbidden {
t.Fatalf("no-org list want 403, got %d", code)
// ---- run ----
// Launching is not implemented and must say so rather than charge for a bot that
// never boots. The runtime has no launch operation, so a 200 here would be a lie
// with a price on it.
func TestRunIsNotImplementedAndStartsNothing(t *testing.T) {
rt := newFake()
app := mountWith(t, rt)
if code, _ := call(t, app, http.MethodPost, "/v1/bots/run", "acme"); code != http.StatusNotImplemented {
t.Fatalf("launch want 501, got %d", code)
}
// Nothing was started, so nothing may be listed as started.
_, body := call(t, app, http.MethodGet, "/v1/bots", "acme")
if ids := listRunIDs(t, body); len(ids) != 0 {
t.Fatalf("a refused launch must not produce a run, got %v", ids)
}
}
// TestListRequiresValidatedPrincipal: a bare, forgeable X-Org-Id (no validated
// X-User-Id) is 403 — an unvalidated caller cannot enumerate a victim tenant.
func TestListRequiresValidatedPrincipal(t *testing.T) {
app := mountGW(t, "http://127.0.0.1:1")
req := httptest.NewRequest(http.MethodGet, "/v1/bots", nil)
req.Header.Set("X-Org-Id", "acme") // forged; no X-User-Id => no validated principal
resp, err := app.Fiber().Test(req)
if err != nil {
t.Fatalf("Test: %v", err)
}
_ = resp.Body.Close()
if resp.StatusCode != http.StatusForbidden {
t.Fatalf("no-principal list want 403, got %d", resp.StatusCode)
}
}
// ---- list ----
// TestListNormalizesAndScopesToCaller: the gateway's session rows are normalized
// into the contract (sessionUrl derived control-plane side from runId), and the
// gateway is called scoped to the CALLER org (acme), never the client default.
func TestListNormalizesAndScopesToCaller(t *testing.T) {
gw := &gwServer{bots: []map[string]any{
{"runId": "bot_abc", "task": "summarize inbox", "surface": "desktop", "status": "running", "startedAt": "2026-07-11T00:00:00Z"},
{"runId": "bot_def", "task": "book flight", "surface": "terminal", "status": "running", "startedAt": "2026-07-11T01:00:00Z"},
}}
app := mountGW(t, gw.start(t))
// A list returns the CALLER's runs and only those, scoped by the validated org the
// runtime is asked with — never a client-supplied one.
func TestListIsScopedToTheCallerOrg(t *testing.T) {
rt := newFake()
rt.seed("acme", Run{ID: "run_acme", Task: "acme work", Surface: "desktop", Status: "running", StartedAt: "2023-11-14T22:13:20Z"})
rt.seed("globex", Run{ID: "run_globex", Task: "globex secret", Surface: "terminal", Status: "running", StartedAt: "2023-11-14T22:13:20Z"})
app := mountWith(t, rt)
code, body := getBots(t, app, "acme")
code, body := call(t, app, http.MethodGet, "/v1/bots", "acme")
if code != http.StatusOK {
t.Fatalf("list want 200, got %d (%s)", code, body)
}
var got botsView
if err := json.Unmarshal(body, &got); err != nil {
t.Fatalf("shape: %v (%s)", err, body)
if ids := listRunIDs(t, body); len(ids) != 1 || ids[0] != "run_acme" {
t.Fatalf("acme must see exactly its own run, got %v (%s)", ids, body)
}
if len(got.Bots) != 2 {
t.Fatalf("want 2 bots, got %d (%s)", len(got.Bots), body)
}
first := got.Bots[0]
if first.RunID != "bot_abc" || first.Task != "summarize inbox" || first.Surface != "desktop" {
t.Fatalf("row not normalized: %+v", first)
}
if first.Status != statusRunning {
t.Fatalf("status want %q, got %q", statusRunning, first.Status)
}
if first.StartedAt != "2026-07-11T00:00:00Z" {
t.Fatalf("startedAt passthrough want set, got %q", first.StartedAt)
}
// sessionUrl is derived here from the browser-facing base + nodeId=runId, NOT
// taken from the gateway — the ONE place a session URL is built.
want := "https://bot.example.test/vnc?nodeId=bot_abc"
if first.SessionURL != want {
t.Fatalf("sessionUrl want %q, got %q", want, first.SessionURL)
}
if org := gw.seenListOrg(); org != "acme" {
t.Fatalf("gateway saw X-Org-Id=%q, want caller %q (never default 'hanzo')", org, "acme")
// The runtime was asked with the caller's validated org, nothing else.
if got := rt.listCalls(); len(got) != 1 || got[0].org != "acme" {
t.Fatalf("runtime must be asked with the validated org only, got %+v", got)
}
}
// TestListHonestEmptyWhenGatewayUnreachable: an unreachable gateway yields a 200
// {"bots":[]}, never a 5xx — the console renders "no bots", not an error.
func TestListHonestEmptyWhenGatewayUnreachable(t *testing.T) {
app := mountGW(t, "http://127.0.0.1:1") // connection refused
code, body := getBots(t, app, "acme")
if code != http.StatusOK {
t.Fatalf("unreachable list want 200, got %d (%s)", code, body)
// The row carries the run's real attributes as the runtime reported them, plus a
// sessionUrl derived here from the runtime's own id.
func TestListRowShape(t *testing.T) {
rt := newFake()
rt.seed("acme", Run{ID: "run_1", Task: "ship it", Surface: "terminal", Status: "running", StartedAt: "2023-11-14T22:13:20Z"})
app := mountWith(t, rt)
_, body := call(t, app, http.MethodGet, "/v1/bots", "acme")
var v botsView
if err := json.Unmarshal(body, &v); err != nil {
t.Fatalf("decode: %v", err)
}
if len(v.Bots) != 1 {
t.Fatalf("want 1 row, got %d", len(v.Bots))
}
want := botView{
RunID: "run_1", Task: "ship it", Surface: "terminal", Status: "running",
SessionURL: "https://bot.example.test/vnc?nodeId=run_1",
StartedAt: "2023-11-14T22:13:20Z",
}
if v.Bots[0] != want {
t.Fatalf("row\n got %+v\nwant %+v", v.Bots[0], want)
}
assertEmptyBots(t, body)
}
// TestListHonestEmptyOnGatewayError: a non-2xx from the gateway is also honest-
// empty, not a propagated 5xx.
func TestListHonestEmptyOnGatewayError(t *testing.T) {
gw := &gwServer{listCode: http.StatusInternalServerError}
app := mountGW(t, gw.start(t))
code, body := getBots(t, app, "acme")
if code != http.StatusOK {
t.Fatalf("gateway-500 list want 200, got %d (%s)", code, body)
// No tenant, no read — and the runtime is never even asked.
func TestListFailsClosedWithoutTenant(t *testing.T) {
rt := newFake()
rt.seed("acme", Run{ID: "run_acme", Status: "running"})
app := mountWith(t, rt)
if code, _ := call(t, app, http.MethodGet, "/v1/bots", ""); code != http.StatusForbidden {
t.Fatalf("no-tenant list want 403, got %d", code)
}
if got := rt.listCalls(); len(got) != 0 {
t.Fatalf("runtime asked without a tenant: %+v", got)
}
assertEmptyBots(t, body)
}
// TestStopStopsCallerRun: a stop the gateway accepts returns {runId,"stopped"}
// and forwards the CALLER org + the exact runId to the gateway.
func TestStopStopsCallerRun(t *testing.T) {
gw := &gwServer{}
app := mountGW(t, gw.start(t))
// A forged X-Org-Id with no validated principal (the direct-to-pod path) must not
// enumerate the victim tenant: principal.Org requires a validated X-User-Id.
func TestListRefusesForgedOrgWithoutValidatedPrincipal(t *testing.T) {
rt := newFake()
rt.seed("acme", Run{ID: "run_acme", Task: "secret", Status: "running"})
app := mountWith(t, rt)
code, body := stopBotReq(t, app, "acme", "bot_abc")
req := httptest.NewRequest(http.MethodGet, "/v1/bots", nil)
req.Header.Set("X-Org-Id", "acme") // forged: no X-User-Id
resp, err := app.Fiber().Test(req, testCfg)
if err != nil {
t.Fatalf("Test: %v", err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusForbidden {
t.Fatalf("forged-org list want 403, got %d", resp.StatusCode)
}
if got := rt.listCalls(); len(got) != 0 {
t.Fatalf("runtime asked on a forged org: %+v", got)
}
}
// A runtime that cannot answer is an error, never an empty list: [] would claim
// "your org has no runs", which is not what we learned.
func TestListReportsRuntimeFailureInsteadOfEmpty(t *testing.T) {
rt := newFake()
rt.listErr = fmt.Errorf("runtime is down")
app := mountWith(t, rt)
code, body := call(t, app, http.MethodGet, "/v1/bots", "acme")
if code != http.StatusBadGateway {
t.Fatalf("runtime failure want 502, got %d (%s)", code, body)
}
}
// ---- stop ----
// One tenant may not stop another's run. The id is real and the caller is
// validated — and it is still a 404, because the runtime is asked under the
// CALLER's org, where that run does not exist.
func TestStopCannotReachAnotherOrgsRun(t *testing.T) {
rt := newFake()
rt.seed("acme", Run{ID: "run_victim", Task: "acme work", Status: "running"})
app := mountWith(t, rt)
code, body := call(t, app, http.MethodPost, "/v1/bots/run_victim/stop", "globex")
if code != http.StatusNotFound {
t.Fatalf("cross-org stop want 404, got %d (%s)", code, body)
}
// The runtime was asked under globex — never under the victim's org.
for _, s := range rt.stopCalls() {
if s.org != "globex" {
t.Fatalf("stop escaped the caller's org: %+v", s)
}
}
if !rt.has("acme", "run_victim") {
t.Fatal("victim run must survive a foreign stop")
}
}
// An unknown id and another tenant's id are indistinguishable: both 404, so the
// endpoint is not an oracle for which run ids exist.
func TestStopUnknownRunIsNotFound(t *testing.T) {
app := mountWith(t, newFake())
if code, _ := call(t, app, http.MethodPost, "/v1/bots/run_nope/stop", "acme"); code != http.StatusNotFound {
t.Fatalf("unknown stop want 404, got %d", code)
}
}
// The happy path: the runtime is driven with the caller's own org and run id, and
// the run is gone from its list.
func TestStopHaltsTheRun(t *testing.T) {
rt := newFake()
rt.seed("acme", Run{ID: "run_1", Task: "work", Status: "running"})
app := mountWith(t, rt)
code, body := call(t, app, http.MethodPost, "/v1/bots/run_1/stop", "acme")
if code != http.StatusOK {
t.Fatalf("stop want 200, got %d (%s)", code, body)
}
var got stopView
if err := json.Unmarshal(body, &got); err != nil {
t.Fatalf("shape: %v (%s)", err, body)
var v stopView
if err := json.Unmarshal(body, &v); err != nil {
t.Fatalf("decode: %v", err)
}
if got.RunID != "bot_abc" || got.Status != statusStopped {
t.Fatalf("stop response want {bot_abc,stopped}, got %+v", got)
if v.RunID != "run_1" || v.Status != statusStopped {
t.Fatalf("stop view %+v", v)
}
org, id := gw.seenStop()
if org != "acme" {
t.Fatalf("gateway saw stop X-Org-Id=%q, want caller %q", org, "acme")
if got := rt.stopCalls(); len(got) != 1 || got[0] != (runKey{"acme", "run_1"}) {
t.Fatalf("runtime must be driven once with the caller's org+run, got %v", got)
}
if id != "bot_abc" {
t.Fatalf("gateway saw stop runId=%q, want %q", id, "bot_abc")
if _, body := call(t, app, http.MethodGet, "/v1/bots", "acme"); len(listRunIDs(t, body)) != 0 {
t.Fatal("a stopped run must leave the list")
}
}
// TestStopNotFound: a run the caller's org does not own (gateway 404) is a 404,
// never a 200 — stop cannot claim a teardown that did not happen.
func TestStopNotFound(t *testing.T) {
gw := &gwServer{stopCode: http.StatusNotFound}
app := mountGW(t, gw.start(t))
if code, body := stopBotReq(t, app, "acme", "bot_nope"); code != http.StatusNotFound {
t.Fatalf("stop of unowned run want 404, got %d (%s)", code, body)
// THE correctness lie this endpoint must never tell: a runtime that does not serve
// stop reports nothing about the run, so claiming "stopped" would make a stop that
// cannot fail. It is a 502, and it says the run was NOT stopped.
func TestStopFailsClosedWhenTheRuntimeDoesNotServeStop(t *testing.T) {
rt := newFake()
rt.seed("acme", Run{ID: "run_1", Status: "running"})
rt.stopErr = runtime.ErrNotServed
app := mountWith(t, rt)
code, body := call(t, app, http.MethodPost, "/v1/bots/run_1/stop", "acme")
if code != http.StatusBadGateway {
t.Fatalf("unserved stop want 502, got %d (%s)", code, body)
}
if !rt.has("acme", "run_1") {
t.Fatal("the run must not be treated as gone when the runtime never answered")
}
}
// TestStopUnreachableIs502: a stop that cannot reach the gateway is a clean 502,
// not a false "stopped".
func TestStopUnreachableIs502(t *testing.T) {
app := mountGW(t, "http://127.0.0.1:1")
if code, _ := stopBotReq(t, app, "acme", "bot_abc"); code != http.StatusBadGateway {
t.Fatalf("stop with unreachable gateway want 502, got %d", code)
// A stop that could not reach the executor must not claim the run was stopped.
func TestStopWithUnreachableRuntimeIs502(t *testing.T) {
rt := newFake()
rt.seed("acme", Run{ID: "run_1", Status: "running"})
rt.stopErr = fmt.Errorf("connection refused")
app := mountWith(t, rt)
if code, _ := call(t, app, http.MethodPost, "/v1/bots/run_1/stop", "acme"); code != http.StatusBadGateway {
t.Fatalf("unreachable runtime want 502, got %d", code)
}
if !rt.has("acme", "run_1") {
t.Fatal("the run must stay live when the halt failed")
}
}
// TestStopRequiresOrg: no X-Org-Id is 403.
func TestStopRequiresOrg(t *testing.T) {
app := mountGW(t, "http://127.0.0.1:1")
if code, _ := stopBotReq(t, app, "", "bot_abc"); code != http.StatusForbidden {
t.Fatalf("no-org stop want 403, got %d", code)
// No tenant, no stop — and the runtime is never driven.
func TestStopFailsClosedWithoutTenant(t *testing.T) {
rt := newFake()
rt.seed("acme", Run{ID: "run_1", Status: "running"})
app := mountWith(t, rt)
if code, _ := call(t, app, http.MethodPost, "/v1/bots/run_1/stop", ""); code != http.StatusForbidden {
t.Fatalf("no-tenant stop want 403, got %d", code)
}
if len(rt.stopCalls()) != 0 {
t.Fatalf("runtime driven without a tenant: %v", rt.stopCalls())
}
if !rt.has("acme", "run_1") {
t.Fatal("run stopped without a tenant")
}
}
func assertEmptyBots(t *testing.T, body []byte) {
t.Helper()
// Must be an explicit empty array, not null — {"bots":[]}.
if !strings.Contains(string(body), `"bots":[]`) {
t.Fatalf("want honest-empty {\"bots\":[]}, got %s", body)
// An oversize id is a miss like any other — it never reaches the runtime.
func TestStopOversizeRunIDIsNotFound(t *testing.T) {
rt := newFake()
app := mountWith(t, rt)
long := make([]byte, maxRunID+1)
for i := range long {
long[i] = 'a'
}
var got botsView
if err := json.Unmarshal(body, &got); err != nil {
t.Fatalf("shape: %v (%s)", err, body)
if code, _ := call(t, app, http.MethodPost, "/v1/bots/"+string(long)+"/stop", "acme"); code != http.StatusNotFound {
t.Fatalf("oversize runId want 404, got %d", code)
}
if len(got.Bots) != 0 {
t.Fatalf("want 0 bots, got %d", len(got.Bots))
if len(rt.stopCalls()) != 0 {
t.Fatalf("runtime driven for an oversize id: %v", rt.stopCalls())
}
}
// /v1/bots/run is the launch literal, never a run id: the router resolves the
// static segment over the :runId param regardless of registration order.
func TestRunLiteralDoesNotBindAsARunID(t *testing.T) {
rt := newFake()
app := mountWith(t, rt)
// POST /v1/bots/run reaches the launch handler (501), NOT the stop handler
// (which would 404 a run named "run" and would have driven the runtime).
if code, _ := call(t, app, http.MethodPost, "/v1/bots/run", "acme"); code != http.StatusNotImplemented {
t.Fatalf("POST /v1/bots/run must hit launch (501), got %d", code)
}
if len(rt.stopCalls()) != 0 {
t.Fatalf("/v1/bots/run bound as a run id and drove a stop: %v", rt.stopCalls())
}
}
-243
View File
@@ -1,243 +0,0 @@
package bots
import (
"bytes"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
"github.com/hanzoai/cloud"
"github.com/hanzoai/cloud/clients/metering"
luxlog "github.com/luxfi/log"
"github.com/zap-proto/zip"
)
// billServer is a minimal commerce double: it returns a fixed balance and
// records the X-Org-Id + usage body of every debit — the SAME double the agents
// billing tests use. A wrong tenant here would prove a cross-tenant leak.
type billServer struct {
available int64
mu sync.Mutex
usageOrg string
usageBody []byte
usages int32
}
func (b *billServer) start(t *testing.T) string {
t.Helper()
mux := http.NewServeMux()
mux.HandleFunc("/v1/billing/balance", func(w http.ResponseWriter, r *http.Request) {
_ = json.NewEncoder(w).Encode(map[string]any{"available": b.available})
})
mux.HandleFunc("/v1/billing/usage", func(w http.ResponseWriter, r *http.Request) {
atomic.AddInt32(&b.usages, 1)
body, _ := io.ReadAll(r.Body)
b.mu.Lock()
b.usageOrg, b.usageBody = r.Header.Get("X-Org-Id"), body
b.mu.Unlock()
w.WriteHeader(http.StatusOK)
_, _ = io.WriteString(w, `{"transactionId":"tx_1","type":"usage"}`)
})
srv := httptest.NewServer(mux)
t.Cleanup(srv.Close)
return srv.URL
}
func (b *billServer) debits() int32 { return atomic.LoadInt32(&b.usages) }
func (b *billServer) lastDebit() (string, []byte) {
b.mu.Lock()
defer b.mu.Unlock()
return b.usageOrg, b.usageBody
}
// waitForDebit polls briefly — debits land on a detached goroutine.
func waitForDebit(cond func() bool) bool {
for i := 0; i < 200; i++ {
if cond() {
return true
}
time.Sleep(5 * time.Millisecond)
}
return cond()
}
// mount builds the bots surface. commerceURL=="" mounts with no billing
// (Gate allows, Meter is a no-op — the unconfigured deployment path).
func mount(t *testing.T, commerceURL string) *zip.App {
t.Helper()
// Pin a deterministic gateway base so the session URL is assertable. Set
// BEFORE Mount, which snapshots gatewayBase() once.
t.Setenv(gatewayURLEnv, "https://bot.example.test")
deps := cloud.Deps{Logger: luxlog.New("test")}
if commerceURL != "" {
// Default org "hanzo" so every "acme is billed" assertion proves the
// per-call org override scopes the ledger to the CALLER, not the default.
m, err := metering.New(metering.Config{BaseURL: commerceURL, Token: "svc-tok", Org: "hanzo"})
if err != nil {
t.Fatalf("metering.New: %v", err)
}
deps.Metering = m
}
app := zip.New(zip.Config{Logger: luxlog.New("test")})
if err := Mount(app, deps); err != nil {
t.Fatalf("Mount: %v", err)
}
return app
}
// do sends a request with the gateway-minted identity headers. A non-empty org
// also sets X-User-Id (the validated principal the money path requires); an
// empty org sends neither (the anonymous path).
func do(t *testing.T, app *zip.App, org string, body any) (int, []byte) {
t.Helper()
var r io.Reader
if body != nil {
b, _ := json.Marshal(body)
r = bytes.NewReader(b)
}
req := httptest.NewRequest(http.MethodPost, "/v1/bots/run", r)
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
if org != "" {
req.Header.Set("X-Org-Id", org)
req.Header.Set("X-User-Id", "u-"+org)
}
resp, err := app.Fiber().Test(req)
if err != nil {
t.Fatalf("Test: %v", err)
}
defer func() { _ = resp.Body.Close() }()
b, _ := io.ReadAll(resp.Body)
return resp.StatusCode, b
}
// TestRunRequiresOrg: a launch with no X-Org-Id is 403 — no tenant, no bot.
func TestRunRequiresOrg(t *testing.T) {
app := mount(t, "")
if code, _ := do(t, app, "", map[string]any{"task": "do a thing"}); code != http.StatusForbidden {
t.Fatalf("no-org launch want 403, got %d", code)
}
}
// TestRunRequiresValidatedPrincipal: a launch carrying only a forgeable X-Org-Id
// (no validated X-User-Id — the direct-to-pod path) is 403. A money-moving
// launch can't ride an unauthenticated org header.
func TestRunRequiresValidatedPrincipal(t *testing.T) {
app := mount(t, "")
req := httptest.NewRequest(http.MethodPost, "/v1/bots/run",
bytes.NewReader([]byte(`{"task":"x"}`)))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-Org-Id", "acme") // forged; no X-User-Id → no validated principal
resp, err := app.Fiber().Test(req)
if err != nil {
t.Fatalf("Test: %v", err)
}
_ = resp.Body.Close()
if resp.StatusCode != http.StatusForbidden {
t.Fatalf("no-principal launch want 403, got %d", resp.StatusCode)
}
}
// TestRunValidatesInput: an empty task is 400; an unknown surface is 400.
func TestRunValidatesInput(t *testing.T) {
app := mount(t, "")
if code, _ := do(t, app, "acme", map[string]any{"task": " "}); code != http.StatusBadRequest {
t.Fatalf("empty task want 400, got %d", code)
}
if code, _ := do(t, app, "acme", map[string]any{"task": "x", "surface": "hologram"}); code != http.StatusBadRequest {
t.Fatalf("unknown surface want 400, got %d", code)
}
if code, _ := do(t, app, "acme", map[string]any{"task": "x", "timeout": "banana"}); code != http.StatusBadRequest {
t.Fatalf("bad timeout want 400, got %d", code)
}
}
// TestRunResponseShapeAndSessionURL: a launch (billing unconfigured → un-gated)
// returns {runId, status, sessionUrl}, with the session URL derived from the
// configured gateway base + nodeId=<runId>.
func TestRunResponseShapeAndSessionURL(t *testing.T) {
app := mount(t, "")
code, body := do(t, app, "acme", map[string]any{"task": "summarize my inbox", "surface": "desktop"})
if code != http.StatusOK {
t.Fatalf("launch want 200, got %d (%s)", code, body)
}
var rv runView
if err := json.Unmarshal(body, &rv); err != nil {
t.Fatalf("shape: %v (%s)", err, body)
}
if !strings.HasPrefix(rv.RunID, "bot_") {
t.Fatalf("runId want bot_ prefix, got %q", rv.RunID)
}
if rv.Status != statusRunning {
t.Fatalf("status want %q, got %q", statusRunning, rv.Status)
}
want := "https://bot.example.test/vnc?nodeId=" + rv.RunID
if rv.SessionURL != want {
t.Fatalf("sessionUrl want %q, got %q", want, rv.SessionURL)
}
}
// TestRunGatesUnfundedOrg: a launch for an org with a balance below the flat fee
// is refused 402 and NEVER debits — the gate is called and fails closed, so an
// unfunded tenant gets no bot.
func TestRunGatesUnfundedOrg(t *testing.T) {
bs := &billServer{available: 0}
app := mount(t, bs.start(t))
code, body := do(t, app, "acme", map[string]any{"task": "x"})
if code != http.StatusPaymentRequired {
t.Fatalf("unfunded launch want 402, got %d (%s)", code, body)
}
if bs.debits() != 0 {
t.Fatalf("a gate-refused launch must not debit, got %d", bs.debits())
}
}
// TestRunDebitsCallerOrg: a funded launch returns the session AND debits the
// CALLER org (acme, never the client default 'hanzo') with product=bot, the flat
// fee, and the surface as the billed unit.
func TestRunDebitsCallerOrg(t *testing.T) {
bs := &billServer{available: 100000}
app := mount(t, bs.start(t))
code, body := do(t, app, "acme", map[string]any{"task": "x", "surface": "terminal"})
if code != http.StatusOK {
t.Fatalf("funded launch want 200, got %d (%s)", code, body)
}
if !waitForDebit(func() bool { return bs.debits() == 1 }) {
t.Fatalf("a launch must debit once, got %d", bs.debits())
}
org, ubody := bs.lastDebit()
if org != "acme" {
t.Fatalf("debited org %q, want caller %q (never default 'hanzo')", org, "acme")
}
var u struct {
User string `json:"user"`
Amount int64 `json:"amount"`
Model string `json:"model"`
Provider string `json:"provider"`
Actor string `json:"actor"`
}
_ = json.Unmarshal(ubody, &u)
if u.User != "acme" {
t.Fatalf("debit user = %q, want caller org %q", u.User, "acme")
}
if u.Amount != cloud.DefaultResourceFeeCents {
t.Fatalf("debit amount = %d, want default fee %d", u.Amount, cloud.DefaultResourceFeeCents)
}
if u.Provider != meterKind {
t.Fatalf("debit provider = %q, want %q (product:bot)", u.Provider, meterKind)
}
if u.Model != surfaceTerminal {
t.Fatalf("debit model = %q, want the surface %q", u.Model, surfaceTerminal)
}
if u.Actor == "" {
t.Fatalf("debit must carry an actor for the audit trail")
}
}
+232
View File
@@ -0,0 +1,232 @@
package bots
import (
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
"github.com/hanzoai/cloud"
"github.com/hanzoai/cloud/clients/runtime"
"github.com/hanzoai/cloud/clients/visor"
luxlog "github.com/luxfi/log"
"github.com/zap-proto/zip"
)
// Regression guard for the /v1/bots route collision.
//
// clients/visor once registered GET /v1/bots for its bot MACHINES and this
// package registered GET /v1/bots for bot RUNS. The router resolves byte-identical
// patterns by first-registration — silently, with no panic — and visor mounts
// first (apps.Wire: visor, then runtime, then bots), so visor's machine list answered
// the console's run list and this package's handler was unreachable. Two values
// sharing one name, one namespace.
//
// These tests pin the fix from both ends: structurally (no two subsystems may
// register the same method+path) and behaviourally (GET /v1/bots serves RUNS),
// with the subsystems mounted in the real Wire order that produced the bug.
// stubRuntime stands in for the bot runtime, serving its documented contract and
// recording the paths + org it was asked with. Real Mounts talk to it through the
// real transport, so these tests exercise the shipping path end to end.
type stubRuntime struct {
mu sync.Mutex
runs map[string][]map[string]any // org -> rows
paths []string
orgs []string
}
func (s *stubRuntime) start(t *testing.T) {
t.Helper()
mux := http.NewServeMux()
mux.HandleFunc("/v1/bots", func(w http.ResponseWriter, r *http.Request) {
org := r.Header.Get("X-Org-Id")
s.mu.Lock()
s.paths, s.orgs = append(s.paths, r.URL.Path), append(s.orgs, org)
rows := s.runs[org]
s.mu.Unlock()
if rows == nil {
rows = []map[string]any{}
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{"bots": rows})
})
mux.HandleFunc("/v1/bots/", func(w http.ResponseWriter, r *http.Request) { // {id}/stop
s.mu.Lock()
s.paths, s.orgs = append(s.paths, r.URL.Path), append(s.orgs, r.Header.Get("X-Org-Id"))
s.mu.Unlock()
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`{"status":"stopped"}`))
})
srv := httptest.NewServer(mux)
t.Cleanup(srv.Close)
t.Setenv("BOT_GATEWAY_URL", srv.URL)
}
func (s *stubRuntime) seen() []string {
s.mu.Lock()
defer s.mu.Unlock()
return append([]string(nil), s.paths...)
}
// mountFleet mounts, in apps.Wire order, the three subsystems that shared the
// /v1/bot* namespace, over a stub runtime.
func mountFleet(t *testing.T, rt *stubRuntime) *zip.App {
t.Helper()
t.Setenv(gatewayURLEnv, "https://bot.example.test")
rt.start(t)
app := zip.New(zip.Config{Logger: luxlog.New("test"), DisableStartupMessage: true})
deps := cloud.Deps{Logger: luxlog.New("test"), DataDir: t.TempDir()}
if err := visor.Mount(app, deps); err != nil { // Wire order: visor first — the shadowing mount
t.Fatalf("visor.Mount: %v", err)
}
if err := runtime.Mount(app, deps); err != nil {
t.Fatalf("runtime.Mount: %v", err)
}
if err := Mount(app, deps); err != nil { // …bots last, as in Wire
t.Fatalf("bots.Mount: %v", err)
}
return app
}
// collisions reports every route claimed by more than one registration.
//
// Counting GetRoutes() entries is NOT enough and looking only at that is how this
// guard was blind at first: the router MERGES byte-identical patterns into ONE
// Route carrying both handlers chained, so the second registration leaves the
// entry count at one and shows up only as a handler count above one. Every real
// route in the fleet registers exactly one handler, so >1 is the collision. The
// entry count is kept as well, for any overlap the router does not merge.
func collisions(app *zip.App) []string {
var out []string
seen := map[string]int{}
for _, r := range app.Fiber().GetRoutes() {
key := r.Method + " " + r.Path
if n := len(r.Handlers); n > 1 {
out = append(out, fmt.Sprintf("%s — %d handlers chained on one route", key, n))
continue
}
if seen[key]++; seen[key] > 1 {
out = append(out, fmt.Sprintf("%s — %d separate registrations", key, seen[key]))
}
}
return out
}
// The guard itself must be shown to FIRE on the bug, or it is decoration. This
// reproduces the original collision — two subsystems, one pattern — and asserts
// collisions() sees it, through the SAME function the real check below uses.
func TestDuplicateRouteGuardDetectsACollision(t *testing.T) {
app := zip.New(zip.Config{Logger: luxlog.New("test"), DisableStartupMessage: true})
app.Get("/v1/bots", func(c *zip.Ctx) error { return c.JSON(200, map[string]any{"bots": []string{"machine"}}) })
app.Get("/v1/bots", func(c *zip.Ctx) error { return c.JSON(200, map[string]any{"bots": []string{"run"}}) })
got := collisions(app)
if len(got) == 0 {
t.Fatal("the guard cannot see the very collision it guards: two GET /v1/bots handlers are registered and collisions() reported none")
}
if !strings.Contains(got[0], "GET /v1/bots") {
t.Fatalf("guard named the wrong route: %v", got)
}
}
// No two subsystems may claim the same method+path. The router does not panic on
// a byte-identical duplicate — it silently merges and keeps the first — so
// nothing but this catches the class of bug.
func TestSubsystemsDoNotRegisterDuplicateRoutes(t *testing.T) {
for _, c := range collisions(mountFleet(t, &stubRuntime{})) {
t.Errorf("route collision: %s — one handler silently shadows the other", c)
}
}
// The behavioural half: GET /v1/bots serves bot RUNS from the runtime. The run the
// runtime holds must come back out — which it cannot do if visor's machine list is
// answering this route, since a machine list never asks the runtime anything.
func TestGetBotsServesRunsNotMachines(t *testing.T) {
rt := &stubRuntime{runs: map[string][]map[string]any{
"acme": {{"runId": "run_1", "task": "prove the route", "surface": "desktop", "status": "running", "startedAt": "2023-11-14T22:13:20Z"}},
}}
app := mountFleet(t, rt)
code, body := call(t, app, http.MethodGet, "/v1/bots", "acme")
if code != http.StatusOK {
t.Fatalf("GET /v1/bots want 200, got %d (%s)", code, body)
}
var v botsView
if err := json.Unmarshal(body, &v); err != nil {
t.Fatalf("decode: %v (%s)", err, body)
}
if len(v.Bots) != 1 || v.Bots[0].RunID != "run_1" {
t.Fatalf("GET /v1/bots must serve the org's runs, got %s", body)
}
// A run row, not a machine row: the fields the console reads are populated.
if v.Bots[0].Task != "prove the route" || v.Bots[0].SessionURL != "https://bot.example.test/vnc?nodeId=run_1" {
t.Fatalf("row is not a run: %+v", v.Bots[0])
}
// It reached the RUNTIME — the only place a run has ever existed.
if got := rt.seen(); len(got) != 1 || got[0] != "/v1/bots" {
t.Fatalf("bots must read the runtime's run list, saw %v", got)
}
}
// The bot MACHINE surface still exists — under visor's own namespace, where a
// machine belongs. It is reachable and it is NOT /v1/bots.
func TestBotMachineSurfaceMovedToCompute(t *testing.T) {
app := mountFleet(t, &stubRuntime{})
paths := map[string]bool{}
for _, r := range app.Fiber().GetRoutes() {
paths[r.Method+" "+r.Path] = true
}
for _, want := range []string{
"GET /v1/compute/bots",
"POST /v1/compute/bots/launch",
"GET /v1/compute/bots/:id",
"DELETE /v1/compute/bots/:id",
"POST /v1/compute/bots/:id/:action",
} {
if !paths[want] {
t.Errorf("bot machine route %q is missing", want)
}
}
// …and nothing but the run control plane claims /v1/bots.
for _, gone := range []string{"GET /v1/bots/:id", "DELETE /v1/bots/:id", "POST /v1/bots/launch", "POST /v1/bots/:id/:action"} {
if paths[gone] {
t.Errorf("machine route %q still squats on the run namespace", gone)
}
}
}
// The three values keep three namespaces: runs at /v1/bots, machines under
// /v1/compute/bots, and the runtime passthrough at /v1/bot/*. Nothing in the run
// namespace may be a wildcard, which would swallow every run id.
func TestRunNamespaceHasNoWildcard(t *testing.T) {
app := mountFleet(t, &stubRuntime{})
for _, r := range app.Fiber().GetRoutes() {
if r.Path == "/v1/bots/*" {
t.Fatalf("%s /v1/bots/* would swallow every run id", r.Method)
}
}
}
// The runtime ops face is a SIBLING namespace, not a parent: /v1/bot/* must never
// match a /v1/bots path, or the control plane would be relayed away.
//
// The discriminator is the op the runtime is asked for. bots' stub addresses
// /v1/bots/{id}/stop; the ops face strips its own /v1/bot prefix and would ask for
// something else entirely. So the path the runtime SAW names which handler ran.
func TestRuntimeOpsFaceDoesNotSwallowTheRunNamespace(t *testing.T) {
rt := &stubRuntime{}
app := mountFleet(t, rt)
if code, body := call(t, app, http.MethodPost, "/v1/bots/run_1/stop", "acme"); code != http.StatusOK {
t.Fatalf("stop want 200, got %d (%s)", code, body)
}
if got := rt.seen(); len(got) != 1 || got[0] != "/v1/bots/run_1/stop" {
t.Fatalf("the run namespace must be served by bots' own stub, runtime saw %v", got)
}
}
+16
View File
@@ -0,0 +1,16 @@
package bots
import (
"time"
"github.com/zap-proto/fiber/v3"
)
// testCfg replaces fiber's Test() default of Timeout: 1s (fiber/v3@v3.2.1
// app.go:1202, FailOnTimeout: true). That default is a WALL-CLOCK deadline on an
// in-process request: under load — a full suite, encrypted SQLite, a busy box — a
// correct handler blows it and the test reports "i/o timeout". A guard that fails
// for reasons unrelated to what it guards teaches nothing, and a tenant-isolation
// guard that is a coin flip is worse than none. The generous bound still fails a
// genuine hang.
var testCfg = fiber.TestConfig{Timeout: 30 * time.Second, FailOnTimeout: true}
+68
View File
@@ -0,0 +1,68 @@
package bots
import (
"context"
"net/url"
"github.com/hanzoai/cloud/clients/runtime"
)
// wire.go is bots' WIRE CONTRACT with the bot runtime — the stub behind the
// Runtime seam. It says WHAT bots asks the runtime (list this org's runs, halt one
// of them); how the bytes get there is runtime's problem.
//
// This file is the ONE place in clients/bots that knows the runtime exists. The
// handlers (bots.go) never see it: they hold the Runtime seam, which a test fills
// with a fake.
//
// The id space is the RUNTIME'S. A run id here is whatever the runtime named its
// session — cloud does not mint it, does not store it, and could not resolve it if
// it tried. That is exactly why list and stop agree: both speak the only id space
// that has ever held a real run.
const listOp = "/v1/bots"
func stopOp(runID string) string { return "/v1/bots/" + url.PathEscape(runID) + "/stop" }
// wire is the Runtime seam over the real runtime.
type wire struct{}
// runRow is the runtime's row shape (its GET /v1/bots emits exactly these). It
// carries no sessionUrl: the runtime does not know its own public origin, so the
// control plane derives that from runId.
type runRow struct {
RunID string `json:"runId"`
Task string `json:"task"`
Surface string `json:"surface"`
Status string `json:"status"`
StartedAt string `json:"startedAt"`
}
// List reads org's runs. The runtime scopes them to the tenant cloud names, so the
// answer is already this org's and only this org's.
func (wire) List(ctx context.Context, org string) ([]Run, error) {
var answer struct {
Bots []runRow `json:"bots"`
}
if err := runtime.Read(ctx, runtime.Call{Op: listOp, Org: org}, &answer); err != nil {
return nil, err
}
out := make([]Run, 0, len(answer.Bots))
for _, b := range answer.Bots {
if b.RunID == "" {
continue // a row cloud cannot address is not a run it can offer
}
out = append(out, Run{
ID: b.RunID, Task: b.Task, Surface: b.Surface,
Status: b.Status, StartedAt: b.StartedAt,
})
}
return out, nil
}
// Stop halts org's run. It reports the runtime's own answer verbatim — absent,
// unserved, or a failure — so the handler decides what each MEANS rather than this
// stub deciding for it.
func (wire) Stop(ctx context.Context, org, runID string) error {
return runtime.Do(ctx, runtime.Call{Op: stopOp(runID), Org: org})
}
+4 -4
View File
@@ -198,7 +198,7 @@ func (s *service) handleSearch(c *zip.Ctx) error {
if err != nil {
return err
}
eng, err := s.engineFor(org, principal.Payer(c), principal.Project(c))
eng, err := s.engineFor(org, principal.HomeOrg(c), principal.Project(c))
if err != nil {
return zip.ErrInternal("open index")
}
@@ -238,7 +238,7 @@ func (s *service) handleContext(c *zip.Ctx) error {
if err != nil {
return err
}
eng, err := s.engineFor(org, principal.Payer(c), principal.Project(c))
eng, err := s.engineFor(org, principal.HomeOrg(c), principal.Project(c))
if err != nil {
return zip.ErrInternal("open index")
}
@@ -343,7 +343,7 @@ func (s *service) handleAsk(c *zip.Ctx) error {
if err != nil {
return err
}
eng, err := s.engineFor(org, principal.Payer(c), principal.Project(c))
eng, err := s.engineFor(org, principal.HomeOrg(c), principal.Project(c))
if err != nil {
return zip.ErrInternal("open index")
}
@@ -393,7 +393,7 @@ func (s *service) handleIndex(c *zip.Ctx) error {
if err != nil {
return zip.ErrInternal("open index")
}
res, err := s.indexRepo(c.Context(), org, principal.Payer(c), principal.Project(c), store, repo, body.Files, body.Prune)
res, err := s.indexRepo(c.Context(), org, principal.HomeOrg(c), principal.Project(c), store, repo, body.Files, body.Prune)
if err != nil {
s.log.Warn("code index failed", "org", org, "repo", repo, "err", err)
return zip.ErrInternal("index failed")
+8 -30
View File
@@ -4,17 +4,18 @@ import (
"context"
"github.com/hanzoai/cloud/clients/agents"
"github.com/hanzoai/cloud/clients/bot"
"github.com/hanzoai/cloud/clients/tracker"
)
// adapters.go binds the coding seams to the real in-process packages. This is the
// ONLY file in clients/coding that imports agents/tracker/bot; coding.go stays
// pure so the orchestration is unit-tested against fakes. None of agents/tracker/
// bot imports clients/git or clients/integrations, so these imports are cycle-free.
// adapters.go binds the session + tracker seams to the real in-process packages.
// This is the ONLY file in clients/coding that imports agents/tracker; coding.go
// stays pure so the orchestration is unit-tested against fakes. Neither agents nor
// tracker imports clients/git or clients/integrations, so these imports are
// cycle-free. The third seam, Runner, is bound in task.go — coding's own wire
// contract with the bot runtime.
// NewDispatcher assembles the production Dispatcher: sessions on the live agent
// registry, PRs on the tracker, the runner on the bot-gateway client, plus the
// registry, PRs on the tracker, the runner on coding's own runtime stub, plus the
// two git seams (cloneURL, verifyRef) the composition root passes from clients/git
// (which coding cannot import directly). log is the structured logger for
// best-effort mirror failures.
@@ -26,7 +27,7 @@ func NewDispatcher(
return Dispatcher{
Sessions: sessionAdapter{},
Tracker: trackerAdapter{},
Runner: botAdapter{},
Runner: runner{},
CloneURL: cloneURL,
VerifyRef: verifyRef,
Log: log,
@@ -59,26 +60,3 @@ func (trackerAdapter) CreatePR(ctx context.Context, in PRInput) (PRRef, error) {
}
return PRRef{Identifier: pr.Identifier, ProjectKey: pr.ProjectKey, Number: pr.Number}, nil
}
// botAdapter forwards to the bot-gateway coding-task client (bot/coding.go),
// bridging the coding Step/RunResult shapes to the bot ones.
type botAdapter struct{}
func (botAdapter) Run(ctx context.Context, org, userID string, req RunRequest, onStep func(Step)) (RunResult, error) {
res, err := bot.RunCodingTask(ctx, org, userID, bot.CodingTaskRequest{
CloneURL: req.CloneURL, BaseBranch: req.BaseBranch, Branch: req.Branch,
Prompt: req.Prompt, SessionID: req.SessionID, RunTimeoutSeconds: req.RunTimeoutSeconds,
Credential: bot.Credential{Username: req.CredUser, Token: req.CredToken},
}, func(s bot.CodingStep) {
if onStep != nil {
onStep(Step{Type: s.Type, Step: s.Step, Message: s.Message, Status: s.Status})
}
})
if err != nil {
return RunResult{}, err
}
return RunResult{
Branch: res.Branch, CommitSha: res.CommitSha, Diffstat: res.Diffstat,
Changed: res.Changed, OK: res.OK, LogTail: res.LogTail, Error: res.Error,
}, nil
}
+114
View File
@@ -0,0 +1,114 @@
package coding
import (
"context"
"encoding/json"
"fmt"
"github.com/hanzoai/cloud/clients/runtime"
)
// task.go is coding's WIRE CONTRACT with the bot runtime — the stub behind the
// Runner seam. It says WHAT coding asks the runtime to do (run one coding job)
// and how a progress line is shaped; how the bytes get there is runtime's problem.
//
// This file is the ONE place in clients/coding that knows the runtime exists.
// coding.go (the orchestrator) is pure and never sees it.
//
// The runtime answers a stream: one message per step/log as the job progresses
// (clone → dev exec → commit → push), then a terminal result (or error). Coding
// mirrors every step into the agent session live, so the run is watchable at
// GET /v1/agents/sessions/:id/stream, and returns the terminal outcome.
//
// CREDENTIAL CUSTODY: the per-org agent git credential travels in the request
// BODY (never a URL, never argv, never a log). That is why the call declares
// Secret — the transport then refuses to carry it over a cleartext hop.
// taskOp addresses the runtime's coding-task operation.
const taskOp = "/v1/coding-tasks"
// credential is the per-org agent git credential the sandbox presents to native
// git. Token is the secret (an hk- key); Username is the basic-auth user label.
// Encoded into the request body only — never logged.
type credential struct {
Username string `json:"username"`
Token string `json:"token"`
}
// taskRequest is the cloud→runtime body for one coding run.
type taskRequest struct {
CloneURL string `json:"cloneUrl"` // https://<domain>/v1/git/<org>/<repo>.git
BaseBranch string `json:"baseBranch"` // branch to start from (default repo default)
Branch string `json:"branch"` // branch to create + push (e.g. agent/<sessionid>)
Prompt string `json:"prompt"` // the engineering task
SessionID string `json:"sessionId"` // cloud session id (correlation)
RunTimeoutSeconds int `json:"runTimeoutSeconds"` // sandbox run budget
Credential credential `json:"credential"` // agent git credential (write-only)
}
// message is the discriminated shape of one streamed line: step/log while the job
// runs, result/error to end it.
type message struct {
Type string `json:"type"` // step | log | result | error
Step string `json:"step,omitempty"`
Message string `json:"message,omitempty"`
Status string `json:"status,omitempty"`
Branch string `json:"branch,omitempty"`
CommitSha string `json:"commitSha,omitempty"`
Diffstat string `json:"diffstat,omitempty"`
Changed bool `json:"changed,omitempty"`
OK bool `json:"ok,omitempty"`
LogTail string `json:"logTail,omitempty"`
}
// runner is the Runner seam over the real runtime. Its fake twin in coding_test.go
// is what the orchestrator is tested against.
type runner struct{}
// Run hands one coding job to the runtime and streams its progress, invoking
// onStep for each line as it arrives, then returns the terminal result. A
// transport failure, or a stream that ends without a terminal message, is an
// error — no partial success is fabricated. org/userID are the tenant context the
// runtime trusts AFTER its own bearer gate.
func (runner) Run(ctx context.Context, org, userID string, req RunRequest, onStep func(Step)) (RunResult, error) {
var out RunResult
var terminal bool
err := runtime.Stream(ctx, runtime.Call{
Op: taskOp,
Org: org,
User: userID,
Body: taskRequest{
CloneURL: req.CloneURL, BaseBranch: req.BaseBranch, Branch: req.Branch,
Prompt: req.Prompt, SessionID: req.SessionID, RunTimeoutSeconds: req.RunTimeoutSeconds,
Credential: credential{Username: req.CredUser, Token: req.CredToken},
},
Secret: true, // the body carries the org's git credential
}, func(msg []byte) {
var m message
if json.Unmarshal(msg, &m) != nil {
return // skip a malformed message rather than abort the whole run
}
switch m.Type {
case "result":
out = RunResult{
Branch: m.Branch, CommitSha: m.CommitSha, Diffstat: m.Diffstat,
Changed: m.Changed, OK: m.OK, LogTail: m.LogTail,
}
terminal = true
case "error":
out = RunResult{OK: false, LogTail: m.LogTail, Error: nonEmpty(m.Message, "coding task failed")}
terminal = true
default: // step | log — mirror live
if onStep != nil {
onStep(Step{Type: m.Type, Step: m.Step, Message: m.Message, Status: m.Status})
}
}
})
if err != nil {
return RunResult{}, fmt.Errorf("coding: run task: %w", err)
}
if !terminal {
return RunResult{}, fmt.Errorf("coding: stream ended without a result")
}
return out, nil
}
@@ -1,4 +1,4 @@
package bot
package coding
import (
"context"
@@ -10,6 +10,11 @@ import (
"testing"
)
// These pin coding's wire contract with the runtime through the REAL transport
// (clients/runtime) against a stub server — so the credential-custody and
// fail-closed properties are proven end to end over the seam, not against a fake
// of it.
// ndjsonServer streams the given lines as application/x-ndjson and records the
// request it received, so a test can assert the wire contract (path, headers,
// body) AND that the credential travels only in the body.
@@ -31,7 +36,7 @@ func ndjsonServer(t *testing.T, lines []string, capture *http.Request, capBody *
}))
}
func TestRunCodingTask_StreamsStepsAndResult(t *testing.T) {
func TestTask_StreamsStepsAndResult(t *testing.T) {
lines := []string{
`{"type":"step","step":"clone","status":"ok"}`,
`{"type":"log","message":"editing handler.go"}`,
@@ -46,11 +51,11 @@ func TestRunCodingTask_StreamsStepsAndResult(t *testing.T) {
t.Setenv("BOT_GATEWAY_ALLOW_PLAINTEXT", "1") // httptest is http://; assert-plaintext guard tested separately
t.Setenv("BOT_GATEWAY_TOKEN", "svc-token-xyz")
var steps []CodingStep
res, err := RunCodingTask(context.Background(), "acme", "u-1", CodingTaskRequest{
var steps []Step
res, err := runner{}.Run(context.Background(), "acme", "u-1", RunRequest{
CloneURL: "https://git.test/v1/git/acme/api.git", Branch: "agent/x", Prompt: "fix",
Credential: Credential{Username: "x-access-token", Token: "hk-SECRETtoken"},
}, func(s CodingStep) { steps = append(steps, s) })
CredUser: "x-access-token", CredToken: "hk-SECRETtoken",
}, func(s Step) { steps = append(steps, s) })
if err != nil {
t.Fatalf("run: %v", err)
}
@@ -81,7 +86,7 @@ func TestRunCodingTask_StreamsStepsAndResult(t *testing.T) {
if strings.Contains(gotReq.URL.RawQuery, "SECRETtoken") || strings.Contains(gotReq.Header.Get("Authorization"), "SECRETtoken") {
t.Fatal("credential leaked onto URL/header")
}
var body CodingTaskRequest
var body taskRequest
if err := json.Unmarshal(gotBody, &body); err != nil {
t.Fatalf("body decode: %v", err)
}
@@ -90,13 +95,13 @@ func TestRunCodingTask_StreamsStepsAndResult(t *testing.T) {
}
}
func TestRunCodingTask_ErrorLine(t *testing.T) {
func TestTask_ErrorLine(t *testing.T) {
srv := ndjsonServer(t, []string{`{"type":"error","message":"dev exec failed","logTail":"boom"}`}, nil, nil)
defer srv.Close()
t.Setenv("BOT_GATEWAY_URL", srv.URL)
t.Setenv("BOT_GATEWAY_ALLOW_PLAINTEXT", "1") // httptest is http://; assert-plaintext guard tested separately
res, err := RunCodingTask(context.Background(), "acme", "u", CodingTaskRequest{}, nil)
res, err := runner{}.Run(context.Background(), "acme", "u", RunRequest{}, nil)
if err != nil {
t.Fatalf("a clean error line is a terminal result, not a transport error: %v", err)
}
@@ -105,29 +110,29 @@ func TestRunCodingTask_ErrorLine(t *testing.T) {
}
}
func TestRunCodingTask_Non2xxIsError(t *testing.T) {
func TestTask_Non2xxIsError(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
http.Error(w, "unauthorized", http.StatusUnauthorized)
}))
defer srv.Close()
t.Setenv("BOT_GATEWAY_URL", srv.URL)
t.Setenv("BOT_GATEWAY_ALLOW_PLAINTEXT", "1") // httptest is http://; assert-plaintext guard tested separately
if _, err := RunCodingTask(context.Background(), "acme", "u", CodingTaskRequest{}, nil); err == nil {
if _, err := (runner{}).Run(context.Background(), "acme", "u", RunRequest{}, nil); err == nil {
t.Fatal("a 401 must be an error")
}
}
func TestRunCodingTask_NoTerminalIsError(t *testing.T) {
func TestTask_NoTerminalIsError(t *testing.T) {
srv := ndjsonServer(t, []string{`{"type":"step","step":"clone"}`}, nil, nil)
defer srv.Close()
t.Setenv("BOT_GATEWAY_URL", srv.URL)
t.Setenv("BOT_GATEWAY_ALLOW_PLAINTEXT", "1") // httptest is http://; assert-plaintext guard tested separately
if _, err := RunCodingTask(context.Background(), "acme", "u", CodingTaskRequest{}, nil); err == nil {
if _, err := (runner{}).Run(context.Background(), "acme", "u", RunRequest{}, nil); err == nil {
t.Fatal("a stream with no result/error line must be an error (no fabricated success)")
}
}
func TestRunCodingTask_RefusesCleartextByDefault(t *testing.T) {
func TestTask_RefusesCleartextByDefault(t *testing.T) {
// The credential-bearing POST must not go cleartext without an explicit mesh
// opt-in: an http target with BOT_GATEWAY_ALLOW_PLAINTEXT unset fails closed and
// never dials.
@@ -135,8 +140,8 @@ func TestRunCodingTask_RefusesCleartextByDefault(t *testing.T) {
defer srv.Close()
t.Setenv("BOT_GATEWAY_URL", srv.URL) // http://
t.Setenv("BOT_GATEWAY_ALLOW_PLAINTEXT", "")
_, err := RunCodingTask(context.Background(), "acme", "u", CodingTaskRequest{
Credential: Credential{Username: "x", Token: "hk-SECRET"},
_, err := runner{}.Run(context.Background(), "acme", "u", RunRequest{
CredUser: "x", CredToken: "hk-SECRET",
}, nil)
if err == nil {
t.Fatal("cleartext coding POST must fail closed by default")
@@ -0,0 +1,79 @@
package commerceinproc
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/zap-proto/zip"
)
// TestRouterSemantics establishes, by PROBE rather than by reading the router, the three
// behaviours that decide what happens if commerce's real /v1/billing/* routes are mounted
// alongside cloud's existing ones. Registration order here mirrors production: commerce
// mounts BEFORE billing (apps/apps.go — commerce at slice position 214, billing at 225),
// and fiber/zip walks its own stack, so "first" below means "commerce".
func TestRouterSemantics(t *testing.T) {
probe := func(t *testing.T, register func(app *zip.App), path string) (status int, body string, panicked any) {
t.Helper()
defer func() { panicked = recover() }()
app := zip.New(zip.Config{})
register(app)
resp, err := app.Fiber().Test(httptest.NewRequest(http.MethodGet, path, nil))
if err != nil {
t.Fatalf("probe %s: %v", path, err)
}
defer func() { _ = resp.Body.Close() }()
buf := make([]byte, 64)
n, _ := resp.Body.Read(buf)
return resp.StatusCode, string(buf[:n]), nil
}
// CASE 1 — BYTE-IDENTICAL patterns. This is exactly commerce's GET /v1/billing/balance
// vs cloud's GET /v1/billing/balance. Does it panic, or silently shadow?
t.Run("byte identical", func(t *testing.T) {
status, body, panicked := probe(t, func(app *zip.App) {
app.Get("/v1/billing/balance", func(c *zip.Ctx) error { return c.Bytes(200, []byte("FIRST")) })
app.Get("/v1/billing/balance", func(c *zip.Ctx) error { return c.Bytes(200, []byte("SECOND")) })
}, "/v1/billing/balance")
t.Logf("byte-identical => panicked=%v status=%d body=%q", panicked, status, body)
if panicked != nil {
t.Logf("VERDICT: duplicate registration PANICS — a boot-time failure, loud not silent")
return
}
t.Logf("VERDICT: NO panic; %q wins => the loser is SILENTLY SHADOWED DEAD CODE", body)
})
// CASE 2 — equal specificity, DIFFERENT param names. If commerce registers
// /v1/billing/:id where cloud has /v1/billing/:runId, does the binary panic AT BOOT?
// That would be a production-down deploy gate.
t.Run("equal specificity different param names", func(t *testing.T) {
status, body, panicked := probe(t, func(app *zip.App) {
app.Get("/v1/billing/:id", func(c *zip.Ctx) error { return c.Bytes(200, []byte("ID")) })
app.Get("/v1/billing/:runId", func(c *zip.Ctx) error { return c.Bytes(200, []byte("RUNID")) })
}, "/v1/billing/xyz")
t.Logf("param-name conflict => panicked=%v status=%d body=%q", panicked, status, body)
if panicked != nil {
t.Logf("VERDICT: PANICS at registration — mounting a conflicting param route takes the binary DOWN AT BOOT")
return
}
t.Logf("VERDICT: no panic; %q wins", body)
})
// CASE 3 — different specificity, registered WORST-first. Does a later, more specific
// static route beat an earlier wildcard? This decides whether the account bridge's
// /v1/billing/* can shadow a static commerce route.
t.Run("wildcard registered before static", func(t *testing.T) {
status, body, panicked := probe(t, func(app *zip.App) {
app.Get("/v1/billing/*", func(c *zip.Ctx) error { return c.Bytes(200, []byte("WILDCARD")) })
app.Get("/v1/billing/balance", func(c *zip.Ctx) error { return c.Bytes(200, []byte("STATIC")) })
}, "/v1/billing/balance")
t.Logf("wildcard-then-static => panicked=%v status=%d body=%q", panicked, status, body)
switch body {
case "STATIC":
t.Logf("VERDICT: MOST-SPECIFIC wins regardless of registration order (ServeMux-1.22 semantics)")
case "WILDCARD":
t.Logf("VERDICT: FIRST REGISTRATION wins (registration-stack order)")
}
})
}
@@ -0,0 +1,84 @@
package commerceinproc
import (
"io"
"net/http"
"sync/atomic"
"testing"
"time"
"github.com/zap-proto/zip"
)
// TestSetAppSelfDispatch pins the hazard that took /v1/billing/balance down in
// production, and that TestInProcessDispatch cannot see because it stubs a SEPARATE
// commerce handler via SetHandler — a condition that is FALSE in the shipped binary.
//
// SetApp publishes the HOST's shared zip app as the "commerce" transport, and RoundTrip
// re-dispatches BY PATH. commerce's own /v1/billing/* routes are NOT registered in the
// co-resident binary (api.Route() is called only from commerce's mount.go, which is
// //go:build cloud and never compiled). So when a cloud handler registered at path P
// "calls commerce" at that same P, the request re-enters THAT HANDLER — the proxy calls
// itself. The re-entrant request carries the service bearer but no validated principal,
// so the handler's own sign-in gate refuses it, and the outer hop reports the refusal as
// an upstream failure.
//
// This test asserts the CURRENT, REAL mechanics: the self-call happens, and it is
// BOUNDED AT DEPTH 2 by the sign-in gate (it is not infinite recursion).
func TestSetAppSelfDispatch(t *testing.T) {
t.Cleanup(func() { SetHandler(nil) })
var depth int32
app := zip.New(zip.Config{})
// A cloud-style handler at /v1/billing/balance that proxies to commerce at the SAME
// path — exactly clients/billing.balance → proxy(s, c, "/v1/billing/balance").
app.Get("/v1/billing/balance", func(c *zip.Ctx) error {
d := atomic.AddInt32(&depth, 1)
// The sign-in gate: no validated principal ⇒ refuse. On the re-entrant hop
// there is none, which is what terminates the recursion.
if d > 1 {
return c.Bytes(http.StatusUnauthorized, []byte(`{"error":"sign in to view billing"}`))
}
req, err := http.NewRequest(http.MethodGet, BaseURL("")+"/v1/billing/balance", nil)
if err != nil {
return err
}
req.Header.Set("Authorization", "Bearer service-token")
req.Header.Set("X-Org-Id", "hanzo")
resp, err := Client(5 * time.Second).Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
// financeGet's shape: a non-2xx upstream becomes "billing upstream status %d".
if resp.StatusCode != http.StatusOK {
return c.Bytes(http.StatusBadGateway, []byte(`{"error":"billing upstream status `+http.StatusText(resp.StatusCode)+`"}`))
}
return c.Bytes(http.StatusOK, body)
})
SetApp(app)
req, err := http.NewRequest(http.MethodGet, BaseURL("")+"/v1/billing/balance", nil)
if err != nil {
t.Fatal(err)
}
resp, err := Client(5 * time.Second).Do(req)
if err != nil {
t.Fatalf("dispatch: %v", err)
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
got := atomic.LoadInt32(&depth)
t.Logf("handler entered %d times; outer status=%d body=%s", got, resp.StatusCode, body)
if got != 2 {
t.Fatalf("handler entered %d times, want exactly 2 (self-call, then the sign-in gate terminates it)", got)
}
if resp.StatusCode != http.StatusBadGateway {
t.Errorf("outer status = %d, want 502 — the self-call is reported as an upstream failure", resp.StatusCode)
}
}
+67
View File
@@ -0,0 +1,67 @@
package connectorruntime
import (
"fmt"
"strings"
"github.com/evanw/esbuild/pkg/api"
)
// apExternals are the packages a connector's source resolves at RUNTIME
// against the in-process shim (shim.js) instead of bundling. The two
// @activepieces/* framework packages are always shimmed; @activepieces/shared
// is shimmed too because pieces import a handful of its pure helpers
// (isNil/isEmpty/assertNotNullOrUndefined) that the shim provides. Everything
// else a piece imports (its own relative files) is bundled into one program.
var apExternals = []string{
"@activepieces/pieces-framework",
"@activepieces/pieces-common",
"@activepieces/shared",
}
// Bundle compiles ONE ActivePieces connector's TypeScript source tree into a
// single CommonJS program, with the framework packages left external so they
// resolve to the in-process shim at run time. This is the connector-ingest
// build step: run it once per connector (offline / CI), commit the JS blob,
// and the runtime compiles+executes that blob natively in goja — no Node.
//
// entryPoint is the connector's index.ts. extraExternal lets a heavier
// connector mark additional npm deps external (each then needs a shim); for
// the framework-only pieces (the long tail) apExternals alone suffice.
func Bundle(entryPoint string, extraExternal ...string) ([]byte, error) {
res := api.Build(api.BuildOptions{
EntryPoints: []string{entryPoint},
Bundle: true,
Format: api.FormatCommonJS,
Platform: api.PlatformNode,
// ES2017 keeps async/await native (goja supports it) so the runtime
// never has to drive a regenerator/generator downlevel.
Target: api.ES2017,
Write: false,
LogLevel: api.LogLevelSilent,
Sourcemap: api.SourceMapNone,
External: append(append([]string{}, apExternals...), extraExternal...),
})
if len(res.Errors) > 0 {
return nil, fmt.Errorf("connectorruntime: bundle %s: %s", entryPoint, esbuildErrs(res.Errors))
}
if len(res.OutputFiles) == 0 {
return nil, fmt.Errorf("connectorruntime: bundle %s: no output", entryPoint)
}
return res.OutputFiles[0].Contents, nil
}
func esbuildErrs(errs []api.Message) string {
var b strings.Builder
for i, e := range errs {
if i > 0 {
b.WriteString("; ")
}
if e.Location != nil {
fmt.Fprintf(&b, "%s:%d: %s", e.Location.File, e.Location.Line, e.Text)
} else {
b.WriteString(e.Text)
}
}
return b.String()
}
@@ -0,0 +1,76 @@
package connectorruntime
import (
"fmt"
"net/http"
"github.com/hanzoai/cloud"
"github.com/hanzoai/cloud/clients/principal"
"github.com/zap-proto/zip"
)
// Mount wires the native single-connector execution surface onto the cloud
// binary, per HIP-0126 / HIP-0106:
//
// POST /v1/automations/connectors/:id/run run one connector action in-process
//
// This is the native replacement for the standalone ActivePieces Node engine's
// /v1/auto/pieces/{piece}/run — same {action,auth,props} -> {ok,output,error}
// contract, executed in goja in-process (no `auto` pod). It is org-gated: only
// a validated principal may run a connector, and the caller's resolved
// credential travels in the request `auth`. The route is DISTINCT from
// automations' GET /v1/automations/connectors (the catalogue), so the two
// subsystems compose without collision.
func Mount(app *zip.App, deps cloud.Deps) error {
if app == nil {
return fmt.Errorf("connectorruntime.Mount: nil zip.App")
}
log := deps.Logger
app.Post("/v1/automations/connectors/:id/run", runHandler)
if log != nil {
log.New("subsystem", "connectorruntime").Info(
"connector runtime mounted", "connectors", len(Connectors()))
}
return nil
}
// runReq mirrors the ActivePieces piece-run body.
type runReq struct {
Action string `json:"action"`
Auth any `json:"auth"`
Props map[string]any `json:"props"`
}
// runResp mirrors the ActivePieces piece-run response. A connector-level
// failure is ok:false with a message (HTTP 200) — an unknown connector or a
// missing action is a 4xx, matching the old engine's infra-vs-piece split.
type runResp struct {
Ok bool `json:"ok"`
Output any `json:"output,omitempty"`
Error string `json:"error,omitempty"`
}
func runHandler(c *zip.Ctx) error {
org, ok := principal.Org(c)
if !ok {
return zip.ErrForbidden("a validated principal is required")
}
id := c.Param("id")
if !Has(id) {
return zip.Errorf(http.StatusNotFound, "unknown connector %q", id)
}
var body runReq
if err := c.Bind(&body); err != nil {
return err
}
if body.Action == "" {
return zip.Errorf(http.StatusUnprocessableEntity, "action is required")
}
out, err := Run(c.Context(), org, id, body.Action, body.Auth, body.Props)
if err != nil {
// The action ran but failed (or the action name is unknown): surface as
// a piece-level failure, not an infra 5xx — the caller inspects ok.
return c.JSON(http.StatusOK, runResp{Ok: false, Error: err.Error()})
}
return c.JSON(http.StatusOK, runResp{Ok: true, Output: out})
}
@@ -0,0 +1,103 @@
var __defProp = Object.defineProperty;
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
var __getOwnPropNames = Object.getOwnPropertyNames;
var __hasOwnProp = Object.prototype.hasOwnProperty;
var __export = (target, all) => {
for (var name in all)
__defProp(target, name, { get: all[name], enumerable: true });
};
var __copyProps = (to, from, except, desc) => {
if (from && typeof from === "object" || typeof from === "function") {
for (let key of __getOwnPropNames(from))
if (!__hasOwnProp.call(to, key) && key !== except)
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
}
return to;
};
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
// ../auto/packages/pieces/community/brave-search/src/index.ts
var index_exports = {};
__export(index_exports, {
braveSearch: () => braveSearch
});
module.exports = __toCommonJS(index_exports);
var import_pieces_framework3 = require("@activepieces/pieces-framework");
// ../auto/packages/pieces/community/brave-search/src/lib/actions/web-search.ts
var import_pieces_framework2 = require("@activepieces/pieces-framework");
var import_pieces_common = require("@activepieces/pieces-common");
// ../auto/packages/pieces/community/brave-search/src/lib/auth.ts
var import_pieces_framework = require("@activepieces/pieces-framework");
var braveSearchAuth = import_pieces_framework.PieceAuth.SecretText({
displayName: "API Key",
required: true,
description: "Your Brave Search API Key (get it from https://brave.com/search/api/)"
});
// ../auto/packages/pieces/community/brave-search/src/lib/actions/web-search.ts
var braveWebSearchAction = (0, import_pieces_framework2.createAction)({
auth: braveSearchAuth,
name: "web_search",
displayName: "Web Search",
description: "Search the web using Brave Search",
props: {
query: import_pieces_framework2.Property.ShortText({
displayName: "Query",
description: "The search query",
required: true
}),
count: import_pieces_framework2.Property.Number({
displayName: "Count",
description: "Number of results (1-20)",
required: false,
defaultValue: 10
})
},
async run(context) {
const query = context.propsValue.query;
const count = context.propsValue.count;
const response = await import_pieces_common.httpClient.sendRequest({
method: import_pieces_common.HttpMethod.GET,
url: "https://api.search.brave.com/res/v1/web/search",
headers: {
"X-Subscription-Token": context.auth.secret_text,
Accept: "application/json"
},
queryParams: {
q: query,
count
}
});
return response.body;
}
});
// ../auto/packages/pieces/community/brave-search/src/index.ts
var import_pieces_common2 = require("@activepieces/pieces-common");
var braveSearch = (0, import_pieces_framework3.createPiece)({
displayName: "Brave Search",
description: "Privacy-preserving search engine",
auth: braveSearchAuth,
minimumSupportedRelease: "0.30.0",
logoUrl: "https://cdn.activepieces.com/pieces/brave-search.png",
authors: ["ErisMorn", "sanket-a11y"],
actions: [
braveWebSearchAction,
(0, import_pieces_common2.createCustomApiCallAction)({
auth: braveSearchAuth,
baseUrl: () => "https://api.search.brave.com/res/v1",
authMapping: async (auth) => {
return {
"X-Subscription-Token": auth.secret_text
};
}
})
],
triggers: []
});
// Annotate the CommonJS export names for ESM import in node:
0 && (module.exports = {
braveSearch
});
@@ -0,0 +1,28 @@
// notion.js is the in-process Notion connector for the KB long-tail sync. It
// is the generic custom_api_call action bound to Notion's base URL + bearer
// auth — behaviourally identical to @activepieces/piece-notion's own
// custom_api_call (baseUrl https://api.notion.com/v1, Authorization: Bearer
// <access_token>), which is the ONLY Notion action the KB sync invokes. It
// carries none of the 12 Notion actions/triggers the sync never touches, so it
// needs no @notionhq/client bundle. When the full Notion piece is vendored via
// the bundle step, this file is replaced by that blob under the same name.
const { createPiece, PieceAuth } = require('@activepieces/pieces-framework');
const { createCustomApiCallAction } = require('@activepieces/pieces-common');
const notionAuth = PieceAuth.OAuth2({ required: true, authUrl: '', tokenUrl: '', scope: [] });
module.exports.notion = createPiece({
displayName: 'Notion',
description: 'The all-in-one workspace',
auth: notionAuth,
actions: [
createCustomApiCallAction({
auth: notionAuth,
baseUrl: () => 'https://api.notion.com/v1',
authMapping: async (auth) => ({
Authorization: 'Bearer ' + (auth && (auth.access_token || auth)),
}),
}),
],
triggers: [],
});
+241
View File
@@ -0,0 +1,241 @@
package connectorruntime
import (
"bytes"
"context"
_ "embed"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strconv"
"strings"
"time"
"github.com/dop251/goja"
)
// shimJS is the in-process ActivePieces framework, embedded so the runtime is
// a single self-contained binary — no JS files on disk at run time.
//
//go:embed shim.js
var shimJS string
// defaultTimeout bounds a connector HTTP call when the connector sets none.
const defaultTimeout = 30 * time.Second
// maxResponseBytes caps a connector response so a hostile endpoint cannot
// exhaust memory (fail-secure, mirrors the KB ingest limit).
const maxResponseBytes = 32 << 20
// defaultDoerClient has NO timeout of its own; per-request deadlines come from
// the ctx the doer derives, so a slow endpoint is bounded by the caller's
// context or defaultTimeout, whichever is first.
var defaultDoerClient = &http.Client{}
// defaultHTTPDoer performs a connector HTTP request with Go net/http. It builds
// the final URL (merging queryParams), JSON-encodes an object body, sends, and
// decodes the response as JSON when it looks like JSON (matching axios' default
// responseType:'json' the connectors were written against).
func defaultHTTPDoer(ctx context.Context, req HTTPRequest) (HTTPResponse, error) {
timeout := defaultTimeout
if req.TimeoutMS > 0 {
timeout = time.Duration(req.TimeoutMS) * time.Millisecond
}
cctx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
u, err := url.Parse(strings.TrimSpace(req.URL))
if err != nil {
return HTTPResponse{}, fmt.Errorf("bad url: %w", err)
}
if u.Scheme != "http" && u.Scheme != "https" {
return HTTPResponse{}, fmt.Errorf("unsupported url scheme %q", u.Scheme)
}
if len(req.QueryParams) > 0 {
q := u.Query()
for k, v := range req.QueryParams {
q.Set(k, v)
}
u.RawQuery = q.Encode()
}
method := strings.ToUpper(strings.TrimSpace(req.Method))
if method == "" {
method = http.MethodGet
}
var bodyReader io.Reader
jsonBody := false
if req.Body != nil {
switch b := req.Body.(type) {
case string:
bodyReader = strings.NewReader(b)
case []byte:
bodyReader = bytes.NewReader(b)
default:
raw, e := json.Marshal(b)
if e != nil {
return HTTPResponse{}, fmt.Errorf("encode body: %w", e)
}
bodyReader = bytes.NewReader(raw)
jsonBody = true
}
}
r, err := http.NewRequestWithContext(cctx, method, u.String(), bodyReader)
if err != nil {
return HTTPResponse{}, err
}
for k, v := range req.Headers {
r.Header.Set(k, v)
}
if jsonBody && r.Header.Get("Content-Type") == "" {
r.Header.Set("Content-Type", "application/json")
}
resp, err := defaultDoerClient.Do(r)
if err != nil {
return HTTPResponse{}, err
}
defer func() { _ = resp.Body.Close() }()
raw, err := io.ReadAll(io.LimitReader(resp.Body, maxResponseBytes))
if err != nil {
return HTTPResponse{}, fmt.Errorf("read response: %w", err)
}
return HTTPResponse{
Status: resp.StatusCode,
Headers: firstValueHeaders(resp.Header),
Body: decodeBody(resp.Header.Get("Content-Type"), raw),
}, nil
}
// decodeBody returns parsed JSON when the response is JSON, else the raw string
// — the same shape axios handed connectors.
func decodeBody(contentType string, raw []byte) any {
looksJSON := strings.Contains(strings.ToLower(contentType), "json")
if !looksJSON {
trimmed := bytes.TrimSpace(raw)
if len(trimmed) > 0 && (trimmed[0] == '{' || trimmed[0] == '[') {
looksJSON = true
}
}
if looksJSON {
var v any
if json.Unmarshal(raw, &v) == nil {
return v
}
}
return string(raw)
}
func firstValueHeaders(h http.Header) map[string]string {
out := make(map[string]string, len(h))
for k, v := range h {
if len(v) > 0 {
out[strings.ToLower(k)] = v[0]
}
}
return out
}
// installConsole wires a no-op console so a connector that logs never throws
// ReferenceError (goja has no console by default).
func installConsole(vm *goja.Runtime) {
if !goja.IsUndefined(vm.Get("console")) {
return
}
noop := func(goja.FunctionCall) goja.Value { return goja.Undefined() }
console := vm.NewObject()
for _, m := range []string{"log", "info", "warn", "error", "debug", "trace"} {
_ = console.Set(m, noop)
}
_ = vm.Set("console", console)
}
// toStringMap converts an exported JS object to string values (headers /
// queryParams are string-keyed in HTTP). Numeric values stringify without a
// trailing .0 so `count: 10` becomes "10", not "10.000000".
func toStringMap(v any) map[string]string {
m, ok := v.(map[string]any)
if !ok || len(m) == 0 {
return nil
}
out := make(map[string]string, len(m))
for k, val := range m {
out[k] = scalarString(val)
}
return out
}
func scalarString(val any) string {
switch x := val.(type) {
case nil:
return ""
case string:
return x
case bool:
return strconv.FormatBool(x)
case int64:
return strconv.FormatInt(x, 10)
case int:
return strconv.Itoa(x)
case float64:
if x == float64(int64(x)) {
return strconv.FormatInt(int64(x), 10)
}
return strconv.FormatFloat(x, 'g', -1, 64)
default:
return fmt.Sprintf("%v", x)
}
}
func toInt(v any) int {
switch x := v.(type) {
case int64:
return int(x)
case int:
return x
case float64:
return int(x)
default:
return 0
}
}
// jsError normalizes a goja call error into a Go error with the JS message.
func jsError(err error) error {
if err == nil {
return nil
}
var ex *goja.Exception
if ok := asException(err, &ex); ok {
return fmt.Errorf("connector: %s", ex.Value().String())
}
return err
}
func asException(err error, target **goja.Exception) bool {
if ex, ok := err.(*goja.Exception); ok {
*target = ex
return true
}
return false
}
// promiseRejection turns a rejected promise value into a Go error. An Error
// object stringifies to "Error: msg"; other values marshal to JSON.
func promiseRejection(vm *goja.Runtime, v goja.Value) error {
if v == nil || goja.IsUndefined(v) || goja.IsNull(v) {
return fmt.Errorf("connector: rejected")
}
if obj := v.ToObject(vm); obj != nil {
if msg := obj.Get("message"); msg != nil && !goja.IsUndefined(msg) {
return fmt.Errorf("connector: %s", msg.String())
}
}
return fmt.Errorf("connector: %s", v.String())
}
@@ -0,0 +1,32 @@
// Command bundlecmd is the offline connector-ingest step: it esbuild-bundles
// ONE ActivePieces connector source tree (its index.ts) into a single
// CommonJS program with the framework packages left external, and writes the
// blob. Run it per connector to vendor the JS the runtime executes natively.
//
// go run ./clients/connectorruntime/internal/bundlecmd <index.ts> <out.js> [extraExternal...]
package main
import (
"fmt"
"os"
connectorruntime "github.com/hanzoai/cloud/clients/connectorruntime"
)
func main() {
if len(os.Args) < 3 {
fmt.Fprintln(os.Stderr, "usage: bundlecmd <entry index.ts> <out.js> [extraExternal...]")
os.Exit(2)
}
entry, out := os.Args[1], os.Args[2]
js, err := connectorruntime.Bundle(entry, os.Args[3:]...)
if err != nil {
fmt.Fprintln(os.Stderr, "bundle:", err)
os.Exit(1)
}
if err := os.WriteFile(out, js, 0o644); err != nil {
fmt.Fprintln(os.Stderr, "write:", err)
os.Exit(1)
}
fmt.Printf("wrote %s (%d bytes)\n", out, len(js))
}
+104
View File
@@ -0,0 +1,104 @@
package connectorruntime
import (
"context"
"embed"
"fmt"
"io/fs"
"sort"
"strings"
"sync"
)
// connectorsFS holds every vendored connector program: real ActivePieces piece
// bundles (produced by the bundle step, *.connector.js) and hand-written
// generic connectors (*.js). Each file's base name (sans extension, sans the
// ".connector" tag) is the connector id.
//
//go:embed connectors/*.js
var connectorsFS embed.FS
// pkg is the process-wide runtime + registry KB and the HTTP surface share.
// One shim, one net/http doer, N compiled connectors. Built at init so the
// in-process API is ready the moment the package is imported (KB does not wait
// for Mount).
var pkg = mustBuildRegistry()
type registry struct {
rt *Runtime
mu sync.RWMutex
byID map[string]*Connector
}
func mustBuildRegistry() *registry {
rt, err := NewRuntime(nil)
if err != nil {
panic("connectorruntime: build runtime: " + err.Error())
}
reg := &registry{rt: rt, byID: map[string]*Connector{}}
entries, err := connectorsFS.ReadDir("connectors")
if err != nil {
panic("connectorruntime: read connectors: " + err.Error())
}
for _, e := range entries {
if e.IsDir() {
continue
}
src, err := fs.ReadFile(connectorsFS, "connectors/"+e.Name())
if err != nil {
panic("connectorruntime: read " + e.Name() + ": " + err.Error())
}
id := connectorID(e.Name())
c, err := rt.Compile(id, src)
if err != nil {
panic("connectorruntime: compile " + id + ": " + err.Error())
}
reg.byID[id] = c
}
return reg
}
// connectorID derives the connector id from a file name:
// "brave-search.connector.js" -> "brave-search", "notion.js" -> "notion".
func connectorID(name string) string {
name = strings.TrimSuffix(name, ".js")
name = strings.TrimSuffix(name, ".connector")
return name
}
// Run executes one connector action natively in-process. org is the caller's
// already-validated tenant — recorded for attribution; the runtime resolves no
// credential itself, it runs with the auth the caller passes (the KB path
// hands it this org's KMS-sealed OAuth token). Returns the action result or an
// error (unknown connector/action, or the action's own failure).
func Run(ctx context.Context, org, connector, action string, auth any, props map[string]any) (any, error) {
pkg.mu.RLock()
c, ok := pkg.byID[connector]
pkg.mu.RUnlock()
if !ok {
return nil, fmt.Errorf("connectorruntime: unknown connector %q", connector)
}
return pkg.rt.Run(ctx, c, RunInput{Action: action, Auth: auth, Props: props})
}
// Has reports whether a connector is registered (used to fail closed before a
// doomed call).
func Has(connector string) bool {
pkg.mu.RLock()
defer pkg.mu.RUnlock()
_, ok := pkg.byID[connector]
return ok
}
// Connectors lists the registered connector ids (stable order) for the
// catalogue / health surface.
func Connectors() []string {
pkg.mu.RLock()
defer pkg.mu.RUnlock()
out := make([]string, 0, len(pkg.byID))
for id := range pkg.byID {
out = append(out, id)
}
sort.Strings(out)
return out
}
+82
View File
@@ -0,0 +1,82 @@
package connectorruntime
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
)
// TestRegistry_NotionCustomApiCall exercises the EXACT call the KB long-tail
// sync makes — Run(ctx, org, "notion", "custom_api_call", auth, props) — end to
// end through the package registry and the default net/http doer. It proves the
// KB path runs native in-process: the Notion bearer auth is injected, the
// request KB builds (url/method/headers/body) is sent, and the JSON response is
// returned. No auto pod, no PIECES_RUNNER_SECRET, no cross-service hop.
func TestRegistry_NotionCustomApiCall(t *testing.T) {
var gotAuth, gotVersion string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotAuth = r.Header.Get("Authorization")
gotVersion = r.Header.Get("Notion-Version")
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"object": "list",
"results": []any{map[string]any{"id": "page-1"}},
})
}))
defer srv.Close()
if !Has("notion") {
t.Fatal("notion connector not registered")
}
out, err := Run(context.Background(), "org-abc", "notion", "custom_api_call",
map[string]any{"access_token": "secret_notion_token"},
map[string]any{
"method": "POST",
"url": map[string]any{"url": srv.URL},
"headers": map[string]any{"Notion-Version": "2022-06-28"},
"body_type": "json",
"body": map[string]any{"data": map[string]any{"page_size": 100}},
},
)
if err != nil {
t.Fatalf("notion custom_api_call: %v", err)
}
if gotAuth != "Bearer secret_notion_token" {
t.Errorf("Authorization = %q, want 'Bearer secret_notion_token'", gotAuth)
}
if gotVersion != "2022-06-28" {
t.Errorf("Notion-Version = %q, want 2022-06-28", gotVersion)
}
resp, _ := out.(map[string]any)
body, _ := resp["body"].(map[string]any)
results, _ := body["results"].([]any)
if len(results) != 1 {
t.Fatalf("results = %v", body)
}
}
// TestRegistry_UnknownConnector fails closed on an unregistered provider.
func TestRegistry_UnknownConnector(t *testing.T) {
if _, err := Run(context.Background(), "org", "does-not-exist", "x", nil, nil); err == nil {
t.Fatal("expected error for unknown connector")
}
}
// TestRegistry_Lists confirms both the real bundle and the generic connector
// are present.
func TestRegistry_Lists(t *testing.T) {
got := map[string]bool{}
for _, id := range Connectors() {
got[id] = true
}
for _, want := range []string{"brave-search", "notion"} {
if !got[want] {
t.Errorf("connector %q not registered; have %v", want, Connectors())
}
}
}
+274
View File
@@ -0,0 +1,274 @@
// Package connectorruntime executes an automation connector's action NATIVELY
// in-process — no Node. A connector authored against the ActivePieces
// framework (@activepieces/pieces-framework + pieces-common) is compiled once
// to a single CommonJS program (see Bundle) and run inside goja, with the
// framework supplied by an in-process shim (shim.js) whose only impure
// primitive is a Go HTTP doer. Because that doer resolves synchronously, an
// action's `async run(ctx)` settles inside goja's own microtask drain, so a
// connector executes as ordinary in-process work — a goroutine, not a service.
//
// This is the substrate that retires the standalone ActivePieces Node engine
// (the `auto` pod). It sits ALONGSIDE clients/automations' Tier-A native Go
// connectors: those are hand-written Go; this runs the long tail of JS
// connectors unchanged. Both are org-scoped by the caller — the runtime never
// resolves a credential itself; it receives the already-resolved `auth`.
package connectorruntime
import (
"context"
"errors"
"fmt"
"strconv"
"github.com/dop251/goja"
)
// HTTPRequest is the request an in-VM httpClient.sendRequest hands to the Go
// doer. It mirrors the ActivePieces HttpRequest fields connectors actually set.
type HTTPRequest struct {
Method string
URL string
Headers map[string]string
QueryParams map[string]string
Body any
TimeoutMS int
}
// HTTPResponse is what the doer returns; Body is the parsed value (JSON
// decoded when the response is JSON, else the raw string) the connector sees
// as response.body.
type HTTPResponse struct {
Status int
Headers map[string]string
Body any
}
// HTTPDoer performs one connector HTTP call. ctx bounds it (the caller's
// request/flow context); the default doer is net/http (see http.go). Injecting
// a doer lets the host enforce SSRF policy or record egress.
type HTTPDoer func(ctx context.Context, req HTTPRequest) (HTTPResponse, error)
// Runtime holds the compiled shim shared across all connectors and the HTTP
// doer. It is immutable after construction and safe for concurrent use — each
// Run builds its own goja VM, so no connector state crosses invocations
// (the tenant-isolation property: org A's run shares no heap with org B's).
type Runtime struct {
shim *goja.Program
http HTTPDoer
}
// Connector is one compiled connector program (an ActivePieces piece bundle
// wrapped as a CommonJS module). Compile once, Run many times.
type Connector struct {
Name string
prog *goja.Program
}
// RunInput is one action invocation.
type RunInput struct {
Action string // action name, e.g. "web_search" / "custom_api_call"
Auth any // resolved credential value handed to ctx.auth
Props map[string]any // ctx.propsValue
}
// NewRuntime compiles the shim and returns a runtime. A nil doer selects the
// default net/http doer.
func NewRuntime(doer HTTPDoer) (*Runtime, error) {
prog, err := goja.Compile("connectorruntime/shim.js", shimJS, true)
if err != nil {
return nil, fmt.Errorf("connectorruntime: compile shim: %w", err)
}
if doer == nil {
doer = defaultHTTPDoer
}
return &Runtime{shim: prog, http: doer}, nil
}
// Compile wraps a connector's bundled CommonJS source as a callable module and
// compiles it. bundledJS is the output of Bundle (framework packages external).
func (rt *Runtime) Compile(name string, bundledJS []byte) (*Connector, error) {
if name == "" {
return nil, errors.New("connectorruntime: empty connector name")
}
// CommonJS harness: the bundle assigns to module.exports / calls require();
// wrapping it as a function lets us supply (module, exports, require).
wrapped := "(function(module, exports, require){\n" + string(bundledJS) + "\n})"
prog, err := goja.Compile(name+".connector.js", wrapped, false)
if err != nil {
return nil, fmt.Errorf("connectorruntime: compile connector %s: %w", name, err)
}
return &Connector{Name: name, prog: prog}, nil
}
// Run executes c's action with the given auth+props and returns the action's
// result (JSON-shaped Go values). It is the single-connector execution the KB
// long-tail sync and the /v1/automations/connectors/:id/run surface call.
func (rt *Runtime) Run(ctx context.Context, c *Connector, in RunInput) (any, error) {
if c == nil {
return nil, errors.New("connectorruntime: nil connector")
}
if err := ctx.Err(); err != nil {
return nil, err
}
vm := goja.New()
installConsole(vm)
// The sole impure primitive: perform the HTTP call synchronously on this
// goroutine. A doer error is thrown into JS (caught by the shim's Promise
// executor -> reject) so the action's try/catch and failsafe paths work.
if err := vm.Set("__hanzoHttpSend", func(call goja.FunctionCall) goja.Value {
resp, err := rt.http(ctx, parseHTTPRequest(vm, call.Argument(0)))
if err != nil {
panic(vm.ToValue(vm.NewGoError(err)))
}
return vm.ToValue(map[string]any{
"status": resp.Status,
"headers": resp.Headers,
"body": resp.Body,
})
}); err != nil {
return nil, fmt.Errorf("connectorruntime: bind http: %w", err)
}
// Install the framework shim (defines require + module table), then the
// connector program (which require()s the shim and calls createPiece).
if _, err := vm.RunProgram(rt.shim); err != nil {
return nil, fmt.Errorf("connectorruntime: init shim: %w", err)
}
if err := runConnectorModule(vm, c.prog); err != nil {
return nil, fmt.Errorf("connectorruntime: load connector %s: %w", c.Name, err)
}
action, err := resolveAction(vm, c.Name, in.Action)
if err != nil {
return nil, err
}
ctxObj, err := makeContext(vm, in)
if err != nil {
return nil, err
}
runFn, ok := goja.AssertFunction(action.Get("run"))
if !ok {
return nil, fmt.Errorf("connectorruntime: action %q has no run()", in.Action)
}
res, err := runFn(action, ctxObj)
if err != nil {
return nil, jsError(err)
}
return settle(vm, res)
}
// runConnectorModule invokes the wrapped bundle with a fresh module/exports and
// the shim-provided require, executing the connector body (which calls
// createPiece).
func runConnectorModule(vm *goja.Runtime, prog *goja.Program) error {
fnVal, err := vm.RunProgram(prog)
if err != nil {
return err
}
fn, ok := goja.AssertFunction(fnVal)
if !ok {
return errors.New("connector program is not a function")
}
module := vm.NewObject()
exports := vm.NewObject()
if err := module.Set("exports", exports); err != nil {
return err
}
requireFn := vm.Get("require")
_, err = fn(goja.Undefined(), module, exports, requireFn)
return err
}
// resolveAction returns the named action object off the connector's piece. A
// connector defines exactly one piece (createPiece); reading the last-created
// piece is unambiguous per VM.
func resolveAction(vm *goja.Runtime, connector, action string) (*goja.Object, error) {
piecesVal := vm.Get("__ap_pieces")
if piecesVal == nil || goja.IsUndefined(piecesVal) || goja.IsNull(piecesVal) {
return nil, fmt.Errorf("connector %q defined no piece", connector)
}
arr := piecesVal.ToObject(vm)
n := int(arr.Get("length").ToInteger())
if n == 0 {
return nil, fmt.Errorf("connector %q did not call createPiece", connector)
}
piece := arr.Get(strconv.Itoa(n - 1)).ToObject(vm)
getAction, ok := goja.AssertFunction(piece.Get("getAction"))
if !ok {
return nil, fmt.Errorf("connector %q piece missing getAction", connector)
}
actVal, err := getAction(piece, vm.ToValue(action))
if err != nil {
return nil, jsError(err)
}
if actVal == nil || goja.IsUndefined(actVal) || goja.IsNull(actVal) {
return nil, fmt.Errorf("unknown action %q on connector %q", action, connector)
}
return actVal.ToObject(vm), nil
}
// makeContext calls the shim's __makeContext(auth, propsValue) to build the
// ActionContext.
func makeContext(vm *goja.Runtime, in RunInput) (goja.Value, error) {
mk, ok := goja.AssertFunction(vm.Get("__makeContext"))
if !ok {
return nil, errors.New("connectorruntime: shim __makeContext missing")
}
props := in.Props
if props == nil {
props = map[string]any{}
}
ctxVal, err := mk(goja.Undefined(), vm.ToValue(in.Auth), vm.ToValue(props))
if err != nil {
return nil, jsError(err)
}
return ctxVal, nil
}
// settle resolves the action's return value. An async run returns a Promise
// that — because every await resolved synchronously through the Go doer — is
// already Fulfilled by the time goja hands control back (the microtask queue
// drained). A Pending promise means the connector used a timer or off-thread
// async the in-process runtime does not drive; that is an honest error, not a
// hang.
func settle(vm *goja.Runtime, v goja.Value) (any, error) {
p, ok := v.Export().(*goja.Promise)
if !ok {
if v == nil || goja.IsUndefined(v) || goja.IsNull(v) {
return nil, nil
}
return v.Export(), nil
}
switch p.State() {
case goja.PromiseStateFulfilled:
r := p.Result()
if r == nil || goja.IsUndefined(r) || goja.IsNull(r) {
return nil, nil
}
return r.Export(), nil
case goja.PromiseStateRejected:
return nil, promiseRejection(vm, p.Result())
default:
return nil, errors.New("connectorruntime: action did not settle synchronously (unsupported timer/async)")
}
}
// parseHTTPRequest extracts the Go HTTPRequest from the in-VM request object.
func parseHTTPRequest(vm *goja.Runtime, v goja.Value) HTTPRequest {
req := HTTPRequest{}
m, _ := v.Export().(map[string]any)
if m == nil {
return req
}
req.Method, _ = m["method"].(string)
req.URL, _ = m["url"].(string)
req.Headers = toStringMap(m["headers"])
req.QueryParams = toStringMap(m["queryParams"])
req.Body = m["body"]
req.TimeoutMS = toInt(m["timeout"])
return req
}
+161
View File
@@ -0,0 +1,161 @@
package connectorruntime
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
)
// loadBrave compiles the committed brave-search bundle — the REAL ActivePieces
// piece source (packages/pieces/community/brave-search) esbuild-bundled with
// the framework left external. This is the keystone: one real connector
// running native in goja proves the pattern for all of them.
func loadBrave(t *testing.T, rt *Runtime) *Connector {
t.Helper()
js, err := os.ReadFile(filepath.Join("connectors", "brave-search.connector.js"))
if err != nil {
t.Fatalf("read brave bundle: %v", err)
}
c, err := rt.Compile("brave-search", js)
if err != nil {
t.Fatalf("compile brave: %v", err)
}
return c
}
// TestBraveWebSearch_NativeGoja runs brave-search's real web_search action
// in-process. A doer captures the request the connector built so we can prove
// the framework shim delivered auth + props correctly: the action reads
// context.auth.secret_text into the X-Subscription-Token header and
// context.propsValue.{query,count} into the query params, then returns
// response.body. No Node, no auto pod.
func TestBraveWebSearch_NativeGoja(t *testing.T) {
var gotReq HTTPRequest
rt, err := NewRuntime(func(_ context.Context, req HTTPRequest) (HTTPResponse, error) {
gotReq = req
return HTTPResponse{
Status: 200,
Headers: map[string]string{"content-type": "application/json"},
Body: map[string]any{
"web": map[string]any{
"results": []any{
map[string]any{"title": "Hanzo AI", "url": "https://hanzo.ai"},
},
},
},
}, nil
})
if err != nil {
t.Fatalf("new runtime: %v", err)
}
brave := loadBrave(t, rt)
out, err := rt.Run(context.Background(), brave, RunInput{
Action: "web_search",
Auth: map[string]any{"secret_text": "sk-brave-test"},
Props: map[string]any{"query": "hanzo ai", "count": 5},
})
if err != nil {
t.Fatalf("run web_search: %v", err)
}
// The action built the exact Brave request from the shim-delivered context.
if gotReq.Method != "GET" {
t.Errorf("method = %q, want GET", gotReq.Method)
}
if gotReq.URL != "https://api.search.brave.com/res/v1/web/search" {
t.Errorf("url = %q", gotReq.URL)
}
if gotReq.Headers["X-Subscription-Token"] != "sk-brave-test" {
t.Errorf("auth header = %q, want sk-brave-test (auth injection failed)", gotReq.Headers["X-Subscription-Token"])
}
if gotReq.QueryParams["q"] != "hanzo ai" {
t.Errorf("query q = %q, want 'hanzo ai'", gotReq.QueryParams["q"])
}
if gotReq.QueryParams["count"] != "5" {
t.Errorf("query count = %q, want 5", gotReq.QueryParams["count"])
}
// The action returned response.body — the search results — verbatim.
body, ok := out.(map[string]any)
if !ok {
t.Fatalf("output type = %T, want map", out)
}
web, _ := body["web"].(map[string]any)
results, _ := web["results"].([]any)
if len(results) != 1 {
t.Fatalf("results = %v", body)
}
first, _ := results[0].(map[string]any)
if first["title"] != "Hanzo AI" {
t.Errorf("first result title = %v", first["title"])
}
}
// TestCustomApiCall_DefaultDoer_EndToEnd exercises the FULL native path with
// the real net/http doer against a live test server: brave-search's
// createCustomApiCallAction (the same generic action the KB long-tail sync
// invokes for notion). It proves the default doer builds a correct request,
// the shim's authMapping injects auth, and the JSON response round-trips.
func TestCustomApiCall_DefaultDoer_EndToEnd(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if got := r.Header.Get("X-Subscription-Token"); got != "sk-live" {
t.Errorf("server saw auth header %q, want sk-live", got)
}
if got := r.URL.Query().Get("q"); got != "native" {
t.Errorf("server saw q=%q, want native", got)
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{"ok": true, "echo": "native"})
}))
defer srv.Close()
rt, err := NewRuntime(nil) // nil => default net/http doer
if err != nil {
t.Fatalf("new runtime: %v", err)
}
brave := loadBrave(t, rt)
out, err := rt.Run(context.Background(), brave, RunInput{
Action: "custom_api_call",
Auth: map[string]any{"secret_text": "sk-live"},
Props: map[string]any{
"method": "GET",
"url": map[string]any{"url": srv.URL},
"queryParams": map[string]any{"q": "native"},
"headers": map[string]any{},
},
})
if err != nil {
t.Fatalf("run custom_api_call: %v", err)
}
resp, ok := out.(map[string]any)
if !ok {
t.Fatalf("output type = %T, want map", out)
}
if resp["status"] != int64(200) && resp["status"] != float64(200) {
t.Errorf("status = %v (%T), want 200", resp["status"], resp["status"])
}
body, _ := resp["body"].(map[string]any)
if body["ok"] != true || body["echo"] != "native" {
t.Errorf("body = %v, want {ok:true, echo:native}", body)
}
}
// TestUnknownActionAndConnector proves honest failures (no silent success).
func TestUnknownAction(t *testing.T) {
rt, err := NewRuntime(func(_ context.Context, _ HTTPRequest) (HTTPResponse, error) {
return HTTPResponse{Status: 200}, nil
})
if err != nil {
t.Fatalf("new runtime: %v", err)
}
brave := loadBrave(t, rt)
if _, err := rt.Run(context.Background(), brave, RunInput{Action: "nope"}); err == nil {
t.Fatal("expected error for unknown action, got nil")
}
}
+277
View File
@@ -0,0 +1,277 @@
// shim.js is the in-process implementation of the ActivePieces authoring
// framework, evaluated once per connector VM. A connector's compiled bundle
// leaves @activepieces/pieces-framework, @activepieces/pieces-common and
// @activepieces/shared external; this file supplies them via a minimal
// require() so the SAME connector source that ran under the Node engine runs
// unmodified in goja.
//
// The only impure primitive is __hanzoHttpSend(request) — a Go function
// injected per invocation that performs the HTTP call synchronously (Go
// net/http) and returns {status,headers,body} or throws. Because it resolves
// synchronously, an action's `async run(ctx)` settles within goja's own
// microtask drain: no event loop, connectors run as plain in-process work.
(function (g) {
'use strict';
// ---- @activepieces/pieces-framework ---------------------------------------
// Property.* / PieceAuth.* are pure metadata builders: they return the
// config object tagged with a type. Execution never inspects the schema —
// propsValue arrives already resolved — so a shallow copy is faithful.
function tagged(type) {
return function (cfg) {
return Object.assign({ type: type, valueSchema: undefined }, cfg || {});
};
}
var Property = {
ShortText: tagged('SHORT_TEXT'),
LongText: tagged('LONG_TEXT'),
Number: tagged('NUMBER'),
Checkbox: tagged('CHECKBOX'),
Json: tagged('JSON'),
Object: tagged('OBJECT'),
Array: tagged('ARRAY'),
File: tagged('FILE'),
DateTime: tagged('DATE_TIME'),
Color: tagged('COLOR'),
Markdown: tagged('MARKDOWN'),
StaticDropdown: tagged('STATIC_DROPDOWN'),
Dropdown: tagged('DROPDOWN'),
StaticMultiSelectDropdown: tagged('STATIC_MULTI_SELECT_DROPDOWN'),
MultiSelectDropdown: tagged('MULTI_SELECT_DROPDOWN'),
DynamicProperties: tagged('DYNAMIC'),
Custom: tagged('CUSTOM'),
SecretText: tagged('SECRET_TEXT'),
};
var PieceAuth = {
SecretText: tagged('SECRET_TEXT'),
OAuth2: tagged('OAUTH2'),
BasicAuth: tagged('BASIC_AUTH'),
CustomAuth: tagged('CUSTOM_AUTH'),
None: function () { return undefined; },
};
function createAction(p) {
return {
name: p.name,
displayName: p.displayName,
description: p.description,
props: p.props || {},
run: p.run,
test: p.test || p.run,
requireAuth: p.requireAuth !== false,
__isAction: true,
};
}
function createTrigger(p) {
return {
name: p.name,
displayName: p.displayName,
description: p.description,
props: p.props || {},
type: p.type,
__isTrigger: true,
};
}
function Piece(params) {
this.displayName = params.displayName;
this.description = params.description || '';
this.auth = params.auth;
this.logoUrl = params.logoUrl;
this._actions = {};
(params.actions || []).forEach(function (a) { this._actions[a.name] = a; }, this);
this._triggers = {};
(params.triggers || []).forEach(function (t) { this._triggers[t.name] = t; }, this);
}
Piece.prototype.getAction = function (n) { return this._actions[n]; };
Piece.prototype.getTrigger = function (n) { return this._triggers[n]; };
Piece.prototype.actions = function () { return this._actions; };
Piece.prototype.triggers = function () { return this._triggers; };
// createPiece registers the constructed piece on a per-VM list; the runtime
// reads the last one after evaluating the bundle (a bundle calls createPiece
// exactly once at module top level).
function createPiece(params) {
var p = new Piece(params);
g.__ap_pieces.push(p);
return p;
}
g.__ap_pieces = [];
var framework = {
Property: Property,
PieceAuth: PieceAuth,
createAction: createAction,
createTrigger: createTrigger,
createPiece: createPiece,
PieceCategory: {},
getAuthPropertyForValue: function () { return undefined; },
};
// ---- @activepieces/pieces-common ------------------------------------------
var HttpMethod = {
GET: 'GET', POST: 'POST', PUT: 'PUT', PATCH: 'PATCH', DELETE: 'DELETE', HEAD: 'HEAD',
};
var AuthenticationType = {
BEARER_TOKEN: 'BEARER_TOKEN', BASIC: 'BASIC', API_KEY: 'API_KEY',
};
// httpClient.sendRequest hands the request to the Go doer synchronously and
// wraps the result in a resolved Promise so `await httpClient.sendRequest`
// and `.then(...)` chains both work.
var httpClient = {
sendRequest: function (req) {
return new Promise(function (resolve, reject) {
try { resolve(g.__hanzoHttpSend(req)); }
catch (e) { reject(e); }
});
},
};
function getAccessTokenOrThrow(auth) {
var t = auth && auth.access_token;
if (t === undefined || t === null) throw new Error('Invalid bearer token');
return t;
}
function joinUrl(base, rel) {
base = base || '';
if (base.charAt(base.length - 1) !== '/') base += '/';
if (rel.charAt(0) === '/') rel = rel.slice(1);
return base + rel;
}
// createCustomApiCallAction is the generic HTTP action every provider gets.
// It is behaviourally identical to @activepieces/pieces-common's own: build
// the request from propsValue (url/method/headers/queryParams/body[_type]),
// fold in the provider authMapping, send. This is the exact action the KB
// long-tail sync invokes (notion custom_api_call), so shimming it here is
// what lets KB run native.
function createCustomApiCallAction(opts) {
opts = opts || {};
var baseUrl = opts.baseUrl || function () { return ''; };
var authMapping = opts.authMapping;
var authLocation = opts.authLocation || 'headers';
return createAction({
name: opts.name || 'custom_api_call',
displayName: opts.displayName || 'Custom API Call',
description: opts.description || 'Make a custom API call to a specific endpoint',
requireAuth: !!opts.auth,
props: opts.props || {},
run: async function (ctx) {
var pv = ctx.propsValue || {};
var method = pv.method;
var headers = pv.headers || {};
var queryParams = pv.queryParams || {};
var body = pv.body;
var bodyType = pv.body_type;
var urlProp = pv.url;
var urlValue = (urlProp && typeof urlProp === 'object') ? urlProp.url : urlProp;
if (!method) throw new Error('Method is required');
if (!urlValue) throw new Error('URL is required');
var authValue = authMapping ? await authMapping(ctx.auth, pv) : {};
var fullUrl =
(urlValue.indexOf('http://') === 0 || urlValue.indexOf('https://') === 0)
? urlValue
: joinUrl(baseUrl(ctx.auth), urlValue);
var reqHeaders = Object.assign({}, headers, authLocation === 'headers' ? authValue : {});
var reqQuery = Object.assign({}, authLocation === 'queryParams' ? authValue : {}, queryParams);
var reqBody;
if (body) {
if (bodyType && bodyType !== 'none') reqBody = body.data;
else if (!bodyType) reqBody = body;
}
var resp = await httpClient.sendRequest({
method: method,
url: fullUrl,
headers: reqHeaders,
queryParams: reqQuery,
body: reqBody,
});
return { status: resp.status, headers: resp.headers, body: resp.body };
},
});
}
var common = {
httpClient: httpClient,
HttpMethod: HttpMethod,
AuthenticationType: AuthenticationType,
getAccessTokenOrThrow: getAccessTokenOrThrow,
createCustomApiCallAction: createCustomApiCallAction,
// HttpError is referenced as a type by some pieces; a constructor keeps
// `instanceof`/`new HttpError` from throwing if executed.
HttpError: function HttpError(request, cause) { this.request = request; this.cause = cause; },
};
// ---- @activepieces/shared (pure helpers pieces import) --------------------
function isNil(v) { return v === null || v === undefined; }
function isEmpty(v) {
if (isNil(v)) return true;
if (typeof v === 'string' || Array.isArray(v)) return v.length === 0;
if (typeof v === 'object') return Object.keys(v).length === 0;
return false;
}
function assertNotNullOrUndefined(v, name) {
if (isNil(v)) throw new Error('Expected ' + (name || 'value') + ' to be defined, received ' + v);
return v;
}
var shared = {
isNil: isNil,
isEmpty: isEmpty,
assertNotNullOrUndefined: assertNotNullOrUndefined,
PieceCategory: {},
TriggerStrategy: { POLLING: 'POLLING', WEBHOOK: 'WEBHOOK', APP_WEBHOOK: 'APP_WEBHOOK' },
};
// ---- module resolution -----------------------------------------------------
g.__ap_modules = {
'@activepieces/pieces-framework': framework,
'@activepieces/pieces-common': common,
'@activepieces/shared': shared,
};
g.require = function (name) {
var m = g.__ap_modules[name];
if (m) return m;
throw new Error('connectorruntime: module not available in-process: ' + name);
};
// __makeContext builds the ActionContext an action's run(ctx) receives.
// auth + propsValue are the real inputs; the rest are safe in-process stubs
// (an in-memory store, no-op files/connections/server) so a connector that
// touches them does not throw. HTTP-only connectors ignore all but the first
// two.
g.__makeContext = function (auth, propsValue) {
var mem = {};
return {
auth: auth,
propsValue: propsValue || {},
store: {
get: async function (k) { return k in mem ? mem[k] : null; },
put: async function (k, v) { mem[k] = v; return v; },
delete: async function (k) { delete mem[k]; },
},
files: {
write: async function (o) { return (o && o.data) || null; },
},
connections: {
get: async function () { return null; },
},
server: { apiUrl: '', publicUrl: '', token: '' },
project: { id: '', externalId: async function () { return undefined; } },
run: { id: '', stop: function () {}, pause: function () {} },
generateResumeUrl: function () { return ''; },
flows: { current: { id: '', version: { id: '' } } },
step: { name: '' },
payload: {},
};
};
})(globalThis);
+1 -1
View File
@@ -39,7 +39,7 @@ import (
// drive the SAME single implementation. The subsystem is a stateless orchestrator over
// framework (which holds the state) + the AI/social edges — it opens no store of its own.
//
// Registration is a one-line cloud.MountSpec in subsystems.Wire() (after framework +
// Registration is a one-line cloud.MountSpec in apps.Wire() (after framework +
// knowledge, before the AI /v1/* catch-all); the module fixtures + lifecycle hooks are
// registered in doctypes.go's init(), process-global and mount-order-independent.
+433
View File
@@ -0,0 +1,433 @@
//go:build controlplane
package controlplane
// cert.go — seam (c) crypto core: the REAL control-plane finality certificate.
//
// This is the independent-signature weighted-quorum certificate the design
// chose over the blocked threshold-Pulsar path (control-plane-increment-2.md
// §2c). Each pod signs the canonical quorum message INDEPENDENTLY with its own
// ML-DSA-65 identity key (seam a) under a DISTINCT cert context; the cert is a
// quasar.ConsensusCert carrying one EvidenceWeightedSigSet leg (a
// WeightedQuorumCert of N independent FIPS-204 signatures + a weighted-Merkle
// quorum). Verification is quasar.VerifyConsensusCert under a control-plane
// policy — the shipped, audited Gen-3 verifier. No DKG, no threshold aggregate,
// no unshipped luxfi/pulsar core: soundness rests only on stock FIPS-204 verify
// + the weighted-validator-set Merkle commitment.
//
// This file is self-contained crypto (independent of the ceremony wiring): it
// composes and verifies a cert from a position + the validator key set + a set
// of collected signatures. cert_test.go exercises it directly.
import (
"errors"
"fmt"
"sort"
"github.com/luxfi/consensus/config"
"github.com/luxfi/consensus/protocol/quasar"
"github.com/luxfi/crypto/mldsa"
pulsar "github.com/luxfi/pulsar/pkg/pulsar"
)
const (
// controlPlaneCertVersion pins the quasar.ConsensusCert envelope version the
// control plane emits. Wire-stable; a quasar bump surfaces loudly as
// ErrConsensusCertVersion (the value is bound into the domain message).
controlPlaneCertVersion uint16 = 1
// controlPlanePolicyID is the single control-plane cert policy the store
// resolves. One posture, one policy — decomplected from the cert bytes.
controlPlanePolicyID uint32 = 1
// controlPlaneQCType names the certificate role (finality) bound into the
// signed quorum message so a signature for one role cannot be replayed as
// another.
controlPlaneQCType uint8 = 0x03
// mldsa65ParamByte is the ML-DSA-65 parameter-set wire byte
// (config.SigSchemeID / QuorumSchemeMLDSA65). Bound into every validator
// leaf and signer record.
mldsa65ParamByte uint8 = 0x42
)
// certContext is the FIPS 204 §5.2 domain-separation context every control-plane
// CERT signature is produced and verified under. It is DELIBERATELY DISTINCT from
// popContext (RED finding R4): reusing the proof-of-possession context for cert
// signing would allow a PoP signature to be cross-protocol-confused with a cert
// signature. It is also the QuorumVerifierConfig.Context the weighted-sig-set
// verifier checks every ML-DSA record under (contextForScheme → cfg.Context), so
// signer and verifier are pinned to the same context by construction.
var certContext = []byte("hanzo/controlplane/cert/v1")
var (
// ErrNoValidators rejects composing/verifying against an empty key set.
ErrNoValidators = errors.New("controlplane: empty validator key set")
// ErrSignerNotInSet rejects a collected signature from a node absent from
// the validator key set (a rogue signer).
ErrSignerNotInSet = errors.New("controlplane: signature from a node not in the validator set")
// ErrInsufficientCertSigners rejects composing a cert below the quorum weight.
ErrInsufficientCertSigners = errors.New("controlplane: collected signatures below quorum weight")
// ErrCertPositionMismatch rejects a cert whose self-described position does
// not match the block the caller expects it to certify. VerifyConsensusCert
// pins the validator set + policy but NOT the caller's height/round/block, so
// the caller MUST bind the cert to its expected position — otherwise a valid
// cert for a DIFFERENT block/height/round would be accepted here.
ErrCertPositionMismatch = errors.New("controlplane: cert position does not match the expected block")
// ErrUnsafeCertFloor is the two-threshold self-defence (RED finding 1): the
// cert quorum floor MUST be the byzantine-safe BFT quorum for the validator-
// set size, NEVER a wallet-custody t = floor(n/2)+1. For n=5 that t is 3, and
// 2*3 is not > N+f = 6, so a 3-of-5 cert could finalize two conflicting
// blocks. The core enforces whatever floor it is handed AND refuses a
// structurally unsafe one — so a mis-wired driver cannot get an unsafe cert
// verified even if it passes the wrong threshold.
ErrUnsafeCertFloor = errors.New("controlplane: cert quorum floor is not the byzantine-safe BFT quorum for the validator set (a wallet-custody t must never be a cert floor)")
)
// guardBFTFloor fails closed unless quorumWeight is a byzantine-safe cert floor
// for a set of n validators: at least the canonical BFT quorum (2n/3+1), no more
// than n, and a (n, quorum, f) triple that satisfies 2q > n+f. This is the
// self-defence RED finding 1 mandates — it is derived from len(keys), so a
// future driver that mis-wires the wallet-custody t (=3 for n=5) instead of the
// BFT quorum (=4) cannot get a cert composed OR verified.
func guardBFTFloor(n int, quorumWeight uint64) error {
if n <= 0 {
return ErrNoValidators
}
floor := bftQuorum(n)
if quorumWeight < uint64(floor) {
return fmt.Errorf("%w: quorum %d < BFT floor %d for N=%d", ErrUnsafeCertFloor, quorumWeight, floor, n)
}
if quorumWeight > uint64(n) {
return fmt.Errorf("%w: quorum %d exceeds validator count %d", ErrUnsafeCertFloor, quorumWeight, n)
}
if err := checkQuorumSafety(n, int(quorumWeight), bftFaultTolerance(n)); err != nil {
return fmt.Errorf("%w: %v", ErrUnsafeCertFloor, err)
}
return nil
}
// certPosition is the consensus position a control-plane cert finalizes. It is
// the minimal, ceremony-independent input to compose/verify a cert, so this
// crypto core is testable in isolation.
type certPosition struct {
NetworkID uint32
ChainID uint32
Epoch uint64
Height uint64
Round uint32
BlockHash [32]byte // the block / value being finalized (inner ValueHash + envelope BlockHash)
ParentRoot [32]byte // chain-extension anchor bound into the round digest
}
// validatorKey pairs a pod's node id with its ML-DSA-65 identity public key —
// the (id, key) the weighted-validator-set leaf commits to. Each pod carries
// unit voting weight; the quorum floor is a signer COUNT.
type validatorKey struct {
Node pulsar.NodeID
Pub *mldsa.PublicKey
}
// sortedValidatorKeys returns the key set sorted strictly by node id (the
// canonical leaf order BuildWeightedValidatorSet imposes). Deterministic across
// all pods so every voter builds the byte-identical validator set.
func sortedValidatorKeys(keys map[pulsar.NodeID]*mldsa.PublicKey) []validatorKey {
out := make([]validatorKey, 0, len(keys))
for n, k := range keys {
out = append(out, validatorKey{Node: n, Pub: k})
}
sort.Slice(out, func(i, j int) bool {
return bytesLessNode(out[i].Node, out[j].Node)
})
return out
}
func bytesLessNode(a, b pulsar.NodeID) bool {
for i := range a {
if a[i] != b[i] {
return a[i] < b[i]
}
}
return false
}
// buildValidatorSet builds the weighted-validator-set commitment (the shipped
// quasar.WeightedValidatorSet) from the pod key set. Unit weights; ML-DSA-65
// parameter byte; key version 0 (rotation is seam-b's concern). Deterministic.
func buildValidatorSet(epoch uint64, keys map[pulsar.NodeID]*mldsa.PublicKey) (*quasar.WeightedValidatorSet, error) {
if len(keys) == 0 {
return nil, ErrNoValidators
}
sorted := sortedValidatorKeys(keys)
leaves := make([]quasar.WeightedValidatorLeaf, 0, len(sorted))
for _, vk := range sorted {
leaves = append(leaves, quasar.WeightedValidatorLeaf{
ValidatorID: vk.Node,
PublicKey: vk.Pub.Bytes(),
VotingWeight: 1,
ParameterSetID: mldsa65ParamByte,
KeyVersion: 0,
})
}
return quasar.BuildWeightedValidatorSet(epoch, leaves)
}
// certEnvelope builds the round-digest envelope the quorum message is derived
// from. The posture axes come from the canonical StrictPQ profile, but the
// proof backend/format/verifier are pinned to the DIRECT weighted-quorum trust
// model (a cert produced under this backend cannot be re-presented under a STARK
// backend's envelope). The committee/group-key/signer roots are bound non-zero
// (ComputeRoundDigest refuses zero security-relevant inputs), deterministically
// derived from the set root so compose and verify agree byte-for-byte.
func certEnvelope(pos certPosition, vsetRoot [48]byte, quorumWeight uint64) quasar.QuorumMessageEnvelope {
p := config.StrictPQProfile
committee := root48("cp-cert-committee", vsetRoot[:])
groupKey := root48("cp-cert-groupkey", vsetRoot[:])
signerSet := root48("cp-cert-signerset", vsetRoot[:])
return quasar.QuorumMessageEnvelope{
ProfileID: p.ProfileID,
HashSuite: p.HashSuiteID,
IdentityScheme: config.IdentitySchemeID(p.IdentitySchemeID),
FinalityScheme: p.FinalitySchemeID,
ProofPolicy: p.ProofPolicyID,
ProofBackend: config.ProofBackendDirectWeightedQuorum,
ProofFormat: config.ProofFormatDirectWeightedQuorumV1,
VerifierID: config.VerifierDirectWeightedQuorumPQ,
EffectivePolicy: byte(p.ProfileID),
NetworkID: pos.NetworkID,
ChainID: pos.ChainID,
Epoch: pos.Epoch,
Height: pos.Height,
Round: pos.Round,
ValueHash: pos.BlockHash,
QCType: controlPlaneQCType,
ValidatorSetRoot: vsetRoot,
QuorumThreshold: quorumWeight,
ParentQBlockHash: pos.ParentRoot,
CommitteeRoot: committee,
GroupPublicKeyHash: groupKey,
SignerSetCommit: signerSet,
}
}
// CertSigningMessage is the canonical quorum message every pod signs for this
// position + validator set + quorum. All pods derive it identically (the set
// root is a deterministic commitment to every pod's registered key), so each
// signs the SAME bytes independently. This is the value passed to
// mldsa PrivateKey.SignCtxDeterministic(msg, certContext).
func CertSigningMessage(pos certPosition, keys map[pulsar.NodeID]*mldsa.PublicKey, quorumWeight uint64) ([]byte, error) {
vset, err := buildValidatorSet(pos.Epoch, keys)
if err != nil {
return nil, err
}
return quasar.QuorumConsensusMessage(certEnvelope(pos, vset.Root(), quorumWeight))
}
// signRecords assembles the per-signer QuorumSignerRecords for the collected
// signatures, attaching each signer's weighted-Merkle inclusion proof against
// the set root. A signature from a node absent from the set is a rogue signer
// (ErrSignerNotInSet) — refused at assembly so it can never reach the cert.
func signRecords(vset *quasar.WeightedValidatorSet, keys map[pulsar.NodeID]*mldsa.PublicKey, sigs map[pulsar.NodeID][]byte) ([]quasar.QuorumSignerRecord, error) {
leaves := vset.Leaves() // canonical sorted order
indexOf := make(map[pulsar.NodeID]int, len(leaves))
for i := range leaves {
var id pulsar.NodeID
copy(id[:], leaves[i].ValidatorID[:])
indexOf[id] = i
}
records := make([]quasar.QuorumSignerRecord, 0, len(sigs))
for node, sig := range sigs {
pub, ok := keys[node]
if !ok {
return nil, fmt.Errorf("%w: %x", ErrSignerNotInSet, node[:8])
}
idx, ok := indexOf[node]
if !ok {
return nil, fmt.Errorf("%w: %x", ErrSignerNotInSet, node[:8])
}
proof, err := vset.InclusionProof(idx)
if err != nil {
return nil, err
}
records = append(records, quasar.QuorumSignerRecord{
ValidatorID: node,
PublicKey: pub.Bytes(),
VotingWeight: 1,
Scheme: quasar.QuorumSchemeMLDSA65,
ParamSetID: mldsa65ParamByte,
KeyVersion: 0,
MerklePath: proof,
Signature: append([]byte(nil), sig...),
})
}
return records, nil
}
// ComposeControlPlaneCert builds the REAL control-plane finality certificate: a
// quasar.ConsensusCert carrying one EvidenceWeightedSigSet leg over the collected
// independent ML-DSA-65 signatures. Permissionless and deterministic — no
// secrets, no randomness. Every honest voter holding the same (position, keys,
// signature set) composes the byte-identical cert.
func ComposeControlPlaneCert(pos certPosition, keys map[pulsar.NodeID]*mldsa.PublicKey, sigs map[pulsar.NodeID][]byte, quorumWeight uint64) (*quasar.ConsensusCert, error) {
// Two-threshold self-defence (RED finding 1): refuse an unsafe floor derived
// from the validator-set size, so a mis-wired wallet-custody t can never
// compose a cert.
if err := guardBFTFloor(len(keys), quorumWeight); err != nil {
return nil, err
}
if uint64(len(sigs)) < quorumWeight {
return nil, fmt.Errorf("%w: have %d need %d", ErrInsufficientCertSigners, len(sigs), quorumWeight)
}
vset, err := buildValidatorSet(pos.Epoch, keys)
if err != nil {
return nil, err
}
records, err := signRecords(vset, keys, sigs)
if err != nil {
return nil, err
}
wqc, err := quasar.BuildWeightedQuorumCert(quasar.QuorumCertParams{
ChainID: pos.ChainID,
Epoch: pos.Epoch,
Height: pos.Height,
Round: pos.Round,
ValueHash: pos.BlockHash,
QCType: controlPlaneQCType,
ValidatorSetRoot: vset.Root(),
QuorumThreshold: quorumWeight,
}, records)
if err != nil {
return nil, err
}
wqcBytes, err := wqc.MarshalBinary()
if err != nil {
return nil, err
}
p := config.StrictPQProfile
cert := &quasar.ConsensusCert{
Version: controlPlaneCertVersion,
Profile: byte(p.ProfileID),
ChainID: pos.ChainID,
Epoch: pos.Epoch,
Height: pos.Height,
Round: pos.Round,
BlockHash: pos.BlockHash,
ValidatorSetRoot: vset.Root(),
PolicyID: controlPlanePolicyID,
RequiredLegsRoot: quasar.HashRequiredLegs(controlPlanePolicy{quorumWeight}.RequiredLegs()),
AggregateWeight: wqc.AggregateWeight,
Evidence: []quasar.LegEvidence{{
Leg: quasar.LegSpec{Kind: quasar.LegPulsarMLDSA, ParamSetID: mldsa65ParamByte},
Mode: quasar.EvidenceWeightedSigSet,
Payload: wqcBytes,
}},
}
return cert, nil
}
// VerifyControlPlaneCert verifies a cert with the shipped, audited Gen-3
// verifier quasar.VerifyConsensusCert under the control-plane policy + the
// verifier-pinned validator set. NEVER the structural QuasarCert.Verify. Returns
// the verifier's typed error verbatim on failure.
func VerifyControlPlaneCert(pos certPosition, keys map[pulsar.NodeID]*mldsa.PublicKey, quorumWeight uint64, cert *quasar.ConsensusCert) error {
if cert == nil {
return quasar.ErrConsensusCertNil
}
// Bind the cert to the EXPECTED position (the caller's block) BEFORE the
// cryptographic verify. A cert self-describes its position; without this a
// perfectly valid cert for a different block/height/round would verify here.
if cert.ChainID != pos.ChainID || cert.Epoch != pos.Epoch ||
cert.Height != pos.Height || cert.Round != pos.Round ||
cert.BlockHash != pos.BlockHash {
return ErrCertPositionMismatch
}
// Two-threshold self-defence (RED finding 1): a mis-wired wallet-custody t
// cannot verify even if a driver passes it here — the floor is re-derived
// from the validator-set size and must be byzantine-safe.
if err := guardBFTFloor(len(keys), quorumWeight); err != nil {
return err
}
vset, err := buildValidatorSet(pos.Epoch, keys)
if err != nil {
return err
}
store := controlPlaneStore{policy: controlPlanePolicy{quorumWeight}}
vs := &controlPlaneValidatorSet{set: vset, pos: pos, quorumWeight: quorumWeight}
return quasar.VerifyConsensusCert(store, vs, cert)
}
// ----------------------------------------------------------------------------
// Policy + validator-set interface implementations (the decomplected inputs to
// quasar.VerifyConsensusCert). The control plane is its OWN policy domain: a
// small fixed committee whose only required leg is the independent-sig
// weighted-sig-set PQ leg. No threshold-sig, no classical, no STARK.
// ----------------------------------------------------------------------------
// controlPlanePolicy is the control-plane cert posture: exactly one required
// leg — LegPulsarMLDSA proven by EvidenceWeightedSigSet — at the BFT quorum
// weight floor. Classical and threshold-sig are forbidden.
type controlPlanePolicy struct {
quorumWeight uint64
}
func (controlPlanePolicy) RequiredLegs() []quasar.LegSpec {
return []quasar.LegSpec{{Kind: quasar.LegPulsarMLDSA, ParamSetID: mldsa65ParamByte}}
}
func (controlPlanePolicy) Allows(leg quasar.LegSpec, mode quasar.EvidenceMode, paramSet uint8) bool {
return leg.Kind == quasar.LegPulsarMLDSA &&
mode == quasar.EvidenceWeightedSigSet &&
paramSet == mldsa65ParamByte
}
func (p controlPlanePolicy) ThresholdWeight() uint64 { return p.quorumWeight }
func (controlPlanePolicy) AllowsClassicalScheme(quasar.ClassicalScheme) bool { return false }
// controlPlaneStore resolves the single control-plane policy. The verifier loads
// policy from here, never from the cert (invariant I1).
type controlPlaneStore struct {
policy controlPlanePolicy
}
func (s controlPlaneStore) Policy(_ uint32, _ uint64, policyID uint32) (quasar.ConsensusCertPolicy, error) {
if policyID != controlPlanePolicyID {
return nil, fmt.Errorf("controlplane: unknown policy id %d", policyID)
}
return s.policy, nil
}
// controlPlaneValidatorSet is the committed epoch validator set the verifier
// pins the cert against. It wraps the weighted set and supplies the weighted-
// sig-set verify axes (allowed schemes, the DISTINCT cert FIPS context, and the
// Direct-weighted-quorum envelope). No threshold group keys, no classical keys.
type controlPlaneValidatorSet struct {
set *quasar.WeightedValidatorSet
pos certPosition
quorumWeight uint64
}
func (v *controlPlaneValidatorSet) Root() [48]byte { return v.set.Root() }
func (v *controlPlaneValidatorSet) Epoch() uint64 { return v.set.Epoch() }
func (v *controlPlaneValidatorSet) WeightedConfig() quasar.QuorumVerifierConfig {
return quasar.QuorumVerifierConfig{
AllowedSchemes: map[quasar.QuorumSchemeID]bool{quasar.QuorumSchemeMLDSA65: true},
Context: certContext,
MinThreshold: v.quorumWeight,
}
}
func (v *controlPlaneValidatorSet) WeightedEnvelope() quasar.QuorumMessageEnvelope {
return certEnvelope(v.pos, v.set.Root(), v.quorumWeight)
}
func (v *controlPlaneValidatorSet) ThresholdGroupKey(quasar.LegKind) (quasar.ThresholdGroupKey, bool) {
return quasar.ThresholdGroupKey{}, false
}
func (v *controlPlaneValidatorSet) ClassicalAggregateKey(quasar.ClassicalScheme) ([]byte, bool) {
return nil, false
}
+492
View File
@@ -0,0 +1,492 @@
//go:build controlplane
package controlplane
// cert_test.go — standalone crypto tests for the seam (c) real certificate.
// These exercise ComposeControlPlaneCert / VerifyControlPlaneCert directly
// (independent of the ceremony wiring) and are the acceptance suite for the
// independent-sig weighted-quorum cert: a real cert verifies under policy; a
// cert missing a leg, below quorum, or carrying a forged / rogue / wrong-context
// signature is REJECTED with the exact typed error.
import (
"bytes"
"crypto/rand"
"errors"
"fmt"
"testing"
"github.com/luxfi/consensus/config"
"github.com/luxfi/consensus/protocol/quasar"
"github.com/luxfi/crypto/mldsa"
pulsar "github.com/luxfi/pulsar/pkg/pulsar"
)
// certTestPods mints n pods each with a fresh ML-DSA-65 identity keypair,
// returning the public key set, the private keys, and the node order.
func certTestPods(t *testing.T, n int) (map[pulsar.NodeID]*mldsa.PublicKey, map[pulsar.NodeID]*mldsa.PrivateKey, []pulsar.NodeID) {
t.Helper()
keys := map[pulsar.NodeID]*mldsa.PublicKey{}
privs := map[pulsar.NodeID]*mldsa.PrivateKey{}
order := make([]pulsar.NodeID, 0, n)
for i := 0; i < n; i++ {
node := NodeIDFromName(fmt.Sprintf("cloud-%d", i))
sk, err := mldsa.GenerateKey(rand.Reader, mldsa.MLDSA65)
if err != nil {
t.Fatalf("keygen %d: %v", i, err)
}
keys[node] = sk.PublicKey
privs[node] = sk
order = append(order, node)
}
return keys, privs, order
}
func certTestPosition() certPosition {
return certPosition{
NetworkID: 0x48414e5a, // "HANZ"
ChainID: 0x43504c4e, // "CPLN"
Epoch: 1,
Height: 7,
Round: 0,
BlockHash: hash32("cp-cert-test-block", []byte("shard-X->cloud-1")),
ParentRoot: hash32("cp-cert-test-parent", []byte("genesis")),
}
}
// signQuorum has the first k pods each independently sign the canonical cert
// message under the DISTINCT cert context.
func signQuorum(t *testing.T, pos certPosition, keys map[pulsar.NodeID]*mldsa.PublicKey, privs map[pulsar.NodeID]*mldsa.PrivateKey, order []pulsar.NodeID, quorumWeight uint64, k int) map[pulsar.NodeID][]byte {
t.Helper()
msg, err := CertSigningMessage(pos, keys, quorumWeight)
if err != nil {
t.Fatalf("cert message: %v", err)
}
sigs := map[pulsar.NodeID][]byte{}
for i := 0; i < k; i++ {
node := order[i]
sig, err := privs[node].SignCtxDeterministic(msg, certContext)
if err != nil {
t.Fatalf("sign %d: %v", i, err)
}
sigs[node] = sig
}
return sigs
}
// TestControlPlaneCert_VerifiesUnderPolicy — the happy path: a 4-of-5 quorum of
// independent ML-DSA-65 signatures composes a real ConsensusCert that
// VerifyConsensusCert accepts under the control-plane weighted-sig-set policy.
func TestControlPlaneCert_VerifiesUnderPolicy(t *testing.T) {
keys, privs, order := certTestPods(t, 5)
pos := certTestPosition()
const quorum uint64 = 4
sigs := signQuorum(t, pos, keys, privs, order, quorum, 4)
cert, err := ComposeControlPlaneCert(pos, keys, sigs, quorum)
if err != nil {
t.Fatalf("compose: %v", err)
}
if err := VerifyControlPlaneCert(pos, keys, quorum, cert); err != nil {
t.Fatalf("verify a real quorum cert: %v", err)
}
// The cert routes through the shipped Gen-3 verifier — confirm it is a
// weighted-sig-set leg, not a structural pass.
if len(cert.Evidence) != 1 || cert.Evidence[0].Mode != quasar.EvidenceWeightedSigSet {
t.Fatalf("cert is not a single weighted-sig-set leg: %+v", cert.Evidence)
}
}
// TestControlPlaneCert_FullQuorumVerifies — all 5 sign; still verifies (weight
// above the floor).
func TestControlPlaneCert_FullQuorumVerifies(t *testing.T) {
keys, privs, order := certTestPods(t, 5)
pos := certTestPosition()
const quorum uint64 = 4
sigs := signQuorum(t, pos, keys, privs, order, quorum, 5)
cert, err := ComposeControlPlaneCert(pos, keys, sigs, quorum)
if err != nil {
t.Fatalf("compose: %v", err)
}
if err := VerifyControlPlaneCert(pos, keys, quorum, cert); err != nil {
t.Fatalf("verify full quorum: %v", err)
}
}
// TestControlPlaneCert_Deterministic — two honest composers over the same inputs
// produce the byte-identical evidence payload (deterministic ML-DSA + permissionless
// assembly), so every voter converges on the same cert.
func TestControlPlaneCert_Deterministic(t *testing.T) {
keys, privs, order := certTestPods(t, 5)
pos := certTestPosition()
const quorum uint64 = 4
sigs := signQuorum(t, pos, keys, privs, order, quorum, 4)
a, err := ComposeControlPlaneCert(pos, keys, sigs, quorum)
if err != nil {
t.Fatalf("compose a: %v", err)
}
b, err := ComposeControlPlaneCert(pos, keys, sigs, quorum)
if err != nil {
t.Fatalf("compose b: %v", err)
}
if !bytes.Equal(a.Evidence[0].Payload, b.Evidence[0].Payload) {
t.Fatal("honest composers diverged on the cert payload (non-deterministic)")
}
if a.RequiredLegsRoot != b.RequiredLegsRoot || a.ValidatorSetRoot != b.ValidatorSetRoot {
t.Fatal("honest composers diverged on the cert header roots")
}
}
// TestControlPlaneCert_BelowQuorumRejected — fewer than quorum signatures cannot
// compose a cert (fail-closed at assembly).
func TestControlPlaneCert_BelowQuorumRejected(t *testing.T) {
keys, privs, order := certTestPods(t, 5)
pos := certTestPosition()
const quorum uint64 = 4
sigs := signQuorum(t, pos, keys, privs, order, quorum, 3) // only 3
if _, err := ComposeControlPlaneCert(pos, keys, sigs, quorum); !errors.Is(err, ErrInsufficientCertSigners) {
t.Fatalf("below quorum: want ErrInsufficientCertSigners, got %v", err)
}
}
// TestControlPlaneCert_MissingLegRejected — a cert with the required PQ leg
// stripped is rejected by the envelope (I5).
func TestControlPlaneCert_MissingLegRejected(t *testing.T) {
keys, privs, order := certTestPods(t, 5)
pos := certTestPosition()
const quorum uint64 = 4
sigs := signQuorum(t, pos, keys, privs, order, quorum, 4)
cert, err := ComposeControlPlaneCert(pos, keys, sigs, quorum)
if err != nil {
t.Fatalf("compose: %v", err)
}
cert.Evidence = nil // strip the required leg
if err := VerifyControlPlaneCert(pos, keys, quorum, cert); !errors.Is(err, quasar.ErrMissingRequiredLeg) {
t.Fatalf("missing leg: want ErrMissingRequiredLeg, got %v", err)
}
}
// TestControlPlaneCert_ForgedSigRejected — a correctly-formed signature under an
// ATTACKER key, attributed to a legitimate validator, is rejected by the stock
// FIPS-204 verify inside the weighted-sig-set predicate.
func TestControlPlaneCert_ForgedSigRejected(t *testing.T) {
keys, privs, order := certTestPods(t, 5)
pos := certTestPosition()
const quorum uint64 = 4
sigs := signQuorum(t, pos, keys, privs, order, quorum, 4)
msg, err := CertSigningMessage(pos, keys, quorum)
if err != nil {
t.Fatalf("cert message: %v", err)
}
attacker, err := mldsa.GenerateKey(rand.Reader, mldsa.MLDSA65)
if err != nil {
t.Fatalf("attacker keygen: %v", err)
}
badSig, err := attacker.SignCtxDeterministic(msg, certContext)
if err != nil {
t.Fatalf("attacker sign: %v", err)
}
sigs[order[0]] = badSig // legit validator id, attacker signature
cert, err := ComposeControlPlaneCert(pos, keys, sigs, quorum) // assembly does not verify sigs
if err != nil {
t.Fatalf("compose: %v", err)
}
if err := VerifyControlPlaneCert(pos, keys, quorum, cert); !errors.Is(err, quasar.ErrQCSigInvalid) {
t.Fatalf("forged sig: want ErrQCSigInvalid, got %v", err)
}
}
// TestControlPlaneCert_RogueSignerRejected — a signature from a node absent from
// the validator key set is refused at assembly (never reaches the cert).
func TestControlPlaneCert_RogueSignerRejected(t *testing.T) {
keys, privs, order := certTestPods(t, 5)
pos := certTestPosition()
const quorum uint64 = 4
sigs := signQuorum(t, pos, keys, privs, order, quorum, 4)
msg, _ := CertSigningMessage(pos, keys, quorum)
rogue := NodeIDFromName("attacker-pod")
rogueKey, err := mldsa.GenerateKey(rand.Reader, mldsa.MLDSA65)
if err != nil {
t.Fatalf("rogue keygen: %v", err)
}
rsig, _ := rogueKey.SignCtxDeterministic(msg, certContext)
sigs[rogue] = rsig // a node not in the registered set
if _, err := ComposeControlPlaneCert(pos, keys, sigs, quorum); !errors.Is(err, ErrSignerNotInSet) {
t.Fatalf("rogue signer: want ErrSignerNotInSet, got %v", err)
}
}
// TestControlPlaneCert_WrongPositionRejected — a valid cert for one block is
// rejected when a verifier expects a different block/height (cross-position
// binding; ErrCertPositionMismatch).
func TestControlPlaneCert_WrongPositionRejected(t *testing.T) {
keys, privs, order := certTestPods(t, 5)
pos := certTestPosition()
const quorum uint64 = 4
sigs := signQuorum(t, pos, keys, privs, order, quorum, 4)
cert, err := ComposeControlPlaneCert(pos, keys, sigs, quorum)
if err != nil {
t.Fatalf("compose: %v", err)
}
other := pos
other.Height = pos.Height + 1
if err := VerifyControlPlaneCert(other, keys, quorum, cert); !errors.Is(err, ErrCertPositionMismatch) {
t.Fatalf("wrong position: want ErrCertPositionMismatch, got %v", err)
}
}
// TestControlPlaneCert_WrongValidatorSetRejected — a cert built for one key set
// is rejected against a different key set (validator-set root pinned by the
// verifier, I3).
func TestControlPlaneCert_WrongValidatorSetRejected(t *testing.T) {
keys, privs, order := certTestPods(t, 5)
pos := certTestPosition()
const quorum uint64 = 4
sigs := signQuorum(t, pos, keys, privs, order, quorum, 4)
cert, err := ComposeControlPlaneCert(pos, keys, sigs, quorum)
if err != nil {
t.Fatalf("compose: %v", err)
}
otherKeys, _, _ := certTestPods(t, 5) // same names, different keys → different set root
if err := VerifyControlPlaneCert(pos, otherKeys, quorum, cert); !errors.Is(err, quasar.ErrValidatorSetRootMismatch) {
t.Fatalf("wrong validator set: want ErrValidatorSetRootMismatch, got %v", err)
}
}
// TestControlPlaneCert_PopContextSigRejected — proves RED finding R4: a signature
// produced under the PoP context (popContext) is NOT accepted as a cert
// signature, because cert signing uses a DISTINCT FIPS-204 context. This is the
// cross-protocol-confusion defence between the identity PoP and the cert leg.
func TestControlPlaneCert_PopContextSigRejected(t *testing.T) {
keys, privs, order := certTestPods(t, 5)
pos := certTestPosition()
const quorum uint64 = 4
msg, err := CertSigningMessage(pos, keys, quorum)
if err != nil {
t.Fatalf("cert message: %v", err)
}
if bytes.Equal(certContext, popContext) {
t.Fatal("R4 VIOLATED: cert context must differ from the PoP context")
}
sigs := map[pulsar.NodeID][]byte{}
for i := 0; i < 4; i++ {
node := order[i]
// Sign the correct message but under the WRONG (PoP) context.
sig, err := privs[node].SignCtxDeterministic(msg, popContext)
if err != nil {
t.Fatalf("sign %d: %v", i, err)
}
sigs[node] = sig
}
cert, err := ComposeControlPlaneCert(pos, keys, sigs, quorum)
if err != nil {
t.Fatalf("compose: %v", err)
}
if err := VerifyControlPlaneCert(pos, keys, quorum, cert); !errors.Is(err, quasar.ErrQCSigInvalid) {
t.Fatalf("pop-context sig accepted as cert sig (R4 broken): want ErrQCSigInvalid, got %v", err)
}
}
// ----------------------------------------------------------------------------
// RED finding 1 (two-threshold self-guard) + finding 3 (raw-craft verify-side
// tests). These build malicious certs by hand — bypassing the honest composer —
// and drive them STRAIGHT at VerifyControlPlaneCert, locking the BFT-floor and
// weighted-Merkle gates black-box rather than trusting only the audited library.
// ----------------------------------------------------------------------------
// certRecordFor builds one signer record for `node`, using an explicit public
// key / weight / signature so a test can stuff a wrong key or an inflated
// weight. The Merkle proof is for the node's real committed leaf; a mismatched
// key/weight therefore reconstructs a leaf that is NOT under the root.
func certRecordFor(t *testing.T, vset *quasar.WeightedValidatorSet, node pulsar.NodeID, pub *mldsa.PublicKey, weight uint64, sig []byte) quasar.QuorumSignerRecord {
t.Helper()
leaves := vset.Leaves()
idx := -1
for i := range leaves {
var id pulsar.NodeID
copy(id[:], leaves[i].ValidatorID[:])
if id == node {
idx = i
break
}
}
if idx < 0 {
t.Fatalf("node %x not in validator set", node[:8])
}
proof, err := vset.InclusionProof(idx)
if err != nil {
t.Fatalf("inclusion proof: %v", err)
}
return quasar.QuorumSignerRecord{
ValidatorID: node,
PublicKey: pub.Bytes(),
VotingWeight: weight,
Scheme: quasar.QuorumSchemeMLDSA65,
ParamSetID: mldsa65ParamByte,
KeyVersion: 0,
MerklePath: proof,
Signature: sig,
}
}
// wrapRawWQC wraps a hand-built WeightedQuorumCert into a ConsensusCert with a
// correct control-plane header, so the verifier reaches the leg's predicate.
func wrapRawWQC(t *testing.T, pos certPosition, vset *quasar.WeightedValidatorSet, wqc *quasar.WeightedQuorumCert) *quasar.ConsensusCert {
t.Helper()
b, err := wqc.MarshalBinary()
if err != nil {
t.Fatalf("marshal wqc: %v", err)
}
p := config.StrictPQProfile
return &quasar.ConsensusCert{
Version: controlPlaneCertVersion,
Profile: byte(p.ProfileID),
ChainID: pos.ChainID,
Epoch: pos.Epoch,
Height: pos.Height,
Round: pos.Round,
BlockHash: pos.BlockHash,
ValidatorSetRoot: vset.Root(),
PolicyID: controlPlanePolicyID,
RequiredLegsRoot: quasar.HashRequiredLegs(controlPlanePolicy{quorumWeight: 4}.RequiredLegs()),
AggregateWeight: wqc.AggregateWeight,
Evidence: []quasar.LegEvidence{{
Leg: quasar.LegSpec{Kind: quasar.LegPulsarMLDSA, ParamSetID: mldsa65ParamByte},
Mode: quasar.EvidenceWeightedSigSet,
Payload: b,
}},
}
}
// rawWQC builds a WeightedQuorumCert over the given records at a claimed
// threshold, straight from the shipped builder.
func rawWQC(t *testing.T, pos certPosition, vset *quasar.WeightedValidatorSet, threshold uint64, records []quasar.QuorumSignerRecord) *quasar.WeightedQuorumCert {
t.Helper()
wqc, err := quasar.BuildWeightedQuorumCert(quasar.QuorumCertParams{
ChainID: pos.ChainID,
Epoch: pos.Epoch,
Height: pos.Height,
Round: pos.Round,
ValueHash: pos.BlockHash,
QCType: controlPlaneQCType,
ValidatorSetRoot: vset.Root(),
QuorumThreshold: threshold,
}, records)
if err != nil {
t.Fatalf("build raw wqc: %v", err)
}
return wqc
}
// TestControlPlaneCert_UnsafeFloorRejected — the two-threshold self-guard (RED
// finding 1): the wallet-custody t=3 can neither compose nor verify a cert for
// N=5 (the BFT floor is 4). This is the exact trap inc-1 fell into (cluster.go
// wiring the Pulsar threshold = the quorum), now impossible in the cert core.
func TestControlPlaneCert_UnsafeFloorRejected(t *testing.T) {
keys, privs, order := certTestPods(t, 5)
pos := certTestPosition()
// Compose with t=3 (the wallet-custody threshold) must be refused.
sigs3 := signQuorum(t, pos, keys, privs, order, 3, 3)
if _, err := ComposeControlPlaneCert(pos, keys, sigs3, 3); !errors.Is(err, ErrUnsafeCertFloor) {
t.Fatalf("compose t=3 for N=5: want ErrUnsafeCertFloor, got %v", err)
}
// A genuine 4-of-5 cert cannot be VERIFIED with a mis-wired floor of 3.
sigs4 := signQuorum(t, pos, keys, privs, order, 4, 4)
cert, err := ComposeControlPlaneCert(pos, keys, sigs4, 4)
if err != nil {
t.Fatalf("compose: %v", err)
}
if err := VerifyControlPlaneCert(pos, keys, 3, cert); !errors.Is(err, ErrUnsafeCertFloor) {
t.Fatalf("verify at floor 3: want ErrUnsafeCertFloor, got %v", err)
}
}
// TestControlPlaneCert_RawSubQuorumRejected — a hand-built, internally-consistent
// 3-of-5 cert (3 valid signatures over the threshold-3 message) is rejected by
// the verifier's mandatory BFT floor: 3 < policy floor 4.
func TestControlPlaneCert_RawSubQuorumRejected(t *testing.T) {
keys, privs, order := certTestPods(t, 5)
pos := certTestPosition()
vset, err := buildValidatorSet(pos.Epoch, keys)
if err != nil {
t.Fatalf("vset: %v", err)
}
msg, err := CertSigningMessage(pos, keys, 3)
if err != nil {
t.Fatalf("msg: %v", err)
}
var records []quasar.QuorumSignerRecord
for i := 0; i < 3; i++ {
node := order[i]
sig, err := privs[node].SignCtxDeterministic(msg, certContext)
if err != nil {
t.Fatalf("sign: %v", err)
}
records = append(records, certRecordFor(t, vset, node, keys[node], 1, sig))
}
cert := wrapRawWQC(t, pos, vset, rawWQC(t, pos, vset, 3, records))
if err := VerifyControlPlaneCert(pos, keys, 4, cert); !errors.Is(err, quasar.ErrQCThresholdBelowFloor) {
t.Fatalf("raw sub-quorum cert: want ErrQCThresholdBelowFloor, got %v", err)
}
}
// TestControlPlaneCert_RawAttackerKeyStuffed — a record for a legitimate
// validator id carrying an ATTACKER public key + attacker signature is rejected
// at the weighted-Merkle inclusion gate (the reconstructed leaf is not in the
// committed tree), even though the signature is valid under the attacker key.
func TestControlPlaneCert_RawAttackerKeyStuffed(t *testing.T) {
keys, privs, order := certTestPods(t, 5)
pos := certTestPosition()
vset, _ := buildValidatorSet(pos.Epoch, keys)
msg, _ := CertSigningMessage(pos, keys, 4)
var records []quasar.QuorumSignerRecord
for i := 0; i < 3; i++ {
node := order[i]
sig, _ := privs[node].SignCtxDeterministic(msg, certContext)
records = append(records, certRecordFor(t, vset, node, keys[node], 1, sig))
}
attacker, err := mldsa.GenerateKey(rand.Reader, mldsa.MLDSA65)
if err != nil {
t.Fatalf("attacker keygen: %v", err)
}
badNode := order[3] // a real validator id, but an attacker key stuffed in
badSig, _ := attacker.SignCtxDeterministic(msg, certContext)
records = append(records, certRecordFor(t, vset, badNode, attacker.PublicKey, 1, badSig))
cert := wrapRawWQC(t, pos, vset, rawWQC(t, pos, vset, 4, records))
if err := VerifyControlPlaneCert(pos, keys, 4, cert); !errors.Is(err, quasar.ErrQCMerkleInclusion) {
t.Fatalf("attacker-key stuffing: want ErrQCMerkleInclusion, got %v", err)
}
}
// TestControlPlaneCert_RawWeightInflated — a cert whose inner AggregateWeight is
// inflated above the true sum of signer weights is rejected by the weight
// recomputation gate.
func TestControlPlaneCert_RawWeightInflated(t *testing.T) {
keys, privs, order := certTestPods(t, 5)
pos := certTestPosition()
vset, _ := buildValidatorSet(pos.Epoch, keys)
msg, _ := CertSigningMessage(pos, keys, 4)
var records []quasar.QuorumSignerRecord
for i := 0; i < 4; i++ {
node := order[i]
sig, _ := privs[node].SignCtxDeterministic(msg, certContext)
records = append(records, certRecordFor(t, vset, node, keys[node], 1, sig))
}
wqc := rawWQC(t, pos, vset, 4, records)
wqc.AggregateWeight += 100 // lie about the total signer weight
cert := wrapRawWQC(t, pos, vset, wqc)
if err := VerifyControlPlaneCert(pos, keys, 4, cert); !errors.Is(err, quasar.ErrQCAggregateWeight) {
t.Fatalf("weight inflation: want ErrQCAggregateWeight, got %v", err)
}
}
+68
View File
@@ -3,6 +3,7 @@
package controlplane
import (
"bytes"
"crypto/rand"
"crypto/sha256"
"encoding/binary"
@@ -13,6 +14,12 @@ import (
pulsar "github.com/luxfi/pulsar/pkg/pulsar"
)
// rekeyContext is the FIPS 204 §5.2 domain-separation context for a key-rotation
// authorization signature. DISTINCT from popContext and certContext so a
// rotation authorization can never be cross-protocol-confused with a leg PoP or
// a cert signature.
var rekeyContext = []byte("hanzo/controlplane/rekey/v1")
// Share is a sealed per-pod signing share. Exactly one is issued per pod.
//
// The Index is the pod's 1-based party index. secret is the stub z-share source
@@ -94,6 +101,20 @@ var (
// node with no verifiable identity could neither authenticate its own legs
// nor have them attributed, so it must never enter the registry.
errMissingIdentityKey = errors.New("controlplane: share has no ML-DSA identity key")
// ErrIdentityKeyConflict is the FIRST-WRITER-WINS guard (RED R1): a node
// already bound to one ML-DSA identity key may NOT be re-registered under a
// DIFFERENT key. A silent overwrite would be a rogue-key swap — an attacker
// re-registering a live validator with its own key would then have its forged
// legs and cert signatures accepted, since the whole cert's rogue-key
// resistance rests on idVerifier being the trusted key source. Idempotent
// re-registration of the IDENTICAL key is allowed; a sanctioned rotation goes
// through Rekey (self-authorized under the current key).
ErrIdentityKeyConflict = errors.New("controlplane: node already bound to a different identity key (first-writer-wins; rotate via an authorized Rekey)")
// ErrRekeyUnknownNode rejects a rotation of a node with no registered key.
ErrRekeyUnknownNode = errors.New("controlplane: rekey of a node with no registered identity key")
// ErrRekeyUnauthorized rejects a rotation whose authorization signature does
// not verify under the node's CURRENT registered key.
ErrRekeyUnauthorized = errors.New("controlplane: rekey authorization does not verify under the current identity key")
)
// ValidatorRegistry pins the bijection party-index <-> node and holds each
@@ -129,12 +150,59 @@ func (r *ValidatorRegistry) Register(s Share) error {
if idx, ok := r.byNode[s.Node]; ok && idx != s.Index {
return fmt.Errorf("%w: node holds index %d, tried %d", ErrNodeHasShare, idx, s.Index)
}
// First-writer-wins on the identity key (RED R1): a node already bound to a
// key may not be silently re-bound to a DIFFERENT one. Idempotent
// re-registration of the identical key is fine (crash-restart / re-sync).
if cur, ok := r.idVerifier[s.Node]; ok && !pubKeyEqual(cur, s.idKey.PublicKey) {
return fmt.Errorf("%w: node %x", ErrIdentityKeyConflict, s.Node[:8])
}
r.byIndex[s.Index] = s.Node
r.byNode[s.Node] = s.Index
r.idVerifier[s.Node] = s.idKey.PublicKey
return nil
}
// rekeyTBS is the to-be-signed preimage authorizing a key rotation: bound to the
// node and the NEW public key. Only the holder of the node's CURRENT secret key
// can produce a signature over it, so a rotation is self-authorized.
func rekeyTBS(node pulsar.NodeID, newPub *mldsa.PublicKey) []byte {
h := sha256.New()
h.Write([]byte("cp-rekey-tbs-v1"))
h.Write(node[:])
h.Write(newPub.Bytes())
return h.Sum(nil)
}
// Rekey rotates a bound node's identity key to newPub, authorized by authSig — a
// signature under the node's CURRENT registered key over rekeyTBS(node, newPub).
// This is the ONLY sanctioned way to change a bound key (Register is
// first-writer-wins); it is the registry action a consensus-ordered
// OpRekeyValidator applies. Self-authorized: an attacker without the current
// secret key cannot forge the authorization, so it cannot rotate a validator's
// key. Refuses an unknown node, a nil key, or an unauthorized signature.
func (r *ValidatorRegistry) Rekey(node pulsar.NodeID, newPub *mldsa.PublicKey, authSig []byte) error {
if newPub == nil {
return errMissingIdentityKey
}
cur, ok := r.idVerifier[node]
if !ok {
return fmt.Errorf("%w: %x", ErrRekeyUnknownNode, node[:8])
}
if !cur.VerifySignatureCtx(rekeyTBS(node, newPub), authSig, rekeyContext) {
return ErrRekeyUnauthorized
}
r.idVerifier[node] = newPub
return nil
}
// pubKeyEqual reports whether two ML-DSA public keys are byte-identical.
func pubKeyEqual(a, b *mldsa.PublicKey) bool {
if a == nil || b == nil {
return a == b
}
return bytes.Equal(a.Bytes(), b.Bytes())
}
// Size is the number of registered voters.
func (r *ValidatorRegistry) Size() int { return len(r.byNode) }
+109
View File
@@ -0,0 +1,109 @@
//go:build controlplane
package controlplane
// custody_r1_test.go — RED finding R1: the registry is the trusted key source
// for the whole cert, so key binding must be first-writer-wins with a
// self-authorized rotation path. These tests lock that.
import (
"crypto/rand"
"errors"
"testing"
"github.com/luxfi/crypto/mldsa"
)
func r1Key(t *testing.T) *mldsa.PrivateKey {
t.Helper()
sk, err := mldsa.GenerateKey(rand.Reader, mldsa.MLDSA65)
if err != nil {
t.Fatalf("keygen: %v", err)
}
return sk
}
// TestRegistry_FirstWriterWins_RefusesKeySwap — a node already bound to a key
// cannot be re-registered under a DIFFERENT key (the rogue-key swap). The
// original key is unchanged.
func TestRegistry_FirstWriterWins_RefusesKeySwap(t *testing.T) {
reg := NewValidatorRegistry()
node := NodeIDFromName("cloud-0")
sk1, sk2 := r1Key(t), r1Key(t)
if err := reg.Register(Share{Index: 1, Node: node, idKey: sk1}); err != nil {
t.Fatalf("first register: %v", err)
}
if err := reg.Register(Share{Index: 1, Node: node, idKey: sk2}); !errors.Is(err, ErrIdentityKeyConflict) {
t.Fatalf("key swap: want ErrIdentityKeyConflict, got %v", err)
}
if pub, ok := reg.idVerifier[node]; !ok || !pubKeyEqual(pub, sk1.PublicKey) {
t.Fatal("the swap attempt overwrote the original key")
}
}
// TestRegistry_IdempotentSameKey — re-registering the IDENTICAL key is allowed
// (crash-restart / re-sync must not fail).
func TestRegistry_IdempotentSameKey(t *testing.T) {
reg := NewValidatorRegistry()
node := NodeIDFromName("cloud-0")
sk := r1Key(t)
if err := reg.Register(Share{Index: 1, Node: node, idKey: sk}); err != nil {
t.Fatalf("register: %v", err)
}
if err := reg.Register(Share{Index: 1, Node: node, idKey: sk}); err != nil {
t.Fatalf("idempotent re-register of the same key must succeed: %v", err)
}
}
// TestRegistry_Rekey_SelfAuthorized — the sanctioned rotation: a signature under
// the CURRENT key authorizes the new key, and the registry updates.
func TestRegistry_Rekey_SelfAuthorized(t *testing.T) {
reg := NewValidatorRegistry()
node := NodeIDFromName("cloud-0")
old, newk := r1Key(t), r1Key(t)
if err := reg.Register(Share{Index: 1, Node: node, idKey: old}); err != nil {
t.Fatalf("register: %v", err)
}
authSig, err := old.SignCtxDeterministic(rekeyTBS(node, newk.PublicKey), rekeyContext)
if err != nil {
t.Fatalf("authorize: %v", err)
}
if err := reg.Rekey(node, newk.PublicKey, authSig); err != nil {
t.Fatalf("authorized rekey: %v", err)
}
if !pubKeyEqual(reg.idVerifier[node], newk.PublicKey) {
t.Fatal("rekey did not install the new key")
}
}
// TestRegistry_Rekey_UnauthorizedRefused — an attacker without the current
// secret key cannot rotate: a rekey authorized under any OTHER key is refused
// and the bound key is untouched.
func TestRegistry_Rekey_UnauthorizedRefused(t *testing.T) {
reg := NewValidatorRegistry()
node := NodeIDFromName("cloud-0")
old, newk, attacker := r1Key(t), r1Key(t), r1Key(t)
if err := reg.Register(Share{Index: 1, Node: node, idKey: old}); err != nil {
t.Fatalf("register: %v", err)
}
badSig, _ := attacker.SignCtxDeterministic(rekeyTBS(node, newk.PublicKey), rekeyContext)
if err := reg.Rekey(node, newk.PublicKey, badSig); !errors.Is(err, ErrRekeyUnauthorized) {
t.Fatalf("unauthorized rekey: want ErrRekeyUnauthorized, got %v", err)
}
if !pubKeyEqual(reg.idVerifier[node], old.PublicKey) {
t.Fatal("unauthorized rekey mutated the key")
}
}
// TestRegistry_Rekey_UnknownNode — rotating a node that was never registered is
// refused (no current key to authorize against).
func TestRegistry_Rekey_UnknownNode(t *testing.T) {
reg := NewValidatorRegistry()
node := NodeIDFromName("ghost")
newk := r1Key(t)
sig, _ := newk.SignCtxDeterministic(rekeyTBS(node, newk.PublicKey), rekeyContext)
if err := reg.Rekey(node, newk.PublicKey, sig); !errors.Is(err, ErrRekeyUnknownNode) {
t.Fatalf("rekey unknown node: want ErrRekeyUnknownNode, got %v", err)
}
}
@@ -1,14 +1,14 @@
// actions.go — the two GitOps write actions.
//
// POST /v1/gitops/{name}/rollback — pin the CR image tag to a prior clean semver.
// POST /v1/deploy/{name}/rollback — pin the CR image tag to a prior clean semver.
// It REUSES the P1 release seam (cloud.OnServiceRelease → clients/paas
// releaseService), so the clean-semver gate + idempotent spec.image patch live
// in exactly ONE place; the operator reconciles the rollout.
// POST /v1/gitops/{name}/sync — request an operator reconcile now by touching the
// POST /v1/deploy/{name}/sync — request an operator reconcile now by touching the
// CR (an annotation bump the operator's watch observes). Today the CR is the
// desired source, so sync = nudge-reconcile; when git.hanzo.ai is the source it
// becomes apply-desired-from-git, same endpoint.
package gitops
package deploy
import (
"encoding/json"
@@ -1,9 +1,9 @@
// applications.go — GET /v1/gitops/applications: the fleet list. Each operator
// applications.go — GET /v1/deploy/applications: the fleet list. Each operator
// Service CR is one Application row: its declared image version, the running
// version observed from the live Deployment, the reconciled health, and the sync
// verdict (declared == running ⇒ Synced, else OutOfSync). The console renders this
// as the ArgoCD application list.
package gitops
package deploy
import (
"context"
@@ -1,4 +1,4 @@
// Package gitops mounts the native GitOps control plane at /v1/gitops — the
// Package gitops mounts the native GitOps control plane at /v1/deploy — the
// ArgoCD-grade deploy dashboard for the operator-managed fleet, made native to
// the cloud binary and parallel to /v1/git (the native git server).
//
@@ -7,16 +7,16 @@
// Deployment + Service + Ingress (+ HPA/PDB/Pods). This plane OBSERVES that
// reconciliation the way ArgoCD observes a synced Application —
//
// GET /v1/gitops/applications — the fleet list: name, declared version,
// GET /v1/deploy/applications — the fleet list: name, declared version,
// health, sync, per app.
// GET /v1/gitops/{name}/tree — the owned-resource tree (ownerRef edges)
// GET /v1/deploy/{name}/tree — the owned-resource tree (ownerRef edges)
// with per-node health + sync.
// GET /v1/gitops/{name}/resource/{ref} — one node's live manifest + a
// GET /v1/deploy/{name}/resource/{ref} — one node's live manifest + a
// desired-vs-live diff.
// GET /v1/gitops/{name}/logs — the app's current pod logs.
// POST /v1/gitops/{name}/rollback — pin the CR image tag to a prior semver
// GET /v1/deploy/{name}/logs — the app's current pod logs.
// POST /v1/deploy/{name}/rollback — pin the CR image tag to a prior semver
// (the operator reconciles the rollout).
// POST /v1/gitops/{name}/sync — request an operator reconcile now.
// POST /v1/deploy/{name}/sync — request an operator reconcile now.
//
// SECURITY — every route is SUPERADMIN ONLY, fail-closed, on the SAME predicate
// the rest of cloud uses (c.IsAdmin()): the plane reads and mutates SYSTEM Service
@@ -32,8 +32,8 @@
// (github.com/hanzoai/git) and this engine syncs that repo → cluster with
// self-heal. The desired-vs-live diff below is already structured for that: it
// reads a desired source that is "cluster last-applied" now and becomes the
// git.hanzo.ai manifest later, with no shape change. See gitopsDesiredTODO.
package gitops
// git.hanzo.ai manifest later, with no shape change. See deployDesiredTODO.
package deploy
import (
"context"
@@ -121,13 +121,13 @@ func scanOrder() []string { return []string{"hanzo", "hanzo-testnet", "hanzo-dev
// CR metadata.name satisfies this) — the injection guard for the CR a route reads.
var appNameRE = regexp.MustCompile(`^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$`)
const userAgent = "hanzo-cloud-gitops"
const userAgent = "hanzo-cloud-deploy"
// gitopsDesiredTODO documents the desired-state source seam: "last-applied" (the
// deployDesiredTODO documents the desired-state source seam: "last-applied" (the
// kubectl last-applied-configuration annotation on the live object) today; the
// git.hanzo.ai manifest repo once RegisterPushBuilder commits CR changes there.
// The diff shape does not change when the source flips.
const gitopsDesiredTODO = "last-applied"
const deployDesiredTODO = "last-applied"
// state is gitops's own data; shared deps live in the embedded cloud.Base.
type state struct {
@@ -136,9 +136,9 @@ type state struct {
initErr string
}
// Mount wires /v1/gitops/* onto app. Every handler gates on c.IsAdmin() first.
// Mount wires /v1/deploy/* onto app. Every handler gates on c.IsAdmin() first.
func Mount(app *zip.App, deps cloud.Deps) error {
return cloud.Mount(app, deps, "gitops", build, routes)
return cloud.Mount(app, deps, "deploy", build, routes)
}
// build resolves the in-process k8s clients (fail-closed: when no kubeconfig
@@ -148,24 +148,24 @@ func build(b cloud.Base) (state, error) {
dyn, cs, err := newClients()
if err != nil {
st.initErr = err.Error()
b.Log.Warn("kubernetes client unavailable; /v1/gitops endpoints will fail closed", "err", err)
b.Log.Warn("kubernetes client unavailable; /v1/deploy endpoints will fail closed", "err", err)
} else {
st.dyn, st.clientset = dyn, cs
}
b.Log.Info("gitops control plane mounted", "prefix", "/v1/gitops", "k8s", st.dyn != nil, "brand", b.Brand, "env", b.Env)
b.Log.Info("deploy control plane mounted", "prefix", "/v1/deploy", "k8s", st.dyn != nil, "brand", b.Brand, "env", b.Env)
return st, nil
}
// routes registers the /v1/gitops/* surface. Every observing/mutating route is
// routes registers the /v1/deploy/* surface. Every observing/mutating route is
// SuperAdmin-gated; the health probe is public (real k8s reachability).
func routes(app *zip.App, s *cloud.Service[state]) {
app.Get("/v1/gitops/applications", guard(s, cloud.Handle(s, listApplications)))
app.Get("/v1/gitops/health", cloud.Handle(s, health))
app.Get("/v1/gitops/:name/tree", guard(s, cloud.Handle(s, appTree)))
app.Get("/v1/gitops/:name/resource/:ref", guard(s, cloud.Handle(s, appResource)))
app.Get("/v1/gitops/:name/logs", guard(s, cloud.Handle(s, appLogs)))
app.Post("/v1/gitops/:name/rollback", guard(s, cloud.Handle(s, rollback)))
app.Post("/v1/gitops/:name/sync", guard(s, cloud.Handle(s, sync)))
app.Get("/v1/deploy/applications", guard(s, cloud.Handle(s, listApplications)))
app.Get("/v1/deploy/health", cloud.Handle(s, health))
app.Get("/v1/deploy/:name/tree", guard(s, cloud.Handle(s, appTree)))
app.Get("/v1/deploy/:name/resource/:ref", guard(s, cloud.Handle(s, appResource)))
app.Get("/v1/deploy/:name/logs", guard(s, cloud.Handle(s, appLogs)))
app.Post("/v1/deploy/:name/rollback", guard(s, cloud.Handle(s, rollback)))
app.Post("/v1/deploy/:name/sync", guard(s, cloud.Handle(s, sync)))
}
// guard wraps a handler with the SuperAdmin gate (fail-closed: a non-SuperAdmin is
@@ -183,7 +183,7 @@ func guard(s *cloud.Service[state], h zip.Handler) zip.Handler {
// served. 200 only when both hold; 503 + the real reason otherwise. Not
// admin-gated — liveness must be probe-able without a JWT.
func health(s *cloud.Service[state], c *zip.Ctx) error {
res := map[string]any{"service": "gitops", "status": "ok"}
res := map[string]any{"service": "deploy", "status": "ok"}
if s.State.dyn == nil {
res["status"], res["k8s"], res["error"] = "degraded", false, s.State.initErr
return c.JSON(http.StatusServiceUnavailable, res)
@@ -215,7 +215,7 @@ func health(s *cloud.Service[state], c *zip.Ctx) error {
// ready fails closed when no cluster client resolved (503 + the real reason).
func ready(s *cloud.Service[state]) error {
if s.State.dyn == nil {
return zip.Errorf(http.StatusServiceUnavailable, "gitops: kubernetes client not configured: %s", s.State.initErr)
return zip.Errorf(http.StatusServiceUnavailable, "deploy: kubernetes client not configured: %s", s.State.initErr)
}
return nil
}
@@ -1,4 +1,4 @@
package gitops
package deploy
import (
"context"
@@ -187,8 +187,8 @@ func TestComputeDiff(t *testing.T) {
"spec": map[string]any{"replicas": int64(2), "selector": map[string]any{"matchLabels": map[string]any{"app.kubernetes.io/instance": "iam"}}, "template": map[string]any{"spec": map[string]any{"containers": []any{map[string]any{"name": "app", "image": "ghcr.io/hanzoai/iam:v1"}}}}}}
db, _ := json.Marshal(desired)
_ = unstructured.SetNestedField(live.Object, map[string]any{lastAppliedAnnotation: string(db)}, "metadata", "annotations")
if src, mod, _ := computeDiff(live); src != gitopsDesiredTODO || mod {
t.Errorf("identical-desired diff = (%q,%v), want (%q,false)", src, mod, gitopsDesiredTODO)
if src, mod, _ := computeDiff(live); src != deployDesiredTODO || mod {
t.Errorf("identical-desired diff = (%q,%v), want (%q,false)", src, mod, deployDesiredTODO)
}
// Annotation with a different image → modified.
desired["spec"].(map[string]any)["template"].(map[string]any)["spec"].(map[string]any)["containers"].([]any)[0].(map[string]any)["image"] = "ghcr.io/hanzoai/iam:v2"
@@ -6,7 +6,7 @@
// P2b swaps the internals for github.com/argoproj/gitops-engine pkg/health
// (health.GetResourceHealth) for exact ArgoCD parity; the CODES emitted here are
// already those strings, so the wire contract the console consumes does not change.
package gitops
package deploy
import (
"strings"
@@ -1,4 +1,4 @@
// logs.go — GET /v1/gitops/{name}/logs: the app's current pod logs, streamed from
// logs.go — GET /v1/deploy/{name}/logs: the app's current pod logs, streamed from
// the newest running pod via the typed CoreV1 GetLogs subresource. The operator
// labels the workload it renders for an App CR with
// app.kubernetes.io/instance=<name>, so that selects the app's pods; the
@@ -6,7 +6,7 @@
// selects a container; ?tail= bounds the lines. Never fabricates output — an
// unreachable cluster or absent pod yields an honest 200 with an empty tail + the
// reason, not invented logs.
package gitops
package deploy
import (
"context"
@@ -1,4 +1,4 @@
// resource.go — GET /v1/gitops/{name}/resource/{ref}: one tree node's live
// resource.go — GET /v1/deploy/{name}/resource/{ref}: one tree node's live
// manifest plus a desired-vs-live diff.
//
// {ref} is the canonical "group:kind:namespace:name" token the tree emits on each
@@ -13,7 +13,7 @@
// (RegisterPushBuilder → commit → engine sync), desiredSource becomes "git" with
// the SAME diff shape. P2b replaces the field-strip diff with gitops-engine
// pkg/diff (three-way) for exact ArgoCD parity.
package gitops
package deploy
import (
"encoding/json"
@@ -136,7 +136,7 @@ func computeDiff(live *unstructured.Unstructured) (source string, modified bool,
return "none", false, nil
}
modified = !jsonEqual(normalizeForDiff(live.Object), normalizeForDiff(d))
return gitopsDesiredTODO, modified, d
return deployDesiredTODO, modified, d
}
// normalizeForDiff strips server-set / volatile fields so a diff reflects only
@@ -1,4 +1,4 @@
// tree.go — GET /v1/gitops/{name}/tree: the owned-resource tree for one
// tree.go — GET /v1/deploy/{name}/tree: the owned-resource tree for one
// Application, the ArgoCD ApplicationTree shape (a FLAT node list with parentRefs
// edges; the console renders the DAG). The root is the Service CR; depth-1 nodes
// are the operator-owned Deployment/Service/Ingress/HPA/PDB/ConfigMap; depth-2 are
@@ -9,7 +9,7 @@
// P2b swaps buildTree's cluster walk for github.com/argoproj/gitops-engine
// pkg/cache (ClusterCache.GetManagedLiveObjs / hierarchy) for a watch-backed tree;
// the Node shape the console consumes does not change.
package gitops
package deploy
import (
"context"
-418
View File
@@ -1,418 +0,0 @@
// Package featureflags is cloud's runtime evaluation seam over the Hanzo Insights
// feature-flag engine (hanzoai/insights `rust/feature-flags`, the PostHog-compatible
// /flags + /decide evaluator). It is NOT a second flag store: Insights OWNS flag
// definitions, targeting, rollout %, and the change/activity log; cloud only EVALUATES
// flags at runtime and surfaces the platform switches to the admin cockpit.
//
// ONE flag engine, EVERY switch. The PLATFORM launch switches (waitlist, public
// signup, subsystem activation, gateway limits, network ids) are Insights feature
// flags; this package NAMES them (the registry below — key + category + the env var
// that supplies the fallback default) and reads their live value. Env is only the
// FALLBACK default; the Insights flag overrides. A subsystem calls Bool/Int/String and
// gets the hot value — a flip in the Insights flag UI takes effect within one cache TTL
// (default 15s) with NO redeploy.
//
// FAIL-SAFE. When Insights is not configured (INSIGHTS_FLAGS_URL / INSIGHTS_PROJECT_
// TOKEN unset) OR unreachable, evaluation degrades to the env fallback -> literal
// default — exactly today's behavior, zero regression. No secret is read here; the
// project token is injected from KMS into the process env by the deployment (the same
// WAITLIST_URL env convention clients/base's waitlist plugin reads), never hardcoded.
package featureflags
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"strconv"
"strings"
"sync"
"time"
"github.com/hanzoai/cloud"
"github.com/zap-proto/zip"
)
// Type is the switch kind the cockpit renders (and how the value is decoded).
type Type string
const (
TypeBool Type = "bool"
TypeInt Type = "int"
TypeString Type = "string"
)
// Def is ONE platform switch: an Insights feature-flag key qualified by the metadata
// the cockpit shows and the env var that provides the fallback default. This table is
// the ONE place the platform switches are named; Insights evaluates them.
type Def struct {
Key string // the Insights feature-flag key (snake_case)
Category string // Launch | Signup | Subsystems | Gateway | Network
Label string
Desc string
Type Type
Env string // env var supplying the fallback default (may be "")
Default string // literal fallback when neither Insights nor env has a value
ReadOnly bool // surfaced read-only (boot-time activation, network ids)
}
// ── registry (open + extensible) ────────────────────────────────────────────────
var (
regMu sync.RWMutex
defs []Def
index = map[string]int{}
)
// Register adds a switch to the platform-flag registry. Any subsystem may call it (in
// its init) to surface its own flag in the cockpit — the registry is open, not
// hardcoded. A duplicate key REPLACES the prior def (last registration wins).
func Register(d Def) {
regMu.Lock()
defer regMu.Unlock()
if i, ok := index[d.Key]; ok {
defs[i] = d
return
}
index[d.Key] = len(defs)
defs = append(defs, d)
}
// Defs returns a snapshot of the registered switches in registration order.
func Defs() []Def {
regMu.RLock()
defer regMu.RUnlock()
out := make([]Def, len(defs))
copy(out, defs)
return out
}
func lookupDef(key string) (Def, bool) {
regMu.RLock()
defer regMu.RUnlock()
if i, ok := index[key]; ok {
return defs[i], true
}
return Def{}, false
}
// ── evaluation client ──────────────────────────────────────────────────────────
// snapshot is one cached read of the Insights /flags response.
type snapshot struct {
flags map[string]json.RawMessage // featureFlags[key] (bool | variant string)
payloads map[string]json.RawMessage // featureFlagPayloads[key] (arbitrary JSON)
at time.Time
ok bool
}
// Client evaluates Insights feature flags over the PostHog-compatible /flags endpoint,
// caching the whole response for one TTL (the hot-apply bound). Reads are lock-guarded
// and degrade to env/default when Insights is unconfigured or unreachable.
type Client struct {
base string
token string
distinctID string
hc *http.Client
ttl time.Duration
mu sync.RWMutex
snap snapshot
}
var mounted *Client
func (c *Client) configured() bool { return c != nil && c.base != "" && c.token != "" }
// resolve returns a switch's live string value and its source. Insights wins when it
// has a value; else the env fallback; else the literal default. Nil-safe.
func (c *Client) resolve(def Def) (value string, source string) {
if c.configured() {
fv, pv, present := c.lookup(def.Key)
if present {
switch def.Type {
case TypeBool:
if b, ok := asBool(fv); ok {
return strconv.FormatBool(b), "insights"
}
case TypeInt:
if n, ok := asInt(pv); ok {
return strconv.Itoa(n), "insights"
}
if n, ok := asInt(fv); ok {
return strconv.Itoa(n), "insights"
}
case TypeString:
if s, ok := asString(pv); ok && s != "" {
return s, "insights"
}
if s, ok := asString(fv); ok && s != "" {
return s, "insights"
}
}
}
}
if def.Env != "" {
if ev := strings.TrimSpace(os.Getenv(def.Env)); ev != "" {
return ev, "env"
}
}
return def.Default, "default"
}
func (c *Client) lookup(key string) (fv, pv json.RawMessage, present bool) {
c.ensureFresh()
c.mu.RLock()
defer c.mu.RUnlock()
var inFlags, inPayloads bool
fv, inFlags = c.snap.flags[key]
pv, inPayloads = c.snap.payloads[key]
return fv, pv, inFlags || inPayloads
}
// ensureFresh refreshes the cached snapshot when older than the TTL. On a fetch error
// the previous snapshot is kept (graceful degradation) and the clock is stamped so a
// persistent failure is retried at most once per TTL, never on every read.
func (c *Client) ensureFresh() {
c.mu.RLock()
fresh := c.snap.ok && time.Since(c.snap.at) < c.ttl
c.mu.RUnlock()
if fresh {
return
}
c.mu.Lock()
defer c.mu.Unlock()
if c.snap.ok && time.Since(c.snap.at) < c.ttl {
return
}
ctx, cancel := context.WithTimeout(context.Background(), 4*time.Second)
defer cancel()
snap, err := c.fetch(ctx)
if err != nil {
c.snap.at = time.Now() // keep last values; bound retry to one per TTL
return
}
c.snap = snap
}
func (c *Client) fetch(ctx context.Context) (snapshot, error) {
body, _ := json.Marshal(map[string]string{"token": c.token, "distinct_id": c.distinctID})
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.base+"/flags", bytes.NewReader(body))
if err != nil {
return snapshot{}, err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
resp, err := c.hc.Do(req)
if err != nil {
return snapshot{}, err
}
defer resp.Body.Close()
if resp.StatusCode/100 != 2 {
return snapshot{}, fmt.Errorf("insights /flags: status %d", resp.StatusCode)
}
var out struct {
FeatureFlags map[string]json.RawMessage `json:"featureFlags"`
FeatureFlagPayloads map[string]json.RawMessage `json:"featureFlagPayloads"`
}
if err := json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&out); err != nil {
return snapshot{}, err
}
if out.FeatureFlags == nil {
out.FeatureFlags = map[string]json.RawMessage{}
}
if out.FeatureFlagPayloads == nil {
out.FeatureFlagPayloads = map[string]json.RawMessage{}
}
return snapshot{flags: out.FeatureFlags, payloads: out.FeatureFlagPayloads, at: time.Now(), ok: true}, nil
}
// ── value parsers (tolerant of PostHog bool/variant/payload shapes) ─────────────
func asBool(raw json.RawMessage) (bool, bool) {
t := strings.TrimSpace(string(raw))
if t == "true" {
return true, true
}
if t == "false" {
return false, true
}
if t == "" || t == "null" {
return false, false
}
var s string
if json.Unmarshal(raw, &s) == nil {
switch strings.ToLower(strings.TrimSpace(s)) {
case "true", "1", "on", "yes":
return true, true
case "false", "0", "off", "no":
return false, true
case "":
return false, false
default:
return true, true // enabled with a variant
}
}
return false, false
}
func asInt(raw json.RawMessage) (int, bool) {
t := strings.TrimSpace(string(raw))
if t == "" || t == "null" {
return 0, false
}
var n json.Number
if json.Unmarshal(raw, &n) == nil {
if i, err := n.Int64(); err == nil {
return int(i), true
}
if f, err := n.Float64(); err == nil {
return int(f), true
}
}
var s string
if json.Unmarshal(raw, &s) == nil {
if i, err := strconv.Atoi(strings.TrimSpace(s)); err == nil {
return i, true
}
}
return 0, false
}
func asString(raw json.RawMessage) (string, bool) {
t := strings.TrimSpace(string(raw))
if t == "" || t == "null" {
return "", false
}
var s string
if json.Unmarshal(raw, &s) == nil {
return s, true
}
return t, true
}
// ── typed live accessors (the in-process evaluation seam) ───────────────────────
// Bool returns the live boolean value of a registered switch (Insights -> env -> default).
func Bool(key string) bool {
def, ok := lookupDef(key)
if !ok {
return false
}
v, _ := mounted.resolve(def)
b, _ := strconv.ParseBool(strings.TrimSpace(v))
return b
}
// Int returns the live integer value of a registered switch.
func Int(key string) int {
def, ok := lookupDef(key)
if !ok {
return 0
}
v, _ := mounted.resolve(def)
n, _ := strconv.Atoi(strings.TrimSpace(v))
return n
}
// String returns the live string value of a registered switch.
func String(key string) string {
def, ok := lookupDef(key)
if !ok {
return ""
}
v, _ := mounted.resolve(def)
return v
}
// ── admin control-plane board ──────────────────────────────────────────────────
// SwitchView is one platform switch as the admin cockpit renders it: the live value +
// where it came from (insights | env | default).
type SwitchView struct {
Key string `json:"key"`
Category string `json:"category"`
Label string `json:"label"`
Description string `json:"description"`
Type string `json:"type"`
Value string `json:"value"`
Source string `json:"source"`
Env string `json:"env,omitempty"`
ReadOnly bool `json:"readOnly"`
}
// BoardView is the full control-plane read board: the engine status + a deep-link to
// the Insights flag manager (the ONE place a switch is edited) and its activity log
// (the native change audit), plus every switch's live value.
type BoardView struct {
Engine string `json:"engine"`
Configured bool `json:"configured"`
ManageURL string `json:"manageUrl"`
AuditURL string `json:"auditUrl"`
Switches []SwitchView `json:"switches"`
}
// Board evaluates every registered switch live and returns the cockpit read board.
func Board() BoardView {
appURL := strings.TrimRight(strings.TrimSpace(os.Getenv("INSIGHTS_APP_URL")), "/")
proj := firstNonEmpty(os.Getenv("INSIGHTS_PROJECT_ID"), "1")
var manage, audit string
if appURL != "" {
manage = fmt.Sprintf("%s/project/%s/feature_flags", appURL, proj)
audit = fmt.Sprintf("%s/project/%s/activity?scope=FeatureFlag", appURL, proj)
}
list := Defs()
sw := make([]SwitchView, 0, len(list))
for _, d := range list {
val, src := mounted.resolve(d)
sw = append(sw, SwitchView{
Key: d.Key, Category: d.Category, Label: d.Label, Description: d.Desc,
Type: string(d.Type), Value: val, Source: src, Env: d.Env, ReadOnly: d.ReadOnly,
})
}
return BoardView{Engine: "insights", Configured: mounted.configured(), ManageURL: manage, AuditURL: audit, Switches: sw}
}
// ── lifecycle ────────────────────────────────────────────────────────────────
// Mount builds the evaluation client from env (Insights base + KMS-injected project
// token) and installs it as the process-wide seam. It serves NO HTTP routes — it is the
// in-process read plane that subsystems and clients/admin consume. Disabled config is a
// no-op (env fallback only), never an error.
func Mount(_ *zip.App, deps cloud.Deps) error {
if deps.Logger == nil {
return fmt.Errorf("featureflags.Mount: nil deps.Logger")
}
log := deps.Logger.New("subsystem", "featureflags")
c := &Client{
base: strings.TrimRight(strings.TrimSpace(os.Getenv("INSIGHTS_FLAGS_URL")), "/"),
token: strings.TrimSpace(os.Getenv("INSIGHTS_PROJECT_TOKEN")),
distinctID: firstNonEmpty(os.Getenv("INSIGHTS_FLAGS_DISTINCT_ID"), "hanzo-platform:"+firstNonEmpty(deps.Brand, "hanzo")),
hc: &http.Client{Timeout: 4 * time.Second},
ttl: ttlFromEnv(),
}
mounted = c
log.Info("featureflags evaluation seam ready", "engine", "insights", "configured", c.configured(), "ttlSeconds", int(c.ttl.Seconds()), "switches", len(Defs()))
return nil
}
func ttlFromEnv() time.Duration {
if v := strings.TrimSpace(os.Getenv("INSIGHTS_FLAGS_TTL_SECONDS")); v != "" {
if n, err := strconv.Atoi(v); err == nil && n > 0 {
return time.Duration(n) * time.Second
}
}
return 15 * time.Second
}
func firstNonEmpty(vals ...string) string {
for _, v := range vals {
if strings.TrimSpace(v) != "" {
return v
}
}
return ""
}
-179
View File
@@ -1,179 +0,0 @@
package featureflags
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
)
// newTestClient points a Client at a fake Insights /flags server and installs it as the
// process seam, restoring the prior seam on cleanup.
func newTestClient(t *testing.T, base string) *Client {
t.Helper()
prev := mounted
c := &Client{base: base, token: "phc_test", distinctID: "test", hc: &http.Client{Timeout: 2 * time.Second}, ttl: time.Minute}
mounted = c
t.Cleanup(func() { mounted = prev })
return c
}
// flagServer returns an httptest server serving a controllable /flags response.
func flagServer(t *testing.T, body func() map[string]any) *httptest.Server {
t.Helper()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_ = json.NewEncoder(w).Encode(body())
}))
t.Cleanup(srv.Close)
return srv
}
func TestEnvFallbackAndDefault(t *testing.T) {
// No Insights configured -> env fallback, then literal default.
prev := mounted
mounted = &Client{} // not configured
t.Cleanup(func() { mounted = prev })
// waitlist_open default is "true" (no env set).
t.Setenv("WAITLIST_OPEN", "")
if !Bool("waitlist_open") {
t.Fatalf("waitlist_open default should be true")
}
// env override beats the literal default.
t.Setenv("WAITLIST_OPEN", "false")
if Bool("waitlist_open") {
t.Fatalf("waitlist_open env=false should win")
}
// int default.
if Int("waitlist_access_capacity") != 0 {
t.Fatalf("capacity default should be 0")
}
t.Setenv("WAITLIST_ACCESS_CAPACITY", "250")
if Int("waitlist_access_capacity") != 250 {
t.Fatalf("capacity env should be 250, got %d", Int("waitlist_access_capacity"))
}
// network id read-only default.
if Int("network_id_localnet") != 1337 {
t.Fatalf("localnet id default should be 1337")
}
// unknown key is safe.
if Bool("nope") || Int("nope") != 0 || String("nope") != "" {
t.Fatalf("unknown key must be zero-valued")
}
}
func TestEvaluateFromInsights(t *testing.T) {
srv := flagServer(t, func() map[string]any {
return map[string]any{
"featureFlags": map[string]any{"waitlist_open": true, "public_signup": false},
"featureFlagPayloads": map[string]any{"waitlist_access_capacity": 500},
}
})
newTestClient(t, srv.URL)
if !Bool("waitlist_open") {
t.Fatalf("insights waitlist_open should be true")
}
if Bool("public_signup") {
t.Fatalf("insights public_signup should be false")
}
if got := Int("waitlist_access_capacity"); got != 500 {
t.Fatalf("insights payload capacity should be 500, got %d", got)
}
}
func TestInsightsOverridesEnv(t *testing.T) {
srv := flagServer(t, func() map[string]any {
return map[string]any{"featureFlags": map[string]any{"waitlist_open": false}}
})
newTestClient(t, srv.URL)
t.Setenv("WAITLIST_OPEN", "true") // env says open, insights says closed -> insights wins
if Bool("waitlist_open") {
t.Fatalf("insights (false) must override env (true)")
}
}
func TestHotApplyAfterTTL(t *testing.T) {
open := true
srv := flagServer(t, func() map[string]any {
return map[string]any{"featureFlags": map[string]any{"waitlist_open": open}}
})
c := newTestClient(t, srv.URL)
if !Bool("waitlist_open") {
t.Fatalf("first read should be true")
}
// Flip the flag at the source, then expire the cache -> next read reflects it (hot-apply).
open = false
c.mu.Lock()
c.snap.at = time.Now().Add(-time.Hour)
c.mu.Unlock()
if Bool("waitlist_open") {
t.Fatalf("after TTL expiry the flip must be visible (hot-apply)")
}
}
func TestGracefulDegradeOnError(t *testing.T) {
// Unreachable base -> fetch fails -> env/default, never a panic.
prev := mounted
mounted = &Client{base: "http://127.0.0.1:0", token: "phc_test", hc: &http.Client{Timeout: 200 * time.Millisecond}, ttl: time.Minute}
t.Cleanup(func() { mounted = prev })
t.Setenv("WAITLIST_OPEN", "false")
if Bool("waitlist_open") {
t.Fatalf("unreachable insights must fall back to env=false")
}
}
func TestBoard(t *testing.T) {
srv := flagServer(t, func() map[string]any {
return map[string]any{"featureFlags": map[string]any{"waitlist_open": true}}
})
newTestClient(t, srv.URL)
t.Setenv("INSIGHTS_APP_URL", "https://insights.hanzo.ai/")
t.Setenv("INSIGHTS_PROJECT_ID", "7")
b := Board()
if b.Engine != "insights" || !b.Configured {
t.Fatalf("board engine/configured wrong: %+v", b)
}
if b.ManageURL != "https://insights.hanzo.ai/project/7/feature_flags" {
t.Fatalf("manage url wrong: %s", b.ManageURL)
}
var open, netID *SwitchView
for i := range b.Switches {
switch b.Switches[i].Key {
case "waitlist_open":
open = &b.Switches[i]
case "network_id_mainnet":
netID = &b.Switches[i]
}
}
if open == nil || open.Source != "insights" || open.Value != "true" {
t.Fatalf("waitlist_open switch view wrong: %+v", open)
}
if netID == nil || !netID.ReadOnly || netID.Value != "1" {
t.Fatalf("network_id_mainnet should be read-only default 1: %+v", netID)
}
}
func TestParsers(t *testing.T) {
if b, ok := asBool(json.RawMessage("true")); !b || !ok {
t.Fatal("asBool true")
}
if b, ok := asBool(json.RawMessage(`"on"`)); !b || !ok {
t.Fatal("asBool variant on")
}
if _, ok := asBool(json.RawMessage("")); ok {
t.Fatal("asBool empty not present")
}
if n, ok := asInt(json.RawMessage("500")); n != 500 || !ok {
t.Fatal("asInt number")
}
if n, ok := asInt(json.RawMessage(`"42"`)); n != 42 || !ok {
t.Fatal("asInt string number")
}
if s, ok := asString(json.RawMessage(`"hi"`)); s != "hi" || !ok {
t.Fatal("asString")
}
}
+222
View File
@@ -0,0 +1,222 @@
// Copyright 2023-2026 Hanzo AI Inc. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package featuregate
import (
"context"
"encoding/json"
"io"
"net/http"
"strings"
"sync"
"time"
"github.com/zap-proto/zip"
)
// approvalStatusPending is the ONE value that gates a user. This mirrors IAM's
// object.ApprovalPending (hanzoai/iam object/user.go) and User.IsApproved() —
// approval is FAIL-OPEN: a user is approved unless properties.approvalStatus is
// EXACTLY "pending" (absent / "approved" / "rejected" all read approved via
// IsApproved). Only "pending" holds a user on the waitlist. Keeping the literal
// here (not importing IAM) keeps featuregate self-contained.
const approvalStatusPending = "pending"
// approvedHeader is the FORWARD-PERFECT path: once IAM carries approvalStatus in
// the token and the gateway mints it as a validated header (the same trust model
// as X-User-IsAdmin), the enforcement points read approval for FREE with no IAM
// round-trip. Until then the resolver falls back to an IAM get-account lookup.
// Values: "true" (approved) / "false" (pending). Any other value → fall through.
const approvedHeader = "X-User-Approved"
// accountLookup fetches a caller's approvalStatus by replaying the caller's own
// credentials to IAM get-account. Injected so the resolver is unit-testable
// without a live IAM. It returns (status, ok): ok=false on any IAM error, which
// the resolver treats FAIL-OPEN (approved) — the documented guard behavior
// (availability over a hard gate when IAM is unreachable).
type accountLookup func(ctx context.Context, cookie, auth string) (status string, ok bool)
// Approvals resolves whether the current caller is off the waitlist. It is the ONE
// approval predicate the native middleware uses, DRY with the @file waitlist-guard
// (both read properties.approvalStatus == "pending"). Resolution order:
//
// 1. global admin (c.IsAdmin()) → approved (admins are never gated)
// 2. validated header X-User-Approved → its bit (forward-perfect, no lookup)
// 3. IAM get-account (caller's creds) → approved unless approvalStatus=="pending"
// — cached per user for ttl; FAIL-OPEN on any IAM error.
type Approvals struct {
lookup accountLookup
ttl time.Duration
mu sync.Mutex
cache map[string]approvalEntry
}
type approvalEntry struct {
approved bool
at time.Time
}
// NewApprovals builds a resolver. iamBase is the in-cluster IAM base
// (e.g. http://iam.hanzo.svc.cluster.local:8000); ttl bounds the per-user cache.
// A zero iamBase yields a resolver whose lookup always fails-open (approved) —
// safe for a deployment where approval is enforced elsewhere (the guard).
func NewApprovals(iamBase string, ttl time.Duration) *Approvals {
if ttl <= 0 {
ttl = 30 * time.Second
}
return &Approvals{
lookup: httpAccountLookup(strings.TrimRight(iamBase, "/")),
ttl: ttl,
cache: map[string]approvalEntry{},
}
}
// newApprovalsWithLookup is the test seam: a resolver over an injected lookup.
func newApprovalsWithLookup(lookup accountLookup, ttl time.Duration) *Approvals {
if ttl <= 0 {
ttl = 30 * time.Second
}
return &Approvals{lookup: lookup, ttl: ttl, cache: map[string]approvalEntry{}}
}
// Approved reports whether the caller is off the waitlist.
func (a *Approvals) Approved(c *zip.Ctx) bool {
// (1) Global admins are ALWAYS approved.
if c.IsAdmin() {
return true
}
// (2) Forward-perfect validated header — no IAM round-trip when present.
switch strings.ToLower(strings.TrimSpace(c.Header(approvedHeader))) {
case "true", "1", "approved":
return true
case "false", "0", "pending":
return false
}
// (3) IAM get-account lookup, cached per user, fail-open on error.
user := strings.TrimSpace(c.User())
if user == "" {
// No validated principal — an unauthenticated caller. The middleware
// resolves login separately; treat as not-approved so an anonymous
// request to a gated host is bounced (never allowed through as approved).
return false
}
if a.lookup == nil {
return true // no lookup wired → fail-open (approval enforced elsewhere)
}
if e, ok := a.get(user); ok {
return e.approved
}
status, ok := a.lookup(c.Context(),
c.Header("Cookie"), c.Header("Authorization"))
if !ok {
// IAM unreachable → FAIL-OPEN (approved). Do NOT cache a fail-open so the
// next request re-probes and a recovered IAM re-gates promptly.
return true
}
approved := strings.TrimSpace(strings.ToLower(status)) != approvalStatusPending
a.put(user, approved)
return approved
}
func (a *Approvals) get(user string) (approvalEntry, bool) {
a.mu.Lock()
defer a.mu.Unlock()
e, ok := a.cache[user]
if !ok || time.Since(e.at) > a.ttl {
return approvalEntry{}, false
}
return e, true
}
func (a *Approvals) put(user string, approved bool) {
a.mu.Lock()
defer a.mu.Unlock()
a.cache[user] = approvalEntry{approved: approved, at: time.Now()}
}
// httpAccountLookup builds the real IAM get-account lookup. It replays the
// caller's Cookie / Authorization to IAM and reads data.properties.approvalStatus
// (the field GetAccount returns via GetMaskedUser). Bounded read + timeout mirror
// the guard's iamGet. Any non-200 / decode error → ok=false (fail-open upstream).
func httpAccountLookup(iamBase string) accountLookup {
if iamBase == "" {
return func(context.Context, string, string) (string, bool) { return "", false }
}
url := iamBase + "/v1/iam/get-account"
return func(ctx context.Context, cookie, auth string) (string, bool) {
ctx, cancel := context.WithTimeout(ctx, 8*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return "", false
}
if cookie != "" {
req.Header.Set("Cookie", cookie)
}
if auth != "" {
req.Header.Set("Authorization", auth)
}
req.Header.Set("Accept", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
return "", false
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
return "", false
}
body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if err != nil {
return "", false
}
return approvalStatusFromAccount(body)
}
}
// approvalStatusFromAccount extracts properties.approvalStatus from an IAM
// get-account response. The user object is at the top level or under `data`
// (the casibase { status, data } envelope). Returns ("", false) on an error
// envelope or a missing user (fail-open upstream). An ABSENT approvalStatus is
// returned as "" (ok=true) — which the resolver reads as approved (fail-open,
// matching IsApproved()).
func approvalStatusFromAccount(body []byte) (string, bool) {
type acct struct {
Owner string `json:"owner"`
Properties map[string]string `json:"properties"`
}
var top struct {
Status string `json:"status"`
acct
Data acct `json:"data"`
}
if err := json.Unmarshal(body, &top); err != nil {
return "", false
}
if top.Status == "error" {
return "", false
}
a := top.acct
if a.Owner == "" && top.Data.Owner != "" {
a = top.Data
}
if a.Owner == "" {
return "", false
}
if a.Properties == nil {
return "", true // no properties → approved (fail-open)
}
return a.Properties["approvalStatus"], true
}
+146
View File
@@ -0,0 +1,146 @@
// Copyright 2023-2026 Hanzo AI Inc. All Rights Reserved.
// Licensed under the Apache License, Version 2.0.
package featuregate
import (
"context"
"io"
"net/http/httptest"
"testing"
"time"
luxlog "github.com/luxfi/log"
"github.com/zap-proto/zip"
)
// ctxFor builds a zip.Ctx carrying the given identity headers by driving a
// throwaway app whose one handler captures the ctx.
func ctxWith(t *testing.T, headers map[string]string, fn func(c *zip.Ctx)) {
t.Helper()
app := zip.New(zip.Config{Logger: luxlog.New("test")})
app.Get("/probe", func(c *zip.Ctx) error {
fn(c)
return c.NoContent(204)
})
hr := httptest.NewRequest("GET", "http://x/probe", nil)
for k, v := range headers {
hr.Header.Set(k, v)
}
resp, err := app.Fiber().Test(hr)
if err != nil {
t.Fatalf("probe: %v", err)
}
_, _ = io.Copy(io.Discard, resp.Body)
_ = resp.Body.Close()
}
func TestApprovals_AdminAlwaysApproved(t *testing.T) {
a := newApprovalsWithLookup(func(context.Context, string, string) (string, bool) {
t.Fatal("admin must not trigger an IAM lookup")
return "", false
}, time.Minute)
ctxWith(t, map[string]string{"X-User-IsAdmin": "true", "X-User-Id": "u"}, func(c *zip.Ctx) {
if !a.Approved(c) {
t.Fatal("admin should be approved")
}
})
}
func TestApprovals_ForwardHeaderWins(t *testing.T) {
a := newApprovalsWithLookup(func(context.Context, string, string) (string, bool) {
t.Fatal("header path must not trigger an IAM lookup")
return "", false
}, time.Minute)
ctxWith(t, map[string]string{"X-User-Id": "u", "X-User-Approved": "true"}, func(c *zip.Ctx) {
if !a.Approved(c) {
t.Fatal("X-User-Approved=true should be approved")
}
})
ctxWith(t, map[string]string{"X-User-Id": "u", "X-User-Approved": "false"}, func(c *zip.Ctx) {
if a.Approved(c) {
t.Fatal("X-User-Approved=false should NOT be approved")
}
})
}
func TestApprovals_IAMLookup_PendingGates(t *testing.T) {
calls := 0
a := newApprovalsWithLookup(func(context.Context, string, string) (string, bool) {
calls++
return "pending", true
}, time.Minute)
ctxWith(t, map[string]string{"X-User-Id": "u", "X-Org-Id": "acme"}, func(c *zip.Ctx) {
if a.Approved(c) {
t.Fatal("approvalStatus=pending should NOT be approved")
}
})
// Second call hits the cache (no second lookup).
ctxWith(t, map[string]string{"X-User-Id": "u", "X-Org-Id": "acme"}, func(c *zip.Ctx) {
if a.Approved(c) {
t.Fatal("cached pending should NOT be approved")
}
})
if calls != 1 {
t.Fatalf("IAM lookups = %d, want 1 (cached)", calls)
}
}
func TestApprovals_IAMLookup_ApprovedAndAbsentPass(t *testing.T) {
for _, status := range []string{"approved", "", "rejected"} {
a := newApprovalsWithLookup(func(context.Context, string, string) (string, bool) {
return status, true
}, time.Minute)
ctxWith(t, map[string]string{"X-User-Id": "u", "X-Org-Id": "acme"}, func(c *zip.Ctx) {
if !a.Approved(c) {
t.Fatalf("approvalStatus=%q should be approved (fail-open, only 'pending' gates)", status)
}
})
}
}
func TestApprovals_FailOpenOnIAMError(t *testing.T) {
a := newApprovalsWithLookup(func(context.Context, string, string) (string, bool) {
return "", false // IAM unreachable
}, time.Minute)
ctxWith(t, map[string]string{"X-User-Id": "u", "X-Org-Id": "acme"}, func(c *zip.Ctx) {
if !a.Approved(c) {
t.Fatal("IAM unreachable should FAIL-OPEN (approved) for availability")
}
})
}
func TestApprovals_UnauthenticatedNotApproved(t *testing.T) {
a := newApprovalsWithLookup(func(context.Context, string, string) (string, bool) {
t.Fatal("no lookup for an unauthenticated caller")
return "", false
}, time.Minute)
ctxWith(t, map[string]string{}, func(c *zip.Ctx) {
if a.Approved(c) {
t.Fatal("an unauthenticated caller is not approved")
}
})
}
func TestApprovalStatusFromAccount(t *testing.T) {
cases := []struct {
name string
body string
wantStatus string
wantOK bool
}{
{"top-level pending", `{"owner":"acme","properties":{"approvalStatus":"pending"}}`, "pending", true},
{"data-wrapped approved", `{"status":"ok","data":{"owner":"acme","properties":{"approvalStatus":"approved"}}}`, "approved", true},
{"no properties", `{"owner":"acme"}`, "", true},
{"error envelope", `{"status":"error","msg":"nope"}`, "", false},
{"no owner", `{"properties":{"approvalStatus":"pending"}}`, "", false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got, ok := approvalStatusFromAccount([]byte(tc.body))
if got != tc.wantStatus || ok != tc.wantOK {
t.Fatalf("= (%q,%v), want (%q,%v)", got, ok, tc.wantStatus, tc.wantOK)
}
})
}
}
+259
View File
@@ -0,0 +1,259 @@
// Copyright 2023-2026 Hanzo AI Inc. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
// Package featuregate is the launch-control ENFORCEMENT for Hanzo's hosted services:
// the native middleware (Enforce) + the per-user approval predicate (Approvals, reused
// from IAM). It is a CONSUMER of the ONE policy engine — the per-service waitlist MODE
// and the host→service registry live in clients/flags (a service's mode IS the
// switch waitlist.<svc>, evaluated through the native engine); the admin board is the
// /v1/admin/services lens and the guard's runtime mode read is /v1/featuregate/mode,
// both served there. This package owns only enforcement, decomplected into two axes:
//
// - PER-SERVICE waitlist mode on|off — the flags switch waitlist.<svc>,
// resolved for a request host via flags.WaitlistModeForHost (the decide).
// - PER-USER approvalStatus pending|approved — owned by IAM (approval.go), REUSED.
//
// THE RULE, applied at ONE native enforcement point (Enforce):
//
// if waitlistMode[host] AND NOT user.approved → bounce to the waitlist
// if approved OR mode=off → allow
// unauthenticated → login first
package featuregate
import (
"context"
"net/http"
"strings"
"github.com/hanzoai/cloud/clients/flags"
"github.com/zap-proto/zip"
)
// Enforce is the NATIVE, forward-perfect enforcement point for the waitlist — the
// single in-binary middleware that reads the registry (in-process, no HTTP hop)
// and the caller's approval, and applies THE RULE for every request whose Host is
// a governed service. As product hosts fold into the one-binary cloud, this is the
// ONE enforcement point (the @file waitlist-guard is the interim gate for hosts
// not yet cloud-fronted; both read the SAME registry so a toggle governs both).
//
// THE RULE (per request, on a governed host in waitlist mode):
//
// carries a Hanzo API key (hk-/sk-/…) → allow (paid inference; possession-gated)
// exempt path (health/iam/waitlist) → allow
// unauthenticated → 302 waitlist (browser) / 401 (API)
// waitlist mode OFF (or host un-governed) → allow (c.Next)
// waitlist mode ON AND approved → allow (c.Next)
// waitlist mode ON AND NOT approved → 302 waitlist (browser) / 403 (API)
//
// INTEGRATION POINT — wire in serve.go RIGHT AFTER SanitizeIdentity:
//
// app.Use(IdentityMiddleware(cfg)) // establishes the validated principal
// app.Use(featuregate.Enforce(featuregate.EnforceConfig{ WaitlistURL: … })) // ← here
//
// It reads the sanitized X-User-Id / X-User-IsAdmin / X-User-Approved that
// IdentityMiddleware minted, so it MUST run after it and (like BillingGate) before
// the subsystem handlers. It is deliberately NOT wired here — the unified-binary
// agent owns serve.go's boot chain; this package exposes Enforce so the one-line
// app.Use lands without a merge collision. The decide (flags.WaitlistModeForHost) is
// resolved PER REQUEST and fail-opens until the flags engine has mounted, so Enforce
// can be constructed before Mount runs.
//
// WHY NATIVE IS CANONICAL (in-cluster-bypass). The @file edge guard only gates
// traffic arriving THROUGH the ingress — a pod reaching another service's pod
// directly in-cluster bypasses it (a cluster-wide baseline NetworkPolicy + Cilium
// broad-allow union means additive netpols can't seal that). For a waitlist (threat
// model = external users) edge-only is acceptable, but this native middleware is
// FORWARD-PERFECT: when the app IS cloud, the gate is IN the request path, so
// reaching the pod directly STILL hits it — there is no edge to go around. That is
// the reason the native middleware is the canonical enforcement and the @file guard
// is purely interim. It is also STATELESS (it reads the sanitized X-User-* headers,
// sets no cookie), so the multi-apex cookie-domain concern the @file guard must
// handle does not exist here at all.
//
// Paths that must NEVER be gated (health, the waitlist page's own API, auth
// callbacks) are skipped via ExemptPrefixes so enforcement can't lock the platform
// out of its own recovery/observability surface.
type EnforceConfig struct {
// WaitlistURL is where an unapproved / unauthenticated browser is bounced
// (per-brand, e.g. https://waitlist.hanzo.ai). Empty → API-style 403/401 for
// everyone (no redirect target), so enforcement still holds.
WaitlistURL string
// Approvals resolves whether the caller is off the waitlist. When nil, Enforce
// builds one from IAMBase.
Approvals *Approvals
// IAMBase is the in-cluster IAM base used to build Approvals when it is nil.
IAMBase string
// ExemptPrefixes are request-path prefixes never gated (health/metrics/auth).
// A sensible default set is used when empty.
ExemptPrefixes []string
// Gate is THE decide: it resolves whether a request host is in waitlist mode,
// via the ONE policy engine. When nil it is flags.WaitlistModeForHost —
// host→service→waitlist.<svc>. Injected only in tests. Fail-open by contract:
// known=false (unmounted / registry error / un-governed host) → not gated.
Gate func(ctx context.Context, host string) (mode bool, service string, known bool)
}
// defaultExemptPrefixes are the paths enforcement must never touch — HIP-0106
// health, the auth/OIDC handshake, and the waitlist join API itself (so a gated
// user can still submit the waitlist form).
var defaultExemptPrefixes = []string{
"/v1/featuregate/", // the mode read + the health route
"/v1/iam/", // auth / OIDC / approval-status / get-account handshake
"/v1/waitlist", // the waitlist join API (a gated user must reach it)
"/health",
"/healthz",
"/__guard/", // the @file guard's own callback surface (defense in depth)
}
// Enforce builds the native enforcement middleware. It is a no-op passthrough when the
// decide reports the host is not governed (gate known=false — the flags registry not
// mounted yet, a store error, or an un-governed host), so a request before boot
// completes is never wrongly gated.
func Enforce(cfg EnforceConfig) zip.Handler {
approvals := cfg.Approvals
if approvals == nil {
approvals = NewApprovals(cfg.IAMBase, 0)
}
gate := cfg.Gate
if gate == nil {
gate = flags.WaitlistModeForHost // the ONE decide: host→service→waitlist.<svc>
}
exempt := cfg.ExemptPrefixes
if len(exempt) == 0 {
exempt = defaultExemptPrefixes
}
waitlistURL := strings.TrimRight(strings.TrimSpace(cfg.WaitlistURL), "/")
return func(c *zip.Ctx) error {
path := c.Path()
for _, p := range exempt {
if strings.HasPrefix(path, p) {
return c.Next()
}
}
// MONEY-CRITICAL EXEMPTION: a request bearing a Hanzo API KEY (hk-/sk-/pk-/…)
// is NEVER waitlist-gated. Paid inference on api.hanzo.ai authenticates by KEY
// POSSESSION + is metered downstream in the `ai` subsystem — SanitizeIdentity
// does NOT mint a session principal for an API key (auth_identity.go isAPIKey →
// validatedPrincipal returns nil), so an API-key request arrives here with an
// empty c.User(); without this exemption THE RULE would misclassify it as
// "unauthenticated API → 401" and break every paid inference call. The gate is
// redundant anyway: an API key is minted ONLY in the (gated) console, so an
// unapproved user can never obtain one. Session/JWT access to a gated host is
// still gated normally — only key possession is exempt.
if carriesAPIKey(c) {
return c.Next()
}
mode, _, known := gate(c.Context(), c.Fiber().Hostname())
if !known || !mode {
// Un-governed host, mode OFF, or a registry read error (the decide folds
// all three into known=false) → allow. A governed host is opened by
// flipping its waitlist.<svc> switch OFF; an unknown host is not ours to
// gate at the shared cloud edge — the @file guard is the belt-and-braces
// gate for the hosts that must stay closed.
return c.Next()
}
// Governed host in waitlist mode. Approved (incl. admins) pass; everyone
// else is bounced.
if approvals.Approved(c) {
return c.Next()
}
return bounce(c, waitlistURL)
}
}
// bounce renders the not-approved verdict: a browser navigation is redirected to
// the waitlist (302); an API client gets a 403 (401 when there is no principal at
// all). Content-negotiated so a fetch/XHR never eats an opaque HTML redirect.
func bounce(c *zip.Ctx, waitlistURL string) error {
if isAPIClient(c) || waitlistURL == "" {
if strings.TrimSpace(c.User()) == "" {
return c.JSON(http.StatusUnauthorized, map[string]any{
"error": "authentication required — sign in to continue",
})
}
return c.JSON(http.StatusForbidden, map[string]any{
"error": "account pending approval — join the waitlist",
})
}
c.SetHeader("Location", waitlistURL)
return c.NoContent(http.StatusFound)
}
// apiKeyPrefixes are the Hanzo API-key prefixes. This MIRRORS cloud
// auth_identity.go isAPIKey (the ONE authority) — kept local so featuregate stays
// self-contained (no cloud-internal import) while agreeing on the exact contract:
// a token with one of these prefixes is a possession-gated API key, not a session
// principal. If cloud adds a prefix there, add it here.
var apiKeyPrefixes = []string{"hk-", "sk-", "pk-", "fw_", "hz_"}
// carriesAPIKey reports whether the request authenticates with a Hanzo API key —
// in the Authorization header (Bearer or Basic-username) or the common api-key /
// x-api-key headers. GENEROUS by design: over-exempting only skips the redundant
// waitlist gate (key validation + billing still run downstream); under-exempting
// would break paid inference. So any recognized key signal exempts the request.
func carriesAPIKey(c *zip.Ctx) bool {
auth := strings.TrimSpace(c.Header("Authorization"))
if tok, ok := cut(auth, "Bearer "); ok && hasAPIKeyPrefix(tok) {
return true
}
// Basic auth carries the key as the username (key:) — OpenAI-compat clients do
// this. The raw base64 is not decoded here; instead accept the header-form keys
// callers commonly send, which is where inference SDKs put the key.
if hasAPIKeyPrefix(strings.TrimSpace(c.Header("api-key"))) ||
hasAPIKeyPrefix(strings.TrimSpace(c.Header("x-api-key"))) {
return true
}
return false
}
func hasAPIKeyPrefix(tok string) bool {
for _, p := range apiKeyPrefixes {
if strings.HasPrefix(tok, p) {
return true
}
}
return false
}
// cut splits s on the first occurrence of prefix at the START (case-insensitive on
// the scheme word), returning the remainder.
func cut(s, prefix string) (string, bool) {
if len(s) >= len(prefix) && strings.EqualFold(s[:len(prefix)], prefix) {
return strings.TrimSpace(s[len(prefix):]), true
}
return "", false
}
// isAPIClient reports whether the caller is a non-browser API client, so we fail
// closed with a status code instead of an interactive redirect. A Bearer/Basic
// Authorization OR an Accept without text/html is an API call; a browser
// navigation sends Accept: text/html.
func isAPIClient(c *zip.Ctx) bool {
if auth := strings.TrimSpace(c.Header("Authorization")); auth != "" {
return true
}
accept := c.Header("Accept")
if accept != "" && !strings.Contains(accept, "text/html") {
return true
}
return false
}
+220
View File
@@ -0,0 +1,220 @@
// Copyright 2023-2026 Hanzo AI Inc. All Rights Reserved.
// Licensed under the Apache License, Version 2.0.
package featuregate
import (
"context"
"io"
"net/http/httptest"
"testing"
"time"
luxlog "github.com/luxfi/log"
"github.com/zap-proto/zip"
)
// testGate is the injected decide (the flags engine's WaitlistModeForHost seam):
// hanzo.chat is gated, api.hanzo.ai is open, everything else is un-governed. This is
// exactly what flags.WaitlistModeForHost returns for the equivalent registry, without
// standing up the native flag engine (cgo) in a middleware unit test.
func testGate(_ context.Context, host string) (mode bool, service string, known bool) {
switch host {
case "hanzo.chat":
return true, "chat", true // gated
case "api.hanzo.ai":
return false, "api", true // open
default:
return false, "", false // un-governed
}
}
// gateApp mounts Enforce over the injected decide and a catch-all "ok" handler. The
// injected approval status decides whether the caller is off the waitlist.
func gateApp(t *testing.T, approvalStatus string) *zip.App {
t.Helper()
approvals := newApprovalsWithLookup(func(context.Context, string, string) (string, bool) {
return approvalStatus, true
}, time.Minute)
app := zip.New(zip.Config{Logger: luxlog.New("test")})
app.Use(Enforce(EnforceConfig{WaitlistURL: "https://waitlist.hanzo.ai", Approvals: approvals, Gate: testGate}))
app.Get("/*", func(c *zip.Ctx) error { return c.String(200, "ok") })
return app
}
type greq struct {
host, path, user, org, accept string
admin, approvedHdr, setApprov bool
authorization string // raw Authorization header (e.g. "Bearer hk-…")
apiKeyHeader string // raw api-key header value
}
func drive(t *testing.T, app *zip.App, r greq) (int, string) {
t.Helper()
hr := httptest.NewRequest("GET", "http://"+r.host+r.path, nil)
hr.Host = r.host
if r.user != "" {
hr.Header.Set("X-User-Id", r.user)
}
if r.org != "" {
hr.Header.Set("X-Org-Id", r.org)
}
if r.admin {
hr.Header.Set("X-User-IsAdmin", "true")
}
if r.setApprov {
if r.approvedHdr {
hr.Header.Set("X-User-Approved", "true")
} else {
hr.Header.Set("X-User-Approved", "false")
}
}
if r.accept != "" {
hr.Header.Set("Accept", r.accept)
}
if r.authorization != "" {
hr.Header.Set("Authorization", r.authorization)
}
if r.apiKeyHeader != "" {
hr.Header.Set("api-key", r.apiKeyHeader)
}
resp, err := app.Fiber().Test(hr)
if err != nil {
t.Fatalf("drive: %v", err)
}
defer func() { _ = resp.Body.Close() }()
_, _ = io.Copy(io.Discard, resp.Body)
return resp.StatusCode, resp.Header.Get("Location")
}
const html = "text/html,application/xhtml+xml"
// THE RULE — the acceptance matrix.
func TestRule_PendingUser_BouncedFromGatedHost(t *testing.T) {
app := gateApp(t, "pending")
// Browser → 302 to the waitlist.
code, loc := drive(t, app, greq{host: "hanzo.chat", path: "/dashboard", user: "u", org: "acme", accept: html})
if code != 302 || loc != "https://waitlist.hanzo.ai" {
t.Fatalf("pending browser on gated host = %d loc=%q, want 302 → waitlist", code, loc)
}
// API (JSON accept) → 403.
code, _ = drive(t, app, greq{host: "hanzo.chat", path: "/dashboard", user: "u", org: "acme", accept: "application/json"})
if code != 403 {
t.Fatalf("pending API on gated host = %d, want 403", code)
}
}
func TestRule_ApprovedUser_ThroughGatedHost(t *testing.T) {
app := gateApp(t, "approved")
code, _ := drive(t, app, greq{host: "hanzo.chat", path: "/dashboard", user: "u", org: "acme", accept: html})
if code != 200 {
t.Fatalf("approved user on gated host = %d, want 200 (through)", code)
}
}
func TestRule_ModeOffService_OpenToPendingUser(t *testing.T) {
app := gateApp(t, "pending")
// api.hanzo.ai is mode OFF → even a pending user passes.
code, _ := drive(t, app, greq{host: "api.hanzo.ai", path: "/v1/chat/completions", user: "u", org: "acme", accept: "application/json"})
if code != 200 {
t.Fatalf("pending user on OPEN service = %d, want 200", code)
}
}
func TestRule_Admin_ThroughGatedHost(t *testing.T) {
app := gateApp(t, "pending") // status irrelevant — admin short-circuits
code, _ := drive(t, app, greq{host: "hanzo.chat", path: "/dashboard", user: "z", org: "admin", admin: true, accept: html})
if code != 200 {
t.Fatalf("admin on gated host = %d, want 200", code)
}
}
func TestRule_UnauthenticatedBrowser_BouncedToWaitlist(t *testing.T) {
app := gateApp(t, "pending")
code, loc := drive(t, app, greq{host: "hanzo.chat", path: "/dashboard", accept: html})
if code != 302 || loc != "https://waitlist.hanzo.ai" {
t.Fatalf("anon browser = %d loc=%q, want 302 → waitlist", code, loc)
}
// Anon API → 401 (authenticate first).
code, _ = drive(t, app, greq{host: "hanzo.chat", path: "/dashboard", accept: "application/json"})
if code != 401 {
t.Fatalf("anon API = %d, want 401", code)
}
}
// MONEY-CRITICAL: a paid inference request with a Hanzo API key MUST flow through
// Enforce even on a waitlist-ON host — it is possession-gated + billed downstream,
// never waitlist-gated. Without the exemption THE RULE would 401 it and break
// inference cluster-wide.
func TestRule_APIKeyInference_NeverGated(t *testing.T) {
app := gateApp(t, "pending")
for _, key := range []string{"hk-43f50b6b", "sk-hz-abc", "pk-hz-obs", "fw_live_x", "hz_secret"} {
// The exact paid-inference shape: Bearer key, JSON accept, NO session/user, on a
// GATED host — the exemption, not mode, must carry it through.
for _, p := range []string{"/v1/chat/completions", "/v1/models", "/v1/embeddings"} {
code, _ := drive(t, app, greq{
host: "hanzo.chat", path: p, accept: "application/json",
authorization: "Bearer " + key,
})
if code != 200 {
t.Fatalf("API key %q on %s = %d, want 200 (paid inference must NOT be waitlist-gated)", key, p, code)
}
}
}
// The api-key / x-api-key header form is exempt too.
code, _ := drive(t, app, greq{host: "hanzo.chat", path: "/v1/chat/completions", accept: "application/json", apiKeyHeader: "hk-headerform"})
if code != 200 {
t.Fatalf("api-key header inference = %d, want 200", code)
}
// A NON-key Bearer (a JWT-shaped token) from a pending user IS still gated — only
// key possession is exempt, not arbitrary bearers.
code, _ = drive(t, app, greq{host: "hanzo.chat", path: "/dashboard", user: "u", org: "acme", accept: "application/json", authorization: "Bearer eyJhbGciOi.jwt.sig"})
if code != 403 {
t.Fatalf("pending JWT bearer on gated host = %d, want 403 (only API keys are exempt)", code)
}
}
func TestRule_UngovernedHost_PassesThrough(t *testing.T) {
app := gateApp(t, "pending")
code, _ := drive(t, app, greq{host: "example.com", path: "/whatever", user: "u", org: "acme", accept: html})
if code != 200 {
t.Fatalf("un-governed host = %d, want 200 (not ours to gate)", code)
}
}
func TestRule_ExemptPaths_NeverGated(t *testing.T) {
app := gateApp(t, "pending")
for _, p := range []string{"/health", "/v1/iam/get-account", "/v1/waitlist/join", "/v1/featuregate/mode"} {
code, _ := drive(t, app, greq{host: "hanzo.chat", path: p, user: "u", org: "acme", accept: html})
if code != 200 {
t.Fatalf("exempt path %q = %d, want 200 (never gated)", p, code)
}
}
}
func TestRule_ForwardHeaderApproved_ThroughWithoutLookup(t *testing.T) {
// Injected lookup returns pending, but the validated X-User-Approved header
// (the forward-perfect path) says approved → the user passes with no lookup.
app := gateApp(t, "pending")
code, _ := drive(t, app, greq{host: "hanzo.chat", path: "/dashboard", user: "u", org: "acme",
accept: html, setApprov: true, approvedHdr: true})
if code != 200 {
t.Fatalf("X-User-Approved=true on gated host = %d, want 200", code)
}
}
// The DEFAULT gate (nil Gate → flags.WaitlistModeForHost) fail-opens before the flags
// engine has mounted: with no engine, WaitlistModeForHost returns known=false for every
// host, so Enforce never gates pre-boot.
func TestEnforce_DefaultGate_FailsOpenPreBoot(t *testing.T) {
app := zip.New(zip.Config{Logger: luxlog.New("test")})
app.Use(Enforce(EnforceConfig{WaitlistURL: "https://waitlist.hanzo.ai",
Approvals: newApprovalsWithLookup(func(context.Context, string, string) (string, bool) { return "pending", true }, time.Minute)}))
app.Get("/*", func(c *zip.Ctx) error { return c.String(200, "ok") })
code, _ := drive(t, app, greq{host: "hanzo.chat", path: "/dashboard", user: "u", org: "acme", accept: html})
if code != 200 {
t.Fatalf("default gate pre-boot = %d, want 200 (never gate before flags mounts)", code)
}
}
+49
View File
@@ -0,0 +1,49 @@
package finance
import "context"
// UsageRow is one recorded usage debit — the READ twin of RecordUsage. The SAME
// wallet→revenue posting a metered call wrote is read back here, so the usage a
// customer SEES is exactly what drained their wallet. Cents is the debit magnitude
// (USD minor units); Model is the metered-unit label the debit carried (Entry.Memo);
// CreatedAt is unix seconds.
type UsageRow struct {
ID string `json:"id"`
Cents int64 `json:"cents"`
Model string `json:"model"`
CreatedAt int64 `json:"createdAt"`
}
// ListUsage returns org's recorded usage debits, most-recent-first, up to limit
// (limit <= 0 lists all). It reads the org's OWN finance file — the file IS the
// tenant boundary, so this can only ever return the caller's org's usage — and keeps
// ONLY the usage-debit entries (a deposit/grant is not usage).
//
// It is the CO-RESIDENT read the customer billing surface uses INSTEAD of the S2S
// HTTP hop: co-resident, commerce's own /v1/billing/usage route is not compiled into
// this binary, so proxying that path self-dispatches straight back into the customer
// handler. Reading the ledger here is the same move balance() already makes, so the
// usage view can never self-answer "sign in to view billing".
func (f *ledgerFinance) ListUsage(ctx context.Context, org string, limit int) ([]UsageRow, error) {
store, err := f.storeFor(org, false)
if err != nil {
return nil, err
}
entries, err := store.Entries(ctx, limit)
if err != nil {
return nil, err
}
rows := make([]UsageRow, 0, len(entries))
for _, e := range entries {
if e.Kind != kindUsage {
continue // deposits/grants are credits, not usage
}
rows = append(rows, UsageRow{
ID: e.ID,
Cents: e.Amount.Cents(),
Model: e.Memo,
CreatedAt: e.CreatedAt,
})
}
return rows, nil
}

Some files were not shown because too many files have changed in this diff Show More