Compare commits
13
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7fbafa54aa | ||
|
|
7ba18a2386 | ||
|
|
5131661f59 | ||
|
|
df6b78ffa1 | ||
|
|
91cd1367ae | ||
|
|
864cbca1b1 | ||
|
|
c4e79aa053 | ||
|
|
ab29588949 | ||
|
|
e6374961a7 | ||
|
|
9052430f9f | ||
|
|
8ae00313e0 | ||
|
|
4117fc1486 | ||
|
|
9957aa1a8b |
@@ -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
@@ -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
@@ -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
@@ -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)) }
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
-1
File diff suppressed because one or more lines are too long
-1
@@ -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}
|
||||
-26
File diff suppressed because one or more lines are too long
+16
-2
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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.")
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Vendored
-9
@@ -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
-42
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
-13
File diff suppressed because one or more lines are too long
Vendored
-7
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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, " ,;:—-") + "…"
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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=
|
||||
|
||||
Reference in New Issue
Block a user