Compare commits

...
Author SHA1 Message Date
antje 7fbafa54aa cloud: take the browser terminal into the reconciled lineage
Hanzo CI/CD / cicd (push) Successful in 35s
CI/CD / gate (push) Successful in 35s
CI/CD / containment (push) Successful in 4m41s
CI/CD / image (push) Skipped
CI/CD / rollout (push) Skipped
CI/CD / reach (push) Skipped
CI/CD / fanout (push) Skipped
CI/CD / receipt (push) Skipped
# Conflicts:
#	apps/sandbox/sandbox.go
2026-08-06 18:45:02 -07:00
antje 7ba18a2386 sandbox: the terminal — a shell a browser can actually open
CI/CD / containment (push) Canceled after 0s
CI/CD / image (push) Canceled after 0s
CI/CD / rollout (push) Canceled after 0s
CI/CD / reach (push) Canceled after 0s
CI/CD / fanout (push) Canceled after 0s
CI/CD / receipt (push) Canceled after 0s
CI/CD / gate (push) Canceled after 0s
Every route on /v1/sandboxes was a request/response, so the one thing a sandbox
could not give anybody was a prompt. exec collects what a command produced; a
person at a keyboard needs the other shape of the same channel.

Two routes and one mechanism:

  POST /v1/sandboxes/:id/terminal      a ticket
  GET  /v1/sandboxes/:id/terminal/ws   the terminal

The ticket exists because a browser's WebSocket constructor takes a URL and
nothing else. There is no header to put a bearer in, and the two usual answers
are both wrong: a bearer in the query string is a long-lived credential written
into every access log on the path, and trusting the session cookie makes the
socket a CSRF target, since no same-origin policy applies to a WebSocket. So the
credential is MINTED for the socket — crypto-random, bound to one org and one
sandbox, thirty seconds, spent on presentation and not on success. That is also
why the origin is not checked: the ticket IS the check, and a second gate beside
it would answer a question the first one already closed.

The wire, both directions, once. A text frame is stdin unless it is the one
control object {"resize":{"cols":N,"rows":M}}; a binary frame is always stdin.
Output comes back BINARY — a text frame must be valid UTF-8, a pty emits
arbitrary bytes cut at arbitrary offsets, and a frame carrying half a rune is one
the browser closes the connection over.

runtime.tty is a second shape of the exec channel, not a flag on exec: no
timeout that would insult whoever is typing, nothing buffered, one stream (a pty
merges stderr by construction and the apiserver refuses to open a second on a
TTY session). The shell is `bash -l` falling back to `sh -l`, so nothing on the
image is a precondition for getting a prompt — the hanzo CLI included.

The session ends once, from either end. The socket's read pump cancels the
stream when the far side goes, because EOF on stdin is a hint a pty may ignore;
the exec stream's end closes the socket with a reason instead of leaving it to
time out. The terminal is bounded by the sandbox's own lease, so it cannot
outlive what it is attached to even when the reaper is behind.

Tickets are held in memory and are replica-local — cloud-api runs one replica.
When that changes, this fails loudly with a 401 rather than quietly, which is
the property worth having in a gate.
2026-08-06 18:42:38 -07:00
antje 5131661f59 cloud: reconcile the two gits — the forge's commerce fix and github's fleet verbs are one lineage
Hanzo CI/CD / cicd (push) Canceled after 0s
CI/CD / gate (push) Canceled after 0s
CI/CD / containment (push) Canceled after 0s
CI/CD / image (push) Canceled after 0s
CI/CD / fanout (push) Canceled after 0s
CI/CD / receipt (push) Canceled after 0s
CI/CD / rollout (push) Canceled after 0s
CI/CD / reach (push) Canceled after 0s
The forge and github.com/hanzo-inc had diverged: six commits here that
github lacked (through commerce v1.50.23), four there that the forge
lacked (fleet verbs, sandbox leases) — and the LIVE image was built from
one of theirs. Shipping either tip alone would have regressed the other.
One lineage, so a release can only ever mean one thing again.
2026-08-06 18:40:54 -07:00
hanzo-dev df6b78ffa1 sandbox: an agent can name the computer it leases
The fleet door served 98 tools and `sandboxes` was not among them, so @hanzo
could not lease a sandbox — while the run path underneath was built, deployed and
working. Real gVisor pods in ns hanzo-sandboxes, digest-pinned, hanzo-mcp
answering over stdio in-pod with 29 tools and a live fetch. All of it unnameable.

Cause: every /v1/sandboxes route is a RAW handler, and a raw route is invisible to
every projection zip derives from its typed registry — REST is the only one it
reaches (zip typed.go:6-9 says exactly this). The child answered tools/list
happily with an empty array, so `sandboxes` was absent from the tool list AND
absent from the outage list. Silent absence: the same shape as the stock node:22
that exited 0, and the callee that logged 200 after the caller had hung up.

The five typed ops already existed one file over. expose() registers them on
cloud.Plane() — a DIFFERENT zip.App on a DIFFERENT socket that the door never
asks (plane.go:29-33 states the rule; manifest/mcp.go:68-76 and webui/mcp.go:37-41
both record this exact failure happening to kms). Same handlers, same types, now
also on the server the door does ask. Nothing new is invented, so there is no
second implementation to drift.

Op names are verbs on the product noun, matching the surface that landed today:
lease_sandbox, run_in_sandbox, read_sandbox_file, write_sandbox_file,
end_sandbox. None trips refuse() — no bearer-secret noun, no mutating verb on an
authority object — so the gate is unchanged and the refused set stays 136.

This is the line between "@hanzo can run code" and "@hanzo can lease a computer".

apps/sandbox builds and its tests pass.
2026-08-06 18:31:00 -07:00
antje 91cd1367ae tracker: one coherent bundle, rebuilt — the merge had minted a chimera
CI/CD / containment (push) Successful in 4m4s
Hanzo CI/CD / cicd (push) Failing after 1h2m15s
CI/CD / gate (push) Failing after 1h2m15s
CI/CD / image (push) Skipped
CI/CD / rollout (push) Skipped
CI/CD / reach (push) Skipped
CI/CD / fanout (push) Skipped
CI/CD / receipt (push) Skipped
The embedded dist carried an unresolved merge (conflict markers in
index.html and .sync-stamp — the new invariants gate refused the tree,
correctly) and something worse underneath: vendor-BgRt8uZI.js was a
LINE-MERGED minified chunk importing BOTH generations' ui chunks — a
hashed filename over bytes no build ever produced. No deletion could
make that set coherent, so the whole dist is replaced by a clean build
from the stamped source (hanzoai/admin apps/tracker@8da44d7: tsc clean,
vitest 7/7, vite build) — six files, every relative import resolving
inside the set, verified. A hashed asset that does not match its hash
is worse than a conflict marker; only a rebuild tells the truth.
2026-08-06 17:50:34 -07:00
zeekayandhanzo-dev 864cbca1b1 commerce v1.50.23 — Solana deposits readable, and a live-chain probe
CI/CD / containment (push) Successful in 3m19s
CI/CD / image (push) Skipped
CI/CD / rollout (push) Skipped
CI/CD / reach (push) Skipped
CI/CD / fanout (push) Skipped
CI/CD / receipt (push) Skipped
Hanzo CI/CD / cicd (push) Failing after 13s
CI/CD / gate (push) Failing after 13s
v1.50.21 adds a probe that drives the deposit watcher against a REAL chain,
which is the one thing 63 unit tests against fakes could not establish. Verified
against Base both ways: the correct USDC contract scans clean, and USDT's
contract labelled "usdc" is REFUSED quoting the on-chain symbol — which is also
the proof that decimals come off the contract rather than a constant.

v1.50.22 (another author) credits top-ups to the signed-identity payer rather
than a client-supplied subject.

v1.50.23 adds the Solana reader. SPL USDC is dollar-pegged, so it needs no price
oracle — which is why Solana came before BTC. The dedup key stopped being
EVM-shaped: chain:txHash:eventIndex, where eventIndex is the log index on EVM
and the token-BALANCE record index on Solana. It credits post − pre rather than
the instruction amount, because under a Token-2022 transfer fee those differ and
only the delta is what actually arrived.

Nothing here can take money: cryptoDepositsCanBeCredited is still false, and an
unconfigured watcher is disabled. GetCryptoOptions additionally intersects
"creditable" with "an address can be minted", so Solana stays out of the picker
until the custody fleet can mint an Ed25519 key.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-06 17:46:04 -07:00
zeekayandzeekay c4e79aa053 sandbox: a caller may not spend our pull secret on another org's images
CI/CD / containment (push) Successful in 3m44s
CI/CD / image (push) Skipped
CI/CD / rollout (push) Skipped
CI/CD / reach (push) Skipped
CI/CD / fanout (push) Skipped
CI/CD / receipt (push) Skipped
Hanzo CI/CD / cicd (push) Failing after 12s
CI/CD / gate (push) Failing after 12s
BYO images already worked — Spec.Image goes straight into the pod spec — and
nothing checked it. Our registry pull secret is attached to the `sandbox`
ServiceAccount and is FLEET-WIDE, so a caller naming
`oci.hanzo.ai/<someone-else>/private` had our credential fetch another tenant's
private image for them. Nothing in the request was forged; the field was simply
never read for what it implied.

The rule is the one the registry already uses. Our registry is org-namespaced as
<host>/<org>/<app>, so on OUR hosts the first path segment must BE the caller's
org — or `hanzoai`, which is the platform's own sandbox images and is the same
bytes imageFor would have chosen anyway. Anywhere else needs no credential of
ours, so it needs no permission from us: a public image is the caller's own
business, and the pod's securityContext contains it either way.

That makes BYO images a real feature rather than an accident. An org pushes to
its own namespace and names it on create.

RUNTIME IS PER SANDBOX NOW. It was one deployment-wide env var read at startup,
so the same task could not be run on two runtimes and compared without a
rollout, and a caller could not choose. Spec.RuntimeClass overrides it, against a
CLOSED set — an unknown runtimeClassName is a pod that never schedules, so a
typo would otherwise become a sandbox stuck Pending with nothing to read.

The row deliberately does NOT record which runtime a sandbox got: the pod is the
source of truth for what it is actually running, and a second copy could only go
stale.
2026-08-06 17:35:49 -07:00
hanzo-devandzeekay ab29588949 sandbox: an exec sandbox's /mnt/data is a mount, not the image's read-only dir
The code interpreter tells the model to persist artifacts in /mnt/data. In a
deployed `exec` sandbox that directory was whatever the image shipped —
root:root 0755 — and the pod runs as runAsUser 1000, so every such write failed
with EACCES. The one directory the tool exists to fill was the one directory it
could not write.

Measured in a live sandbox rather than inferred:

    $ kubectl -n hanzo-sandboxes exec m-643df8b317a6052579035359 -- sh -c '...'
    id: uid=1000(sandbox) gid=1000(sandbox) groups=1000(sandbox)
    drwxr-xr-x 1 root root 40 /mnt/data
    mkdir: Permission denied

Only `dev` sandboxes ever got a mount at their workdir, because only they have a
project PVC. `exec` fell through the `if m.Volume != ""` and got nothing. It
survived because nothing checks: a run that prints its answer looks perfectly
successful, and only a run that SAVES something notices — as a traceback the
model apologises for rather than as an error anyone sees.

emptyDir, not a PVC: a code-interpreter session is exactly as long-lived as its
pod, which is what emptyDir already means. It is also what makes the mount
writable — the kubelet chowns an emptyDir to the pod's fsGroup, and the
securityContext already sets fsGroup 1000 — so the fix is the mount, not a chown
in the image. Bounded by SANDBOX_WORKDIR_SIZE (2Gi) because a container's
ephemeral-storage limit does not cover emptyDir usage on every runtime.
2026-08-06 17:35:48 -07:00
antje e6374961a7 meet: the previous build's chunks leave with the build that replaced them
CI/CD / containment (push) Successful in 3m4s
CI/CD / image (push) Skipped
CI/CD / rollout (push) Skipped
CI/CD / reach (push) Skipped
CI/CD / fanout (push) Skipped
CI/CD / receipt (push) Skipped
Hanzo CI/CD / cicd (push) Failing after 17s
CI/CD / gate (push) Failing after 18s
The re-embed at 86de6b94e copied the new bundle beside the old one
instead of over it, so dist carried two generations of the ui chunk —
and TestTheAuthModuleIsBundledOnce counted the auth module once per
generation and went red, holding every release since. The three files
the current index.html cannot reach (the old entry, its ui chunk, its
css — they reference only each other) are removed; the live five are
untouched.
2026-08-06 17:14:47 -07:00
zeekay 9052430f9f coding: a sandbox run is a computer, so the wire says what kind
The runtime already grew `tool`, an optional repo and `desktop`; this is the
cloud half of that contract.

`tool` (dev|claude|codex|python|node) and `desktop` are carried, not
interpreted. Cloud ships a NAME; the runtime owns the name→argv table, so
adding a tool is one edit over there and none here. `desktop` selects an image
variant — a tag — and nothing on this side branches on it.

THE REPO IS OPTIONAL, AND THE CREDENTIAL LIVES INSIDE IT. Every git field is
`omitempty` and `credential` is a POINTER: a value type always marshals, so a
repo-less run would still ship a blank `credential` object and the runtime could
not tell an absent grant from an empty one. A caller that supplies a credential
with no repo is REFUSED rather than quietly trimmed — silently dropping a secret
hides the bug that minted it, and the runtime says the same thing at its own
boundary, so both ends agree.

`Secret` is now true only when the body actually carries a credential. It was
unconditional, which meant the cleartext guard — a rule written to protect a git
token — refused every research and bare-exec run that has no token to protect.

SANDBOX_URL names where a run goes. A sandbox is not the bot: coding, deep
research and bare exec all want a computer to run something in, and none of them
wants the service that runs Slack channels. BOT_GATEWAY_URL stays correct for
bot traffic and is accepted here for one release, then deleted.

The transport learns none of this. `Call.Base` is a DESTINATION, which is a
transport concern; the caller resolves the address because resolving it inside
transport.go would mean that file learning what a run is — the exact line its
header draws. requireSecure now checks the base the call will ACTUALLY use;
checking the default while the bytes went elsewhere was a guard on the wrong hop.
2026-08-06 17:01:54 -07:00
antje 8ae00313e0 deps: hanzoai/ai v1.832.36 — the usage dedup stops filtering through its own aliases
Hanzo CI/CD / cicd (push) Failing after 17s
CI/CD / gate (push) Failing after 18s
CI/CD / containment (push) Successful in 4m56s
CI/CD / image (push) Skipped
CI/CD / rollout (push) Skipped
CI/CD / reach (push) Skipped
CI/CD / fanout (push) Skipped
CI/CD / receipt (push) Skipped
The console overview's 'usage totals: code 184' card was ClickHouse
refusing WHERE timestamp beside any(timestamp) AS timestamp in the
dedup subquery. v1.832.36 moves the predicate to an inner plain scan.
2026-08-06 16:49:11 -07:00
zeekay 4117fc1486 fleet: an operation is named for what it does, and says so in one line
The `projects` tool offered 37 operations spelled
`get_v1_projects_by_slug_deployments`, with no prose beside any of them.
Those are ROUTES — zip derives an operation id from method and path when
nobody declares one — so a model's first job was to reverse-engineer a URL
back into an intention, its second was to call describe to find out what the
intention took, and only its third was to act. Three round trips to use a
menu written in URL, and the assistant reads as stupid for the first two.

phrase() reads the route back as a verb on an object. The SHAPE decides it,
not a dictionary: a mutating method with a singular segment sitting after a
parameter is the author's own verb (deploy_project), a path that ends in a
parameter acts on one row (get_project, delete_project_domain), a GET of a
plural tail is a list and keeps it plural (list_project_deployments). PUT
and PATCH get different words — `set` replaces, `update` merges — because
they are different operations at one address. A DECLARED id is already a
verb on an object and is left alone, so o11y and iam are untouched.

	get_v1_projects_by_slug_deployments              list_project_deployments
	post_v1_projects_by_slug_domains_by_host_verify  verify_project_domain
	post_v1_projects_by_slug_deploy                  deploy_project
	delete_v1_projects_by_slug                       delete_project

THE REFUSED SET IS UNCHANGED, measured rather than reasoned. Naming happens
AFTER the gate and only there: refuse() judges the child's own id exactly as
it always did, fleet/surface.go is byte-identical, and the refused list is
the same element for element over the fleet's 2,440 declared operations —
211 — checked against the same rule compiled from HEAD and from this tree.
rank() reads the route for the same reason, so the order is unchanged too.

The mapping back is exact because offer() REFUSES to guess: a phrase two
operations would share, or one that is already some operation's own id, is
not used and both keep their ids. 182 of 2,229 do, nearly all of them the
fleet's own duplicates — one handler at /tasks and /v1/tasks, post_v1_agent
beside post_v1_agents in another subsystem. An operation's id also still
routes, and that is forced rather than kind: describe hands back the owning
subsystem's descriptor bytes verbatim, and those carry the owner's name.

And the enum now carries a line of the operation's own documentation beside
the product operations, which is the half that removes the describe round
trip for the calls an agent actually makes. It is rationed to productStems
because the bytes say so, over the 2,229 offered ops:

	routes, as they shipped           63,370
	verb phrases                      50,151   a phrase is SHORTER than a route
	+ a summary on the 143 ranked     64,081   +711 against the routes  <- shipped
	+ a summary on ALL of them       255,488   four times over

So the whole change costs 711 bytes: the naming pays for the prose. The
console tail stays bare and named well enough to recognise, with describe
one call away, because the point of grouping was 977 KB down to 71 and four
times the enum puts most of it back.

Proved on real children over real sockets under the real door:
TestACallByThePUBLISHEDNameReachesTheSameHandler stands up a child whose ids
zip DERIVES from its routes, then shows deploy_project and
post_v1_projects_by_slug_deploy reaching one handler with one reply, and
TestAColdDoorDispatchesAPublishedNameOnTheFirstCall covers the path that
caught a real bug in the first draft — a door that has answered no
tools/list holds neither table, so resolving before discovering answered
"unknown tool" for an operation it publishes. find() does both in one
lookup.

apps/agents/door.go stops requiring a descriptor's name to equal the op it
asked for. The door publishes the phrase and the descriptor carries the id,
so that check would have rejected 1,730 of 2,229 operations for being
correctly named; the seam's real guarantee — the model is offered exactly
what it will call — is kept by naming the offer with what came out of the
enum.
2026-08-06 16:49:01 -07:00
zeekay 9957aa1a8b fleet: a tool is named for what it is, and the server is the namespace
The door's tools shipped this morning as `hanzo_<app>` plus `hanzo_describe`.
The MCP server IS Hanzo — a client reaches these names through it and through
nothing else — so the prefix disambiguated nothing from nothing, and charged a
token for it on every one of 118 tools on every turn. Gone, in one change, with
no aliases: `hanzo_git` -> `git`, `hanzo_websearch` -> `websearch`,
`hanzo_describe` -> `describe`. Clients re-discover on connect, which is why
this is cheap now and would not have been in a week.

The prefix was also doing load-bearing work nobody had written down: composed()
read it to tell one of the door's own tools from an operation a child declared.
A convention holding up dispatch is a convention someone will break thinking it
is cosmetic — so that test is now an exact membership check against the app set
the door was mounted over (Door.composed). A child's operation id is
`<method>_<path>` or a declared PascalCase verb; an app name is neither, so
nothing a subsystem serves can be mistaken for an envelope.

Dropping the prefix puts the door's own tools in one namespace with the app
names, which creates exactly one new way to be wrong: an app called `describe`
would publish a second tool under that name and be silently unreachable.
TestNoSubsystemIsCalledDescribe reads the manifest and fails the build if one
ever appears — a fact about the app list, checked where it is decidable.

THE REFUSED SET IS UNCHANGED, and this was measured rather than reasoned:
refuse() reads CHILD operation ids and never saw the door's own names. Over the
fleet's own corpus (plugin/*/openapi.json, 117 subsystems, 2,440 ops) the
refused and kept lists are byte-for-byte identical before and after —
211 refused, 2,229 kept.

Measured through the real door, real children, real sockets:
  the head of the surface  describe ai agent agents code lsp product
                           provisioning git deploy exec projects …
  118 tools, 106,282 bytes, 2,229 operations offered, 211 withheld

e2e/mcp-door.sh asked whether a tool name started with `hanzo_` to decide it was
a subsystem and not a flat operation. It now asks whether the tool CARRIES an
`op` enum, which is the thing itself rather than a convention about it.
2026-08-06 16:46:32 -07:00
36 changed files with 2440 additions and 305 deletions
+27 -14
View File
@@ -1197,8 +1197,8 @@ of them refuted a claim that had been repeated confidently for weeks.
- **`GET /v1/commands` is live**: 2448 commands over 194 services, under a strong
ETag that answers 304 to a matching `If-None-Match`.
- **`POST /v1/mcp` works end to end**: `tools/list` returns 88 tools —
`hanzo_describe` plus ONE tool per subsystem, each carrying its operations in an
`op` enum — and `tools/call` on `hanzo_describe` returns the prose zipdoc lifted
`describe` plus ONE tool per subsystem, each carrying its operations in an
`op` enum — and `tools/call` on `describe` returns the prose zipdoc lifted
off the Go handler. 0 of the 88 have an empty description or a missing input
schema.
- **HALF the surface is not reachable as a tool, and only a tenth of that gap is
@@ -1206,7 +1206,7 @@ of them refuted a claim that had been repeated confidently for weeks.
(49%)**. The `_meta` names 134 as refused by the projection rule (a name that
discloses a bearer secret; a mutating verb on an identity or authority object)
and exactly ONE subsystem as unavailable (`x402`) — so roughly 1,100 operations
are absent with no stated reason. `hanzo_ai` is the extreme: **1 op in its enum
are absent with no stated reason. `ai` is the extreme: **1 op in its enum
against ~300 in the document.** An untyped route earns no tool by construction,
so most of this is the typed migration's remaining tail showing up in the one
projection where it is countable — but it is NOT all of it, and nothing today
@@ -3081,21 +3081,34 @@ semantic is identical — fail closed once armed, allow before.
bytes** — ~244k tokens to merely enumerate what can be called — and MCP clients
truncate (Slack keeps 128), so 1,061 operations were unreachable no matter how
they were ordered. Ordering (`rank`) fixes which tools a truncating client keeps;
it cannot fix a hard cap. So the surface is `hanzo_<app>` carrying
it cannot fix a hard cap. So the surface is ONE TOOL PER APP, NAMED FOR IT, carrying
`{"op":"<operation>","input":{…}}`, whose `op` enum holds NAMES ONLY, plus
`hanzo_describe` — which returns one operation's own descriptor, so a model
`describe` — which returns one operation's own descriptor, so a model
searches the enum and fetches the schema for the one it picked. The whole
corpus is **116 tools in 106,847 bytes** (`fleet.TestTheWholeFleetFitsInAModelsHead`,
corpus is **118 tools in 106,282 bytes** (`fleet.TestTheWholeFleetFitsInAModelsHead`,
which builds it from `plugin/*/openapi.json`) — 17× less per operation, for 1.9×
MORE operations than the baseline carried. `hanzo_describe` is FIRST because it is
MORE operations than the baseline carried. `describe` is FIRST because it is
what makes every other tool usable, so truncation must never take it. The envelope
is a DECODING and not a second route: it yields the (name, message) a direct call
carries, and `refuse()` in `gather` remains the only gate, so a refused name is in
no enum, dispatchable through no envelope, and describable by nothing.
**Headroom: 12 subsystems.** 116 of the 128 a client keeps. The manifest is 119
apps and growing, so the next dozen subsystems put the door back over the cap; the
**Headroom: 10 subsystems.** 118 of the 128 a client keeps. The manifest is 121
apps and growing, so the next ten subsystems put the door back over the cap; the
move then is to group by product surface (`productStems`, 17 buckets), not to add
a second projection.
**The tools carry no prefix.** They shipped as `hanzo_<app>` + `hanzo_describe`
and were renamed hours later to the bare app names + `describe`, in one change
with no aliases. The MCP server is the namespace — a client reaches these names
through it and through nothing else — so `hanzo_` disambiguated nothing and cost
a token per tool per turn. Two consequences worth knowing before touching this:
the prefix was also how `composed()` told one of the door's own tools from a
child's operation, which is now an exact membership test against the app set
(`Door.composed`); and the door's tools and the app names now share one
namespace, so **no subsystem may be named `describe`**
`fleet.TestNoSubsystemIsCalledDescribe` reads the manifest and fails the build if
one ever is. A door still on an older image answers the prefixed names; the
refused set is untouched, because `refuse()` reads CHILD operation ids and never
saw the door's own names.
It used to read a BUILD-TIME catalogue — `plugin/<app>/mcp.json`, embedded by
`plugin/embed.go` and handed to zip as `Plugin.Tools` — and `tools/list` was a
memcpy. **Those 116 files are deleted (49,865 lines).** They were a second source
@@ -4758,7 +4771,7 @@ interchangeable and are not:
| relative position | 187 ahead of inc HEAD | 16 ahead of forge/main |
Production runs `ghcr.io/hanzoai/cloud:sha-8465354e6bf3`, and its live behavior —
88 grouped tools, `hanzo_describe` first, 134 refused — exists **only on the inc
88 grouped tools, `describe` first, 134 refused — exists **only on the inc
line**. An earlier draft of § 6 and § 9 was written against a forge-line checkout
and concluded the agent door withholds nothing and that nothing in cloud calls
it. Both were false in production, and false in the dangerous direction:
@@ -5094,7 +5107,7 @@ irreversible.
**The tool surface already refuses, and it refuses our own agents too.**
Measured on the live door, `POST https://api.hanzo.ai/v1/mcp` `tools/list`:
88 tools in 63,468 bytes, the first of them `hanzo_describe`, and
88 tools in 63,468 bytes, the first of them `describe`, and
```
_meta["hanzo.ai/refused"] = {count: 134, rule: "a tool is not projected when its
@@ -5232,9 +5245,9 @@ or forgotten. Two properties come free and both matter here:
forwarded, because a scheduled run has no inbound request and a nested one may
be running for a different principal.
Every rung of § 4 is already on that door: `hanzo_analytics` publishes
`get_v1_errors`, `hanzo_o11y` publishes 318 ops including the review queues,
and `hanzo_tracker` and `hanzo_help` are both projected. So the ladder needs no
Every rung of § 4 is already on that door: `analytics` publishes
`get_v1_errors`, `o11y` publishes 318 ops including the review queues,
and `tracker` and `help` are both projected. So the ladder needs no
new tool surface — only the policy about which rung may be pulled without a
human, which is § 4's job and not the door's.
+18 -10
View File
@@ -159,9 +159,9 @@ func (doorTools) catalog(ctx context.Context, org, actor string, want []string)
offered[op] = true
}
}
// ToolsAll offers the door's tools AS THE DOOR GROUPS THEM — hanzo_<subsystem>
// carrying an `op` enum, plus hanzo_describe — and not the ops flattened back
// out.
// ToolsAll offers the door's tools AS THE DOOR GROUPS THEM — one per subsystem,
// named for it, carrying an `op` enum, plus [fleet.Describe] — and not the ops
// flattened back out.
//
// The grouping is the whole reason the surface is affordable: 1,189 flat tools
// were 977 KB (~244k tokens) merely to LIST, and the same operations grouped
@@ -169,7 +169,7 @@ func (doorTools) catalog(ctx context.Context, org, actor string, want []string)
// door just saved and blow the context before the question is read.
//
// It is also what the assistant's instructions describe — pick a subsystem,
// choose an op from its enum, call hanzo_describe for a shape you do not know.
// choose an op from its enum, call [fleet.Describe] for a shape you do not know.
// The prose and the offer have to be the same surface or the model is being
// taught a protocol it cannot practise.
if all {
@@ -232,10 +232,18 @@ func opsOf(schema json.RawMessage) []string {
// describe fetches ONE operation's descriptor through the door's own
// fleet.Describe, and reads the owning subsystem's bytes back out of it.
//
// The name check is not paranoia about the door: it is what makes "the model is
// offered exactly what it will call" true at the seam, since a descriptor under
// another name would put a schema in front of the model for a tool it cannot
// reach.
// What the seam guarantees is that the model is offered exactly what it will
// CALL, and op is that name: it came out of a subsystem tool's `op` enum a
// moment ago, and a tools/call naming it reaches the operation's own handler.
// So the offer is named op, with the owner's own description and schema behind
// it.
//
// The descriptor's own `name` is NOT compared to op, and that is a change. The
// door publishes an operation as a verb on an object — `deploy_project` for
// `post_v1_projects_by_slug_deploy` (fleet/verbs.go) — while the descriptor it
// hands back is the owning subsystem's, carried verbatim, so it still says the
// id. Requiring the two to match would reject 1,730 of the fleet's 2,229
// operations for being correctly named.
func describe(ctx context.Context, org, actor, op string) (types.ToolDef, error) {
args, err := json.Marshal(map[string]string{"op": op})
if err != nil {
@@ -258,10 +266,10 @@ func describe(ctx context.Context, org, actor, op string) (types.ToolDef, error)
Description string `json:"description"`
InputSchema json.RawMessage `json:"inputSchema"`
}
if err := json.Unmarshal([]byte(text), &d); err != nil || d.Name != op {
if err := json.Unmarshal([]byte(text), &d); err != nil || d.Name == "" || len(d.InputSchema) == 0 {
return types.ToolDef{}, fmt.Errorf("agents: %s did not answer %s's own descriptor", fleet.Describe, op)
}
return types.ToolDef{Name: d.Name, Description: d.Description, Schema: d.InputSchema}, nil
return types.ToolDef{Name: op, Description: d.Description, Schema: d.InputSchema}, nil
}
// maxDoorMeta bounds what one span attribute may carry: `_meta` names every
+1 -1
View File
@@ -85,7 +85,7 @@ func mountRelay(app cloud.Router, deps cloud.Deps) error {
return fmt.Errorf("bots.mountRelay: nil deps.Logger")
}
s := &relay{
target: executorURL(),
target: executorURL(""),
log: deps.Logger.New("subsystem", "bots"),
cc: &http.Client{Timeout: 60 * time.Second},
}
+25 -8
View File
@@ -96,6 +96,17 @@ type Call struct {
User string
Body any
Secret bool
// Base overrides the destination for THIS call. Empty means the bot runtime.
//
// This stays a transport concern and not a meaning one: a caller names WHERE
// its bytes go, and this file still does not know a coding run from a channel
// relay. It exists because a sandbox is not the bot — deep research, bare
// exec and coding all want a SANDBOX, and whatever runs sandboxes may not be
// the service that runs channels. Resolving that address inside here would
// mean this file learning what a run is, which is exactly the line the header
// draws.
Base string
}
// Do invokes c and discards any response payload — the command form. It returns
@@ -173,7 +184,7 @@ func Stream(ctx context.Context, c Call, fn func(msg []byte)) error {
// off: the runtime then fails the request closed at its own auth gate.
func send(ctx context.Context, c Call, method, accept string) (*http.Response, error) {
if c.Secret {
if err := requireSecure(); err != nil {
if err := requireSecure(c.Base); err != nil {
return nil, err
}
}
@@ -185,7 +196,7 @@ func send(ctx context.Context, c Call, method, accept string) (*http.Response, e
}
body = bytes.NewReader(b)
}
req, err := http.NewRequestWithContext(ctx, method, executorURL()+c.Op, body)
req, err := http.NewRequestWithContext(ctx, method, executorURL(c.Base)+c.Op, body)
if err != nil {
return nil, fmt.Errorf("runtime: build call: %w", err)
}
@@ -262,8 +273,12 @@ func ErrBody(resp *http.Response) string {
return strings.TrimSpace(string(b))
}
// executorURL resolves the executor base, no trailing slash.
func executorURL() string {
// executorURL resolves the base for one call, no trailing slash. A Call's own
// Base wins; otherwise the bot runtime.
func executorURL(base string) string {
if base != "" {
return strings.TrimRight(base, "/")
}
if v := getenv(urlEnv); v != "" {
return strings.TrimRight(v, "/")
}
@@ -279,13 +294,15 @@ func executorURL() string {
// This guard exists because the transport does not authenticate its peer. A ZAP
// entry point pins X25519MLKEM768 and refuses a classical-only peer structurally,
// which is what makes this check unnecessary rather than merely satisfied.
func requireSecure() error {
u := executorURL()
// It checks the base the call will ACTUALLY use: guarding the default while the
// bytes go somewhere else would be a guard on the wrong hop.
func requireSecure(base string) error {
u := executorURL(base)
if strings.HasPrefix(u, "https://") || getenv(plaintextEnv) == "1" {
return nil
}
return fmt.Errorf("runtime: refusing to send a credential over cleartext %q (set %s to https, or %s=1 if the hop is mesh-mTLS secured)",
u, urlEnv, plaintextEnv)
return fmt.Errorf("runtime: refusing to send a credential over cleartext %q (set the destination to https, or %s=1 if the hop is mesh-mTLS secured)",
u, plaintextEnv)
}
func getenv(key string) string { return strings.TrimSpace(os.Getenv(key)) }
+13
View File
@@ -114,6 +114,19 @@ type PRRef struct {
// RunRequest / Step / RunResult mirror the bot coding contract.
type RunRequest struct {
// Tool names what runs inside the sandbox: dev | claude | codex | python |
// node. Empty means dev. The runtime owns the name→argv table; cloud only
// carries the name, so adding a tool is one edit over there and none here.
Tool string
// Desktop selects the xvfb IMAGE VARIANT — a tag, not a mode. A desktop run
// gets an X server, so a real browser window exists to drive; every class can
// already drive a headless one.
Desktop bool
// The repo is OPTIONAL. CloneURL empty means the run has no checkout — and
// then CredUser/CredToken MUST be empty too, because a credential a run can
// never use only exists to leak. The runtime refuses the combination.
CloneURL string
BaseBranch string
Branch string
+62 -15
View File
@@ -3,7 +3,10 @@ package coding
import (
"context"
"encoding/json"
"errors"
"fmt"
"os"
"strings"
"github.com/hanzoai/cloud/apps/bots"
)
@@ -24,9 +27,29 @@ import (
// 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.
// taskOp addresses the runtime's sandbox-run operation.
const taskOp = "/v1/coding-tasks"
// sandboxURL is WHERE A RUN GOES, and it is deliberately not the bot address.
//
// A sandbox is not the bot. Coding, deep research and bare exec all want the
// same thing — a computer to run something in — and none of them wants the
// service that runs Slack channels. BOT_GATEWAY_URL is the right name for bot
// traffic and stays that; this is the name for a sandbox.
//
// The old name is accepted for ONE release so a deploy cannot half-land, then it
// is deleted. Not a permanent alias: two live names for one address is how the
// two ends stop agreeing about where a run went, with nothing in a log to say so.
// Empty here means the transport's own default, which today is the same pod.
func sandboxURL() string {
for _, k := range []string{"SANDBOX_URL", "BOT_GATEWAY_URL"} {
if v := strings.TrimSpace(os.Getenv(k)); v != "" {
return v
}
}
return ""
}
// credential is the per-org agent git credential the sandbox presents to native
// git. Token is the secret (an sk- key); Username is the basic-auth user label.
// Encoded into the request body only — never logged.
@@ -35,15 +58,23 @@ type credential struct {
Token string `json:"token"`
}
// taskRequest is the cloud→runtime body for one coding run.
// taskRequest is the cloud→runtime body for one sandbox run.
//
// EVERY GIT FIELD IS omitempty, AND THAT IS THE CONTRACT, NOT A TIDINESS
// PREFERENCE. A run with no repo must put NO credential on the wire at all —
// not an empty one. `credential` is a pointer for the same reason: a value type
// would always marshal, so "no repo" would still ship a `credential` object and
// the runtime could not tell an absent grant from a blank one.
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)
Prompt string `json:"prompt"` // the task
Tool string `json:"tool,omitempty"` // dev|claude|codex|python|node (default dev)
Desktop bool `json:"desktop,omitempty"` // select the xvfb image variant
SessionID string `json:"sessionId"` // cloud session id (correlation)
RunTimeoutSeconds int `json:"runTimeoutSeconds"` // sandbox run budget
CloneURL string `json:"cloneUrl,omitempty"` // https://<domain>/v1/git/<org>/<repo>.git
BaseBranch string `json:"baseBranch,omitempty"` // branch to start from (default repo default)
Branch string `json:"branch,omitempty"` // branch to create + push (e.g. agent/<sessionid>)
Credential *credential `json:"credential,omitempty"` // agent git credential (write-only); nil when there is no repo
}
// message is the discriminated shape of one streamed line: step/log while the job
@@ -73,16 +104,32 @@ type runner struct{}
func (runner) Run(ctx context.Context, org, userID string, req RunRequest, onStep func(Step)) (RunResult, error) {
var out RunResult
var terminal bool
body := taskRequest{
Prompt: req.Prompt, Tool: req.Tool, Desktop: req.Desktop,
SessionID: req.SessionID, RunTimeoutSeconds: req.RunTimeoutSeconds,
}
// The git half travels together or not at all — there is no path here that
// puts a credential on the wire without the repo it belongs to. A caller
// that supplies one anyway is REFUSED rather than quietly trimmed: silently
// dropping a secret hides the bug that minted it, and the runtime says the
// same thing at its own boundary, so the two ends agree.
if req.CloneURL == "" && (req.CredToken != "" || req.CredUser != "") {
return out, errors.New("coding: a credential without a repo cannot be used, and must not be sent")
}
if req.CloneURL != "" {
body.CloneURL, body.BaseBranch, body.Branch = req.CloneURL, req.BaseBranch, req.Branch
body.Credential = &credential{Username: req.CredUser, Token: req.CredToken}
}
err := bots.Stream(ctx, bots.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
Base: sandboxURL(),
Body: body,
// Secret ONLY when the body actually carries the org's git credential.
// A run with no repo has no secret to protect, so it must not be refused
// by the cleartext guard that exists to protect one.
Secret: body.Credential != nil,
}, func(msg []byte) {
var m message
if json.Unmarshal(msg, &m) != nil {
+54
View File
@@ -140,7 +140,11 @@ func TestTask_RefusesCleartextByDefault(t *testing.T) {
defer srv.Close()
t.Setenv("BOT_GATEWAY_URL", srv.URL) // http://
t.Setenv("BOT_GATEWAY_ALLOW_PLAINTEXT", "")
// The repo is what makes this POST credential-bearing: with no CloneURL the
// credential never reaches the wire, so there would be no secret for the
// cleartext guard to protect and nothing for this test to prove.
_, err := runner{}.Run(context.Background(), "acme", "u", RunRequest{
CloneURL: "https://git.hanzo.ai/v1/git/acme/api.git", Branch: "agent/abc123",
CredUser: "x", CredToken: "sk-SECRET",
}, nil)
if err == nil {
@@ -150,3 +154,53 @@ func TestTask_RefusesCleartextByDefault(t *testing.T) {
t.Fatalf("error must not leak the credential: %v", err)
}
}
// A run with NO repo carries no credential, so it is not a "secret" call and the
// cleartext guard must not refuse it — otherwise every research and bare-exec run
// is blocked by a rule written to protect a git token that is not there.
func TestTask_NoRepoRunIsNotSecret(t *testing.T) {
var raw []byte
srv := ndjsonServer(t, []string{`{"type":"result","ok":true}`}, nil, &raw)
defer srv.Close()
t.Setenv("BOT_GATEWAY_URL", srv.URL) // http://
t.Setenv("BOT_GATEWAY_ALLOW_PLAINTEXT", "")
res, err := runner{}.Run(context.Background(), "acme", "u", RunRequest{
Prompt: "read the docs and summarise", Tool: "python", Desktop: true,
}, nil)
if err != nil {
t.Fatalf("a repo-less run must not be refused as cleartext-secret: %v", err)
}
if !res.OK {
t.Fatal("expected the terminal result to carry through")
}
// The wire says what the run is, and says nothing about git.
var got map[string]any
if err := json.Unmarshal(raw, &got); err != nil {
t.Fatalf("body was not JSON: %v", err)
}
if got["tool"] != "python" || got["desktop"] != true {
t.Fatalf("tool/desktop did not reach the runtime: %#v", got)
}
for _, k := range []string{"credential", "cloneUrl", "branch", "baseBranch"} {
if _, ok := got[k]; ok {
t.Fatalf("a repo-less run must not put %q on the wire: %#v", k, got)
}
}
}
// A credential with no repo is a caller bug, and it is refused at the door rather
// than trimmed in silence.
func TestTask_RefusesCredentialWithoutRepo(t *testing.T) {
srv := ndjsonServer(t, []string{`{"type":"result","ok":true}`}, nil, nil)
defer srv.Close()
t.Setenv("BOT_GATEWAY_URL", strings.Replace(srv.URL, "http://", "https://", 1))
_, err := runner{}.Run(context.Background(), "acme", "u", RunRequest{
Prompt: "x", CredUser: "x", CredToken: "sk-SECRET",
}, nil)
if err == nil {
t.Fatal("a credential with no repo must be refused")
}
if strings.Contains(err.Error(), "sk-SECRET") {
t.Fatalf("error must not leak the credential: %v", err)
}
}
File diff suppressed because one or more lines are too long
-1
View File
@@ -1 +0,0 @@
:root ._gap-c-space-2{gap:var(--c-space-2)}:root ._fw-f-weight-6{font-weight:var(--f-weight-6)}:root ._ls-f-letterSpa1360334200{letter-spacing:var(--f-letterSpacing-6)}:root ._fs-f-size-6{font-size:var(--f-size-6)}:root ._lh-f-lineHeigh112925{line-height:var(--f-lineHeight-6)}:root ._fw-f-weight-4{font-weight:var(--f-weight-4)}:root ._ls-f-letterSpa1360334202{letter-spacing:var(--f-letterSpacing-4)}:root ._lh-f-lineHeigh112923{line-height:var(--f-lineHeight-4)}:root ._maxW-520px{max-width:520px}:root ._text-center{text-align:center}:root ._mt-c-space-3{margin-top:var(--c-space-3)}:root ._fw-f-weight-5{font-weight:var(--f-weight-5)}:root ._ls-f-letterSpa1360334201{letter-spacing:var(--f-letterSpacing-5)}:root ._fs-f-size-5{font-size:var(--f-size-5)}:root ._lh-f-lineHeigh112924{line-height:var(--f-lineHeight-5)}:root ._col-fca5a535{color:#fca5a5}:root ._fs-f-size-2{font-size:var(--f-size-2)}:root ._height-28px{height:28px}:root ._bg-borderColor{background-color:var(--borderColor)}:root ._btlr-c-radius-2{border-top-left-radius:var(--c-radius-2)}:root ._btrr-c-radius-2{border-top-right-radius:var(--c-radius-2)}:root ._bbrr-c-radius-2{border-bottom-right-radius:var(--c-radius-2)}:root ._bblr-c-radius-2{border-bottom-left-radius:var(--c-radius-2)}:root ._o-0--5{opacity:.5}:root ._width-200px{width:200px}:root ._height-56px{height:56px}:root ._o-0--3{opacity:.3}:root ._fd-row{flex-direction:row}:root ._pt-c-space-5{padding-top:var(--c-space-5)}:root ._pr-c-space-5{padding-right:var(--c-space-5)}:root ._pb-c-space-5{padding-bottom:var(--c-space-5)}:root ._pl-c-space-5{padding-left:var(--c-space-5)}:root ._fd-column{flex-direction:column}:root ._grow-1{flex-grow:1}:root ._shrink-1{flex-shrink:1}:root ._fb-0px{flex-basis:0}:root ._items-center{align-items:center}:root ._justify-center{justify-content:center}:root ._minH-100vh{min-height:100vh}:root ._bg-background{background-color:var(--background)}:root ._pt-c-space-4{padding-top:var(--c-space-4)}:root ._pr-c-space-4{padding-right:var(--c-space-4)}:root ._pb-c-space-4{padding-bottom:var(--c-space-4)}:root ._pl-c-space-4{padding-left:var(--c-space-4)}:root ._gap-c-space-3{gap:var(--c-space-3)}:root ._maxW-400px{max-width:400px}:root ._col-red10{color:var(--red10)}:root ._fs-f-size-4{font-size:var(--f-size-4)}:root ._col-placeholder65290051{color:var(--placeholderColor)}:root ._fs-f-size-3{font-size:var(--f-size-3)}:root ._gap-c-space-4{gap:var(--c-space-4)}:root ._select-auto{-webkit-user-select:auto;user-select:auto}:root ._col-color{color:var(--color)}:root ._ws-normal{white-space:normal}:root ._ff-f-family{font-family:var(--f-family)}:root ._fw-f-weight-9{font-weight:var(--f-weight-9)}:root ._ls-f-letterSpa1360334197{letter-spacing:var(--f-letterSpacing-9)}:root ._fs-f-size-9{font-size:var(--f-size-9)}:root ._lh-f-lineHeigh112928{line-height:var(--f-lineHeight-9)}:root ._mt-0px{margin-top:0}:root ._mr-0px{margin-right:0}:root ._mb-0px{margin-bottom:0}:root ._ml-0px{margin-left:0}
File diff suppressed because one or more lines are too long
+16 -2
View File
@@ -46,7 +46,12 @@ type Spec struct {
Class string
Project string
Image string
TTLSec int
// RuntimeClass is the isolation boundary, per sandbox. It was deployment-wide
// (one env var read at startup), which made it impossible to run the same
// task on two runtimes and compare — and impossible for a caller to choose.
// Empty means the deployment's default.
RuntimeClass string
TTLSec int
}
// Cmd is one command to run inside a sandbox. Argv is the honest form; Command is
@@ -111,6 +116,15 @@ func Lease(s *Service, ctx context.Context, org string, spec Spec) (Sandbox, err
if !classes[class] {
return Sandbox{}, zip.ErrBadRequest("class must be one of exec, dev, desktop")
}
// A caller-supplied image is spent against OUR pull secret, so the namespace
// is checked before it reaches a pod spec. See image.go — unchecked, this
// field let one org's request fetch another org's private image.
if err := checkImage(org, spec.Image); err != nil {
return Sandbox{}, zip.ErrBadRequest(err.Error())
}
if err := checkRuntime(spec.RuntimeClass); err != nil {
return Sandbox{}, zip.ErrBadRequest(err.Error())
}
project := slug(spec.Project)
if class != "exec" && project == "" {
return Sandbox{}, zip.ErrBadRequest("project required for class " + class)
@@ -186,7 +200,7 @@ func Lease(s *Service, ctx context.Context, org string, spec Spec) (Sandbox, err
// A failure to start is RECORDED on the row and answered 503 — the row stays so
// an operator can see what was asked for and why it did not happen, rather than
// the request vanishing with the evidence.
if err := s.State.rt.start(ctx, m); err != nil {
if err := s.State.rt.start(ctx, m, spec.RuntimeClass); err != nil {
m.Status, m.Error = "error", err.Error()
_ = store.Put(ctx, m)
return Sandbox{}, zip.Errorf(http.StatusServiceUnavailable, "start sandbox: %v", err)
+89
View File
@@ -0,0 +1,89 @@
package sandbox
// Which images a caller may name, and which runtime may run them.
//
// BOTH of these are caller-supplied fields on create, and both were previously
// taken on trust. The image one is a cross-tenant read: the pull secret on the
// `sandbox` ServiceAccount is OURS and fleet-wide, so a caller naming
// `oci.hanzo.ai/<someone-else>/private` had our credential fetch another org's
// private image for them. Nothing in the request was forged — the field was
// simply never checked.
//
// The rule is the one the registry already uses: our registry is org-namespaced
// as `<host>/<org>/<app>`, so on OUR hosts the first path segment must BE the
// caller's org. Everywhere else needs no credential of ours, so it needs no
// permission from us either — a public image is the caller's own business, and
// the pod's securityContext (non-root uid, no service-account token, caps
// dropped) is what contains it either way.
import (
"fmt"
"strings"
)
// ours are the registry hosts whose pull credential we supply. An image on one
// of these is fetched with OUR secret, which is exactly why the namespace has to
// be checked. Hosts not listed here are pulled anonymously or not at all.
var ours = []string{"oci.hanzo.ai", "registry.hanzo.ai"}
// platformOrg owns the images WE publish (the sandbox classes themselves). Any
// caller may name them: they are the same bytes `imageFor` would have chosen,
// and refusing them would mean a caller could not pin the class image they are
// already running.
const platformOrg = "hanzoai"
// checkImage refuses a caller-supplied image that would spend our pull
// credential on somebody else's namespace. An empty image is not a request, so
// it is not an error — the caller gets the class default.
func checkImage(org, image string) error {
image = strings.TrimSpace(image)
if image == "" {
return nil
}
host, rest, ok := strings.Cut(image, "/")
if !ok {
// No slash means a Docker Hub library image (`node:22`). Public, no
// credential of ours, nothing to check.
return nil
}
if !isOurs(host) {
return nil
}
ns, _, _ := strings.Cut(rest, "/")
if ns == platformOrg || ns == slug(org) {
return nil
}
return fmt.Errorf(
"image %q is in namespace %q on our registry; an org may name only its own images there (or %q)",
image, ns, platformOrg)
}
func isOurs(host string) bool {
for _, h := range ours {
if strings.EqualFold(host, h) {
return true
}
}
return false
}
// runtimes are the isolation boundaries a caller may ask for. It is a CLOSED
// set, not free text, because runtimeClassName is passed to the apiserver and an
// unknown value is a pod that never schedules — a caller typo would become a
// sandbox stuck Pending with no explanation.
//
// The empty string is the deployment's own default (SANDBOX_RUNTIME_CLASS), and
// it is what a caller naming nothing gets.
var runtimes = map[string]bool{"gvisor": true, "kata-fc": true, "kata-clh": true}
// checkRuntime refuses a runtime we do not run. Naming one that is not installed
// on any node is the same failure with a slower clock, so this is only half the
// check — the RuntimeClass has to exist in the cluster too, and the apiserver is
// the one that knows.
func checkRuntime(rc string) error {
rc = strings.TrimSpace(rc)
if rc == "" || runtimes[rc] {
return nil
}
return fmt.Errorf("runtime %q is not one we run (gvisor, kata-fc, kata-clh)", rc)
}
+51
View File
@@ -0,0 +1,51 @@
package sandbox
// The image field is caller-supplied and is spent against OUR pull secret, so
// these cases are a tenant boundary, not input hygiene.
import "testing"
func TestCheckImageRefusesAnotherOrgsNamespaceOnOurRegistry(t *testing.T) {
for _, c := range []struct {
name, org, image string
wantErr bool
}{
{"empty is not a request", "acme", "", false},
{"a public library image needs no credential of ours", "acme", "node:22", false},
{"a public registry is the caller's own business", "acme", "ghcr.io/someone/thing:v1", false},
{"an org may name its own images on our registry", "acme", "oci.hanzo.ai/acme/tools:v1", false},
{"any org may name the platform's own sandbox images", "acme", "oci.hanzo.ai/hanzoai/sandbox:dev-1.0.0", false},
// THE HOLE. Our pull secret is fleet-wide, so without this the request
// fetches another tenant's private image and nothing in it is forged.
{"an org may NOT name another org's images on our registry", "acme", "oci.hanzo.ai/globex/private:v1", true},
{"the deprecated alias is still our registry", "acme", "registry.hanzo.ai/globex/private:v1", true},
{"host match is case-insensitive", "acme", "OCI.HANZO.AI/globex/private:v1", true},
} {
t.Run(c.name, func(t *testing.T) {
err := checkImage(c.org, c.image)
if c.wantErr && err == nil {
t.Fatalf("checkImage(%q, %q) = nil, want a refusal", c.org, c.image)
}
if !c.wantErr && err != nil {
t.Fatalf("checkImage(%q, %q) = %v, want nil", c.org, c.image, err)
}
})
}
}
// A runtime is passed to the apiserver as runtimeClassName, so an unknown value
// is a pod that never schedules. Refusing it here turns a silent Pending into a
// 400 that says which runtimes exist.
func TestCheckRuntimeIsAClosedSet(t *testing.T) {
for _, c := range []struct {
rc string
wantErr bool
}{
{"", false}, {"gvisor", false}, {"kata-fc", false}, {"kata-clh", false},
{"runsc", true}, {"gVisor", true}, {"anything", true},
} {
if err := checkRuntime(c.rc); (err != nil) != c.wantErr {
t.Fatalf("checkRuntime(%q) err=%v, wantErr=%v", c.rc, err, c.wantErr)
}
}
}
+2 -2
View File
@@ -54,7 +54,7 @@ func TestLiveSandboxRunsRealCode(t *testing.T) {
defer cancel()
t.Logf("starting %s image=%s ns=%s runtimeClass=%q", m.Pod, m.Image, r.ns, r.runtimeClass)
if err := r.start(ctx, m); err != nil {
if err := r.start(ctx, m, ""); err != nil {
t.Fatalf("start: %v", err)
}
// Always clean up: a leaked pod on a shared cluster is somebody else's
@@ -149,7 +149,7 @@ func TestLiveSandboxDoesGit(t *testing.T) {
}
ctx, cancel := context.WithTimeout(context.Background(), 4*time.Minute)
defer cancel()
if err := r.start(ctx, m); err != nil {
if err := r.start(ctx, m, ""); err != nil {
t.Fatalf("start: %v", err)
}
defer func() {
+95 -7
View File
@@ -98,9 +98,12 @@ type ExecResult struct {
// streamer is the exec channel, behind an interface for exactly one reason: the
// real one opens an SPDY stream to the apiserver, and a test has no apiserver.
// It is NOT an abstraction over "ways to run a command" — there is one way.
// It is NOT an abstraction over "ways to run a command" — there is one way, and
// its two shapes are the two things a caller can want from it: collect what a
// command produced, or hand a person a terminal.
type streamer interface {
stream(ctx context.Context, ns, pod string, argv []string, stdin io.Reader, stdout, stderr io.Writer) error
tty(ctx context.Context, ns, pod string, argv []string, stdin io.Reader, stdout io.Writer, size remotecommand.TerminalSizeQueue) error
}
type runtime struct {
@@ -232,7 +235,7 @@ func (r *runtime) pods() dynamic.ResourceInterface {
// start creates the sandbox's volume (if it has one) and its pod, and waits for
// the pod to be running. A create that returns before the sandbox can answer is
// a create that hands the caller a 502 on its very next call.
func (r *runtime) start(ctx context.Context, m Sandbox) error {
func (r *runtime) start(ctx context.Context, m Sandbox, rc string) error {
if err := r.ready(); err != nil {
return err
}
@@ -241,7 +244,7 @@ func (r *runtime) start(ctx context.Context, m Sandbox) error {
return err
}
}
if _, err := r.pods().Create(ctx, r.podSpec(m), metav1.CreateOptions{}); err != nil {
if _, err := r.pods().Create(ctx, r.podSpec(m, rc), metav1.CreateOptions{}); err != nil {
if !apierrors.IsAlreadyExists(err) {
return fmt.Errorf("create pod: %w", err)
}
@@ -283,7 +286,7 @@ func (r *runtime) ensureVolume(ctx context.Context, m Sandbox) error {
}
// podSpec is the sandbox, stated once.
func (r *runtime) podSpec(m Sandbox) *unstructured.Unstructured {
func (r *runtime) podSpec(m Sandbox, rc string) *unstructured.Unstructured {
c := map[string]any{
"name": container,
"image": m.Image,
@@ -387,15 +390,54 @@ func (r *runtime) podSpec(m Sandbox) *unstructured.Unstructured {
// names it — so stating it again here would be a second copy that goes stale
// the day the runsc node pool moves, and a wrong copy pins pods Pending
// forever with a message that blames the wrong object.
if r.runtimeClass != "" {
spec["runtimeClassName"] = r.runtimeClass
// PER SANDBOX, falling back to the deployment default. It was
// deployment-wide, which meant the same task could not be run on two
// runtimes and compared without a rollout — and a caller could not choose.
// The row does NOT record it: the pod is the source of truth for what a
// sandbox is actually running, and a second copy could only go stale.
if rc == "" {
rc = r.runtimeClass
}
if m.Volume != "" {
if rc != "" {
spec["runtimeClassName"] = rc
}
// THE WORKDIR IS MOUNTED EITHER WAY, and until now only one of the two ways
// existed. A `dev` sandbox gets its project PVC at /work; an `exec` sandbox has
// no project and got NOTHING, so /mnt/data was whatever the image shipped —
// root:root 0755 — against a pod this file pins to runAsUser 1000. The
// interpreter's own contract tells the model to "persist handoff artifacts in
// /mnt/data", and every such write failed with EACCES: the one directory the
// tool exists to fill was the one directory it could not write.
//
// It survived because nothing checks. A run that prints its answer looks
// perfectly successful; only a run that saves a plot notices, and it notices as
// a traceback the model apologises for rather than as an error anyone sees.
// (Measured 2026-08-06 in a live exec sandbox: `mkdir /mnt/data/.x` →
// Permission denied, `id` → uid=1000(sandbox).)
//
// emptyDir, not a PVC: a code-interpreter session is exactly as long-lived as
// its pod, which is what emptyDir already means. And it is what makes the mount
// WRITABLE — the kubelet chowns an emptyDir to the pod's fsGroup, which
// securityContext above already sets to 1000, so the fix is the mount itself
// rather than a chown in the image.
switch {
case m.Volume != "":
c["volumeMounts"] = []any{map[string]any{"name": "project", "mountPath": workdirFor(m.Class)}}
spec["volumes"] = []any{map[string]any{
"name": "project",
"persistentVolumeClaim": map[string]any{"claimName": m.Volume},
}}
default:
c["volumeMounts"] = []any{map[string]any{"name": "work", "mountPath": workdirFor(m.Class)}}
spec["volumes"] = []any{map[string]any{
"name": "work",
// Bounded like everything else the pod can fill. The container's own
// ephemeral-storage limit does NOT cover an emptyDir's usage on every
// runtime, so the volume states its own ceiling and the kubelet evicts
// the pod that exceeds it — which is the sandbox's problem to have,
// not the node's.
"emptyDir": map[string]any{"sizeLimit": envOr("SANDBOX_WORKDIR_SIZE", "2Gi")},
}}
}
return &unstructured.Unstructured{Object: map[string]any{
"apiVersion": "v1",
@@ -558,6 +600,29 @@ func (r *runtime) exec(ctx context.Context, m Sandbox, argv []string, stdin io.R
return res, err
}
// tty runs argv on a PSEUDO-TERMINAL inside the sandbox and stays for as long as
// the person on the other end does.
//
// It is a different call from exec and not a flag on it, because almost nothing
// they do is the same. exec bounds the run with a timeout, buffers both streams
// to a ceiling and reports an exit code; a terminal has no timeout that is not an
// insult to whoever is typing, keeps nothing (the bytes go straight to the
// socket), and ends when the shell does. What they share is the channel, which is
// the one thing that is stated once.
//
// STDERR IS NOT REQUESTED. A pty has one stream by construction — the kernel
// merges them onto the same device — and asking the apiserver for a second one on
// a TTY session is rejected outright.
func (r *runtime) tty(ctx context.Context, m Sandbox, argv []string, stdin io.Reader, stdout io.Writer, size remotecommand.TerminalSizeQueue) error {
if err := r.ready(); err != nil {
return err
}
if m.Status != "running" || m.Pod == "" {
return fmt.Errorf("sandbox is %s", firstNonEmpty(m.Status, "unknown"))
}
return r.str.tty(ctx, r.ns, m.Pod, argv, stdin, stdout, size)
}
func asCodeExit(err error, out *utilexec.CodeExitError) bool {
for err != nil {
if c, ok := err.(utilexec.CodeExitError); ok {
@@ -627,6 +692,29 @@ func (s *spdy) stream(ctx context.Context, ns, pod string, argv []string, stdin
})
}
func (s *spdy) tty(ctx context.Context, ns, pod string, argv []string, stdin io.Reader, stdout io.Writer, size remotecommand.TerminalSizeQueue) error {
cl, err := rest.RESTClientFor(coreConfig(s.cfg))
if err != nil {
return err
}
req := cl.Post().Resource("pods").Namespace(ns).Name(pod).SubResource("exec").
VersionedParams(&corev1.PodExecOptions{
Container: container,
Command: argv,
Stdin: true,
Stdout: true,
Stderr: false,
TTY: true,
}, scheme.ParameterCodec)
ex, err := remotecommand.NewSPDYExecutor(s.cfg, "POST", req.URL())
if err != nil {
return err
}
return ex.StreamWithContext(ctx, remotecommand.StreamOptions{
Stdin: stdin, Stdout: stdout, Tty: true, TerminalSizeQueue: size,
})
}
// coreConfig points a REST client at the core/v1 group. A copy, because the
// shared config is also the one the SPDY dialer reads.
func coreConfig(in *rest.Config) *rest.Config {
+70 -4
View File
@@ -15,6 +15,8 @@
// POST /v1/sandboxes/:id/exec {argv|command, stdin?, timeoutSec?} -> {exitCode,stdout,stderr}
// GET /v1/sandboxes/:id/fs ?path= read a file, or list a directory
// POST /v1/sandboxes/:id/fs ?path= write a file
// POST /v1/sandboxes/:id/terminal a single-use ticket for one terminal
// GET /v1/sandboxes/:id/terminal/ws ?ticket= the terminal itself
//
// THERE IS EXACTLY ONE WAY INTO A SANDBOX, and it is the Kubernetes exec
// subresource. fs read/list/write are not a second channel — they are `cat`,
@@ -58,6 +60,7 @@ import (
"github.com/hanzoai/cloud"
"github.com/hanzoai/cloud/apps/principal"
"github.com/hanzoai/cloud/openapi"
"github.com/hanzoai/cloud/plane"
"github.com/zap-proto/zip"
)
@@ -88,6 +91,10 @@ func Ours(id string) bool { return strings.HasPrefix(strings.TrimSpace(id), IDPr
type state struct {
stores *cloud.OrgStore[*Store]
rt *runtime
// tickets are the thirty-second, single-use credentials a browser presents
// to open a terminal. Per service and in memory — see terminal.go for why
// the one credential a WebSocket can carry is minted rather than borrowed.
tickets *tickets
}
// storeFor is the ONE way this package reaches a store, through
@@ -120,8 +127,9 @@ func Mount(app cloud.Router, deps cloud.Deps) error {
}
b := cloud.NewBase(deps, "sandbox")
s := &cloud.Service[state]{Base: b, State: state{
stores: cloud.NewOrgStore(b, "sandbox", openStore),
rt: newRuntime(),
stores: cloud.NewOrgStore(b, "sandbox", openStore),
rt: newRuntime(),
tickets: newTickets(),
}}
Routes(app, s)
// The peer half. Registered beside the routes because they are two adapters
@@ -163,6 +171,42 @@ func Routes(app cloud.Router, s *cloud.Service[state]) {
g.Post("/:id/exec", cloud.Handle(s, execIn))
g.Get("/:id/fs", cloud.Handle(s, fsRead))
g.Post("/:id/fs", cloud.Handle(s, fsWrite))
terminal(g, s)
// THE AGENT'S DOOR. Everything above is a RAW route, and a raw route is
// invisible to every projection zip derives from its typed registry — REST is
// the only one it reaches. So an agent asking the fleet door what it can do
// was told nothing about sandboxes, while the child answered tools/list
// happily with an empty array: absent from the tool list AND absent from the
// outage list. Silent absence, which is the shape that cost the most today.
//
// The typed ops are registered here rather than written fresh, because they
// already exist one file over — expose() puts these exact five on
// cloud.Plane() (apps/sandbox/plane.go), which is a DIFFERENT zip.App on a
// DIFFERENT socket that the door never asks. Same handlers, same types, now
// also on the server the door does ask. Nothing new is invented and there is
// no second implementation to drift.
//
// This is what stands between "@hanzo can run code" and "@hanzo can lease a
// computer": the run path was built and reachable, and no agent could name it.
if reg := cloud.ZipApp(app); reg != nil {
zip.Post[plane.LeaseIn, plane.Leased](reg, "/v1/sandboxes/lease", planeLease,
zip.WithOperationID("lease_sandbox"),
zip.WithSummary("Lease a sandbox — a real computer — or resume one you hold"))
zip.Post[plane.RunIn, plane.Ran](reg, "/v1/sandboxes/run", planeRun,
zip.WithOperationID("run_in_sandbox"),
zip.WithSummary("Run a command in a sandbox you hold and read its output"))
zip.Post[plane.PathIn, plane.Blob](reg, "/v1/sandboxes/read", planeRead,
zip.WithOperationID("read_sandbox_file"),
zip.WithSummary("Read a file from a sandbox you hold"))
zip.Post[plane.WriteIn, plane.Wrote](reg, "/v1/sandboxes/write", planeWrite,
zip.WithOperationID("write_sandbox_file"),
zip.WithSummary("Write a file into a sandbox you hold"))
zip.Post[plane.EndIn, struct{}](reg, "/v1/sandboxes/end", planeEnd,
zip.WithOperationID("end_sandbox"),
zip.WithSummary("End a sandbox and release it"))
}
}
func orgOf(c *zip.Ctx) (string, bool) { return principal.Org(c) }
@@ -183,8 +227,9 @@ func New(deps cloud.Deps) (*Service, error) {
}
b := cloud.NewBase(deps, "sandbox")
return &cloud.Service[state]{Base: b, State: state{
stores: cloud.NewOrgStore(b, "sandbox", openStore),
rt: newRuntime(),
stores: cloud.NewOrgStore(b, "sandbox", openStore),
rt: newRuntime(),
tickets: newTickets(),
}}, nil
}
@@ -438,4 +483,25 @@ func init() {
"Write a file",
"Writes the request body to one file in the sandbox's project directory, creating "+
"parent directories. Same confinement as the read above.")
openapi.Describe("/v1/sandboxes/:id/terminal", http.MethodPost,
"Open a terminal",
"Mints a SINGLE-USE ticket for one interactive terminal in this sandbox and returns "+
"`{ticket, url}`, where url is the socket's path with the ticket already on it.\n\n"+
"It exists because a browser's WebSocket carries no Authorization header, so the "+
"socket cannot be authenticated the way every other route here is. The ticket is a "+
"credential MINTED for that one socket: bound to this org and this sandbox, valid "+
"for thirty seconds, and gone the first time it is presented. A long-lived bearer in "+
"a query string would instead be written into every access log on the path.")
openapi.Describe("/v1/sandboxes/:id/terminal/ws", http.MethodGet,
"The terminal itself",
"Upgrades to a WebSocket carrying a login shell on a pseudo-terminal inside the "+
"sandbox. Requires `ticket` from the POST above; a missing, expired or already-spent "+
"ticket answers 401 without upgrading.\n\n"+
"THE WIRE. A text frame is stdin, unless it is the one control object "+
"`{\"resize\":{\"cols\":N,\"rows\":M}}`; a binary frame is always stdin. Output comes "+
"back as BINARY frames, because a pty emits arbitrary bytes cut at arbitrary offsets "+
"and a text frame carrying half a rune is one the browser closes the connection over.\n\n"+
"The shell is `bash -l`, falling back to `sh -l`. Whatever else the sandbox image "+
"carries — the hanzo CLI included — is a command to type, never a requirement to get "+
"a prompt.")
}
+524
View File
@@ -0,0 +1,524 @@
package sandbox
// terminal.go — the INTERACTIVE way into a sandbox, and the one credential a
// browser can carry through a WebSocket handshake.
//
// Every other route here is a request/response: the caller presents a bearer, the
// identity boundary mints X-User-Id, and principal.Org turns that into the org
// whose store may be read. A WebSocket cannot do that. The browser's WebSocket
// constructor takes a URL and nothing else — no Authorization header, no way to
// add one — so a socket authenticated the way exec is authenticated is a socket
// no browser can open.
//
// The usual answers to that are both wrong. Putting the bearer in the query
// string writes a long-lived credential into every access log, proxy buffer and
// browser history entry on the path. Trusting the session cookie makes the socket
// a CSRF target: no same-origin policy applies to a WebSocket, so any page the
// user visits could open one against their session.
//
// So the credential for the socket is MINTED for the socket. A ticket is a
// crypto-random token bound to ONE org and ONE sandbox, valid for thirty seconds,
// and spent the first time it is presented. Minting one requires the ordinary
// validated principal on the ordinary POST; presenting one grants exactly one
// terminal in exactly one sandbox and then no longer exists. A page that could
// somehow open the socket still has nothing to present, which is why the origin
// is not checked here — the ticket IS the check, and an origin allowlist beside
// it would be a second gate answering a question the first one already closed.
//
// THE TICKETS LIVE IN MEMORY, which is a decision with a stated bound rather than
// a shortcut. A thirty-second secret written to storage is a secret that can be
// read from storage for far longer than it is worth, so this one is never written
// anywhere. The cost is that a ticket is REPLICA-LOCAL: the socket has to reach
// the process that minted it, and cloud-api runs one replica (universe:
// charts/app/values/hanzo/cloud.yaml, replicas: 1), so today it always does.
//
// The day that number changes, this is what changes with it, and it fails LOUDLY
// — a 401 on the socket, saying the ticket is unknown — rather than quietly
// serving the wrong tenant. That is the property worth having in a gate: when its
// assumption stops holding, it refuses.
import (
"context"
"crypto/rand"
"encoding/base64"
"encoding/binary"
"encoding/json"
"errors"
"io"
"net/http"
"sync"
"time"
"github.com/hanzoai/cloud"
"github.com/zap-proto/zip"
"github.com/zap-proto/zip/wsx"
"k8s.io/client-go/tools/remotecommand"
)
// errClosed is what a write attempted after the terminal is over returns. The
// session is already ending when it appears, so it is a stop signal for the
// heartbeat and never a failure anybody reports.
var errClosed = errors.New("sandbox: terminal closed")
// ticketTTL is how long a ticket is worth anything. It is a handshake window and
// nothing more: the console mints one and dials immediately, so thirty seconds is
// slack for a slow network rather than a lifetime anybody is meant to hold.
const ticketTTL = 30 * time.Second
// login is what a terminal runs. It asks for bash and settles for sh, because the
// three sandbox images are not one image and a shell that must exist is a shell
// that will one day not — the exec class is a stock node image today. Whatever
// tools the image carries, the hanzo CLI included, are commands the user types;
// none of them is a requirement to get a prompt.
var login = []string{"/bin/sh", "-lc", "exec bash -l 2>/dev/null || exec sh -l"}
// keystrokes bounds one inbound frame. A terminal's input is keys and pastes, so
// this is generous for a paste and far below anything a socket could be used to
// push into our memory.
const keystrokes = 1 << 16
// beat is how often the server pings, and idle is how long it waits to hear
// anything back. A terminal sits untouched for long stretches on purpose — that
// is what a shell IS — so the socket is kept alive by the heartbeat rather than
// by the user, and what the idle deadline detects is a client that has gone away
// without closing, not a user who is thinking.
const (
beat = 30 * time.Second
idle = 3 * time.Minute
)
// ─────────────────────────────────────────────────────────────────────────────
// The ticket
// ─────────────────────────────────────────────────────────────────────────────
// ticket is one permission to open one terminal: which org it was minted for,
// which sandbox it opens, and when it stops being worth anything.
type ticket struct {
org string
sandbox string
expires time.Time
}
// tickets is the live set. Small by construction — a ticket lives thirty seconds
// and every mint sweeps — so this is a map and a mutex rather than a cache.
type tickets struct {
mu sync.Mutex
live map[string]ticket
}
func newTickets() *tickets { return &tickets{live: map[string]ticket{}} }
// mint issues a ticket for one org's sandbox. now is a parameter rather than a
// call to time.Now so that expiry is a fact a test can state instead of one it
// has to wait for.
func (t *tickets) mint(now time.Time, org, sandbox string) (string, error) {
var b [32]byte
if _, err := rand.Read(b[:]); err != nil {
return "", err
}
tok := base64.RawURLEncoding.EncodeToString(b[:])
t.mu.Lock()
defer t.mu.Unlock()
t.sweep(now)
t.live[tok] = ticket{org: org, sandbox: sandbox, expires: now.Add(ticketTTL)}
return tok, nil
}
// redeem spends a ticket for one sandbox and answers the org it was minted for.
//
// The token is REMOVED the moment it is presented, before anything about it is
// checked. Spending it only on success would leave a rejected ticket live for the
// rest of its window — so a caller who presented it against the wrong sandbox
// could simply try again with the right one, which is the whole property
// single-use is supposed to remove.
func (t *tickets) redeem(now time.Time, tok, sandbox string) (string, bool) {
t.mu.Lock()
defer t.mu.Unlock()
t.sweep(now)
k, ok := t.live[tok]
if !ok {
return "", false
}
delete(t.live, tok)
if k.sandbox != sandbox || !now.Before(k.expires) {
return "", false
}
return k.org, true
}
// sweep drops what has expired. Called under the lock by both operations, so an
// unspent ticket cannot accumulate: nothing here is reachable without a mint, and
// every mint clears the ones before it.
func (t *tickets) sweep(now time.Time) {
for tok, k := range t.live {
if !now.Before(k.expires) {
delete(t.live, tok)
}
}
}
// ─────────────────────────────────────────────────────────────────────────────
// The routes
// ─────────────────────────────────────────────────────────────────────────────
// open mints the ticket for one terminal. Gated exactly like its siblings — a
// validated principal, resolved to the org whose sandboxes may be addressed —
// and it resolves the sandbox before minting, so a ticket never names a sandbox
// the caller does not own or one that is not running.
func open(s *Service, c *zip.Ctx) error {
o, ok := orgOf(c)
if !ok {
return zip.ErrForbidden("X-Org-Id required")
}
id := idParam(c)
m, _, err := find(s, c.Context(), o, id)
if err != nil {
return err
}
if m.Status != "running" {
return zip.Errorf(http.StatusConflict, "sandbox is %s", firstNonEmpty(m.Status, "unknown"))
}
tok, err := s.State.tickets.mint(time.Now(), o, m.ID)
if err != nil {
return zip.Errorf(http.StatusInternalServerError, "ticket: %v", err)
}
return c.JSON(http.StatusCreated, map[string]any{
"ticket": tok,
// The PATH, not a URL. Which host this address wears in public is the
// edge's answer and not ours — behind the gateway this process only ever
// sees an internal name — so handing back an absolute URL would hand back
// a guess. The client already knows the host it is talking to.
"url": "/v1/sandboxes/" + m.ID + "/terminal/ws?ticket=" + tok,
})
}
// attach serves one terminal. The ticket is spent BEFORE the upgrade, so a
// request that presents nothing gets an ordinary 401 with a body a client can
// read, rather than a socket that opens and immediately closes for reasons the
// browser will not tell it.
func attach(s *Service, c *zip.Ctx) error {
id := idParam(c)
org, ok := s.State.tickets.redeem(time.Now(), c.Query("ticket"), id)
if !ok {
return zip.ErrUnauthorized("terminal ticket is missing, expired or already spent")
}
m, store, err := find(s, c.Context(), org, id)
if err != nil {
return err
}
if m.Status != "running" {
return zip.Errorf(http.StatusConflict, "sandbox is %s", firstNonEmpty(m.Status, "unknown"))
}
touched(c.Context(), store, m)
// The session's own context. Background, not the request's: the request is
// over the instant the connection is hijacked, and a socket bounded by it
// would be closed before the first keystroke. What bounds it instead is the
// LEASE — the same expiry the reaper enforces — so a terminal cannot outlive
// the sandbox it is attached to even if the reaper is behind.
ctx, stop := context.WithDeadline(context.Background(), leaseEnd(m))
log := s.Log
// The session runs AFTER this handler returns: the upgrade hands fasthttp a
// hijack callback and answers immediately. So the cancel is deferred inside
// the callback, where the session actually ends — deferring it here would
// cancel the terminal before its first keystroke — and called by hand on the
// one path where the callback never runs at all.
serve := wsx.Upgrade(func(conn *wsx.Conn) error {
defer stop()
if err := bridge(ctx, conn, func(ctx context.Context, in *pipe, out *frames, w *window) error {
return s.State.rt.tty(ctx, m, login, in, out, w)
}); err != nil {
log.Debug("terminal ended", "sandbox", m.ID, "err", err)
}
return nil
})
if err := serve(c); err != nil {
stop()
return err
}
return nil
}
// leaseEnd is when this terminal must be over: the sandbox's own expiry, or the
// longest lease anything here may hold when the row carries none. A row with no
// expiry is a row written before the lease was, not permission to run forever.
func leaseEnd(m Sandbox) time.Time {
if m.ExpiresAt > 0 {
return time.Unix(m.ExpiresAt, 0)
}
return time.Now().Add(maxTTL * time.Second)
}
// ─────────────────────────────────────────────────────────────────────────────
// The socket, seen as a terminal
// ─────────────────────────────────────────────────────────────────────────────
// bridge runs one terminal session over one socket, start to finish.
//
// THE WIRE, both directions, once:
//
// client → server a text frame is stdin, unless it is the one JSON object
// {"resize":{"cols":N,"rows":M}}, which is a resize. A binary
// frame is always stdin.
// server → client a BINARY frame is stdout.
//
// stdout is binary and not text for a reason that is not a preference. A text
// frame must be valid UTF-8 or the browser fails the connection, and what comes
// back from a pty is arbitrary bytes cut at arbitrary offsets — a multi-byte rune
// straddling two reads makes both frames individually invalid, so a terminal that
// sent text would die the first time anyone printed an emoji. Binary frames carry
// the bytes as they are and the decoder on the far side is the one that already
// knows how to carry a partial rune between writes.
func bridge(ctx context.Context, conn *wsx.Conn, run func(context.Context, *pipe, *frames, *window) error) error {
// The session's cancel lives HERE, with the socket, because the socket is what
// ends first. A client that closes its tab leaves a shell sitting at a prompt
// with nothing to read and nothing to print, and EOF on stdin is a hint a pty
// is free to ignore — so the end of the socket cancels the stream outright
// rather than waiting for the far end to agree.
ctx, stop := context.WithCancel(ctx)
defer stop()
in := newPipe()
out := &frames{conn: conn}
w := newWindow()
conn.SetReadLimit(keystrokes)
conn.SetPongHandler(func(string) error {
return conn.SetReadDeadline(time.Now().Add(idle))
})
done := make(chan struct{})
defer close(done)
go heartbeat(out, done)
// The read pump owns everything the far side can affect: stdin's bytes and the
// window's size. It is the ONE writer to each, so neither needs a lock of its
// own — and when it returns, the far side is gone and the session goes with it.
go func() {
defer stop()
defer w.close()
defer in.eof()
for {
if err := conn.SetReadDeadline(time.Now().Add(idle)); err != nil {
return
}
typ, msg, err := conn.ReadMessage()
if err != nil {
return
}
if typ != wsx.TextMessage && typ != wsx.BinaryMessage {
continue
}
if cols, rows, ok := resize(typ, msg); ok {
w.to(cols, rows)
continue
}
if _, err := in.Write(msg); err != nil {
return
}
}
}()
err := run(ctx, in, out, w)
// The far side is told the session is over rather than left holding a socket
// that has simply stopped answering. shut is a barrier as well as a notice:
// once it returns no goroutine is inside a write, which matters because
// fasthttp hands this connection's write buffer back to a shared pool the
// moment this handler returns.
out.shut(err)
in.drop()
return err
}
// heartbeat keeps an idle terminal open. A shell that nobody has typed into for
// an hour is a working shell, so the socket has to stay up without traffic — and
// the ping is also how a client that vanished without closing is noticed, since
// the read deadline is only ever extended by a pong.
func heartbeat(out *frames, done <-chan struct{}) {
t := time.NewTicker(beat)
defer t.Stop()
for {
select {
case <-done:
return
case <-t.C:
if err := out.ping(); err != nil {
return
}
}
}
}
// resize reads the one control frame. It is recognised only in a TEXT frame that
// is a JSON object carrying a plausible window — a binary frame is stdin whatever
// it contains, and a text frame that is not this exact shape is stdin too.
func resize(typ int, msg []byte) (uint16, uint16, bool) {
if typ != wsx.TextMessage || len(msg) == 0 || msg[0] != '{' {
return 0, 0, false
}
var ctl struct {
Resize *struct {
Cols uint16 `json:"cols"`
Rows uint16 `json:"rows"`
} `json:"resize"`
}
if json.Unmarshal(msg, &ctl) != nil || ctl.Resize == nil {
return 0, 0, false
}
if ctl.Resize.Cols == 0 || ctl.Resize.Rows == 0 {
return 0, 0, false
}
return ctl.Resize.Cols, ctl.Resize.Rows, true
}
// pipe is stdin: what the read pump writes and the exec stream reads.
//
// Its two ends are retired by two different events and so they are two methods.
// Both are idempotent and safe from any goroutine — io.Pipe closes each end
// through a sync.Once of its own — which is what lets the pump and the session
// end in either order without either checking on the other.
type pipe struct {
r *io.PipeReader
w *io.PipeWriter
}
func newPipe() *pipe {
r, w := io.Pipe()
return &pipe{r: r, w: w}
}
func (p *pipe) Read(b []byte) (int, error) { return p.r.Read(b) }
func (p *pipe) Write(b []byte) (int, error) { return p.w.Write(b) }
// eof ends stdin. The reader sees io.EOF, which is what tells a shell its input
// is over — a pty whose stdin merely stops producing bytes waits forever.
func (p *pipe) eof() { _ = p.w.Close() }
// drop retires the READER, and it is not the same act. It unblocks a read pump
// that is mid-write when the shell exits: an io.Pipe write waits for a reader,
// and after the stream is gone no reader is ever coming — so without this the
// pump's goroutine waits for the life of the process holding one keystroke.
func (p *pipe) drop() { _ = p.r.Close() }
// frames is stdout as WebSocket frames, serialized. fasthttp/websocket permits
// exactly one writer at a time and there are three here — the shell's output, the
// heartbeat and the closing notice — so they go through one lock or they corrupt
// each other's frames.
type frames struct {
mu sync.Mutex
conn *wsx.Conn
done bool
}
func (f *frames) Write(b []byte) (int, error) {
f.mu.Lock()
defer f.mu.Unlock()
if f.done {
return 0, errClosed
}
if err := f.conn.SetWriteDeadline(time.Now().Add(beat)); err != nil {
return 0, err
}
if err := f.conn.WriteMessage(wsx.BinaryMessage, b); err != nil {
return 0, err
}
return len(b), nil
}
func (f *frames) ping() error {
f.mu.Lock()
defer f.mu.Unlock()
if f.done {
return errClosed
}
return f.conn.WriteControl(wsx.PingMessage, nil, time.Now().Add(beat))
}
// shut says why the terminal ended and then bars every later write. Taking the
// same lock every write takes is what makes it a barrier rather than a flag: a
// write already inside finishes first, and none can start after.
func (f *frames) shut(cause error) {
f.mu.Lock()
defer f.mu.Unlock()
if f.done {
return
}
f.done = true
reason := "terminal closed"
if cause != nil {
reason = cause.Error()
}
_ = f.conn.WriteControl(wsx.CloseMessage, closing(reason), time.Now().Add(beat))
_ = f.conn.Close()
}
// closing builds the close payload: the status code big-endian, then the reason.
// Written here because wsx re-exports the message TYPES and not the helper, and
// reaching past it to the socket library for two bytes would give this package a
// direct dependency on the transport the framework exists to own.
//
// A control frame carries at most 125 bytes and two of them are the code, so the
// reason is cut at 123 — over that the frame is not merely long, it is invalid.
func closing(reason string) []byte {
if len(reason) > 123 {
reason = reason[:123]
}
b := make([]byte, 2, 2+len(reason))
binary.BigEndian.PutUint16(b, 1000) // normal closure
return append(b, reason...)
}
// window is the terminal's size, in the shape the exec stream reads one: Next
// blocks until the size CHANGES and answers nil once the session is over.
//
// Only the LATEST size is kept. A resize that arrives while the previous one is
// still unread replaces it, because the intermediate widths of a window somebody
// is dragging are sizes nobody needs to see and a queue of them is a queue the
// shell would redraw its way through.
type window struct {
mu sync.Mutex
size remotecommand.TerminalSize
wake chan struct{}
over chan struct{}
once sync.Once
}
func newWindow() *window {
return &window{wake: make(chan struct{}, 1), over: make(chan struct{})}
}
func (w *window) to(cols, rows uint16) {
w.mu.Lock()
w.size = remotecommand.TerminalSize{Width: cols, Height: rows}
w.mu.Unlock()
select {
case w.wake <- struct{}{}:
default: // one is already pending and it will read the size just written
}
}
// close retires the window, which is what lets the goroutine the exec stream
// runs Next in return instead of blocking on a socket nobody is reading.
func (w *window) close() { w.once.Do(func() { close(w.over) }) }
func (w *window) Next() *remotecommand.TerminalSize {
select {
case <-w.over:
return nil
case <-w.wake:
w.mu.Lock()
defer w.mu.Unlock()
size := w.size
return &size
}
}
// terminal registers the interactive pair on the group that already owns the
// member routes. One function and not two lines in Routes, so that what a
// terminal needs — a ticket door and a socket door, never one without the other
// — cannot be half registered.
func terminal(g zip.Router, s *Service) {
g.Post("/:id/terminal", cloud.Handle(s, open))
g.Get("/:id/terminal/ws", cloud.Handle(s, attach))
}
+263
View File
@@ -0,0 +1,263 @@
package sandbox
// The ticket is the ONLY thing standing between a WebSocket URL and a shell
// inside somebody's sandbox, so every property it claims is measured here rather
// than argued for in a comment. None of it needs a cluster: a ticket is decided
// before a pod is ever addressed, which is exactly why it can be tested at all.
import (
"strings"
"sync"
"testing"
"time"
"k8s.io/client-go/tools/remotecommand"
)
func TestTicketIsSpentExactlyOnce(t *testing.T) {
ks := newTickets()
now := time.Now()
tok, err := ks.mint(now, "acme", "m_1")
if err != nil {
t.Fatalf("mint: %v", err)
}
org, ok := ks.redeem(now, tok, "m_1")
if !ok {
t.Fatal("a fresh ticket was refused")
}
if org != "acme" {
t.Errorf("redeem gave org %q, want the org it was minted for", org)
}
if _, ok := ks.redeem(now, tok, "m_1"); ok {
t.Fatal("a ticket was accepted TWICE — single-use is the whole point: a " +
"credential in a URL ends up in logs and history, and one that still works " +
"when it is read there is a shell somebody else can open")
}
}
func TestTicketExpires(t *testing.T) {
ks := newTickets()
now := time.Now()
tok, err := ks.mint(now, "acme", "m_1")
if err != nil {
t.Fatalf("mint: %v", err)
}
// One instant before the window closes it still works…
if _, ok := ks.redeem(now.Add(ticketTTL-time.Millisecond), tok, "m_1"); !ok {
t.Fatal("a ticket inside its window was refused")
}
tok, _ = ks.mint(now, "acme", "m_1")
// …and at the boundary it does not. The edge is checked, not a comfortable
// distance past it, because an off-by-one in a credential's lifetime is the
// kind of bug that only shows up as a flake.
if _, ok := ks.redeem(now.Add(ticketTTL), tok, "m_1"); ok {
t.Fatal("an expired ticket was accepted")
}
}
func TestTicketOpensOnlyTheSandboxItNames(t *testing.T) {
ks := newTickets()
now := time.Now()
tok, err := ks.mint(now, "acme", "m_1")
if err != nil {
t.Fatalf("mint: %v", err)
}
if _, ok := ks.redeem(now, tok, "m_2"); ok {
t.Fatal("a ticket minted for one sandbox opened another — the binding is what " +
"stops a caller with a legitimate ticket from walking the id space")
}
// And the failed attempt SPENT it. A ticket that survives being presented
// against the wrong sandbox can simply be retried against the right one,
// which is single-use in name only.
if _, ok := ks.redeem(now, tok, "m_1"); ok {
t.Fatal("a ticket survived being presented — it must be spent on presentation, " +
"not on success")
}
}
func TestUnknownTicketIsRefused(t *testing.T) {
ks := newTickets()
now := time.Now()
for _, tok := range []string{"", "not-a-ticket", strings.Repeat("A", 43)} {
if _, ok := ks.redeem(now, tok, "m_1"); ok {
t.Errorf("redeem accepted %q, which was never minted", tok)
}
}
}
func TestTicketsAreUnguessableAndDistinct(t *testing.T) {
ks := newTickets()
now := time.Now()
seen := map[string]bool{}
for i := 0; i < 256; i++ {
tok, err := ks.mint(now, "acme", "m_1")
if err != nil {
t.Fatalf("mint: %v", err)
}
if seen[tok] {
t.Fatal("two mints produced the same ticket")
}
seen[tok] = true
// 32 random bytes, unpadded base64url: anything shorter is a token
// somebody could get through by trying.
if len(tok) != 43 {
t.Fatalf("ticket is %d characters, want the 43 that 32 random bytes make", len(tok))
}
}
}
func TestExpiredTicketsDoNotAccumulate(t *testing.T) {
ks := newTickets()
now := time.Now()
for i := 0; i < 100; i++ {
if _, err := ks.mint(now, "acme", "m_1"); err != nil {
t.Fatalf("mint: %v", err)
}
}
// One mint past the window, and the hundred that expired are gone. Nothing
// else sweeps: a ticket nobody redeems is a ticket only a later mint can
// clear, so if this leaks it leaks for the life of the process.
if _, err := ks.mint(now.Add(2*ticketTTL), "acme", "m_1"); err != nil {
t.Fatalf("mint: %v", err)
}
ks.mu.Lock()
n := len(ks.live)
ks.mu.Unlock()
if n != 1 {
t.Errorf("%d tickets held after the window passed, want 1", n)
}
}
func TestConcurrentRedeemHasExactlyOneWinner(t *testing.T) {
ks := newTickets()
now := time.Now()
tok, err := ks.mint(now, "acme", "m_1")
if err != nil {
t.Fatalf("mint: %v", err)
}
var wg sync.WaitGroup
won := make(chan struct{}, 8)
for i := 0; i < 8; i++ {
wg.Add(1)
go func() {
defer wg.Done()
if _, ok := ks.redeem(now, tok, "m_1"); ok {
won <- struct{}{}
}
}()
}
wg.Wait()
close(won)
if n := len(won); n != 1 {
t.Fatalf("%d of 8 racing redemptions succeeded, want exactly 1", n)
}
}
// The control frame is the other half of the wire, and getting it wrong is not a
// cosmetic failure: a resize misread as input types garbage at the prompt, and
// input misread as a resize silently drops what somebody typed.
func TestResizeIsToldFromInput(t *testing.T) {
control := []struct {
frame string
cols, rows uint16
}{
{`{"resize":{"cols":80,"rows":24}}`, 80, 24},
{` {"resize":{"cols":200,"rows":50}}`, 0, 0}, // leading space: not the frame
{`{"resize":{"rows":24,"cols":80}}`, 80, 24},
}
for _, tc := range control {
cols, rows, ok := resize(0x1 /* text */, []byte(tc.frame))
if tc.cols == 0 {
if ok {
t.Errorf("%q was read as a resize", tc.frame)
}
continue
}
if !ok || cols != tc.cols || rows != tc.rows {
t.Errorf("resize(%q) = (%d,%d,%v), want (%d,%d,true)",
tc.frame, cols, rows, ok, tc.cols, tc.rows)
}
}
typed := []string{
"ls -la\n", "", "{", "{}", `{"resize":null}`,
`{"resize":{"cols":0,"rows":24}}`, // a zero column count is not a window
`{"other":{"cols":80,"rows":24}}`,
"echo '{\"resize\":\"whatever\"}'\n",
}
for _, in := range typed {
if _, _, ok := resize(0x1, []byte(in)); ok {
t.Errorf("%q was swallowed as a resize instead of reaching the shell", in)
}
}
// A BINARY frame is stdin whatever it contains. Nothing a program pipes in
// can be mistaken for a control frame, which is the reason the two frame
// types carry two meanings rather than one type carrying both.
if _, _, ok := resize(0x2 /* binary */, []byte(`{"resize":{"cols":80,"rows":24}}`)); ok {
t.Error("a binary frame was read as a resize")
}
}
// The window is what the exec stream reads sizes from, and the two things it must
// do are report the LATEST size and stop reporting when the session ends. A Next
// that blocks forever leaks the goroutine Kubernetes runs it in.
func TestWindowReportsTheLatestSizeAndThenEnds(t *testing.T) {
w := newWindow()
w.to(80, 24)
w.to(120, 40) // a drag: the size in between is not one anyone needs to see
if got := w.Next(); got == nil || got.Width != 120 || got.Height != 40 {
t.Fatalf("Next() = %+v, want the latest size (120x40)", got)
}
ended := make(chan *remotecommand.TerminalSize, 1)
go func() { ended <- w.Next() }()
w.close()
select {
case got := <-ended:
if got != nil {
t.Fatalf("Next() answered %+v after close, want nil", got)
}
case <-time.After(2 * time.Second):
t.Fatal("Next() never returned after close — the goroutine the exec stream " +
"runs it in would be leaked for the life of the process")
}
// Closing twice is what happens when the read pump and the session end at
// once, and a second close of a channel is a panic.
w.close()
}
// The login shell must not require anything of the image beyond a shell. The
// three sandbox classes are three different images and the exec one is stock
// node today, so a command that assumed a tool would be a terminal that opens
// and immediately dies with a message nobody can read through a closed socket.
func TestLoginShellRequiresOnlySh(t *testing.T) {
if len(login) != 3 || login[0] != "/bin/sh" || login[1] != "-lc" {
t.Fatalf("login = %q, want a plain /bin/sh -lc invocation", login)
}
if !strings.Contains(login[2], "exec sh -l") {
t.Errorf("login has no fallback to sh: %q — every image has /bin/sh and not "+
"every image has bash", login[2])
}
if strings.Contains(login[2], "hanzo") {
t.Errorf("login names the hanzo CLI: %q — the CLI is a command the user types, "+
"not a precondition for getting a prompt", login[2])
}
}
// A terminal may not outlive the sandbox it is attached to. The reaper is the
// floor; this is the ceiling, and it is read off the row rather than from a
// second knob that could disagree with it.
func TestTerminalEndsWithTheLease(t *testing.T) {
at := time.Now().Add(37 * time.Minute).Truncate(time.Second)
if got := leaseEnd(Sandbox{ExpiresAt: at.Unix()}); !got.Equal(at) {
t.Errorf("leaseEnd = %v, want the row's own expiry %v", got, at)
}
// A row with no expiry is a row written before the lease was, not permission
// to hold a socket open forever.
got := leaseEnd(Sandbox{})
if bound := time.Now().Add(maxTTL * time.Second); got.After(bound.Add(time.Minute)) {
t.Errorf("leaseEnd = %v for an expiry-less row, want no later than %v", got, bound)
}
}
-9
View File
@@ -6,18 +6,9 @@ app=@hanzo/tracker (apps/tracker)
base=/tracker/
api_prefix=/v1/tracker
auth=AuthGate (@hanzogui/admin) + @hanzo/iam@0.9.4 PKCE
<<<<<<< HEAD
idp=https://hanzo.id/v1/iam # from the brand registry; VITE_IAM_BRAND, not a URL
callback=/tracker/callback
chrome=OrgSwitcher (left) + UserMenu (right); no nav rail, no env/version/clock/theme
org_scope=X-Org-Id, resolved against the token's signed `orgs` claim; cloud re-mints it
source=hanzoai/admin:apps/tracker@577bb4c (main)
built=turbo typecheck 11/11 + turbo test 6/6 (609) + playwright 5/5 + vite build
=======
idp=brand registry: hostname -> hanzo.id | lux.id | zoo.id (VITE_IAM_BRAND pins)
callback=/tracker/callback
chrome=OrgSwitcher (left) + UserMenu (right); no nav rail, no env/version/clock/theme
org_scope=X-Org-Id, resolved against the token's signed `orgs` claim; cloud re-mints it
source=hanzoai/admin:apps/tracker@8da44d7 (main)
built=turbo typecheck 11/11 + turbo test 6/6 (633) + playwright 5/5 (TLS, brand host)
>>>>>>> hanzogit/main
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
-7
View File
@@ -24,17 +24,10 @@
sans-serif;
}
</style>
<<<<<<< HEAD
<script type="module" crossorigin src="/tracker/assets/index-BmEe8eT8.js"></script>
<link rel="modulepreload" crossorigin href="/tracker/assets/rolldown-runtime-CNC7AqOf.js">
<link rel="modulepreload" crossorigin href="/tracker/assets/ui-CxRmfSHn.js">
<link rel="modulepreload" crossorigin href="/tracker/assets/vendor-Cxl82NwH.js">
=======
<script type="module" crossorigin src="/tracker/assets/index-sSPGgtFa.js"></script>
<link rel="modulepreload" crossorigin href="/tracker/assets/rolldown-runtime-CNC7AqOf.js">
<link rel="modulepreload" crossorigin href="/tracker/assets/ui-DUPdl3yE.js">
<link rel="modulepreload" crossorigin href="/tracker/assets/vendor-BgRt8uZI.js">
>>>>>>> hanzogit/main
<link rel="stylesheet" crossorigin href="/tracker/assets/ui-DWvNfFBG.css">
<link rel="stylesheet" crossorigin href="/tracker/assets/index-k2IirYww.css">
</head>
+14 -6
View File
@@ -81,10 +81,18 @@ print("every listed tool carries prose and a schema")
# ONE TOOL PER SUBSYSTEM, the operation in an argument (fleet/grouped.go). The
# flat surface was 1,189 tools in 977 KB and clients keep 128, so the count is a
# correctness property here and not a nicety.
stray = [t["name"] for t in tools if not t["name"].startswith("hanzo_")]
#
# What makes a tool a subsystem is that it CARRIES an `op` enum, not what it is
# called. The names used to be `hanzo_<app>` and this read the prefix; a prefix is
# a convention and the enum is the thing itself, so it asks about the thing.
def enum_of(t):
return t["inputSchema"].get("properties", {}).get("op", {}).get("enum", [])
stray = [t["name"] for t in tools if t["name"] != "describe" and not enum_of(t)]
if stray:
print("FAIL: the door published a flat operation:", ", ".join(stray[:10])); sys.exit(1)
ops = [op for t in tools for op in t["inputSchema"].get("properties", {}).get("op", {}).get("enum", [])]
if not any(t["name"] == "describe" for t in tools):
print("FAIL: the door published no `describe`; the enums carry names only and cannot be read without it"); sys.exit(1)
ops = [op for t in tools for op in enum_of(t)]
print("%d tools carrying %d operations (a client keeps 128)" % (len(tools), len(ops)))
if len(tools) >= 128:
print("FAIL: %d tools is back over the cap" % len(tools)); sys.exit(1)
@@ -95,7 +103,7 @@ PY
echo
echo "== one operation, with the doc comment its handler carries =="
# A READ by default: the point is to show the owner answering, not to mutate.
# hanzo_describe is how a schema is fetched now — the enums carry names only.
# `describe` is how a schema is fetched now — the enums carry names only.
TOOL="${TOOL:-$(python3 -c '
import json,sys
tools=json.load(open(sys.argv[1]))["result"]["tools"]
@@ -110,17 +118,17 @@ for t in json.load(open(sys.argv[1]))["result"]["tools"]:
else:
sys.exit("FAIL: %s is in no subsystem enum" % sys.argv[2])' "$CLOUD_DATA_DIR/list.json" "$TOOL")"
echo "$TOOL is served through $GROUP"
rpc "{\"jsonrpc\":\"2.0\",\"id\":9,\"method\":\"tools/call\",\"params\":{\"name\":\"hanzo_describe\",\"arguments\":{\"op\":\"$TOOL\"}}}" \
rpc "{\"jsonrpc\":\"2.0\",\"id\":9,\"method\":\"tools/call\",\"params\":{\"name\":\"describe\",\"arguments\":{\"op\":\"$TOOL\"}}}" \
> "$CLOUD_DATA_DIR/describe.json"
python3 - "$CLOUD_DATA_DIR/describe.json" "$TOOL" <<'PY'
import json, sys
res = json.load(open(sys.argv[1])).get("result") or {}
text = "".join(c.get("text", "") for c in res.get("content", []))
if not text:
print("FAIL: hanzo_describe returned nothing for %s: %s" % (sys.argv[2], json.dumps(res)[:400])); sys.exit(1)
print("FAIL: describe returned nothing for %s: %s" % (sys.argv[2], json.dumps(res)[:400])); sys.exit(1)
d = json.loads(text)
if d.get("name") != sys.argv[2] or d.get("inputSchema") is None:
print("FAIL: hanzo_describe answered %s" % text[:400]); sys.exit(1)
print("FAIL: describe answered %s" % text[:400]); sys.exit(1)
print(json.dumps(d, indent=2)[:1600])
PY
+88
View File
@@ -0,0 +1,88 @@
// Copyright © 2026 Hanzo AI. MIT License.
package fleet
import (
"encoding/json"
"os"
"path/filepath"
"sort"
"testing"
)
// THE FLEET'S OWN OPERATIONS, for any test that needs to be about this fleet.
//
// The corpus is plugin/*/openapi.json — each subsystem's own spec, written by
// its own binary, which is where its operation ids and its documentation come
// from in the first place. Names someone invented for a test would measure a
// fleet that does not exist, and a naming rule tuned against invented names is a
// rule tuned against nothing.
//
// It is exported so the internal tests (the gate, the naming) and the wire tests
// (package fleet_test) read ONE loader. Go gives a package and its external test
// package no other way to share a helper, and two loaders over one directory is
// the kind of second source this whole package exists to delete.
// Op is one operation of the corpus: the subsystem that declares it, the id it
// declares, and what that subsystem wrote about it.
//
// Doc is the OpenAPI `description`, not the `summary`, because that is what a
// child's MCP descriptor actually carries — zip's mcpToolOf prefers the doc
// comment the generator lifted and falls back to the summary (zip@v1.27.0
// mcp.go). A fixture built on summaries would measure prose the fleet does not
// send.
type Op struct {
App string
ID string
Doc string
}
// Corpus reads every operation this fleet declares, ordered by subsystem and
// then by id — the order gather would see before it sorts by [rank], so a test
// that prints it prints something stable.
func Corpus(t *testing.T) []Op {
t.Helper()
specs, err := filepath.Glob(filepath.Join("..", "plugin", "*", "openapi.json"))
if err != nil || len(specs) == 0 {
t.Fatalf("no plugin specs at ../plugin/*/openapi.json (%v): the corpus is this fleet's own ops, not invented ones", err)
}
var out []Op
for _, path := range specs {
raw, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read %s: %v", path, err)
}
var doc struct {
Paths map[string]map[string]struct {
OperationID string `json:"operationId"`
Summary string `json:"summary"`
Description string `json:"description"`
} `json:"paths"`
}
if err := json.Unmarshal(raw, &doc); err != nil {
t.Fatalf("%s: %v", path, err)
}
app := filepath.Base(filepath.Dir(path))
seen := map[string]bool{}
for _, methods := range doc.Paths {
for _, op := range methods {
if op.OperationID == "" || seen[op.OperationID] {
continue
}
seen[op.OperationID] = true
d := op.Description
if d == "" {
d = op.Summary
}
out = append(out, Op{App: app, ID: op.OperationID, Doc: d})
}
}
}
sort.Slice(out, func(i, j int) bool {
if out[i].App != out[j].App {
return out[i].App < out[j].App
}
return out[i].ID < out[j].ID
})
return out
}
+13 -7
View File
@@ -85,14 +85,17 @@ func TestTheDoorDoesNotProjectACredentialOpItsChildServes(t *testing.T) {
for _, n := range order(t, h) {
got[n] = true
}
// The enum carries the name the door PUBLISHES, so both halves are asked in
// that spelling — and the dangerous half is asked in both, because a refused
// operation must not reappear under a friendlier name.
for _, n := range dangerous {
if got[n] {
if got[n] || got[fleet.Phrase(n)] {
t.Errorf("the door PROJECTED %q — an agent can mint or read a credential with it", n)
}
}
for _, n := range useful {
if !got[n] {
t.Errorf("the door dropped %q — the gate ate a product tool", n)
if !got[fleet.Phrase(n)] {
t.Errorf("the door dropped %q (offered as %q) — the gate ate a product tool", n, fleet.Phrase(n))
}
}
}
@@ -154,16 +157,19 @@ func TestTheProductSurfaceLeadsTheList(t *testing.T) {
if len(got) == 0 {
t.Fatal("the door listed nothing")
}
if got[0] != "post_v1_chat_completions" {
if got[0] != fleet.Phrase("post_v1_chat_completions") {
t.Errorf("the first tool is %q; chat leads the product surface", got[0])
}
at := func(name string) int {
// Ranking reads the ROUTE and the enum carries the phrase, so a lookup names
// the operation the way the fixture declared it and finds it the way the door
// published it. That the two agree for every entry is the point.
at := func(id string) int {
for i, n := range got {
if n == name {
if n == fleet.Phrase(id) {
return i
}
}
t.Fatalf("%q is missing from %v", name, got)
t.Fatalf("%q (offered as %q) is missing from %v", id, fleet.Phrase(id), got)
return -1
}
for _, product := range []string{"post_v1_chat_completions", "get_v1_models", "post_v1_code_ask"} {
+116 -28
View File
@@ -4,6 +4,7 @@ package fleet
import (
"encoding/json"
"slices"
"sort"
"strconv"
"strings"
@@ -25,7 +26,7 @@ import (
// an OPERATION, and there are two orders of magnitude between them. So the door
// projects one tool per SUBSYSTEM and carries the operation in an argument:
//
// hanzo_git {"op":"post_v1_git_repos","input":{…}}
// git {"op":"post_v1_git_repos","input":{…}}
//
// The `op` enum carries NAMES ONLY. That is the whole saving — the 977 KB is
// almost entirely input schemas, and a schema is only needed for the ONE
@@ -38,7 +39,7 @@ import (
//
// - THE GATE. [Door.gather] refuses a name before it writes the routing table,
// and that is still the only gate. A refused name never reaches [group], so
// it is in no enum; never reaches [Door.ownerOf], so [Door.call] cannot
// it is in no enum; never reaches [Door.lookup], so [Door.call] cannot
// dispatch it through an envelope any more than it could directly; and
// [Door.describe] answers out of the same gathered set, so it cannot be read
// either. One rule, one place, three paths through it.
@@ -50,17 +51,33 @@ import (
// package exists to delete — and it could go stale in the one way that
// matters, by describing an operation the rule has since refused.
// groupPrefix namespaces the tools this door composes ITSELF, as opposed to the
// ones its children declare. A child's operation id is either `<method>_<path>`
// or a declared PascalCase verb, so nothing a subsystem serves lands in here.
const groupPrefix = "hanzo_"
// A TOOL IS NAMED FOR WHAT IT IS, and nothing else.
//
// These tools were `hanzo_<app>` for a few hours. The server is Hanzo — the MCP
// server IS the namespace, and a client reaches these names through it and
// through nothing else — so the prefix said, once per tool per turn, a thing
// every one of its neighbours also said. It was not disambiguating anything:
// there is no second `git` in here to tell it apart from. So it is gone, and the
// door's tools are the app names themselves.
//
// The prefix was also carrying a second job, and that is the part worth stating
// rather than rediscovering: [Door.composed] used it to tell one of THIS door's
// tools from an operation a child declared. A convention doing load-bearing work
// is a convention that will be broken by someone who thinks it is cosmetic — so
// that test is now a membership check against the door's own app set, which is
// exact where a prefix was only probable.
// Describe is the door's own tool: the input schema of ONE operation, by name.
//
// It is the fetch half of the surface — the enums say what exists, this says
// what an operation takes — and it is exported because the fleet's own agent
// runs are clients of this door like any other (apps/agents/door.go).
const Describe = groupPrefix + "describe"
//
// It shares a namespace with the app names, so no subsystem may be called
// `describe` — asserted against the manifest in fleet/grouped_test.go, which is
// where a fact about the app list can be checked before it ships rather than
// discovered as a shadowed tool at runtime.
const Describe = "describe"
// group projects the surviving operations as one tool per OWNING app, behind
// [Describe].
@@ -83,13 +100,13 @@ const Describe = groupPrefix + "describe"
// serving nothing, because "one way to ask" does not depend on how much there
// is to ask about.
func group(all []named) []map[string]any {
ops := map[string][]string{}
ops := map[string][]named{}
best := map[string]int{}
for _, t := range all {
if r := rank(t.name); len(ops[t.app]) == 0 || r < best[t.app] {
best[t.app] = r
}
ops[t.app] = append(ops[t.app], t.name)
ops[t.app] = append(ops[t.app], t)
}
apps := make([]string, 0, len(ops))
for a := range ops {
@@ -111,15 +128,57 @@ func group(all []named) []map[string]any {
}
// subsystemTool is one app's whole operation set as a single MCP tool.
func subsystemTool(app string, ops []string) map[string]any {
//
// The enum carries PUBLISHED names — `deploy_project`, not
// `post_v1_projects_by_slug_deploy` (fleet/verbs.go) — and beside the product
// ones it carries a line of their own documentation, which is the half that
// removes a round trip. A name says what an operation is called and a model can
// still be wrong about what it does; `create_project_fork — Creates a project
// seeded from a PUBLISHED EXAMPLE.` leaves nothing to guess and nothing to fetch.
//
// PROSE IS RATIONED, and [productStems] is the ration, because it is already the
// answer to "which of these does an agent actually reach for". Measured over the
// fleet's own ~2,230 offered operations — fleet/verbs_internal_test.go prints
// these to the byte, and reprints them as the fleet grows:
//
// routes, as they shipped 63 KB
// verb phrases 50 KB a phrase is SHORTER than a route
// + a summary on the ~140 ranked 64 KB under a KB more than the routes ← shipped
// + a summary on ALL of them 255 KB four times over
//
// So the whole change is close to free: the naming pays for the prose. Giving
// every operation a sentence would not — the point of grouping was 977 KB down
// to 71, and four times the enum puts most of it back. The product surface reads
// without asking, the console tail is named well enough to recognise, and
// [Describe] is one call away for the rest. One curated list doing the one job of
// saying what matters, rather than a second list to keep in step with the first.
func subsystemTool(app string, ops []named) map[string]any {
names := make([]string, len(ops))
var doc strings.Builder
for i, t := range ops {
names[i] = t.as
if rank(t.name) == len(productStems) {
continue
}
if s := summary(t.desc); s != "" {
if doc.Len() > 0 {
doc.WriteByte('\n')
}
doc.WriteString(t.as + " — " + s)
}
}
op := map[string]any{"type": "string", "enum": names}
if doc.Len() > 0 {
op["description"] = doc.String()
}
return map[string]any{
"name": groupPrefix + app,
"name": app,
"description": app + ": " + strconv.Itoa(len(ops)) + " operations. Name one in \"op\" and pass " +
"that operation's own arguments in \"input\". " + Describe + " returns an operation's input schema.",
"inputSchema": map[string]any{
"type": "object",
"properties": map[string]any{
"op": map[string]any{"type": "string", "enum": ops},
"op": op,
"input": map[string]any{"type": "object", "description": "arguments for the chosen op"},
},
"required": []string{"op"},
@@ -140,9 +199,20 @@ func describeTool() map[string]any {
}
}
// composed reports whether a tools/call names one of THIS door's own tools
// rather than an operation a child declared. See [groupPrefix].
func composed(tool string) bool { return strings.HasPrefix(tool, groupPrefix) }
// composed reports whether a tools/call names one of THIS door's own tools
// a SUBSYSTEM, carrying its operation in an argument — rather than an operation
// a child declared.
//
// The door's tools ARE its apps, so the question is membership in the set it was
// mounted over. Nothing a subsystem serves can be mistaken for one: a child's
// operation id is either `<method>_<path>` or a declared PascalCase verb, and an
// app name is a bare lowercase word, which is neither.
//
// It reads d.apps rather than the last gather because a call must be classified
// BEFORE anything is asked — and because the composed set is what the deployment
// runs, which does not change between requests, while what an app is serving at
// this instant does.
func (d *Door) composed(tool string) bool { return slices.Contains(d.apps, tool) }
// envelope is what a subsystem tool carries: the operation to run, and that
// operation's own arguments.
@@ -151,32 +221,47 @@ type envelope struct {
Input json.RawMessage `json:"input"`
}
// unwrap reads the envelope back into what a DIRECT tools/call for the same
// operation is: its name, and the canonical message that runs it. ok is false
// unwrap reads the envelope back into the two things a DIRECT tools/call for the
// same operation carries: the operation named, and its own arguments. ok is false
// when the model named a subsystem without naming an operation in it.
//
// The operation's arguments are carried UNPARSED. They belong to the subsystem
// that declared the schema, which is the only thing that knows how to read them,
// and re-encoding them here would be this process having an opinion about a
// shape it does not own.
func unwrap(id, args json.RawMessage) (op string, msg []byte, ok bool) {
// The operation's arguments come back UNPARSED. They belong to the subsystem that
// declared the schema, which is the only thing that knows how to read them, and
// re-encoding them here would be this process having an opinion about a shape it
// does not own.
func unwrap(args json.RawMessage) (op string, input json.RawMessage, ok bool) {
var e envelope
if err := json.Unmarshal(args, &e); err != nil || e.Op == "" {
return "", nil, false
}
if len(e.Input) == 0 {
e.Input = json.RawMessage("{}")
return e.Op, e.Input, true
}
// callBody is the tools/call a child is asked, written out.
//
// It exists once, and only for the calls that could not be forwarded verbatim —
// an envelope to open, or a published name to read back to an id. Both arrive as
// (name, arguments) by then, which is the whole of a tools/call, so there is one
// spelling of the request no matter which decoding produced it.
//
// Arguments that will not re-encode become `{}` rather than an error: the bytes
// came out of a document this door already parsed, so the only way here is a
// caller who sent something the child was going to reject anyway, and the child
// is the thing that owns that judgement.
func callBody(id json.RawMessage, op string, input json.RawMessage) []byte {
if len(input) == 0 {
input = json.RawMessage("{}")
}
msg, err := json.Marshal(map[string]any{
"jsonrpc": "2.0",
"id": idOrNull(id),
"method": "tools/call",
"params": map[string]any{"name": e.Op, "arguments": e.Input},
"params": map[string]any{"name": op, "arguments": input},
})
if err != nil {
return "", nil, false
return callBody(id, op, nil)
}
return e.Op, msg, true
return msg
}
// describe answers the one question a surface of names leaves open: what does
@@ -194,7 +279,10 @@ func (d *Door) describe(c *zip.Ctx, req message, args json.RawMessage) error {
tools, _, _ := d.gather(c)
for _, t := range tools {
if t.name == in.Op {
// Either spelling: the name the enum published, or the id the owner knows.
// The gathered set carries both, so this needs no table and cannot answer
// out of a different one than list() and call() read.
if t.as == in.Op || t.name == in.Op {
return c.JSON(200, rpcResult(req.ID, map[string]any{
"content": []map[string]any{{"type": "text", "text": string(t.raw)}},
}))
+186 -65
View File
@@ -23,7 +23,6 @@ package fleet_test
import (
"context"
"encoding/json"
"os"
"path/filepath"
"sort"
"strconv"
@@ -31,6 +30,7 @@ import (
"testing"
"github.com/hanzoai/cloud/fleet"
"github.com/hanzoai/cloud/manifest"
"github.com/zap-proto/zip"
)
@@ -50,50 +50,27 @@ const slackKeeps = 128
// the fleet's own operation corpus
// ---------------------------------------------------------------------------
// corpus is every operation id this fleet declares, by the subsystem that
// declares it, read from the plugins' own OpenAPI documents.
func corpus(t *testing.T) map[string][]string {
// corpus is every operation this fleet declares, by the subsystem that declares
// it. The reading lives in fleet/corpus_test.go, because the naming tests are
// inside the package and read the same one — a corpus with two loaders is the
// second source this package exists to delete.
func corpus(t *testing.T) map[string][]fleet.Op {
t.Helper()
specs, err := filepath.Glob(filepath.Join("..", "plugin", "*", "openapi.json"))
if err != nil || len(specs) == 0 {
t.Fatalf("no plugin specs at ../plugin/*/openapi.json (%v): the corpus is this fleet's own ops, not invented ones", err)
}
out := map[string][]string{}
for _, path := range specs {
raw, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read %s: %v", path, err)
}
var doc struct {
Paths map[string]map[string]struct {
OperationID string `json:"operationId"`
} `json:"paths"`
}
if err := json.Unmarshal(raw, &doc); err != nil {
t.Fatalf("%s: %v", path, err)
}
app := filepath.Base(filepath.Dir(path))
seen := map[string]bool{}
for _, methods := range doc.Paths {
for _, op := range methods {
if op.OperationID == "" || seen[op.OperationID] {
continue
}
seen[op.OperationID] = true
out[app] = append(out[app], op.OperationID)
}
}
sort.Strings(out[app])
out := map[string][]fleet.Op{}
for _, op := range fleet.Corpus(t) {
out[op.App] = append(out[op.App], op)
}
return out
}
// serving brings up one child per app, each declaring the operation ids given,
// and returns a door over all of them.
// serving brings up one child per app, each declaring the operations given, and
// returns a door over all of them.
//
// The routes are this test's, the ids are the fleet's: zip derives a tool name
// from the route only when nobody declared one, and every op here declares.
func serving(t *testing.T, by map[string][]string) *zip.App {
// The routes are this test's, the ids and the DOCUMENTATION are the fleet's: zip
// derives a tool name from the route only when nobody declared one, and every op
// here declares. Carrying the real doc comment is what makes the byte
// measurement below a measurement — an enum's prose is a projection of it.
func serving(t *testing.T, by map[string][]fleet.Op) *zip.App {
t.Helper()
dir := t.TempDir()
kids := map[string]*child{}
@@ -108,14 +85,14 @@ func serving(t *testing.T, by map[string][]string) *zip.App {
return host(t, apps, kids)
}
func serve(t *testing.T, dir, name string, ops []string) *child {
func serve(t *testing.T, dir, name string, ops []fleet.Op) *child {
t.Helper()
sock := filepath.Join(dir, name+".sock")
a := zip.New(zip.Config{AppName: name, DisableStartupMessage: true})
for i, id := range ops {
for i, op := range ops {
zip.Post(a, "/v1/"+name+"/op"+strconv.Itoa(i), func(_ context.Context, in *thingIn) (*thingOut, error) {
return &thingOut{App: name, Which: in.Which}, nil
}, zip.WithOperationID(id), zip.WithSummary("what "+name+" does at "+id))
}, zip.WithOperationID(op.ID), zip.WithSummary(op.Doc))
}
go func() { _ = a.Listen(sock) }()
t.Cleanup(func() { _ = a.Shutdown() })
@@ -123,14 +100,28 @@ func serve(t *testing.T, dir, name string, ops []string) *child {
return &child{name: name, addr: sock, app: a}
}
// declaring is a fixture built from ids alone, for the tests whose subject is the
// shape of the surface rather than what the fleet documents.
func declaring(t *testing.T, by map[string][]string) *zip.App {
t.Helper()
ops := map[string][]fleet.Op{}
for app, list := range by {
ops[app] = make([]fleet.Op, 0, len(list))
for _, id := range list {
ops[app] = append(ops[app], fleet.Op{App: app, ID: id, Doc: "what " + app + " does at " + id})
}
}
return serving(t, ops)
}
// ---------------------------------------------------------------------------
// what the door publishes
// ---------------------------------------------------------------------------
// TestTheDoorPublishesOneToolPerSubsystem is the shape of the answer: a tool per
// app that has something to offer, plus hanzo_describe. Nothing else.
// app that has something to offer, plus describe. Nothing else.
func TestTheDoorPublishesOneToolPerSubsystem(t *testing.T) {
h := serving(t, map[string][]string{
h := declaring(t, map[string][]string{
"ai": {"post_v1_chat_completions", "get_v1_models"},
"git": {"post_v1_git_repos", "get_v1_git_repos"},
"iam": {"CreateUser", "DeleteUser"}, // every op refused: no tool at all
@@ -139,7 +130,7 @@ func TestTheDoorPublishesOneToolPerSubsystem(t *testing.T) {
res := rpc(t, h, `{"jsonrpc":"2.0","id":1,"method":"tools/list"}`)
got := names(res)
want := []string{fleet.Describe, "hanzo_ai", "hanzo_git"}
want := []string{fleet.Describe, "ai", "git"}
if strings.Join(got, ",") != strings.Join(want, ",") {
t.Fatalf("the door publishes %v, want %v", got, want)
}
@@ -149,9 +140,11 @@ func TestTheDoorPublishesOneToolPerSubsystem(t *testing.T) {
t.Errorf("the first tool is %q; the tool a truncated surface cannot do without leads", got[0])
}
// Within an enum the order is gather's: rank first (chat before models),
// then name inside a bucket (both git ops share one).
// then name inside a bucket (both git ops share one) — and rank still reads
// the ROUTE, so ordering is unchanged by naming. What the enum CARRIES is the
// verb phrase: `create_chat_completion`, not `post_v1_chat_completions`.
if ops := offered(res); strings.Join(ops, ",") !=
"post_v1_chat_completions,get_v1_models,get_v1_git_repos,post_v1_git_repos" {
"create_chat_completion,list_models,list_git_repos,create_git_repo" {
t.Errorf("the enums carry %v; within a subsystem the product surface still leads", ops)
}
// Names only. The 977 KB was the schemas, so an enum that carried them would
@@ -163,6 +156,23 @@ func TestTheDoorPublishesOneToolPerSubsystem(t *testing.T) {
}
}
// TestNoSubsystemIsCalledDescribe is the one thing dropping the `hanzo_` prefix
// put at risk, checked where it is decidable: the door's tools are the app names
// plus [fleet.Describe], so an app called `describe` would publish a SECOND tool
// under that name and the door would answer it as its own — the subsystem
// silently unreachable, with nothing in either file to say why.
//
// It reads the manifest, which is the fleet's source of truth for app names, so
// the collision is caught when the row is added rather than when a model calls it.
func TestNoSubsystemIsCalledDescribe(t *testing.T) {
for _, a := range manifest.Apps {
if a.Name == fleet.Describe {
t.Fatalf("manifest declares an app named %q, which is also the door's own tool; "+
"rename the app or rename the tool — they cannot share one name", a.Name)
}
}
}
// TestTheWholeFleetFitsInAModelsHead is the measurement, over this fleet's own
// operation corpus, through the real door, on the wire.
func TestTheWholeFleetFitsInAModelsHead(t *testing.T) {
@@ -181,13 +191,15 @@ func TestTheWholeFleetFitsInAModelsHead(t *testing.T) {
tools, ops := names(res), offered(res)
// Every published tool is ONE subsystem of the corpus, and every name in its
// enum is an operation THAT subsystem declared. Nothing leaks between apps,
// and nothing is invented.
// enum is an operation THAT subsystem declared — under the name the door
// publishes for it, or under its own id where naming it would have been
// ambiguous. Nothing leaks between apps, and nothing is invented.
owns := map[string]map[string]bool{}
for app, ids := range by {
for app, ops := range by {
owns[app] = map[string]bool{}
for _, id := range ids {
owns[app][id] = true
for _, op := range ops {
owns[app][op.ID] = true
owns[app][fleet.Phrase(op.ID)] = true
}
}
subsystems := 0
@@ -198,7 +210,7 @@ func TestTheWholeFleetFitsInAModelsHead(t *testing.T) {
continue
}
subsystems++
app := strings.TrimPrefix(name, "hanzo_")
app := name
if owns[app] == nil {
t.Fatalf("the door published %q and no such subsystem is in the corpus", name)
}
@@ -238,6 +250,11 @@ func TestTheWholeFleetFitsInAModelsHead(t *testing.T) {
}
t.Logf("MEASURED — the fleet's own corpus (plugin/*/openapi.json), one child per subsystem:")
// The names themselves, because they are the surface a model reads and a bare
// list is the only way to SEE that they carry no prefix. The loop above already
// fails if one does — `app := name` is the whole mapping now — but a reader of
// this output should not have to take that on faith.
t.Logf(" the head of the surface %s …", strings.Join(tools[:min(12, len(tools))], " "))
t.Logf(" operations declared %5d across %d subsystems", declared, len(by))
t.Logf(" operations offered %5d (%d withheld by refuse())", len(ops), declared-len(ops))
t.Logf(" BEFORE flat tools/list %5d tools %8d bytes %6.0f B/op [api.hanzo.ai, 2026-08-06]",
@@ -264,12 +281,12 @@ func TestASubsystemToolDispatchesExactlyAsTheFlatCallDid(t *testing.T) {
flat := rpc(t, h, `{"jsonrpc":"2.0","id":7,"method":"tools/call",`+
`"params":{"name":"beta_opb","arguments":{"which":"x"}}}`)
grouped := rpc(t, h, `{"jsonrpc":"2.0","id":7,"method":"tools/call",`+
`"params":{"name":"hanzo_beta","arguments":{"op":"beta_opb","input":{"which":"x"}}}}`)
`"params":{"name":"beta","arguments":{"op":"beta_opb","input":{"which":"x"}}}}`)
want, _ := json.Marshal(flat)
got, _ := json.Marshal(grouped)
if string(got) != string(want) {
t.Fatalf("hanzo_beta{op:beta_opb} answered\n %s\nand the direct call answered\n %s", got, want)
t.Fatalf("beta{op:beta_opb} answered\n %s\nand the direct call answered\n %s", got, want)
}
if text := textOf(t, grouped); !strings.Contains(text, `"app":"beta"`) || !strings.Contains(text, `"which":"x"`) {
t.Fatalf("neither call reached beta's own handler with its arguments: %q", text)
@@ -292,11 +309,91 @@ func textOf(t *testing.T, res map[string]any) string {
return text
}
// routed brings up a child whose operation ids zip DERIVES from its routes,
// which is where `post_v1_projects_by_slug_deploy` comes from in the first place
// — every fixture above declares its ids, and a declared id is never renamed, so
// nothing above exercises this at all.
func routed(t *testing.T, name string, routes ...string) *child {
t.Helper()
sock := filepath.Join(t.TempDir(), name+".sock")
a := zip.New(zip.Config{AppName: name, DisableStartupMessage: true})
for _, r := range routes {
zip.Post(a, r, func(_ context.Context, in *thingIn) (*thingOut, error) {
return &thingOut{App: name, Which: in.Which}, nil
}, zip.WithSummary("what "+name+" does at "+r))
}
go func() { _ = a.Listen(sock) }()
t.Cleanup(func() { _ = a.Shutdown() })
waitFor(t, sock)
return &child{name: name, addr: sock, app: a}
}
// TestACallByThePUBLISHEDNameReachesTheSameHandler is the claim the renaming
// lives or dies on, and it is asked of a real child over a real socket rather
// than of the naming function.
//
// The door publishes `deploy_project`. A model reads that in the enum and sends
// it back, and what has to happen is that the child's own
// `post_v1_projects_by_slug_deploy` handler runs, with the model's arguments,
// and answers what it would have answered anyway. So the two spellings are
// compared to EACH OTHER — a change that broke either path by breaking both
// would still fail here, because the third assertion is that the handler was
// actually reached and got its argument.
func TestACallByThePUBLISHEDNameReachesTheSameHandler(t *testing.T) {
kid := routed(t, "projects", "/v1/projects/:slug/deploy", "/v1/projects")
h := host(t, []string{"projects"}, map[string]*child{"projects": kid})
// The child really derives those ids — otherwise this proves nothing.
served := map[string]bool{}
for _, tl := range kid.app.MCPTools() {
served[tl["name"].(string)] = true
}
if !served["post_v1_projects_by_slug_deploy"] {
t.Fatalf("fixture is wrong: the child derived %v, not the route id this renames", served)
}
// 1. The enum reads as a verb on an object, and the route is nowhere in it.
if got := strings.Join(offered(rpc(t, h, toolsListBody)), ","); got != "create_project,deploy_project" {
t.Fatalf("the enum carries %q, want the verb phrases", got)
}
// 2. A call by the published name and a call by the operation's own id reach
// one handler and come back byte for byte identical.
as := rpc(t, h, `{"jsonrpc":"2.0","id":9,"method":"tools/call","params":{"name":"projects",`+
`"arguments":{"op":"deploy_project","input":{"which":"ship-it"}}}}`)
id := rpc(t, h, `{"jsonrpc":"2.0","id":9,"method":"tools/call","params":{"name":"projects",`+
`"arguments":{"op":"post_v1_projects_by_slug_deploy","input":{"which":"ship-it"}}}}`)
byName, _ := json.Marshal(as)
byID, _ := json.Marshal(id)
if string(byName) != string(byID) {
t.Fatalf("deploy_project answered\n %s\nand post_v1_projects_by_slug_deploy answered\n %s", byName, byID)
}
// 3. …and that one handler is the child's, with the model's own argument in
// it. Two identical -32602s would satisfy (2) and nothing else.
if isErr, _ := as["isError"].(bool); isErr {
t.Fatalf("deploy_project reported an error: %v", as)
}
if text := textOf(t, as); !strings.Contains(text, `"app":"projects"`) || !strings.Contains(text, `"which":"ship-it"`) {
t.Fatalf("deploy_project did not reach projects' own handler with its arguments: %q", text)
}
// 4. describe answers to the published name too, with the OWNER's own
// descriptor — which carries the child's name, which is why that spelling
// has to keep working.
desc := rpc(t, h, `{"jsonrpc":"2.0","id":10,"method":"tools/call","params":{"name":"`+fleet.Describe+
`","arguments":{"op":"deploy_project"}}}`)
if text := textOf(t, desc); !strings.Contains(text, `"name":"post_v1_projects_by_slug_deploy"`) ||
!strings.Contains(text, `"which"`) {
t.Fatalf("describe deploy_project returned %q", text)
}
}
// TestASubsystemToolWithNoOpSaysWhatItNeeds: a model that named the subsystem
// and forgot the operation gets told, and nothing is dispatched.
func TestASubsystemToolWithNoOpSaysWhatItNeeds(t *testing.T) {
h := host(t, []string{"alpha"}, map[string]*child{"alpha": start(t, "alpha", 1)})
res := rpc(t, h, `{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"hanzo_alpha","arguments":{}}}`)
res := rpc(t, h, `{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"alpha","arguments":{}}}`)
e, ok := res["error"].(map[string]any)
if !ok {
t.Fatalf("an envelope with no op must be refused, got %v", res)
@@ -314,9 +411,9 @@ func TestASubsystemToolWithNoOpSaysWhatItNeeds(t *testing.T) {
func TestAnUnservedOpInAnEnvelopeIsRefusedNotForwarded(t *testing.T) {
h := host(t, []string{"alpha"}, map[string]*child{"alpha": start(t, "alpha", 1)})
res := rpc(t, h, `{"jsonrpc":"2.0","id":3,"method":"tools/call",`+
`"params":{"name":"hanzo_alpha","arguments":{"op":"ghost_op","input":{}}}}`)
`"params":{"name":"alpha","arguments":{"op":"ghost_op","input":{}}}}`)
if _, refused := res["error"].(map[string]any); !refused {
t.Fatalf("hanzo_alpha forwarded an operation nobody serves: %v", res)
t.Fatalf("alpha forwarded an operation nobody serves: %v", res)
}
}
@@ -387,8 +484,8 @@ func TestDescribeOfANameNobodyServesIsRefused(t *testing.T) {
// door's refusal at every path that now exists:
//
// tools/list the name is in no subsystem's `op` enum
// tools/call hanzo_console{op:CreateServiceAccountKey} does not run it
// hanzo_describe its schema cannot be read either
// tools/call console{op:CreateServiceAccountKey} does not run it
// describe its schema cannot be read either
//
// All three are the same gate: [fleet.Door.gather] refuses before it writes the
// routing table, and list, call and describe all read that one gathered set.
@@ -418,16 +515,16 @@ func TestARefusedOpIsInvisibleUncallableAndUndescribable(t *testing.T) {
t.Errorf("the name survives somewhere in tools/list: %s", raw)
}
// …and the surviving siblings are still offered, so this is a gate and not a broken door.
if got := strings.Join(offered(res), ","); got != "post_v1_chat_completions,GetUser" {
if got := strings.Join(offered(res), ","); got != "create_chat_completion,GetUser" {
t.Errorf("the gate ate a surviving op: enum is %q", got)
}
// 2. not callable through the subsystem tool.
call := rpc(t, h, `{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"hanzo_console",`+
call := rpc(t, h, `{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"console",`+
`"arguments":{"op":"CreateServiceAccountKey","input":{"which":"mint"}}}}`)
e, refused := call["error"].(map[string]any)
if !refused {
t.Fatalf("hanzo_console DISPATCHED CreateServiceAccountKey: %v", call)
t.Fatalf("console DISPATCHED CreateServiceAccountKey: %v", call)
}
if code, _ := e["code"].(float64); int(code) != -32602 {
t.Errorf("code = %v, want -32602", e["code"])
@@ -455,14 +552,38 @@ func TestARefusedOpIsInvisibleUncallableAndUndescribable(t *testing.T) {
// And the sibling still runs through the same envelope, so none of the above
// passes because the door is broken.
ok := rpc(t, h, `{"jsonrpc":"2.0","id":4,"method":"tools/call","params":{"name":"hanzo_console",`+
ok := rpc(t, h, `{"jsonrpc":"2.0","id":4,"method":"tools/call","params":{"name":"console",`+
`"arguments":{"op":"post_v1_chat_completions","input":{"which":"hello"}}}}`)
content, _ := ok["content"].([]any)
if len(content) == 0 {
t.Fatalf("the product op did not run through hanzo_console: %v", ok)
t.Fatalf("the product op did not run through console: %v", ok)
}
first, _ := content[0].(map[string]any)
if text, _ := first["text"].(string); !strings.Contains(text, `"which":"hello"`) {
t.Fatalf("the grouped call lost its arguments: %q", text)
}
}
// TestAColdDoorDispatchesAPublishedNameOnTheFirstCall is the path a real client
// takes and the fixtures above do not: a process that has answered no
// tools/list has an empty routing table AND an empty published-name table, and
// it must fill BOTH before it decides the name is nobody's.
//
// A client caches tools/list across reconnects; the door remembers nothing
// between requests. So the very first thing a restarted door sees can be a
// tools/call naming an operation it has never gathered, spelled the way it
// published it an hour ago.
func TestAColdDoorDispatchesAPublishedNameOnTheFirstCall(t *testing.T) {
kid := routed(t, "projects", "/v1/projects/:slug/deploy")
h := host(t, []string{"projects"}, map[string]*child{"projects": kid})
// No tools/list first. This is the door's first request.
res := rpc(t, h, `{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"projects",`+
`"arguments":{"op":"deploy_project","input":{"which":"cold"}}}}`)
if e, refused := res["error"].(map[string]any); refused {
t.Fatalf("a cold door refused its own published name: %v", e)
}
if text := textOf(t, res); !strings.Contains(text, `"which":"cold"`) {
t.Fatalf("the cold call did not reach projects' handler: %q", text)
}
}
+86 -21
View File
@@ -24,8 +24,9 @@ import (
// exactly one handler at this address and not two chained by the router).
//
// Every method below is answered from the children. Nothing is remembered
// between requests except which app listed a tool name, and that is a routing
// table, not a catalogue: see [Door.owner].
// between requests except how to ROUTE a name — which app listed it, and which
// operation the door published it as. Both are written by one gather and neither
// is a catalogue: see [Door.owner].
// protocolVersion is the MCP spec revision this door speaks. It is zip's
// (mcpProtocolVersion) — the children answer initialize with the same string,
@@ -64,8 +65,15 @@ type Door struct {
// registry decides whether the tool exists, and a name it no longer serves
// yields that child's own -32602 rather than a mis-dispatch. A name nobody
// listed is discovered by asking, not by guessing.
//
// alias is the same kind of fact and rides with it: the name the door
// PUBLISHED for an operation → the id its owner knows it by. It holds only the
// operations whose published name differs, it is rewritten by the same gather,
// and it is read at exactly two places — [Door.lookup] and [Door.describe]. See
// fleet/verbs.go for why the name it undoes is not the name the gate judged.
mu sync.RWMutex
owner map[string]string
alias map[string]string
}
// Mount serves the fleet's agent door at path, over apps, reaching one with at.
@@ -208,24 +216,25 @@ func (d *Door) call(c *zip.Ctx, req message) error {
return d.describe(c, req, p.Arguments)
}
// A subsystem tool is an ENVELOPE over one operation. Unwrapping it yields
// exactly the name and message a direct call carries, so everything below is
// ONE dispatch for both spellings: the same routing table, the same gate on
// the way into it, the same hop, the same reply.
// Two DECODINGS stand between what a client sends and what a child is asked,
// and neither is a second route. A subsystem tool is an ENVELOPE over one
// operation; a published name is that operation's id spelled as a verb. Undo
// both and what is left is the name and message a direct tools/call carries,
// so everything below is ONE dispatch for every spelling: the same routing
// table, the same gate on the way into it, the same hop, the same reply.
//
// The caller's own body is forwarded BYTE FOR BYTE when neither decoding fired
// — which is every call that already names an operation as its owner does.
msg := c.Fiber().Request().Body()
if composed(p.Name) {
op, body, ok := unwrap(req.ID, p.Arguments)
if d.composed(p.Name) {
op, input, ok := unwrap(p.Arguments)
if !ok {
return c.JSON(200, rpcErr(req.ID, -32602, p.Name+` needs {"op":"<operation>","input":{}}`))
}
p.Name, msg = op, body
p.Name, p.Arguments, msg = op, input, nil
}
app := d.ownerOf(p.Name)
if app == "" {
d.gather(c)
app = d.ownerOf(p.Name)
}
op, app := d.find(c, p.Name)
if app == "" {
// Either nobody serves it, or refuse() withheld it — and the caller gets
// the same answer for both. Telling a client which of the two it hit would
@@ -233,6 +242,11 @@ func (d *Door) call(c *zip.Ctx, req message) error {
// to expose.
return c.JSON(200, rpcErr(req.ID, -32602, "unknown tool: "+p.Name))
}
// A published name is not the name its owner answers to, so a call that
// carried one is spelled out again as the call it decodes to.
if msg == nil || op != p.Name {
msg = callBody(req.ID, op, p.Arguments)
}
// The caller's own REQUEST — its headers, so identity propagates — carrying
// msg, which for a direct call is the caller's own body byte for byte and for
// an envelope is that same call spelled out. The child's registry invokes it,
@@ -256,12 +270,18 @@ func (d *Door) call(c *zip.Ctx, req message) error {
return c.Bytes(200, ans.Body)
}
// named is one tool with its name and its OWNER lifted out, so the composed list
// sorts and groups without re-parsing and each descriptor is carried VERBATIM
// the bytes the child's own registry projected, never a re-encoding.
// named is one tool with its name, its OWNER and its one-line documentation
// lifted out, so the composed list sorts, groups and reads without re-parsing
// and each descriptor is still carried VERBATIM, the bytes the child's own
// registry projected, never a re-encoding.
type named struct {
app string
name string
// as is the name the door PUBLISHES for this operation: its id read back as
// a verb on an object (fleet/verbs.go), or the id itself when that reading
// would be ambiguous. Written by [offer], never by the child.
as string
desc string
raw json.RawMessage
}
@@ -337,17 +357,58 @@ func (d *Door) gather(c *zip.Ctx) ([]named, []Outage, int) {
return all[i].name < all[j].name
})
// AFTER the gate and after the sort, because both read the CHILD's own name:
// [refuse] must judge the route it was given, and [rank] matches route stems.
// Naming is the last thing that happens to a surviving operation. See
// fleet/verbs.go.
offer(all)
alias := make(map[string]string, len(all))
for _, t := range all {
if t.as != t.name {
alias[t.as] = t.name
}
}
d.mu.Lock()
d.owner = owner
d.owner, d.alias = owner, alias
d.mu.Unlock()
return all, down, held
}
func (d *Door) ownerOf(tool string) string {
// find reads whatever a tools/call named into the operation id its owner knows,
// and the app that owns it — asking the fleet ONCE if the tables are cold.
//
// Both halves have to be answered by one lookup, and that is the whole reason
// this is a function. The tables are written together by [Door.gather] and a
// process remembers nothing between requests, so a door that has just started —
// or has just answered a tools/call for a client that cached tools/list across a
// reconnect — holds neither. Resolving the name first and discovering second
// would resolve against an empty table, then look up an unresolved name in a
// full one, and answer "unknown tool" for an operation it publishes.
//
// The resolution is a DECODING and not a second route, in exactly the sense the
// envelope is: what comes out is the name a direct tools/call carries, and
// everything after it — the owner map, the gate that wrote it, the hop — is what
// it always was. A name nobody published is returned unchanged, so an
// operation's own id still arrives at its own handler; it must, because describe
// hands back the child's descriptor bytes verbatim and those carry the child's
// own name.
func (d *Door) find(c *zip.Ctx, name string) (op, app string) {
if op, app = d.lookup(name); app != "" {
return op, app
}
d.gather(c)
return d.lookup(name)
}
func (d *Door) lookup(name string) (op, app string) {
d.mu.RLock()
defer d.mu.RUnlock()
return d.owner[tool]
if id, published := d.alias[name]; published {
name = id
}
return name, d.owner[name]
}
// toolsOf lifts the descriptors out of one child's tools/list reply.
@@ -374,11 +435,15 @@ func toolsOf(body []byte) ([]named, error) {
for _, raw := range env.Result.Tools {
var hdr struct {
Name string `json:"name"`
// The op's own doc comment, as zip's generator lifted it into the
// descriptor. The flat list carried it and the grouped list threw it
// away; [summary] keeps the first sentence so an enum reads.
Description string `json:"description"`
}
if err := json.Unmarshal(raw, &hdr); err != nil || hdr.Name == "" {
return nil, errNamelessTool
}
out = append(out, named{name: hdr.Name, raw: raw})
out = append(out, named{name: hdr.Name, desc: hdr.Description, raw: raw})
}
return out, nil
}
+2 -2
View File
@@ -143,7 +143,7 @@ func rpc(t *testing.T, h *zip.App, body string) map[string]any {
// tool's `op` enum (fleet/grouped.go), so the operations are read out of the
// enums rather than off the tool names. That is the same question these tests
// always asked — "what can be called through this door" — put to the surface
// that now answers it. hanzo_describe has no enum and contributes nothing.
// that now answers it. describe has no enum and contributes nothing.
func offered(res map[string]any) []string {
var out []string
for _, tl := range published(res) {
@@ -160,7 +160,7 @@ func offered(res map[string]any) []string {
return out
}
// published is the TOOLS the door publishes — the hanzo_<app> envelopes
// published is the TOOLS the door publishes — the per-subsystem envelopes
// themselves, not the operations inside them.
func published(res map[string]any) []any {
tools, _ := res["tools"].([]any)
+27 -5
View File
@@ -26,7 +26,7 @@ package fleet_test
//
// It asserts the OPERATIONS, not the tool count. The door projects one tool per
// subsystem and carries the operations in that tool's `op` enum (fleet/grouped.go),
// so `hanzo_websearch` existing is not the claim — `post_v1_websearch` being
// so a `websearch` tool existing is not the claim — `post_v1_websearch` being
// inside it is.
import (
@@ -38,6 +38,7 @@ import (
"github.com/hanzoai/cloud/apps/crawl"
"github.com/hanzoai/cloud/apps/exec"
"github.com/hanzoai/cloud/apps/websearch"
"github.com/hanzoai/cloud/fleet"
)
// reach is one capability the agent needs, and the operation that is its door.
@@ -80,12 +81,33 @@ func TestTheAgentCanReachTheWeb(t *testing.T) {
offering := offered(res)
sort.Strings(offering)
for _, w := range want {
if !contains(offering, w.op) {
t.Errorf("%s does NOT project — so the assistant still cannot %s.\n"+
" the door offers: %s", w.op, w.why, strings.Join(offering, " "))
// The enum carries the name the door PUBLISHES for an operation, so that
// is what a model reads and that is what is asked for here. `create_crawl`
// is what `post_v1_crawl` is called; fleet/verbs.go is why.
as := fleet.Phrase(w.op)
if !contains(offering, as) {
t.Errorf("%s (offered as %s) does NOT project — so the assistant still cannot %s.\n"+
" the door offers: %s", w.op, as, w.why, strings.Join(offering, " "))
continue
}
t.Logf("%-20s projects, so the assistant can %s", w.op, w.why)
// …and the name resolves back to the operation the subsystem actually
// serves. describe answers out of the gathered set, so this is the door
// mapping a published name onto a REAL child's own descriptor — not a
// string this test computed twice.
desc := rpc(t, h, `{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"`+
fleet.Describe+`","arguments":{"op":"`+as+`"}}}`)
content, _ := desc["content"].([]any)
if len(content) == 0 {
t.Errorf("%s describes to nothing: %v", as, desc)
continue
}
first, _ := content[0].(map[string]any)
text, _ := first["text"].(string)
if !strings.Contains(text, `"name":"`+w.op+`"`) {
t.Errorf("%s describes to something that is not %s: %s", as, w.op, text)
continue
}
t.Logf("%-16s → %-24s projects and resolves, so the assistant can %s", w.op, as, w.why)
}
// The tools themselves, for the record: one per subsystem, the operations
+305
View File
@@ -0,0 +1,305 @@
// Copyright © 2026 Hanzo AI. MIT License.
package fleet
import "strings"
// AN OPERATION IS NAMED FOR WHAT IT DOES. It was named for where it lived.
//
// Measured on the deployed door: the `projects` tool offered 37 operations and
// the enum read
//
// delete_v1_projects_by_slug
// delete_v1_projects_by_slug_domains_by_host
// get_v1_projects
// get_v1_projects_by_slug
// get_v1_projects_by_slug_deployments
//
// with no prose beside any of them. Those are ROUTES. zip derives an operation
// id from the route when nobody declared one — lower(method) plus the path with
// '/'→'_' and a parameter rendered `by_<name>` (zip@v1.27.0 openapi.go, ID) —
// so the model's first job was to reverse-engineer a URL back into an intention,
// its second was to call [Describe] to find out what the intention took, and only
// its third was to act. Three round trips to use a menu written in URL, and the
// assistant reads as stupid for the whole of the first two.
//
// The route is not wrong, it is just not the ANSWER to "what can you do". A
// derived id already contains everything a verb phrase needs; it is spelled in
// the wrong order and padded with scaffolding that names nothing. So [phrase]
// reads it back:
//
// get_v1_projects_by_slug_deployments list_project_deployments
// post_v1_projects_by_slug_domains_by_host_verify verify_project_domain
// post_v1_projects_by_slug_deploy deploy_project
// delete_v1_projects_by_slug delete_project
//
// # Where this happens, and why THERE
//
// After the gate, in the projection, and never before either.
//
// [refuse] reads a tool NAME to decide whether the fleet will project it at all
// (fleet/surface.go), and it is the only gate there is. If a rename ran first, an
// operation whose route says `post_v1_iam_users` would be offered up as something
// whose words no longer trip clause 2 — a credential-minting operation projected
// because it was renamed politely. So [Door.gather] refuses the CHILD's own name,
// exactly as it always did, and [offer] runs over what survives. The refused set
// is therefore identical by construction and not by luck: the gate's input never
// changed. fleet/verbs_test.go asserts that over the fleet's whole corpus.
//
// [rank] reads the same child name for the same reason — it matches route stems
// (fleet/surface.go, productStems), so it must see a route.
//
// # The presented name is a DECODING, exactly like the envelope
//
// A tools/call arrives carrying whatever the door published, so the mapping back
// must be exact. [offer] guarantees it by REFUSING to rename rather than by
// guessing: a phrase that two operations would share, or that collides with some
// operation's own id, is not used and both keep their ids. The door then holds
// published-name → id beside its tool → app routing (see [Door.alias]), which is
// the same kind of fact with the same lifetime — written by every gather, read by
// call and describe, remembered no longer than the routing table it rides with.
//
// An operation's own id keeps working, and that is not a compatibility shim: it
// is forced. [Door.describe] hands back the OWNING subsystem's descriptor bytes
// verbatim, and those bytes carry the child's own name — so a model that reads a
// descriptor and calls what it saw must be right.
// phrase is one operation's id read back as a verb on an object.
//
// A DECLARED id is returned untouched. `GetUserPreference` is already a verb
// followed by its object, which is what this function exists to produce, and the
// subsystems that declare ids (o11y, iam) would only be damaged by a second
// opinion about their own naming.
//
// For a derived id the shape is decided by the ROUTE, not by a dictionary:
//
// .../{id}/ACTION a mutating method, a singular segment sitting after a
// parameter and last — the author already wrote the verb, so
// it leads and the rest is its object: deploy_project.
// .../{id} the path identifies one row, so the method is the verb and
// every noun is singular: get_project, delete_project_domain.
// .../things GET of a plural tail is a list and keeps it plural:
// list_project_deployments. Every other method acts on one
// member: create_project_domain.
//
// The method supplies the verb when the route does not, and PUT and PATCH are
// deliberately different words — `set` replaces, `update` merges — because they
// are different operations at one address and one word for both would collide.
func phrase(op string) string {
method, segs, endsInID, ok := route(op)
if !ok || len(segs) == 0 {
return op
}
tail := segs[len(segs)-1]
if changes(method) && tail.afterID && !endsInID && !plural(tail.word) && len(segs) > 1 {
return join(tail.word, nouns(segs[:len(segs)-1], false))
}
verb := methodVerb[method]
if method == "get" && !endsInID && plural(tail.word) {
verb = "list"
}
return join(verb, nouns(segs, verb == "list"))
}
// segment is one LITERAL segment of a route, and whether a path parameter stood
// immediately before it — which is the difference between a sub-collection and
// an action on a row, and the only thing the parameter itself is good for here.
type segment struct {
word string
afterID bool
}
// route splits a derived operation id back into the parts of the route it was
// made from. ok is false for a declared id, which is not a route at all.
//
// A parameter contributes NO word: `by_slug` says a row is addressed, not which
// one, and the name of the addressing column is the caller's business at call
// time and nobody's here. The version is dropped for the same reason — it names
// nothing, and every operation in the fleet carries the same one.
//
// zip renders a parameter as `by_<name>` after reducing the name to [a-z0-9.-],
// so a parameter whose name contains an underscore (`{file_id}` → `by_file_id`)
// spends one token on `by` and TWO on the name, and this reads the second as a
// literal segment. Three of the fleet's 2,060 derived operations are shaped that
// way; they get a clumsier phrase, not a wrong one, and if the phrase they get
// is ambiguous [offer] keeps their id instead. Guessing where a parameter's name
// stops would trade a cosmetic defect for an inexact mapping.
func route(op string) (method string, segs []segment, endsInID, ok bool) {
head, rest, found := strings.Cut(op, "_")
method = strings.ToLower(head)
if !found || !httpMethod[method] {
return "", nil, false, false
}
after := false
toks := strings.Split(rest, "_")
for i := 0; i < len(toks); i++ {
if toks[i] == "by" && i+1 < len(toks) {
i++ // the parameter's own name
after, endsInID = true, true
continue
}
if len(segs) == 0 && isVersion(toks[i]) {
continue
}
segs = append(segs, segment{word: toks[i], afterID: after})
after, endsInID = false, false
}
return method, segs, endsInID, true
}
// nouns is the object of the phrase: every literal segment, singular, except
// that a list keeps its last noun plural because a list is what it returns.
func nouns(segs []segment, listing bool) []string {
out := make([]string, len(segs))
for i, s := range segs {
if listing && i == len(segs)-1 {
out[i] = s.word
continue
}
out[i] = singular(s.word)
}
return out
}
// join puts a verb and its object together as one operation name, normalising
// the separators a path segment may legally carry ('-', '.') onto the one this
// name already uses.
func join(verb string, obj []string) string {
s := verb
if len(obj) > 0 {
s += "_" + strings.Join(obj, "_")
}
return strings.NewReplacer("-", "_", ".", "_").Replace(s)
}
// methodVerb is what an HTTP method MEANS, as the leading word of a phrase.
var methodVerb = map[string]string{
"get": "get", "head": "check", "post": "create",
"put": "set", "patch": "update", "delete": "delete", "options": "options",
}
// changes reports whether a method mutates, which is what makes a trailing
// singular segment an action rather than a thing.
//
// It does NOT read [mutatingVerb]. That set is the gate's, it holds policy words
// like `grant` and `login` that are not HTTP methods, and an edit made there for
// a security reason must not silently rename operations. Same four words, two
// jobs, two places — because they are two jobs.
func changes(method string) bool {
switch method {
case "post", "put", "patch", "delete":
return true
}
return false
}
// isVersion reports whether a segment is an API version — `v1` and nothing else
// in this fleet, but the shape rather than the value, because a `v2` would be
// just as much scaffolding.
func isVersion(w string) bool {
if len(w) < 2 || w[0] != 'v' {
return false
}
for _, r := range w[1:] {
if r < '0' || r > '9' {
return false
}
}
return true
}
// plural reports whether a segment names a COLLECTION.
//
// It is a spelling test and it is allowed to be one: it decides `list_` against
// `get_` and whether to strip an `s`, so its failures are cosmetic and its
// caller is never a gate. The guards are the endings that are not plurals at all
// — `status`, `address`, `analysis` — and a short word, because `dns` is not
// several `dn`.
func plural(w string) bool {
if len(w) < 4 || !strings.HasSuffix(w, "s") {
return false
}
return !strings.HasSuffix(w, "ss") && !strings.HasSuffix(w, "us") && !strings.HasSuffix(w, "is")
}
// singular is the member of a collection: `policies`→`policy`,
// `processes`→`process`, `releases`→`release`.
//
// `es` comes off only after a sibilant that REQUIRED it (`sses`, `uses`, `xes`);
// everything else loses one letter, which is what keeps `releases` from becoming
// `releas` and `sizes` from becoming `siz`.
func singular(w string) string {
switch {
case !plural(w):
return w
case strings.HasSuffix(w, "ies"):
return w[:len(w)-3] + "y"
case strings.HasSuffix(w, "sses"), strings.HasSuffix(w, "uses"), strings.HasSuffix(w, "xes"):
return w[:len(w)-2]
}
return w[:len(w)-1]
}
// offer gives every gathered operation the name the door will publish for it,
// in place, and it is where the mapping is made EXACT.
//
// A phrase is used only when it is unambiguous in both directions across the
// whole gathered set: no second operation produces it, and no operation is
// already called it. Otherwise the operation keeps its id — both of them do,
// when two collide — because a tools/call arrives carrying whatever was
// published and a door that guessed which of two operations was meant would be
// dispatching on a coin toss. 7.3% of the fleet's operations keep their ids, and
// they are overwhelmingly the fleet's own duplicates: `/tasks` and `/v1/tasks`
// serving one handler at two addresses, `post_v1_agent` beside `post_v1_agents`
// in a different subsystem.
//
// This runs over the SURVIVORS. Everything [refuse] withheld is already gone
// (see [Door.gather]), so no phrase can name a refused operation and no refused
// operation can be reached by naming one.
func offer(all []named) {
ids := make(map[string]string, len(all))
for _, t := range all {
ids[t.name] = t.name
}
count := make(map[string]int, len(all))
for i := range all {
all[i].as = phrase(all[i].name)
count[all[i].as]++
}
for i := range all {
if taken, held := ids[all[i].as]; count[all[i].as] > 1 || (held && taken != all[i].name) {
all[i].as = all[i].name
}
}
}
// summaryMax is how much of an operation's own documentation fits beside its
// name. See [summary]; the fleet's median first sentence is 63 characters and
// its 90th percentile is 157, so this keeps nearly all of them whole and clips
// the essays.
const summaryMax = 120
// summary is the ONE line of an operation's own documentation that goes in an
// enum: the first sentence of the first paragraph, unwrapped, clipped on a word.
//
// The prose is already there — it is the doc comment zip's generator lifts into
// the descriptor, the same bytes [Door.describe] hands back whole — so this is a
// projection of what the fleet wrote about itself, never a second description
// that could disagree with the first.
func summary(doc string) string {
para, _, _ := strings.Cut(strings.TrimSpace(doc), "\n\n")
para = strings.Join(strings.Fields(para), " ")
if i := strings.Index(para, ". "); i >= 0 {
para = para[:i+1]
}
if len(para) <= summaryMax {
return para
}
cut := para[:summaryMax]
if i := strings.LastIndexByte(cut, ' '); i > summaryMax/2 {
cut = cut[:i]
}
return strings.TrimRight(cut, " ,;:—-") + "…"
}
+287
View File
@@ -0,0 +1,287 @@
// Copyright © 2026 Hanzo AI. MIT License.
package fleet
import (
"encoding/json"
"sort"
"strings"
"testing"
)
// The naming, against the fleet's own operations — and against the gate, which
// is the thing renaming could break and must not.
// readings is what a route MEANS, one real operation at a time.
//
// Every id here is declared by a subsystem in plugin/*/openapi.json, and the
// route is beside it, because the claim is that the phrase says what the route
// says. TestAPhraseSaysWhatTheRouteSays checks that the ids still exist, so a
// route that is renamed upstream turns this red instead of quietly testing a
// fleet that has moved on.
var readings = []struct{ id, want, route string }{
// The shape that made the surface unreadable, whole.
{"get_v1_projects", "list_projects", "GET /v1/projects"},
{"post_v1_projects", "create_project", "POST /v1/projects"},
{"get_v1_projects_by_slug", "get_project", "GET /v1/projects/{slug}"},
{"patch_v1_projects_by_slug", "update_project", "PATCH /v1/projects/{slug}"},
{"delete_v1_projects_by_slug", "delete_project", "DELETE /v1/projects/{slug}"},
{"get_v1_projects_by_slug_deployments", "list_project_deployments", "GET /v1/projects/{slug}/deployments"},
{"get_v1_projects_by_slug_deployments_by_id", "get_project_deployment", "GET /v1/projects/{slug}/deployments/{id}"},
{"delete_v1_projects_by_slug_domains_by_host", "delete_project_domain", "DELETE /v1/projects/{slug}/domains/{host}"},
// .../{id}/ACTION — the author wrote the verb, so it leads.
{"post_v1_projects_by_slug_deploy", "deploy_project", "POST /v1/projects/{slug}/deploy"},
{"post_v1_projects_by_slug_purge", "purge_project", "POST /v1/projects/{slug}/purge"},
{"post_v1_projects_by_slug_domains_by_host_verify", "verify_project_domain", "POST /v1/projects/{slug}/domains/{host}/verify"},
{"post_v1_projects_by_slug_deployments_by_id_complete", "complete_project_deployment", "POST /v1/projects/{slug}/deployments/{id}/complete"},
{"post_v1_sites_by_slug_releases_by_release_activate", "activate_site_release", "POST /v1/sites/{slug}/releases/{release}/activate"},
// A singular segment is only an action when a parameter put it after a ROW.
// `/v1/commerce/product` is a collection someone spelled singular, and reading
// it as a verb produced `product_commerce` — for four methods at once.
{"post_v1_commerce_product", "create_commerce_product", "POST /v1/commerce/product"},
{"patch_v1_commerce_product_by_productid", "update_commerce_product", "PATCH /v1/commerce/product/{productid}"},
{"delete_v1_commerce_product_by_productid", "delete_commerce_product", "DELETE /v1/commerce/product/{productid}"},
// …nor when the row comes AFTER it: `listing` is what {key} indexes into.
{"put_v1_store_by_storeid_listing_by_key", "set_store_listing", "PUT /v1/store/{storeid}/listing/{key}"},
{"post_v1_store_by_storeid_listing_by_key", "create_store_listing", "POST /v1/store/{storeid}/listing/{key}"},
{"post_v1_sandboxes_by_id_exec", "exec_sandbox", "POST /v1/sandboxes/{id}/exec — the action shape again"},
// The inference surface.
{"post_v1_chat_completions", "create_chat_completion", "POST /v1/chat/completions"},
{"get_v1_models", "list_models", "GET /v1/models"},
{"post_v1_embeddings", "create_embedding", "POST /v1/embeddings"},
{"post_v1_rerank", "create_rerank", "POST /v1/rerank — one singular segment is the thing, not a verb"},
// Spelling that a naive plural rule gets wrong in both directions.
{"get_v1_sandboxes", "list_sandboxes", "GET /v1/sandboxes — plural stays plural for a list"},
{"get_v1_sandboxes_by_id", "get_sandbox", "GET /v1/sandboxes/{id} — `xes` loses two letters"},
{"get_v1_platform_sites_by_slug_releases", "list_platform_site_releases", "GET /v1/platform/sites/{slug}/releases"},
{"post_v1_sites_by_slug_releases", "create_site_release", "POST /v1/sites/{slug}/releases — `releases` loses only one"},
// A DECLARED id is already a verb on an object and is left alone.
{"GetUserPreference", "GetUserPreference", "o11y declares its own ids"},
{"ListTraceFunnels", "ListTraceFunnels", "…and they are not routes to read back"},
}
func TestAPhraseSaysWhatTheRouteSays(t *testing.T) {
declared := map[string]bool{}
for _, op := range Corpus(t) {
declared[op.ID] = true
}
for _, r := range readings {
if got := phrase(r.id); got != r.want {
t.Errorf("%s\n %s\n reads as %q, want %q", r.route, r.id, got, r.want)
}
if !declared[r.id] {
t.Errorf("%s is in this table and no subsystem declares it — the route moved, or the id did", r.id)
}
}
}
// TestEveryPublishedNameMeansExactlyOneOperation is the property a tools/call
// depends on: the door publishes a name, a client sends that name back, and the
// door must know which operation it meant. Over the whole corpus, at once,
// because that is the set one gather holds.
func TestEveryPublishedNameMeansExactlyOneOperation(t *testing.T) {
all := gathered(t)
offer(all)
means := map[string]string{}
kept := 0
for _, tl := range all {
if was, dup := means[tl.as]; dup {
t.Fatalf("the door would publish %q for BOTH %s and %s — a call naming it is a coin toss", tl.as, was, tl.name)
}
means[tl.as] = tl.name
if tl.as == tl.name {
kept++
}
}
// Reversible the other way too: nothing is published under a name that is
// some OTHER operation's own id, which a describe of the child's own bytes
// would otherwise send a model straight at.
ids := map[string]string{}
for _, tl := range all {
ids[tl.name] = tl.name
}
for _, tl := range all {
if id, taken := ids[tl.as]; taken && id != tl.name {
t.Fatalf("%s is published as %q, which is %s's own id", tl.name, tl.as, id)
}
}
// And a published name can never be mistaken for one of the door's own tools:
// a phrase always carries a verb and an object, no app name in the manifest
// has an underscore, and [Describe] has none either.
for _, tl := range all {
if !strings.Contains(tl.as, "_") && tl.as != tl.name {
t.Errorf("%s is published as the bare word %q, which could shadow a subsystem tool", tl.name, tl.as)
}
}
// Two different reasons an operation keeps its id, and only one of them is a
// cost: a DECLARED id was already a verb on an object and was never a
// candidate, while an AMBIGUOUS one is a phrase this refused to publish.
declared, ambiguous := 0, 0
for _, tl := range all {
if tl.as != tl.name {
continue
}
if _, _, _, isRoute := route(tl.name); isRoute {
ambiguous++
continue
}
declared++
}
t.Logf("MEASURED — %d operations survive the gate:", len(all))
t.Logf(" %4d read back as a verb on an object", len(all)-kept)
t.Logf(" %4d already were one — a subsystem declared its own id, and it is left alone", declared)
t.Logf(" %4d keep a route for a name (%.1f%%): the phrase would have been ambiguous, so it is not used.",
ambiguous, 100*float64(ambiguous)/float64(len(all)))
t.Logf(" These are overwhelmingly the fleet's own duplicates — one handler at /tasks and")
t.Logf(" /v1/tasks, or post_v1_agent in one subsystem beside post_v1_agents in another.")
}
// TestTheGateStillJudgesTheROUTE is the security bar for this change, and it is
// one claim: refuse() reads what the CHILD called a tool, and renaming happens
// after it. So the refused set cannot have moved.
//
// It is checked rather than asserted from the code's shape, because the failure
// it guards against is silent: an operation whose route says `post_v1_iam_users`
// reads back as `create_iam_user`, which still trips clause 2 — but
// `delete_v1_keys` reads back as `delete_key`, and a rule applied to THAT would
// have to re-derive a decision it has already made correctly once.
func TestTheGateStillJudgesTheROUTE(t *testing.T) {
var held, offered []string
for _, op := range Corpus(t) {
if refuse(op.ID) {
held = append(held, op.ID)
continue
}
offered = append(offered, op.ID)
}
sort.Strings(held)
// 1. Nothing refused is reachable under any name the door would publish.
all := gathered(t)
offer(all)
withheld := map[string]bool{}
for _, id := range held {
withheld[id] = true
}
for _, tl := range all {
if withheld[tl.name] {
t.Fatalf("%s is refused and the door still holds it", tl.name)
}
if withheld[tl.as] {
t.Fatalf("%s is published as %q, which is a REFUSED operation's id — naming it would reach the survivor "+
"and a reader would think the refused one is offered", tl.name, tl.as)
}
}
// 2. The set that survives is exactly the set the gate lets through: naming
// neither added an operation nor lost one.
if len(all) != len(offered) {
t.Fatalf("the gate passes %d operations and the door holds %d", len(offered), len(all))
}
t.Logf("MEASURED — over %d declared operations: %d refused, %d offered", len(held)+len(offered), len(held), len(offered))
t.Logf(" the gate's input is the child's own id, before naming; see fleet/verbs.go.")
t.Logf(" first refusals, in order: %s", strings.Join(held[:min(6, len(held))], " "))
}
// TestProseIsRationedToTheProductSurface is the OTHER half of the change and the
// one with a budget. It prints what prose costs so the ration is a decision
// somebody made on a number rather than a taste.
func TestProseIsRationedToTheProductSurface(t *testing.T) {
all := gathered(t)
offer(all)
var routes, names, ranked, everything int
described := 0
for _, tl := range all {
r, _ := json.Marshal(tl.name)
n, _ := json.Marshal(tl.as)
s, _ := json.Marshal(tl.as + " — " + summary(tl.desc))
routes += len(r)
names += len(n)
everything += len(s)
if rank(tl.name) < len(productStems) {
ranked += len(s)
described++
continue
}
ranked += len(n)
}
if ranked >= everything {
t.Fatalf("rationing prose to the product surface costs %d bytes and giving it to everything costs %d", ranked, everything)
}
t.Logf("MEASURED — the enum's own bytes over %d offered operations:", len(all))
t.Logf(" routes, as it shipped %7d ← before", routes)
t.Logf(" verb phrases %7d (%+d: a phrase drops the method, the version and every", names, names-routes)
t.Logf(" parameter, so the new surface is SHORTER than the old one)")
t.Logf(" + a summary on the %3d ranked %7d (%+d against the routes, %.2fx) ← shipped",
described, ranked, ranked-routes, float64(ranked)/float64(routes))
t.Logf(" + a summary on ALL of them %7d (%+d, %.1fx)", everything, everything-routes, float64(everything)/float64(routes))
t.Logf(" The whole grouped tools/list was 71 KB. Five times the enum is not a surface that fits in a")
t.Logf(" head, so prose stops at the product surface and [Describe] answers for the tail.")
}
// TestASummaryIsOneSentenceAndFits: the descriptors carry whole doc comments,
// and an enum carries one line of one.
func TestASummaryIsOneSentenceAndFits(t *testing.T) {
for _, c := range []struct{ doc, want string }{
{"Returns every project your org owns.\n\nEach row carries the slug, name,\nframework and status.",
"Returns every project your org owns."},
{"Returns one project of yours by slug — its settings, its live URL\nand the deployment currently serving it.",
"Returns one project of yours by slug — its settings, its live URL and the deployment currently serving it."},
{"", ""},
{" \n\n ", ""},
} {
if got := summary(c.doc); got != c.want {
t.Errorf("summary(%q)\n = %q\n want %q", c.doc, got, c.want)
}
}
over := 0
for _, op := range Corpus(t) {
if s := summary(op.Doc); len([]rune(s)) > summaryMax+1 { // +1 for the ellipsis
over++
if over < 3 {
t.Errorf("%s summarises to %d characters: %q", op.ID, len([]rune(s)), s)
}
}
}
if over > 0 {
t.Errorf("%d summaries are longer than %d characters", over, summaryMax)
}
}
// Phrase is [phrase] for the wire tests, which run in package fleet_test and
// need to know what the door will call an operation before they can assert that
// it published it. Exported here rather than duplicated there for the same
// reason [Corpus] is: two readings of one rule is one reading too many.
func Phrase(op string) string { return phrase(op) }
// gathered is the corpus as [Door.gather] would hold it: refused operations
// dropped, one owner per name, sorted by [rank] then name. Everything this file
// asserts is asserted against THAT set, because it is the set the door names.
func gathered(t *testing.T) []named {
t.Helper()
var all []named
owner := map[string]bool{}
for _, op := range Corpus(t) {
if refuse(op.ID) || owner[op.ID] {
continue
}
owner[op.ID] = true
all = append(all, named{app: op.App, name: op.ID, desc: op.Doc})
}
sort.Slice(all, func(i, j int) bool {
if ri, rj := rank(all[i].name), rank(all[j].name); ri != rj {
return ri < rj
}
return all[i].name < all[j].name
})
return all
}
+2 -2
View File
@@ -16,7 +16,7 @@ require (
github.com/google/go-github/v52 v52.0.0
github.com/hanzoai/account v0.2.1
github.com/hanzoai/cek v0.2.3
github.com/hanzoai/commerce v1.50.20
github.com/hanzoai/commerce v1.50.23
github.com/hanzoai/decimal v0.1.2
github.com/hanzoai/flags/go v0.1.1
github.com/hanzoai/go-openai v1.41.0
@@ -689,7 +689,7 @@ require (
github.com/hanzo-ds/go v1.0.1
github.com/hanzo-ds/native v0.72.0 // indirect
github.com/hanzoai/agent v0.1.3
github.com/hanzoai/ai v1.832.35
github.com/hanzoai/ai v1.832.36
github.com/hanzoai/authz v1.10.29
github.com/hanzoai/base v1.5.15
github.com/hanzoai/licensing v0.1.10
+4 -2
View File
@@ -990,8 +990,8 @@ github.com/hanzoai/account v0.2.1 h1:OpODtK/N+qcUy83yUj6br+yTTBoItJWneDtOZ3NlyFU
github.com/hanzoai/account v0.2.1/go.mod h1:8OzIGRphAhlabOI74O4GoL3RM0y8mbUV0pQUKgXLjkw=
github.com/hanzoai/agent v0.1.3 h1:zzV4t8kN/m/wTLrqzEy0fxxONSZbx3XSVH7TIR9gZNU=
github.com/hanzoai/agent v0.1.3/go.mod h1:Z3hCBdSeN/nGV4o+3F4psQ2bbFk17+tMP5l+G2ssNNA=
github.com/hanzoai/ai v1.832.35 h1:q9vk2Wjj4gikryYXP+MNGRQSUprbXsnSJdJzWND4O2c=
github.com/hanzoai/ai v1.832.35/go.mod h1:+iuYYEWUhWYTzy6Y5U/E9kATWk5/+xoQn3TqNv3DWQQ=
github.com/hanzoai/ai v1.832.36 h1:dMVrGCDvTekFtRu0F62VQnkXtA+YUICzGY532liOaFo=
github.com/hanzoai/ai v1.832.36/go.mod h1:+iuYYEWUhWYTzy6Y5U/E9kATWk5/+xoQn3TqNv3DWQQ=
github.com/hanzoai/authz v1.10.29 h1:b4vWtI9g4Mvay1zizW7cwly/hDk06r+oAdA/y+317Do=
github.com/hanzoai/authz v1.10.29/go.mod h1:xkzFdiIFx4UQMlU0NkmSRz0dgRQYqSXpKgVjn8ijn3E=
github.com/hanzoai/base v1.5.15 h1:IWPoiNpAyEdrRgvwm03LCPEyWaaqkpuahmCnTvSNyZU=
@@ -1004,6 +1004,8 @@ github.com/hanzoai/cek v0.2.3 h1:wOVav3abWAWiIyqTr9pKK/b4PjUCT1j3mdU5LzKpxSQ=
github.com/hanzoai/cek v0.2.3/go.mod h1:T9c9qr9x+0kHsk0J57KElY7l2jgWF08onbxZ3ck5nxM=
github.com/hanzoai/commerce v1.50.20 h1:BLNRT2nRJXkCxGd2QJWtcP9J9Ze2Zez0nVZ84YLBP0g=
github.com/hanzoai/commerce v1.50.20/go.mod h1:TtxF3nlzmKav9XCVcNF2h03jFPukzTLemTuG9my38jY=
github.com/hanzoai/commerce v1.50.23 h1:jla445GyV3l7MtxNpd5GLpfik3fB6WPjalXOFORIV1Y=
github.com/hanzoai/commerce v1.50.23/go.mod h1:uSIgQKPa1QXPIgTBkNMLZ3PK0B9EfkKBkH+t25EmvB8=
github.com/hanzoai/csqlite v0.1.0 h1:suwC3dh0INlfP/U0Es6cDf6JNQ+2+GVLLATPWCUux6k=
github.com/hanzoai/csqlite v0.1.0/go.mod h1:H31a/O6VXuklR9UBkgY++bmAK5uzVfXPqU0F6P9Wsos=
github.com/hanzoai/dashscopego v0.6.0 h1:sLUepwcnVajaDgzlgfuJWkZblBg9UIalkpFUjXHx0Fg=