mirror of
https://github.com/hanzoai/agents.git
synced 2026-10-30 18:48:36 +00:00
The admin control surface was the last gRPC/protobuf in the control plane. Replace it end to end with the native ZAP stack — no protobuf, no gRPC, no build-tag dance. Schema + wire: - proto/admin/reasoner_admin.zap: the AdminReasoner interface as a .zap schema (text fields; param-less listReasoners since ZAP rejects an empty struct). - pkg/adminwire: zapgen output — zero-copy New<Msg>/Wrap<Msg>, the bytes are the message, no marshal step. Server: - internal/server/server_admin.go: transport.Serve over the ZAP transport. Capability auth carries the API key in each request's Cap field, checked in constant time (crypto/subtle) — an upgrade over the deleted gRPC path, which compared with ==, a timing oracle. - H-1 hardening: with no API key configured the surface is unauthenticated, so it binds loopback only (adminBindHost) and is unreachable off-box; a keyed surface binds all interfaces. Reachability and authentication are decided separately, not entangled. - server.go: the adminTransport/UnimplementedServer gRPC scaffolding drops out for a *transport.Server. Removed: - google.golang.org/grpc, the direct google.golang.org/protobuf dependency, pkg/adminpb (the generated .pb.go + grpc stub), the grpc_auth middleware, server_grpc.go / server_zap.go, and the dead `replace operator => ../`. Tests (all pass): adminwire round-trip over real TCP and Unix sockets with all seven Reasoner fields intact, unknown-ordinal rejection, the five authorize cases, and the loopback-bind decision.
125 lines
3.6 KiB
Go
125 lines
3.6 KiB
Go
package server
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/hanzoai/agents/control-plane/internal/storage"
|
|
"github.com/hanzoai/agents/control-plane/pkg/types"
|
|
|
|
adminwire "github.com/hanzoai/agents/control-plane/pkg/adminwire"
|
|
"github.com/stretchr/testify/require"
|
|
"github.com/zap-proto/go/rpc"
|
|
)
|
|
|
|
func TestListReasonersAggregatesNodes(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
ctx := context.Background()
|
|
tempDir := t.TempDir()
|
|
cfg := storage.StorageConfig{
|
|
Mode: "local",
|
|
Local: storage.LocalStorageConfig{
|
|
DatabasePath: filepath.Join(tempDir, "hanzo-agents.db"),
|
|
KVStorePath: filepath.Join(tempDir, "hanzo-agents.bolt"),
|
|
},
|
|
}
|
|
|
|
localStore := storage.NewLocalStorage(storage.LocalStorageConfig{})
|
|
if err := localStore.Initialize(ctx, cfg); err != nil {
|
|
if strings.Contains(strings.ToLower(err.Error()), "fts5") {
|
|
t.Skip("sqlite3 compiled without FTS5; skipping reasoner aggregation test")
|
|
}
|
|
require.NoError(t, err)
|
|
}
|
|
t.Cleanup(func() { _ = localStore.Close(ctx) })
|
|
|
|
srv := &HanzoAgentsServer{storage: localStore}
|
|
|
|
schema := json.RawMessage("{}")
|
|
node := &types.AgentNode{
|
|
ID: "node-1",
|
|
HealthStatus: types.HealthStatusActive,
|
|
Version: "1.0.0",
|
|
LastHeartbeat: time.Now().UTC(),
|
|
Reasoners: []types.ReasonerDefinition{
|
|
{ID: "reason", InputSchema: schema, OutputSchema: schema},
|
|
{ID: "another", InputSchema: schema, OutputSchema: schema},
|
|
},
|
|
}
|
|
require.NoError(t, localStore.RegisterAgent(ctx, node))
|
|
|
|
body, err := srv.ListReasoners()
|
|
require.NoError(t, err)
|
|
|
|
resp, err := adminwire.WrapListReasonersResponse(body)
|
|
require.NoError(t, err)
|
|
list := resp.Reasoners()
|
|
require.Equal(t, 2, list.Len())
|
|
|
|
first, err := adminwire.WrapReasoner(list.BytesAt(0))
|
|
require.NoError(t, err)
|
|
require.Equal(t, "node-1.reason", first.ReasonerId())
|
|
require.Equal(t, "node-1", first.AgentNodeId())
|
|
require.NotEmpty(t, first.LastHeartbeat())
|
|
}
|
|
|
|
func TestAuthorize(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
listReq := func(cap string) []byte {
|
|
return rpc.BuildRequest(rpc.Call{
|
|
Method: adminwire.AdminReasonerListReasonersOrdinal,
|
|
PromiseID: 7,
|
|
Cap: []byte(cap),
|
|
})
|
|
}
|
|
|
|
t.Run("no key configured allows any cap", func(t *testing.T) {
|
|
require.Nil(t, authorize(listReq(""), ""))
|
|
require.Nil(t, authorize(listReq("anything"), ""))
|
|
})
|
|
|
|
t.Run("matching cap proceeds", func(t *testing.T) {
|
|
require.Nil(t, authorize(listReq("secret-key"), "secret-key"))
|
|
})
|
|
|
|
t.Run("missing cap is unauthorized", func(t *testing.T) {
|
|
denied := authorize(listReq(""), "secret-key")
|
|
require.NotNil(t, denied)
|
|
resp, err := rpc.ParseResponse(denied)
|
|
require.NoError(t, err)
|
|
require.Equal(t, rpc.StatusUnauthorized, resp.Status)
|
|
require.Equal(t, uint32(7), resp.PromiseID)
|
|
})
|
|
|
|
t.Run("wrong cap is unauthorized", func(t *testing.T) {
|
|
denied := authorize(listReq("wrong-key"), "secret-key")
|
|
require.NotNil(t, denied)
|
|
resp, err := rpc.ParseResponse(denied)
|
|
require.NoError(t, err)
|
|
require.Equal(t, rpc.StatusUnauthorized, resp.Status)
|
|
})
|
|
|
|
t.Run("malformed envelope is bad request", func(t *testing.T) {
|
|
denied := authorize([]byte("not a zap envelope"), "secret-key")
|
|
require.NotNil(t, denied)
|
|
resp, err := rpc.ParseResponse(denied)
|
|
require.NoError(t, err)
|
|
require.Equal(t, rpc.StatusBadRequest, resp.Status)
|
|
})
|
|
}
|
|
|
|
func TestAdminBindHost(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
// Unauthenticated (no key) must bind loopback so the surface is not
|
|
// reachable off-box; an authenticated surface binds all interfaces.
|
|
require.Equal(t, "127.0.0.1", adminBindHost(""), "no key must bind loopback only")
|
|
require.Equal(t, "", adminBindHost("secret-key"), "with a key, bind all interfaces")
|
|
}
|