mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-07-25 14:22:57 +00:00
657 lines
21 KiB
Go
657 lines
21 KiB
Go
package http
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"database/sql"
|
|
"encoding/json"
|
|
"errors"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
|
|
"github.com/nextlevelbuilder/goclaw/internal/store"
|
|
)
|
|
|
|
type gatewayOperatorSecureCLIStore struct {
|
|
binaries []store.SecureCLIBinary
|
|
created *store.SecureCLIBinary
|
|
updated map[uuid.UUID]map[string]any
|
|
}
|
|
|
|
func (s *gatewayOperatorSecureCLIStore) Create(_ context.Context, b *store.SecureCLIBinary) error {
|
|
cp := *b
|
|
if cp.ID == uuid.Nil {
|
|
cp.ID = store.GenNewID()
|
|
b.ID = cp.ID
|
|
}
|
|
s.created = &cp
|
|
s.binaries = append(s.binaries, cp)
|
|
return nil
|
|
}
|
|
|
|
func (s *gatewayOperatorSecureCLIStore) Get(context.Context, uuid.UUID) (*store.SecureCLIBinary, error) {
|
|
return nil, sql.ErrNoRows
|
|
}
|
|
|
|
func (s *gatewayOperatorSecureCLIStore) Update(_ context.Context, id uuid.UUID, updates map[string]any) error {
|
|
if s.updated == nil {
|
|
s.updated = map[uuid.UUID]map[string]any{}
|
|
}
|
|
cp := map[string]any{}
|
|
for k, v := range updates {
|
|
cp[k] = v
|
|
}
|
|
s.updated[id] = cp
|
|
for i := range s.binaries {
|
|
if s.binaries[i].ID != id {
|
|
continue
|
|
}
|
|
for k, v := range updates {
|
|
switch k {
|
|
case "binary_path":
|
|
if path, ok := v.(string); ok {
|
|
s.binaries[i].BinaryPath = &path
|
|
}
|
|
case "description":
|
|
if description, ok := v.(string); ok {
|
|
s.binaries[i].Description = description
|
|
}
|
|
case "deny_args":
|
|
if raw, ok := v.(json.RawMessage); ok {
|
|
s.binaries[i].DenyArgs = raw
|
|
}
|
|
case "deny_verbose":
|
|
if raw, ok := v.(json.RawMessage); ok {
|
|
s.binaries[i].DenyVerbose = raw
|
|
}
|
|
case "timeout_seconds":
|
|
if timeoutSeconds, ok := v.(int); ok {
|
|
s.binaries[i].TimeoutSeconds = timeoutSeconds
|
|
}
|
|
case "tips":
|
|
if tips, ok := v.(string); ok {
|
|
s.binaries[i].Tips = tips
|
|
}
|
|
case "is_global":
|
|
if isGlobal, ok := v.(bool); ok {
|
|
s.binaries[i].IsGlobal = isGlobal
|
|
}
|
|
case "enabled":
|
|
if enabled, ok := v.(bool); ok {
|
|
s.binaries[i].Enabled = enabled
|
|
}
|
|
case "adapter_name":
|
|
if adapterName, ok := v.(*string); ok {
|
|
s.binaries[i].AdapterName = adapterName
|
|
} else {
|
|
s.binaries[i].AdapterName = nil
|
|
}
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *gatewayOperatorSecureCLIStore) Delete(context.Context, uuid.UUID) error { return nil }
|
|
|
|
func (s *gatewayOperatorSecureCLIStore) List(context.Context) ([]store.SecureCLIBinary, error) {
|
|
out := make([]store.SecureCLIBinary, len(s.binaries))
|
|
copy(out, s.binaries)
|
|
return out, nil
|
|
}
|
|
|
|
func (s *gatewayOperatorSecureCLIStore) LookupByBinary(context.Context, string, *uuid.UUID, string) (*store.SecureCLIBinary, error) {
|
|
return nil, sql.ErrNoRows
|
|
}
|
|
|
|
func (s *gatewayOperatorSecureCLIStore) ListEnabled(context.Context) ([]store.SecureCLIBinary, error) {
|
|
return nil, nil
|
|
}
|
|
|
|
func (s *gatewayOperatorSecureCLIStore) ListForAgent(context.Context, uuid.UUID) ([]store.SecureCLIBinary, error) {
|
|
return nil, nil
|
|
}
|
|
|
|
func (s *gatewayOperatorSecureCLIStore) IsRegisteredBinary(context.Context, string) (bool, error) {
|
|
return false, nil
|
|
}
|
|
|
|
func (s *gatewayOperatorSecureCLIStore) GetUserCredentials(context.Context, uuid.UUID, string) (*store.SecureCLIUserCredential, error) {
|
|
return nil, sql.ErrNoRows
|
|
}
|
|
|
|
func (s *gatewayOperatorSecureCLIStore) SetUserCredentials(context.Context, uuid.UUID, string, []byte) error {
|
|
return nil
|
|
}
|
|
|
|
func (s *gatewayOperatorSecureCLIStore) SetUserCredentialsTyped(context.Context, uuid.UUID, string, []byte, *string, *string) error {
|
|
return nil
|
|
}
|
|
|
|
func (s *gatewayOperatorSecureCLIStore) DeleteUserCredentials(context.Context, uuid.UUID, string) error {
|
|
return nil
|
|
}
|
|
|
|
func (s *gatewayOperatorSecureCLIStore) ListUserCredentials(context.Context, uuid.UUID) ([]store.SecureCLIUserCredential, error) {
|
|
return nil, nil
|
|
}
|
|
|
|
type gatewayOperatorGrantStore struct {
|
|
grants map[uuid.UUID]*store.SecureCLIAgentGrant
|
|
env map[uuid.UUID][]byte
|
|
}
|
|
|
|
func (s *gatewayOperatorGrantStore) BinaryExists(context.Context, uuid.UUID) (bool, error) {
|
|
return true, nil
|
|
}
|
|
|
|
func (s *gatewayOperatorGrantStore) AgentExists(context.Context, uuid.UUID) (bool, error) {
|
|
return true, nil
|
|
}
|
|
|
|
func (s *gatewayOperatorGrantStore) Create(_ context.Context, g *store.SecureCLIAgentGrant) error {
|
|
if s.grants == nil {
|
|
s.grants = map[uuid.UUID]*store.SecureCLIAgentGrant{}
|
|
}
|
|
if g.ID == uuid.Nil {
|
|
g.ID = store.GenNewID()
|
|
}
|
|
cp := *g
|
|
s.grants[g.ID] = &cp
|
|
return nil
|
|
}
|
|
|
|
func (s *gatewayOperatorGrantStore) Get(_ context.Context, id uuid.UUID) (*store.SecureCLIAgentGrant, error) {
|
|
if g := s.grants[id]; g != nil {
|
|
cp := *g
|
|
return &cp, nil
|
|
}
|
|
return nil, sql.ErrNoRows
|
|
}
|
|
|
|
func (s *gatewayOperatorGrantStore) Update(_ context.Context, id uuid.UUID, updates map[string]any) error {
|
|
g := s.grants[id]
|
|
if g == nil {
|
|
return sql.ErrNoRows
|
|
}
|
|
if enabled, ok := updates["enabled"].(bool); ok {
|
|
g.Enabled = enabled
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *gatewayOperatorGrantStore) Delete(context.Context, uuid.UUID) error { return nil }
|
|
|
|
func (s *gatewayOperatorGrantStore) ListByBinary(_ context.Context, binaryID uuid.UUID) ([]store.SecureCLIAgentGrant, error) {
|
|
out := []store.SecureCLIAgentGrant{}
|
|
for _, g := range s.grants {
|
|
if g.BinaryID == binaryID {
|
|
out = append(out, *g)
|
|
}
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func (s *gatewayOperatorGrantStore) ListByAgent(_ context.Context, agentID uuid.UUID) ([]store.SecureCLIAgentGrant, error) {
|
|
out := []store.SecureCLIAgentGrant{}
|
|
for _, g := range s.grants {
|
|
if g.AgentID == agentID {
|
|
out = append(out, *g)
|
|
}
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func (s *gatewayOperatorGrantStore) UpdateGrantEnv(_ context.Context, grantID uuid.UUID, plaintextEnv []byte) error {
|
|
if s.env == nil {
|
|
s.env = map[uuid.UUID][]byte{}
|
|
}
|
|
if s.grants[grantID] == nil {
|
|
return sql.ErrNoRows
|
|
}
|
|
cp := append([]byte(nil), plaintextEnv...)
|
|
s.env[grantID] = cp
|
|
s.grants[grantID].EncryptedEnv = cp
|
|
return nil
|
|
}
|
|
|
|
type gatewayOperatorAgentCredentialStore struct {
|
|
env map[uuid.UUID][]byte
|
|
createdBy map[uuid.UUID]string
|
|
}
|
|
|
|
func (s *gatewayOperatorAgentCredentialStore) BinaryExists(context.Context, uuid.UUID) (bool, error) {
|
|
return true, nil
|
|
}
|
|
|
|
func (s *gatewayOperatorAgentCredentialStore) AgentExists(context.Context, uuid.UUID) (bool, error) {
|
|
return true, nil
|
|
}
|
|
|
|
func (s *gatewayOperatorAgentCredentialStore) GetAgentCredentials(_ context.Context, _ uuid.UUID, agentID uuid.UUID) (*store.SecureCLIAgentCredential, error) {
|
|
if s.env == nil || len(s.env[agentID]) == 0 {
|
|
return nil, nil
|
|
}
|
|
return &store.SecureCLIAgentCredential{
|
|
ID: store.GenNewID(),
|
|
AgentID: agentID,
|
|
EncryptedEnv: append([]byte(nil), s.env[agentID]...),
|
|
CreatedBy: s.createdBy[agentID],
|
|
}, nil
|
|
}
|
|
|
|
func (s *gatewayOperatorAgentCredentialStore) SetAgentCredentials(_ context.Context, _ uuid.UUID, agentID uuid.UUID, encryptedEnv []byte, createdBy string) error {
|
|
if s.env == nil {
|
|
s.env = map[uuid.UUID][]byte{}
|
|
}
|
|
if s.createdBy == nil {
|
|
s.createdBy = map[uuid.UUID]string{}
|
|
}
|
|
s.env[agentID] = append([]byte(nil), encryptedEnv...)
|
|
s.createdBy[agentID] = createdBy
|
|
return nil
|
|
}
|
|
|
|
func (s *gatewayOperatorAgentCredentialStore) SetAgentCredentialsTyped(ctx context.Context, binaryID uuid.UUID, agentID uuid.UUID, encryptedEnv []byte, _ *string, _ *string, createdBy string) error {
|
|
return s.SetAgentCredentials(ctx, binaryID, agentID, encryptedEnv, createdBy)
|
|
}
|
|
|
|
func (s *gatewayOperatorAgentCredentialStore) DeleteAgentCredentials(context.Context, uuid.UUID, uuid.UUID) error {
|
|
return nil
|
|
}
|
|
|
|
func (s *gatewayOperatorAgentCredentialStore) ListAgentCredentials(context.Context, uuid.UUID) ([]store.SecureCLIAgentCredential, error) {
|
|
return nil, nil
|
|
}
|
|
|
|
func gatewayOperatorContext() context.Context {
|
|
ctx := context.Background()
|
|
ctx = store.WithTenantID(ctx, uuid.MustParse("0193a5b0-7000-7000-8000-000000000002"))
|
|
ctx = store.WithUserID(ctx, "system")
|
|
ctx = store.WithRole(ctx, store.RoleOwner)
|
|
return ctx
|
|
}
|
|
|
|
type gatewayOperatorAgentStore struct {
|
|
store.AgentStore
|
|
agents []store.AgentData
|
|
files map[uuid.UUID]map[string]string
|
|
}
|
|
|
|
func (s *gatewayOperatorAgentStore) Create(_ context.Context, agent *store.AgentData) error {
|
|
if agent.ID == uuid.Nil {
|
|
agent.ID = store.GenNewID()
|
|
}
|
|
if agent.CreatedAt.IsZero() {
|
|
agent.CreatedAt = time.Now().UTC()
|
|
}
|
|
agent.UpdatedAt = agent.CreatedAt
|
|
cp := *agent
|
|
s.agents = append(s.agents, cp)
|
|
return nil
|
|
}
|
|
|
|
func (s *gatewayOperatorAgentStore) GetByKey(_ context.Context, agentKey string) (*store.AgentData, error) {
|
|
for i := range s.agents {
|
|
if s.agents[i].AgentKey == agentKey {
|
|
cp := s.agents[i]
|
|
return &cp, nil
|
|
}
|
|
}
|
|
return nil, sql.ErrNoRows
|
|
}
|
|
|
|
func (s *gatewayOperatorAgentStore) List(context.Context, string) ([]store.AgentData, error) {
|
|
out := make([]store.AgentData, len(s.agents))
|
|
copy(out, s.agents)
|
|
return out, nil
|
|
}
|
|
|
|
func (s *gatewayOperatorAgentStore) GetAgentContextFiles(context.Context, uuid.UUID) ([]store.AgentContextFileData, error) {
|
|
return nil, nil
|
|
}
|
|
|
|
func (s *gatewayOperatorAgentStore) SetAgentContextFile(_ context.Context, agentID uuid.UUID, fileName, content string) error {
|
|
if s.files == nil {
|
|
s.files = map[uuid.UUID]map[string]string{}
|
|
}
|
|
if s.files[agentID] == nil {
|
|
s.files[agentID] = map[string]string{}
|
|
}
|
|
s.files[agentID][fileName] = content
|
|
return nil
|
|
}
|
|
|
|
func TestGatewayOperatorBootstrapCreatesBinaryGrantAndSensitiveTokenEnv(t *testing.T) {
|
|
setupTestToken(t, "test-gateway-token")
|
|
secureCLI := &gatewayOperatorSecureCLIStore{}
|
|
grants := &gatewayOperatorGrantStore{}
|
|
agentCreds := &gatewayOperatorAgentCredentialStore{}
|
|
agentID := uuid.New()
|
|
handler := &AgentsHandler{}
|
|
handler.SetGatewayOperatorBootstrap(secureCLI, grants, agentCreds, "http://127.0.0.1:18790")
|
|
handler.findGatewayOperatorBinary = func() (string, error) {
|
|
return "/usr/local/bin/goclaw", nil
|
|
}
|
|
|
|
result, err := handler.bootstrapGatewayOperatorAccess(gatewayOperatorContext(), agentID)
|
|
if err != nil {
|
|
t.Fatalf("bootstrap returned error: %v", err)
|
|
}
|
|
if result.BinaryID == uuid.Nil || result.GrantID == uuid.Nil {
|
|
t.Fatalf("bootstrap result missing IDs: %#v", result)
|
|
}
|
|
if secureCLI.created == nil {
|
|
t.Fatal("expected goclaw secure CLI binary to be registered")
|
|
}
|
|
if secureCLI.created.BinaryName != "goclaw" {
|
|
t.Fatalf("binary name=%q, want goclaw", secureCLI.created.BinaryName)
|
|
}
|
|
if secureCLI.created.IsGlobal {
|
|
t.Fatal("gateway operator binary must be non-global")
|
|
}
|
|
if secureCLI.created.BinaryPath == nil || *secureCLI.created.BinaryPath != "/usr/local/bin/goclaw" {
|
|
t.Fatalf("binary path not recorded: %#v", secureCLI.created.BinaryPath)
|
|
}
|
|
if !strings.Contains(string(secureCLI.created.DenyArgs), "auth") {
|
|
t.Fatalf("expected lifecycle/auth deny patterns, got %s", string(secureCLI.created.DenyArgs))
|
|
}
|
|
|
|
if len(grants.env) != 0 {
|
|
t.Fatalf("gateway token must not be stored in revealable grant env: %#v", grants.env)
|
|
}
|
|
|
|
envJSON := agentCreds.env[agentID]
|
|
if len(envJSON) == 0 {
|
|
t.Fatal("expected non-revealable per-agent credential env")
|
|
}
|
|
entries, err := store.ParseSecureCLIEnv(envJSON)
|
|
if err != nil {
|
|
t.Fatalf("parse agent credential env: %v", err)
|
|
}
|
|
tokenEntry := entries["GOCLAW_GATEWAY_TOKEN"]
|
|
if tokenEntry.Kind != store.SecureCLIEnvKindSensitive || tokenEntry.Value != "test-gateway-token" {
|
|
t.Fatalf("token entry not stored as sensitive override: %#v", tokenEntry)
|
|
}
|
|
if entries["GOCLAW_SERVER"].Value != "http://127.0.0.1:18790" {
|
|
t.Fatalf("GOCLAW_SERVER not injected: %#v", entries["GOCLAW_SERVER"])
|
|
}
|
|
|
|
safeEnv := agentCredentialResponse(store.SecureCLIAgentCredential{EncryptedEnv: envJSON}).Env
|
|
if safeEnv["GOCLAW_GATEWAY_TOKEN"].Value != nil || !safeEnv["GOCLAW_GATEWAY_TOKEN"].Masked {
|
|
encoded, _ := json.Marshal(safeEnv)
|
|
t.Fatalf("gateway token not masked in agent credential response: %s", encoded)
|
|
}
|
|
}
|
|
|
|
func TestGatewayOperatorBootstrapReusesExistingGrant(t *testing.T) {
|
|
setupTestToken(t, "rotated-test-token")
|
|
binaryID := uuid.New()
|
|
grantID := uuid.New()
|
|
agentID := uuid.New()
|
|
secureCLI := &gatewayOperatorSecureCLIStore{
|
|
binaries: []store.SecureCLIBinary{{
|
|
BaseModel: store.BaseModel{ID: binaryID},
|
|
BinaryName: "goclaw",
|
|
Enabled: true,
|
|
IsGlobal: false,
|
|
}},
|
|
}
|
|
grants := &gatewayOperatorGrantStore{
|
|
grants: map[uuid.UUID]*store.SecureCLIAgentGrant{
|
|
grantID: {
|
|
BaseModel: store.BaseModel{ID: grantID},
|
|
BinaryID: binaryID,
|
|
AgentID: agentID,
|
|
Enabled: false,
|
|
},
|
|
},
|
|
}
|
|
agentCreds := &gatewayOperatorAgentCredentialStore{}
|
|
handler := &AgentsHandler{}
|
|
handler.SetGatewayOperatorBootstrap(secureCLI, grants, agentCreds, "http://127.0.0.1:18790")
|
|
handler.findGatewayOperatorBinary = func() (string, error) {
|
|
return "/usr/local/bin/goclaw", nil
|
|
}
|
|
|
|
result, err := handler.bootstrapGatewayOperatorAccess(gatewayOperatorContext(), agentID)
|
|
if err != nil {
|
|
t.Fatalf("bootstrap returned error: %v", err)
|
|
}
|
|
if result.GrantID != grantID {
|
|
t.Fatalf("expected existing grant %s, got %s", grantID, result.GrantID)
|
|
}
|
|
if !grants.grants[grantID].Enabled {
|
|
t.Fatal("existing disabled grant should be re-enabled")
|
|
}
|
|
if secureCLI.created != nil {
|
|
t.Fatal("existing binary should be reused, not recreated")
|
|
}
|
|
if len(agentCreds.env[agentID]) == 0 {
|
|
t.Fatal("expected agent credential env to be refreshed for existing grant")
|
|
}
|
|
}
|
|
|
|
func TestGatewayOperatorBootstrapConvertsExistingGlobalBinaryToGrantScoped(t *testing.T) {
|
|
setupTestToken(t, "test-gateway-token")
|
|
binaryID := uuid.New()
|
|
agentID := uuid.New()
|
|
secureCLI := &gatewayOperatorSecureCLIStore{
|
|
binaries: []store.SecureCLIBinary{{
|
|
BaseModel: store.BaseModel{ID: binaryID},
|
|
BinaryName: "goclaw",
|
|
Enabled: true,
|
|
IsGlobal: true,
|
|
}},
|
|
}
|
|
grants := &gatewayOperatorGrantStore{}
|
|
agentCreds := &gatewayOperatorAgentCredentialStore{}
|
|
handler := &AgentsHandler{}
|
|
handler.SetGatewayOperatorBootstrap(secureCLI, grants, agentCreds, "127.0.0.1:19999")
|
|
handler.findGatewayOperatorBinary = func() (string, error) {
|
|
return "/opt/goclaw/current/goclaw", nil
|
|
}
|
|
|
|
result, err := handler.bootstrapGatewayOperatorAccess(gatewayOperatorContext(), agentID)
|
|
if err != nil {
|
|
t.Fatalf("bootstrap returned error: %v", err)
|
|
}
|
|
if result.BinaryID != binaryID {
|
|
t.Fatalf("expected existing binary %s, got %s", binaryID, result.BinaryID)
|
|
}
|
|
if secureCLI.created != nil {
|
|
t.Fatal("existing binary should be policy-updated, not duplicated")
|
|
}
|
|
updates := secureCLI.updated[binaryID]
|
|
if updates == nil {
|
|
t.Fatal("expected existing global binary to be updated")
|
|
}
|
|
if updates["is_global"] != false || updates["enabled"] != true {
|
|
t.Fatalf("expected explicit non-global enabled policy, got %#v", updates)
|
|
}
|
|
if got := secureCLI.binaries[0].BinaryPath; got == nil || *got != "/opt/goclaw/current/goclaw" {
|
|
t.Fatalf("binary path not updated: %#v", got)
|
|
}
|
|
if len(grants.grants) != 1 {
|
|
t.Fatalf("expected one explicit agent grant, got %d", len(grants.grants))
|
|
}
|
|
entries, err := store.ParseSecureCLIEnv(agentCreds.env[agentID])
|
|
if err != nil {
|
|
t.Fatalf("parse agent credential env: %v", err)
|
|
}
|
|
if entries["GOCLAW_SERVER"].Value != "http://127.0.0.1:19999" {
|
|
t.Fatalf("GOCLAW_SERVER not normalized: %#v", entries["GOCLAW_SERVER"])
|
|
}
|
|
}
|
|
|
|
func TestGatewayOperatorBootstrapRewritesUnsafeExistingNonGlobalBinary(t *testing.T) {
|
|
setupTestToken(t, "test-gateway-token")
|
|
binaryID := uuid.New()
|
|
agentID := uuid.New()
|
|
unsafePath := "/tmp/wrapper-goclaw"
|
|
adapterName := "git"
|
|
secureCLI := &gatewayOperatorSecureCLIStore{
|
|
binaries: []store.SecureCLIBinary{{
|
|
BaseModel: store.BaseModel{ID: binaryID},
|
|
BinaryName: "goclaw",
|
|
BinaryPath: &unsafePath,
|
|
Enabled: true,
|
|
IsGlobal: false,
|
|
DenyArgs: json.RawMessage(`[]`),
|
|
DenyVerbose: json.RawMessage(`[]`),
|
|
TimeoutSeconds: 300,
|
|
AdapterName: &adapterName,
|
|
}},
|
|
}
|
|
grants := &gatewayOperatorGrantStore{}
|
|
agentCreds := &gatewayOperatorAgentCredentialStore{}
|
|
handler := &AgentsHandler{}
|
|
handler.SetGatewayOperatorBootstrap(secureCLI, grants, agentCreds, "http://127.0.0.1:18790")
|
|
handler.findGatewayOperatorBinary = func() (string, error) {
|
|
return "/opt/goclaw/current/goclaw", nil
|
|
}
|
|
|
|
result, err := handler.bootstrapGatewayOperatorAccess(gatewayOperatorContext(), agentID)
|
|
if err != nil {
|
|
t.Fatalf("bootstrap returned error: %v", err)
|
|
}
|
|
if result.BinaryID != binaryID {
|
|
t.Fatalf("expected existing binary %s, got %s", binaryID, result.BinaryID)
|
|
}
|
|
updates := secureCLI.updated[binaryID]
|
|
if updates == nil {
|
|
t.Fatal("expected unsafe existing binary policy to be rewritten before grant")
|
|
}
|
|
if got := secureCLI.binaries[0].BinaryPath; got == nil || *got != "/opt/goclaw/current/goclaw" {
|
|
t.Fatalf("safe binary path not enforced: %#v", got)
|
|
}
|
|
if !strings.Contains(string(secureCLI.binaries[0].DenyArgs), "migrate") {
|
|
t.Fatalf("safe deny policy not enforced: %s", string(secureCLI.binaries[0].DenyArgs))
|
|
}
|
|
if secureCLI.binaries[0].AdapterName != nil {
|
|
t.Fatalf("gateway operator binary must use passthrough adapter, got %q", *secureCLI.binaries[0].AdapterName)
|
|
}
|
|
if len(grants.grants) != 1 || len(agentCreds.env[agentID]) == 0 {
|
|
t.Fatal("expected grant and non-revealable credential after safe policy rewrite")
|
|
}
|
|
}
|
|
|
|
func TestAgentsCreateAddsGatewayOperatorBootstrapMetadataWhenRequested(t *testing.T) {
|
|
setupTestToken(t, "test-gateway-token")
|
|
agents := &gatewayOperatorAgentStore{}
|
|
secureCLI := &gatewayOperatorSecureCLIStore{}
|
|
grants := &gatewayOperatorGrantStore{}
|
|
agentCreds := &gatewayOperatorAgentCredentialStore{}
|
|
handler := &AgentsHandler{
|
|
agents: agents,
|
|
defaultWorkspace: "/tmp/workspace",
|
|
}
|
|
handler.SetGatewayOperatorBootstrap(secureCLI, grants, agentCreds, "http://127.0.0.1:18790")
|
|
handler.findGatewayOperatorBinary = func() (string, error) {
|
|
return "/usr/local/bin/goclaw", nil
|
|
}
|
|
|
|
body := []byte(`{
|
|
"agent_key":"assistant",
|
|
"display_name":"Assistant",
|
|
"provider":"anthropic",
|
|
"model":"claude",
|
|
"grant_gateway_operator_access":true
|
|
}`)
|
|
req := httptest.NewRequest(http.MethodPost, "/v1/agents", bytes.NewReader(body))
|
|
req = req.WithContext(gatewayOperatorContext())
|
|
rr := httptest.NewRecorder()
|
|
|
|
handler.handleCreate(rr, req)
|
|
|
|
if rr.Code != http.StatusCreated {
|
|
t.Fatalf("status=%d body=%s", rr.Code, rr.Body.String())
|
|
}
|
|
var response struct {
|
|
ID uuid.UUID `json:"id"`
|
|
AgentKey string `json:"agent_key"`
|
|
GatewayOperatorBootstrap *gatewayOperatorBootstrapResult `json:"gateway_operator_bootstrap"`
|
|
}
|
|
if err := json.Unmarshal(rr.Body.Bytes(), &response); err != nil {
|
|
t.Fatalf("decode response: %v body=%s", err, rr.Body.String())
|
|
}
|
|
if response.ID == uuid.Nil || response.AgentKey != "assistant" {
|
|
t.Fatalf("unexpected agent response: %#v", response)
|
|
}
|
|
if response.GatewayOperatorBootstrap == nil || response.GatewayOperatorBootstrap.Status != "granted" {
|
|
t.Fatalf("expected granted bootstrap metadata, got %#v body=%s", response.GatewayOperatorBootstrap, rr.Body.String())
|
|
}
|
|
if len(grants.grants) != 1 {
|
|
t.Fatalf("expected one grant, got %d", len(grants.grants))
|
|
}
|
|
if len(agentCreds.env[response.ID]) == 0 {
|
|
t.Fatal("expected per-agent gateway credentials to be stored")
|
|
}
|
|
if strings.Contains(rr.Body.String(), "test-gateway-token") {
|
|
t.Fatalf("gateway token leaked in create response: %s", rr.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestGatewayOperatorBootstrapFailsClosedWithoutToken(t *testing.T) {
|
|
setupTestToken(t, "")
|
|
secureCLI := &gatewayOperatorSecureCLIStore{}
|
|
grants := &gatewayOperatorGrantStore{}
|
|
agentCreds := &gatewayOperatorAgentCredentialStore{}
|
|
handler := &AgentsHandler{}
|
|
handler.SetGatewayOperatorBootstrap(secureCLI, grants, agentCreds, "http://127.0.0.1:18790")
|
|
handler.findGatewayOperatorBinary = func() (string, error) {
|
|
return "/usr/local/bin/goclaw", nil
|
|
}
|
|
|
|
_, err := handler.bootstrapGatewayOperatorAccess(gatewayOperatorContext(), uuid.New())
|
|
if err == nil {
|
|
t.Fatal("expected missing gateway token to fail closed")
|
|
}
|
|
if secureCLI.created != nil || len(grants.grants) > 0 || len(agentCreds.env) > 0 {
|
|
t.Fatal("missing token must not create binary, grant, or agent credential")
|
|
}
|
|
}
|
|
|
|
func TestGatewayOperatorBootstrapReportsBinaryDiscoveryFailure(t *testing.T) {
|
|
setupTestToken(t, "test-gateway-token")
|
|
handler := &AgentsHandler{}
|
|
handler.SetGatewayOperatorBootstrap(&gatewayOperatorSecureCLIStore{}, &gatewayOperatorGrantStore{}, &gatewayOperatorAgentCredentialStore{}, "http://127.0.0.1:18790")
|
|
handler.findGatewayOperatorBinary = func() (string, error) {
|
|
return "", errors.New("not found")
|
|
}
|
|
|
|
_, err := handler.bootstrapGatewayOperatorAccess(gatewayOperatorContext(), uuid.New())
|
|
if !errors.Is(err, errGatewayOperatorBinaryMissing) {
|
|
t.Fatalf("expected binary discovery warning, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestGatewayOperatorFirstAgentGateUsesDeterministicEarliestRow(t *testing.T) {
|
|
firstID := uuid.MustParse("0193a5b0-7000-7000-8000-000000000001")
|
|
secondID := uuid.MustParse("0193a5b0-7000-7000-8000-000000000002")
|
|
createdAt := time.Date(2026, 6, 12, 10, 0, 0, 0, time.UTC)
|
|
|
|
got := deterministicFirstAgentID([]store.AgentData{
|
|
{BaseModel: store.BaseModel{ID: secondID, CreatedAt: createdAt.Add(time.Second)}},
|
|
{BaseModel: store.BaseModel{ID: firstID, CreatedAt: createdAt}},
|
|
})
|
|
|
|
if got != firstID {
|
|
t.Fatalf("deterministic first id=%s, want %s", got, firstID)
|
|
}
|
|
|
|
tieWinner := deterministicFirstAgentID([]store.AgentData{
|
|
{BaseModel: store.BaseModel{ID: secondID, CreatedAt: createdAt}},
|
|
{BaseModel: store.BaseModel{ID: firstID, CreatedAt: createdAt}},
|
|
})
|
|
if tieWinner != firstID {
|
|
t.Fatalf("tie winner id=%s, want lexical uuid %s", tieWinner, firstID)
|
|
}
|
|
}
|