Compare commits

...
1 Commits
Author SHA1 Message Date
hanzo-dev e090c4928e feat(visor): /v1/bots surface + machine agent-binding proxies (CLOUD, mirrors machines)
release / build-amd64 (push) Failing after 7m16s
release / notify-universe (push) Skipped
Mounts /v1/bots in cloud as the sibling of /v1/machines — a Bot is an
Agent(cloud /v1/agents) + a kind=bot Machine(vm) + their AgentBinding,
composed as a thin proxy over the SAME Visor client the machines routes use:

  GET    /v1/bots                    list (vm /v1/machines?kind=bot + bindings join)
  POST   /v1/bots/launch             machine launch{kind:bot} THEN bind-agent
  GET    /v1/bots/:id                machine + its binding (404 if not a bot)
  DELETE /v1/bots/:id                unbind THEN terminate the machine
  POST   /v1/bots/:id/:action        message=agent run | stop|pause=unbind

Plus the machine agent-binding proxies cloud lacked (vm already serves them):

  POST   /v1/machines/:id/bind-agent
  GET    /v1/machines/:id/agent-binding
  DELETE /v1/machines/:id/agent-binding
  GET    /v1/agent-bindings

Every route org-gated by the validated principal (principal.Tenant), forwarded
to vm as ?owner=<org> — 403 without a valid IAM owner, exactly like machines.
No vm change: kind=bot launch + bind-agent are already live at visor:19000.
message runs the bot's bound agent via the ONE agent runner (/v1/agents/:agent/run).
2026-07-03 14:45:45 -07:00
4 changed files with 848 additions and 0 deletions
+422
View File
@@ -0,0 +1,422 @@
// bots.go mounts the Hanzo Cloud BOT surface (/v1/bots) plus the machine
// agent-binding proxies (/v1/machines/:id/{bind-agent,agent-binding},
// /v1/agent-bindings). It is the SIBLING of machines: a Bot is not a new state
// this subsystem owns, it is a composition of two things vm already owns — a
// kind=bot Machine and an AgentBinding. So every route here is a thin, org-scoped
// translation over the SAME Visor client the machines routes use (client.go),
// never a second store.
//
// A Bot = Agent (cloud /v1/agents) + Machine (vm, kind=bot) + the binding between
// them. Composition, one way per verb:
//
// launch = vm POST /v1/machines/launch {kind:bot} THEN vm POST .../bind-agent
// list = vm GET /v1/machines?kind=bot joined with the org's bindings
// get = vm GET /v1/machines/:id joined with its binding
// delete = vm DELETE .../agent-binding (unbind) THEN vm DELETE /v1/machines/:id
// message = the AGENT path: run the bot's bound agent via /v1/agents/:agent/run
// stop = vm DELETE .../agent-binding — halt the bot's @hanzo/bot runtime
// pause = the same halt: DigitalOcean/vm expose no VM-suspend primitive, so a
// bot's stop and pause are one honest capability (detach the agent
// runtime); powering the underlying machine off/on is a machine-lifecycle
// concern handled by launch/delete, not a fabricated bot state.
//
// Tenancy is identical to machines: the org is the VALIDATED principal
// (principal.Tenant, taken from the IAM owner claim), forwarded to vm as
// ?owner=<org>, so a caller can only ever read or mutate its OWN bots. No
// validated principal ⇒ 403, before anything reaches vm.
package visor
import (
"bytes"
"encoding/json"
"io"
"net/http"
"net/url"
"os"
"strings"
"time"
"github.com/zap-proto/zip"
)
// agentBinding mirrors vm/object.AgentBinding — the record that a machine runs
// the @hanzo/bot runtime for a cloud Agent. Emitted verbatim so vm stays the one
// source of truth for the binding shape (status/message are vm's honest,
// reconciled values, never invented here).
type agentBinding struct {
Owner string `json:"owner,omitempty"`
Name string `json:"name,omitempty"`
MachineId string `json:"machineId,omitempty"`
Org string `json:"org,omitempty"`
AgentName string `json:"agentName,omitempty"`
Provider string `json:"provider,omitempty"`
PublicIp string `json:"publicIp,omitempty"`
BotVersion string `json:"botVersion,omitempty"`
Status string `json:"status,omitempty"`
Message string `json:"message,omitempty"`
CreatedTime string `json:"createdTime,omitempty"`
UpdatedTime string `json:"updatedTime,omitempty"`
}
// identifies reports whether a binding carries any real identity — used to tell a
// present binding from an empty (no-binding) zero value returned by vm.
func (b agentBinding) identifies() bool {
return b.Name != "" || b.MachineId != "" || b.AgentName != ""
}
// botView is what /v1/bots emits: the bot's machine (the clean machineView the
// console already consumes) with the bound agent surfaced. binding carries the
// honest, vm-reconciled lifecycle status when present.
type botView struct {
machineView
Agent string `json:"agent,omitempty"`
Binding *agentBinding `json:"binding,omitempty"`
}
func toBotView(m visorMachine, b *agentBinding) botView {
v := botView{machineView: toMachineView(m)}
if b != nil && b.identifies() {
v.Agent = b.AgentName
v.Binding = b
}
return v
}
// machineIsBot reports whether a machine is a Bot, from its own tags — the
// read-back of vm's launch-time hanzo-kind:bot stamp (SetKind). It is the same
// signal vm's own ?kind=bot list filter uses, so a get is consistent with a list.
func machineIsBot(m visorMachine) bool {
for _, t := range strings.Split(m.Tag, ",") {
if strings.EqualFold(strings.TrimSpace(t), "hanzo-kind:bot") {
return true
}
}
return false
}
// ---- bots ----
func (s *svc) listBots(c *zip.Ctx) error {
org, ok := tenant(c)
if !ok {
return zip.ErrForbidden("X-Org-Id required")
}
var machines []visorMachine
if err := s.cl.call(c, http.MethodGet, "/v1/machines", q("owner", org, "kind", "bot"), nil, &machines); err != nil {
return err
}
// Join the org's bindings ONCE (O(1), not N+1), keyed by machine id — the same
// id vm binds a machine by. Enrichment only: a bindings read failure never
// blanks the list (a bot still lists without its reconciled status).
byMachine := map[string]*agentBinding{}
var bindings []agentBinding
if err := s.cl.call(c, http.MethodGet, "/v1/agent-bindings", q("owner", org), nil, &bindings); err == nil {
for i := range bindings {
byMachine[bindings[i].Name] = &bindings[i]
}
}
out := make([]botView, 0, len(machines))
for _, m := range machines {
out = append(out, toBotView(m, byMachine[firstNonEmpty(m.Id, m.Name)]))
}
return c.JSON(http.StatusOK, map[string]any{"bots": out})
}
// botLaunchReq is the POST /v1/bots/launch body. A bot needs a machine size and,
// for a real launch, a name; agent is the cloud /v1/agents identity the bot runs
// (defaulting to the bot's name so a bot is self-named by default).
type botLaunchReq struct {
Name string `json:"name"`
Agent string `json:"agent"`
Size string `json:"size"`
InstanceType string `json:"instanceType"`
Region string `json:"region"`
BotVersion string `json:"botVersion"`
DryRun bool `json:"dryRun"`
}
func (s *svc) launchBot(c *zip.Ctx) error {
org, ok := tenant(c)
if !ok {
return zip.ErrForbidden("X-Org-Id required")
}
var body botLaunchReq
if err := c.Bind(&body); err != nil {
return err
}
size := strings.TrimSpace(firstNonEmpty(body.Size, body.InstanceType))
if size == "" {
return zip.ErrBadRequest("size is required")
}
name := strings.TrimSpace(body.Name)
if !body.DryRun && name == "" {
return zip.ErrBadRequest("name is required to launch a bot")
}
// The machine half: launch a kind=bot machine. vm stamps hanzo-kind:bot and
// bootstraps the @hanzo/bot runtime cloud-init for a bot spec (specIsBot).
launch := map[string]any{
"name": name, "size": size, "region": body.Region,
"kind": "bot", "dryRun": body.DryRun,
}
var data json.RawMessage
if err := s.cl.call(c, http.MethodPost, "/v1/machines/launch", q("owner", org), launch, &data); err != nil {
return err
}
// dryRun: pass vm's price quote through unchanged (the authoritative price).
if body.DryRun {
var quote any
if len(data) > 0 {
_ = json.Unmarshal(data, &quote)
}
return c.JSON(http.StatusOK, quote)
}
// vm returns {machine, quote[, meteringError]} — extract the launched machine.
var wrap struct {
Machine visorMachine `json:"machine"`
}
_ = json.Unmarshal(data, &wrap)
if wrap.Machine.Name == "" && wrap.Machine.Id == "" {
_ = json.Unmarshal(data, &wrap.Machine)
}
machineID := firstNonEmpty(wrap.Machine.Id, wrap.Machine.Name)
if machineID == "" {
return zip.Errorf(http.StatusBadGateway, "bot launch: vm returned no machine")
}
// The agent half: bind the cloud Agent to the freshly-launched machine. org is
// the validated tenant (never a client field); agent defaults to the bot name.
agent := firstNonEmpty(strings.TrimSpace(body.Agent), name)
var binding agentBinding
if err := s.cl.call(c, http.MethodPost, "/v1/machines/"+url.PathEscape(machineID)+"/bind-agent",
q("owner", org),
map[string]any{"org": org, "agentName": agent, "botVersion": body.BotVersion},
&binding); err != nil {
return err
}
return c.JSON(http.StatusCreated, toBotView(wrap.Machine, &binding))
}
func (s *svc) getBot(c *zip.Ctx) error {
org, id, err := s.botScope(c)
if err != nil {
return err
}
var m visorMachine
if err := s.cl.call(c, http.MethodGet, "/v1/machines/"+url.PathEscape(id), q("owner", org), nil, &m); err != nil {
return err
}
if m.Name == "" && m.Id == "" {
return zip.ErrNotFound("bot not found")
}
// Attach the binding (best-effort). A machine is a Bot if it carries the
// hanzo-kind:bot tag OR has an agent binding — either signal is authoritative,
// so a bot resolves even before its cloud-init has stamped every tag.
var binding agentBinding
_ = s.cl.call(c, http.MethodGet, "/v1/machines/"+url.PathEscape(id)+"/agent-binding", q("owner", org), nil, &binding)
if !machineIsBot(m) && !binding.identifies() {
return zip.ErrNotFound("bot not found")
}
return c.JSON(http.StatusOK, toBotView(m, &binding))
}
func (s *svc) deleteBot(c *zip.Ctx) error {
org, id, err := s.botScope(c)
if err != nil {
return err
}
// Tear down both halves: unbind the agent first (best-effort — a bot with no
// binding still deletes), then terminate the machine.
_ = s.cl.call(c, http.MethodDelete, "/v1/machines/"+url.PathEscape(id)+"/agent-binding", q("owner", org), nil, nil)
if err := s.cl.call(c, http.MethodDelete, "/v1/machines/"+url.PathEscape(id), q("owner", org), nil, nil); err != nil {
return err
}
return c.NoContent(http.StatusNoContent)
}
// botAction dispatches /v1/bots/:id/:action. message routes to the AGENT path;
// stop and pause both halt the bot's agent runtime (one honest capability — see
// the package doc). An unknown action is a clean 400, never a silent no-op.
func (s *svc) botAction(c *zip.Ctx) error {
org, id, err := s.botScope(c)
if err != nil {
return err
}
switch strings.ToLower(strings.TrimSpace(c.Param("action"))) {
case "message":
return s.messageBot(c, org, id)
case "stop", "pause":
return s.stopBot(c, org, id)
default:
return zip.ErrBadRequest("unknown bot action (want stop|pause|message)")
}
}
// stopBot halts the bot's runtime by unbinding its agent — the machine stays
// (re-bind to resume, or DELETE /v1/bots/:id to tear it down). Idempotent: a bot
// with no binding still reports stopped.
func (s *svc) stopBot(c *zip.Ctx, org, id string) error {
if err := s.cl.call(c, http.MethodDelete, "/v1/machines/"+url.PathEscape(id)+"/agent-binding", q("owner", org), nil, nil); err != nil {
return err
}
return c.JSON(http.StatusOK, map[string]any{"id": id, "status": "stopped"})
}
// messageBot runs the bot's bound agent with the caller's message. It resolves
// the agent from the machine's binding, then forwards the body to the ONE agent
// runner (/v1/agents/:agent/run) so a message is a real agent run — recorded,
// billed and traced exactly like any other. The caller's identity is forwarded so
// the run is scoped + gated as the same principal (never a fabricated identity).
func (s *svc) messageBot(c *zip.Ctx, org, id string) error {
var binding agentBinding
if err := s.cl.call(c, http.MethodGet, "/v1/machines/"+url.PathEscape(id)+"/agent-binding", q("owner", org), nil, &binding); err != nil {
return err
}
agent := strings.TrimSpace(binding.AgentName)
if agent == "" {
return zip.ErrBadRequest("bot has no bound agent to message")
}
target := agentsBase() + "/v1/agents/" + url.PathEscape(agent) + "/run"
req, err := http.NewRequestWithContext(c.Context(), http.MethodPost, target, bytes.NewReader(c.Body()))
if err != nil {
return zip.Errorf(http.StatusInternalServerError, "bots: build agent request: %v", err)
}
req.Header.Set("Content-Type", "application/json")
for _, h := range selfIdentityHeaders {
if v := c.Header(h); v != "" {
req.Header.Set(h, v)
}
}
resp, err := selfClient.Do(req)
if err != nil {
return zip.Errorf(http.StatusBadGateway, "bots: agent path unreachable: %v", err)
}
defer func() { _ = resp.Body.Close() }()
rb, _ := io.ReadAll(io.LimitReader(resp.Body, maxBody))
if ct := resp.Header.Get("Content-Type"); ct != "" {
c.SetHeader("Content-Type", ct)
}
return c.Bytes(resp.StatusCode, rb)
}
// botScope validates the principal and extracts the bot id in one place, so every
// bot :id handler gates identically (403 before vm) and rejects an empty id.
func (s *svc) botScope(c *zip.Ctx) (org, id string, err error) {
org, ok := tenant(c)
if !ok {
return "", "", zip.ErrForbidden("X-Org-Id required")
}
id = strings.TrimSpace(c.Param("id"))
if id == "" {
return "", "", zip.ErrBadRequest("bot id required")
}
return org, id, nil
}
// ---- machine agent-binding proxies (thin, mirror vm exactly) ----
type bindAgentReq struct {
AgentName string `json:"agentName"`
BotVersion string `json:"botVersion"`
}
func (s *svc) bindMachineAgent(c *zip.Ctx) error {
org, ok := tenant(c)
if !ok {
return zip.ErrForbidden("X-Org-Id required")
}
id := strings.TrimSpace(c.Param("id"))
if id == "" {
return zip.ErrBadRequest("machine id required")
}
var body bindAgentReq
if err := c.Bind(&body); err != nil {
return err
}
if strings.TrimSpace(body.AgentName) == "" {
return zip.ErrBadRequest("agentName is required")
}
// org is the validated tenant (never a client field) — vm records it as the
// Agent's owning org and scopes the machine by ?owner.
var binding agentBinding
if err := s.cl.call(c, http.MethodPost, "/v1/machines/"+url.PathEscape(id)+"/bind-agent",
q("owner", org),
map[string]any{"org": org, "agentName": body.AgentName, "botVersion": body.BotVersion},
&binding); err != nil {
return err
}
return c.JSON(http.StatusOK, binding)
}
func (s *svc) getMachineAgentBinding(c *zip.Ctx) error {
org, ok := tenant(c)
if !ok {
return zip.ErrForbidden("X-Org-Id required")
}
id := strings.TrimSpace(c.Param("id"))
if id == "" {
return zip.ErrBadRequest("machine id required")
}
var binding agentBinding
if err := s.cl.call(c, http.MethodGet, "/v1/machines/"+url.PathEscape(id)+"/agent-binding", q("owner", org), nil, &binding); err != nil {
return err
}
if !binding.identifies() {
return zip.ErrNotFound("no agent binding for machine")
}
return c.JSON(http.StatusOK, binding)
}
func (s *svc) unbindMachineAgent(c *zip.Ctx) error {
org, ok := tenant(c)
if !ok {
return zip.ErrForbidden("X-Org-Id required")
}
id := strings.TrimSpace(c.Param("id"))
if id == "" {
return zip.ErrBadRequest("machine id required")
}
if err := s.cl.call(c, http.MethodDelete, "/v1/machines/"+url.PathEscape(id)+"/agent-binding", q("owner", org), nil, nil); err != nil {
return err
}
return c.NoContent(http.StatusNoContent)
}
func (s *svc) listAgentBindings(c *zip.Ctx) error {
org, ok := tenant(c)
if !ok {
return zip.ErrForbidden("X-Org-Id required")
}
var bindings []agentBinding
if err := s.cl.call(c, http.MethodGet, "/v1/agent-bindings", q("owner", org), nil, &bindings); err != nil {
return err
}
if bindings == nil {
bindings = []agentBinding{}
}
return c.JSON(http.StatusOK, map[string]any{"agentBindings": bindings})
}
// ---- agent path (self) ----
// selfIdentityHeaders are the gateway-minted identity a bot message forwards to
// the agent run so the run is scoped + gated as the SAME principal.
var selfIdentityHeaders = []string{
"Authorization", "X-Org-Id", "X-User-Id", "X-User-Email", "X-Project-Id", "X-Environment",
}
// selfClient reaches the cloud binary's OWN agent surface. A bot message is a real
// agent run — the run path (records, billing, tracing) is not re-implemented here.
var selfClient = &http.Client{Timeout: 60 * time.Second}
// agentsBase is the base of the /v1/agents surface. In the unified binary the
// agents subsystem is mounted on THIS process's app listener, so the default is
// self (CLOUD_LISTEN :8000); CLOUD_AGENTS_URL overrides it (a split deploy, or a
// test's fake agents server).
func agentsBase() string {
if v := strings.TrimSpace(os.Getenv("CLOUD_AGENTS_URL")); v != "" {
return strings.TrimRight(v, "/")
}
return "http://127.0.0.1:8000"
}
+395
View File
@@ -0,0 +1,395 @@
package visor
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/hanzoai/cloud"
luxlog "github.com/luxfi/log"
"github.com/zap-proto/zip"
)
// botVM is a stand-in for the vm (Visor) resell compute + agent-binding surface.
// It speaks the casibase {status,msg,data} envelope, scopes every read by the
// ?owner query (so a test proves cloud forwards the VALIDATED principal's org),
// and records the last bind/unbind it saw so a test can assert the composition.
type botVM struct {
bots map[string]map[string]any // id -> machine (kind=bot)
bindings map[string]agentBinding // id -> binding
lastOwner string
lastBindOrg string
lastBindName string // agentName last bound
lastUnbind string // id last unbound
}
func newBotVM() *botVM {
return &botVM{bots: map[string]map[string]any{}, bindings: map[string]agentBinding{}}
}
func (f *botVM) server(t *testing.T) *httptest.Server {
t.Helper()
mux := http.NewServeMux()
// GET /v1/machines?owner=&kind=bot — the resell list, kind-filtered.
mux.HandleFunc("/v1/machines", func(w http.ResponseWriter, r *http.Request) {
f.lastOwner = r.URL.Query().Get("owner")
out := []map[string]any{}
if r.URL.Query().Get("kind") == "bot" || r.URL.Query().Get("kind") == "" {
for _, m := range f.bots {
out = append(out, m)
}
}
envelope200(w, out)
})
// POST /v1/machines/launch — quote (dryRun) or launch a machine.
mux.HandleFunc("/v1/machines/launch", func(w http.ResponseWriter, r *http.Request) {
f.lastOwner = r.URL.Query().Get("owner")
var body map[string]any
_ = json.NewDecoder(r.Body).Decode(&body)
quote := map[string]any{"size": body["size"], "region": body["region"], "priceHourly": 1.57, "currency": "usd"}
if dry, _ := body["dryRun"].(bool); dry {
envelope200(w, quote)
return
}
name, _ := body["name"].(string)
id := "drop-" + name
machine := map[string]any{
"owner": f.lastOwner, "name": name, "id": id,
"size": body["size"], "region": body["region"], "state": "provisioning",
"tag": "hanzo-kind:bot",
}
f.bots[id] = machine
envelope200(w, map[string]any{"machine": machine, "quote": quote})
})
// /v1/machines/{id}[/bind-agent|/agent-binding] — the machine sub-resources.
mux.HandleFunc("/v1/machines/", func(w http.ResponseWriter, r *http.Request) {
f.lastOwner = r.URL.Query().Get("owner")
rest := strings.TrimPrefix(r.URL.Path, "/v1/machines/")
parts := strings.Split(rest, "/")
id := parts[0]
switch {
case len(parts) == 1 && r.Method == http.MethodGet: // GetComputeMachine
if m, ok := f.bots[id]; ok {
envelope200(w, m)
return
}
envelope200(w, map[string]any{}) // not found -> empty machine
case len(parts) == 1 && r.Method == http.MethodDelete: // DeleteComputeMachine
delete(f.bots, id)
envelope200(w, "deleted")
case len(parts) == 2 && parts[1] == "bind-agent" && r.Method == http.MethodPost:
var b struct {
Org, AgentName, BotVersion string
}
_ = json.NewDecoder(r.Body).Decode(&b)
f.lastBindOrg, f.lastBindName = b.Org, b.AgentName
binding := agentBinding{
Owner: b.Org, Name: id, MachineId: b.Org + "/" + id, Org: b.Org,
AgentName: b.AgentName, BotVersion: b.BotVersion, Status: "Pending",
Message: "machine provisioning; @hanzo/bot runtime not yet confirmed",
}
f.bindings[id] = binding
envelope200(w, binding)
case len(parts) == 2 && parts[1] == "agent-binding" && r.Method == http.MethodGet:
if b, ok := f.bindings[id]; ok {
envelope200(w, b)
return
}
envelope200(w, map[string]any{}) // no binding -> empty
case len(parts) == 2 && parts[1] == "agent-binding" && r.Method == http.MethodDelete:
f.lastUnbind = id
delete(f.bindings, id)
envelope200(w, "Affected")
default:
http.Error(w, "unhandled "+r.Method+" "+r.URL.Path, http.StatusNotFound)
}
})
// GET /v1/agent-bindings?owner= — the org's bindings.
mux.HandleFunc("/v1/agent-bindings", func(w http.ResponseWriter, r *http.Request) {
f.lastOwner = r.URL.Query().Get("owner")
out := []agentBinding{}
for _, b := range f.bindings {
out = append(out, b)
}
envelope200(w, out)
})
srv := httptest.NewServer(mux)
t.Cleanup(srv.Close)
return srv
}
// mountBots wires the visor surface against the fake vm (and, when given, a fake
// agents server for the message path).
func mountBots(t *testing.T, f *botVM, agentsURL string) *zip.App {
t.Helper()
srv := f.server(t)
t.Setenv("VISOR_URL", srv.URL)
t.Setenv("VISOR_CLIENT_ID", "")
t.Setenv("VISOR_CLIENT_SECRET", "")
if agentsURL != "" {
t.Setenv("CLOUD_AGENTS_URL", agentsURL)
}
app := zip.New(zip.Config{Logger: luxlog.New("test")})
if err := Mount(app, cloud.Deps{Logger: luxlog.New("test")}); err != nil {
t.Fatalf("Mount: %v", err)
}
return app
}
func TestBotsGatedNoPrincipal(t *testing.T) {
app := mountBots(t, newBotVM(), "")
// Every bot + agent-binding route must 403 (not 404) without a validated
// principal — routed and org-gated exactly like /v1/machines.
cases := []struct {
method, path string
}{
{http.MethodGet, "/v1/bots"},
{http.MethodPost, "/v1/bots/launch"},
{http.MethodGet, "/v1/bots/launch"}, // matches /v1/bots/:id — still gated
{http.MethodGet, "/v1/bots/drop-x"},
{http.MethodDelete, "/v1/bots/drop-x"},
{http.MethodPost, "/v1/bots/drop-x/stop"},
{http.MethodPost, "/v1/bots/drop-x/message"},
{http.MethodPost, "/v1/machines/drop-x/bind-agent"},
{http.MethodGet, "/v1/machines/drop-x/agent-binding"},
{http.MethodDelete, "/v1/machines/drop-x/agent-binding"},
{http.MethodGet, "/v1/agent-bindings"},
}
for _, tc := range cases {
if code, _ := do(t, app, tc.method, tc.path, "", nil); code != http.StatusForbidden {
t.Errorf("%s %s no-principal = %d, want 403", tc.method, tc.path, code)
}
}
}
func TestBotLaunchQuoteAndReal(t *testing.T) {
f := newBotVM()
app := mountBots(t, f, "")
// dryRun → the price quote verbatim, no machine, no bind.
code, body := do(t, app, http.MethodPost, "/v1/bots/launch", "acme",
map[string]any{"size": "s-2vcpu-4gb", "region": "sfo3", "name": "helper", "dryRun": true})
if code != http.StatusOK || !strings.Contains(string(body), `"priceHourly"`) {
t.Fatalf("dryRun want 200 quote, got %d %s", code, body)
}
if len(f.bots) != 0 || f.lastBindName != "" {
t.Fatalf("dryRun must not launch or bind (bots=%d bind=%q)", len(f.bots), f.lastBindName)
}
// A real launch → 201 botView with the agent bound (agent defaults to name).
code, body = do(t, app, http.MethodPost, "/v1/bots/launch", "acme",
map[string]any{"size": "s-2vcpu-4gb", "region": "sfo3", "name": "helper"})
if code != http.StatusCreated {
t.Fatalf("launch want 201, got %d %s", code, body)
}
var bv botView
if err := json.Unmarshal(body, &bv); err != nil {
t.Fatalf("shape: %v (%s)", err, body)
}
if bv.Name != "helper" || bv.Status != "provisioning" || bv.Agent != "helper" || bv.Binding == nil {
t.Fatalf("bot view mismatch: %+v", bv)
}
if f.lastOwner != "acme" || f.lastBindOrg != "acme" || f.lastBindName != "helper" {
t.Fatalf("cloud must forward validated org+agent: owner=%q bindOrg=%q agent=%q", f.lastOwner, f.lastBindOrg, f.lastBindName)
}
// An explicit agent overrides the name default.
_, body = do(t, app, http.MethodPost, "/v1/bots/launch", "acme",
map[string]any{"size": "s-2vcpu-4gb", "region": "sfo3", "name": "sup", "agent": "support"})
_ = json.Unmarshal(body, &bv)
if f.lastBindName != "support" || bv.Agent != "support" {
t.Fatalf("explicit agent want support, got bind=%q view=%q", f.lastBindName, bv.Agent)
}
// size required; real launch requires a name.
if code, _ := do(t, app, http.MethodPost, "/v1/bots/launch", "acme", map[string]any{"region": "sfo3"}); code != http.StatusBadRequest {
t.Fatalf("launch without size want 400, got %d", code)
}
if code, _ := do(t, app, http.MethodPost, "/v1/bots/launch", "acme", map[string]any{"size": "s-2vcpu-4gb"}); code != http.StatusBadRequest {
t.Fatalf("real launch without name want 400, got %d", code)
}
}
func TestBotListGetDelete(t *testing.T) {
f := newBotVM()
app := mountBots(t, f, "")
// Launch two bots for acme.
for _, n := range []string{"a", "b"} {
if code, body := do(t, app, http.MethodPost, "/v1/bots/launch", "acme",
map[string]any{"size": "s-2vcpu-4gb", "region": "sfo3", "name": n}); code != http.StatusCreated {
t.Fatalf("seed launch %s: %d %s", n, code, body)
}
}
// list → both bots, kind=bot, each joined with its binding.
code, body := do(t, app, http.MethodGet, "/v1/bots", "acme", nil)
if code != http.StatusOK {
t.Fatalf("list want 200, got %d %s", code, body)
}
var listed struct {
Bots []botView `json:"bots"`
}
if err := json.Unmarshal(body, &listed); err != nil {
t.Fatalf("shape: %v", err)
}
if len(listed.Bots) != 2 {
t.Fatalf("want 2 bots, got %d", len(listed.Bots))
}
for _, b := range listed.Bots {
if b.Agent == "" || b.Binding == nil {
t.Fatalf("listed bot missing joined binding: %+v", b)
}
}
// get one → botView.
code, body = do(t, app, http.MethodGet, "/v1/bots/drop-a", "acme", nil)
if code != http.StatusOK {
t.Fatalf("get want 200, got %d %s", code, body)
}
var bv botView
_ = json.Unmarshal(body, &bv)
if bv.Agent != "a" {
t.Fatalf("get bot agent want a, got %q", bv.Agent)
}
// a non-bot machine (no kind tag, no binding) is not a bot → 404.
f.bots["plain"] = map[string]any{"owner": "acme", "name": "plain", "id": "plain", "state": "running"}
if code, _ := do(t, app, http.MethodGet, "/v1/bots/plain", "acme", nil); code != http.StatusNotFound {
t.Fatalf("get non-bot want 404, got %d", code)
}
// delete → 204, and the bot unbinds AND the machine is gone.
if code, _ := do(t, app, http.MethodDelete, "/v1/bots/drop-a", "acme", nil); code != http.StatusNoContent {
t.Fatalf("delete want 204, got %d", code)
}
if f.lastUnbind != "drop-a" {
t.Fatalf("delete must unbind the agent, lastUnbind=%q", f.lastUnbind)
}
if _, ok := f.bots["drop-a"]; ok {
t.Fatalf("delete must terminate the machine")
}
}
func TestBotStopPause(t *testing.T) {
f := newBotVM()
app := mountBots(t, f, "")
if code, _ := do(t, app, http.MethodPost, "/v1/bots/launch", "acme",
map[string]any{"size": "s-2vcpu-4gb", "region": "sfo3", "name": "c"}); code != http.StatusCreated {
t.Fatal("seed launch")
}
// stop → unbind (halt the agent), 200 {status:stopped}. Machine stays.
code, body := do(t, app, http.MethodPost, "/v1/bots/drop-c/stop", "acme", nil)
if code != http.StatusOK || !strings.Contains(string(body), `"stopped"`) {
t.Fatalf("stop want 200 stopped, got %d %s", code, body)
}
if f.lastUnbind != "drop-c" {
t.Fatalf("stop must unbind, lastUnbind=%q", f.lastUnbind)
}
if _, ok := f.bots["drop-c"]; !ok {
t.Fatalf("stop must NOT delete the machine")
}
// pause routes to the same halt.
f.lastUnbind = ""
if code, _ := do(t, app, http.MethodPost, "/v1/bots/drop-c/pause", "acme", nil); code != http.StatusOK {
t.Fatalf("pause want 200, got %d", code)
}
if f.lastUnbind != "drop-c" {
t.Fatalf("pause must unbind, lastUnbind=%q", f.lastUnbind)
}
// an unknown action is a clean 400.
if code, _ := do(t, app, http.MethodPost, "/v1/bots/drop-c/frobnicate", "acme", nil); code != http.StatusBadRequest {
t.Fatalf("unknown action want 400, got %d", code)
}
}
func TestBotMessageRunsAgent(t *testing.T) {
f := newBotVM()
// Fake agents surface: records the agent + input, returns a run result.
var gotAgent, gotInput string
agents := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotAgent = strings.TrimSuffix(strings.TrimPrefix(r.URL.Path, "/v1/agents/"), "/run")
var b struct {
Input string `json:"input"`
}
_ = json.NewDecoder(r.Body).Decode(&b)
gotInput = b.Input
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`{"status":"ok","output":"pong"}`))
}))
defer agents.Close()
app := mountBots(t, f, agents.URL)
if code, _ := do(t, app, http.MethodPost, "/v1/bots/launch", "acme",
map[string]any{"size": "s-2vcpu-4gb", "region": "sfo3", "name": "chat", "agent": "concierge"}); code != http.StatusCreated {
t.Fatal("seed launch")
}
// message → runs the BOUND agent with the caller's input; response passes through.
code, body := do(t, app, http.MethodPost, "/v1/bots/drop-chat/message", "acme",
map[string]any{"input": "ping"})
if code != http.StatusOK || !strings.Contains(string(body), `"pong"`) {
t.Fatalf("message want 200 pong, got %d %s", code, body)
}
if gotAgent != "concierge" || gotInput != "ping" {
t.Fatalf("message must run the bound agent: agent=%q input=%q", gotAgent, gotInput)
}
}
func TestMachineAgentBindingProxies(t *testing.T) {
f := newBotVM()
app := mountBots(t, f, "")
// Seed a resell machine to bind against.
f.bots["drop-m"] = map[string]any{"owner": "acme", "name": "m", "id": "drop-m", "state": "running"}
// bind-agent → 200 binding, org forwarded from the validated principal.
code, body := do(t, app, http.MethodPost, "/v1/machines/drop-m/bind-agent", "acme",
map[string]any{"agentName": "worker", "botVersion": "1.2.3"})
if code != http.StatusOK {
t.Fatalf("bind want 200, got %d %s", code, body)
}
var b agentBinding
_ = json.Unmarshal(body, &b)
if b.AgentName != "worker" || f.lastBindOrg != "acme" || f.lastBindName != "worker" {
t.Fatalf("bind mismatch: view=%+v bindOrg=%q", b, f.lastBindOrg)
}
if code, _ := do(t, app, http.MethodPost, "/v1/machines/drop-m/bind-agent", "acme", map[string]any{}); code != http.StatusBadRequest {
t.Fatalf("bind without agentName want 400, got %d", code)
}
// GET the binding → 200; a machine with none → 404.
if code, _ := do(t, app, http.MethodGet, "/v1/machines/drop-m/agent-binding", "acme", nil); code != http.StatusOK {
t.Fatalf("get binding want 200, got %d", code)
}
if code, _ := do(t, app, http.MethodGet, "/v1/machines/nope/agent-binding", "acme", nil); code != http.StatusNotFound {
t.Fatalf("get missing binding want 404, got %d", code)
}
// list agent-bindings → 200 {agentBindings:[...]} with the one we bound.
code, body = do(t, app, http.MethodGet, "/v1/agent-bindings", "acme", nil)
if code != http.StatusOK {
t.Fatalf("list bindings want 200, got %d", code)
}
var out struct {
AgentBindings []agentBinding `json:"agentBindings"`
}
_ = json.Unmarshal(body, &out)
if len(out.AgentBindings) != 1 || out.AgentBindings[0].AgentName != "worker" {
t.Fatalf("list bindings mismatch: %+v", out.AgentBindings)
}
// DELETE the binding → 204, and it is gone.
if code, _ := do(t, app, http.MethodDelete, "/v1/machines/drop-m/agent-binding", "acme", nil); code != http.StatusNoContent {
t.Fatalf("unbind want 204, got %d", code)
}
if f.lastUnbind != "drop-m" || len(f.bindings) != 0 {
t.Fatalf("unbind must remove the binding, lastUnbind=%q left=%d", f.lastUnbind, len(f.bindings))
}
}
+5
View File
@@ -41,6 +41,11 @@ type visorMachine struct {
PublicIp string `json:"publicIp"`
PrivateIp string `json:"privateIp"`
CpuSize string `json:"cpuSize"`
// Tag is the comma-joined provider tag list (the resell /v1/machines surface
// carries it). It records `hanzo-kind:<kind>` (kind=bot for a Bot) and, once
// the launch cloud-init installs the runtime, `hanzo-bot:<agentName>`. bots.go
// reads it to tell a Bot machine from a plain one.
Tag string `json:"tag"`
}
// visorNodePool mirrors visor/object.NodePool (JSON subset relevant to clusters).
+26
View File
@@ -24,6 +24,15 @@
// POST /v1/clusters/:clusterId/pools add a node pool -> nodePoolView
// POST /v1/clusters/:clusterId/pools/:poolId/scale scale a node pool -> nodePoolView
// DELETE /v1/clusters/:clusterId/pools/:poolId delete a node pool -> 204
// POST /v1/machines/:id/bind-agent bind a cloud Agent to a machine -> agentBinding
// GET /v1/machines/:id/agent-binding the machine's agent binding -> agentBinding (404 if none)
// DELETE /v1/machines/:id/agent-binding unbind the agent -> 204
// GET /v1/agent-bindings the org's agent bindings -> {agentBindings:[agentBinding]}
// GET /v1/bots the org's bots (kind=bot) -> {bots:[botView]}
// POST /v1/bots/launch launch a bot (machine+bind) -> botView | quote
// GET /v1/bots/:id one bot by id -> botView (404 if not a bot)
// DELETE /v1/bots/:id terminate a bot (unbind+delete) -> 204
// POST /v1/bots/:id/:action stop|pause|message the bot -> action result
//
// The tenant (principal.Tenant) is passed to Visor as ?owner=<org>, so a caller
// can only ever read or mutate their OWN tenant's compute; the org is taken from
@@ -81,6 +90,23 @@ func Mount(app *zip.App, deps cloud.Deps) error {
app.Get("/v1/compute/regions", s.listRegions)
app.Get("/v1/compute/sizes", s.listSizes)
// Agent↔machine binding — thin proxy over vm's binding surface (mark a machine
// as running the @hanzo/bot runtime for a cloud Agent). Deeper than
// /v1/machines/:id so no machine id captures these literals. See bots.go.
app.Post("/v1/machines/:id/bind-agent", s.bindMachineAgent)
app.Get("/v1/machines/:id/agent-binding", s.getMachineAgentBinding)
app.Delete("/v1/machines/:id/agent-binding", s.unbindMachineAgent)
app.Get("/v1/agent-bindings", s.listAgentBindings)
// Bots — a Bot is a kind=bot machine + an agent binding, composed from the vm
// compute + binding surface (bots.go). The sibling of /v1/machines. launch is
// an explicit literal, registered before :id so it never binds as an id.
app.Get("/v1/bots", s.listBots)
app.Post("/v1/bots/launch", s.launchBot)
app.Get("/v1/bots/:id", s.getBot)
app.Delete("/v1/bots/:id", s.deleteBot)
app.Post("/v1/bots/:id/:action", s.botAction)
s.log.Info("visor compute surface mounted", "target", s.cl.target,
"serviceAuth", serviceClientID() != "", "brand", deps.Brand)
return nil