Files
goclaw/internal/http/agents_gateway_operator_bootstrap.go

334 lines
11 KiB
Go

package http
import (
"context"
"encoding/json"
"errors"
"fmt"
"log/slog"
"net/url"
"os"
"os/exec"
"path/filepath"
"sort"
"strings"
"github.com/google/uuid"
"github.com/nextlevelbuilder/goclaw/internal/i18n"
"github.com/nextlevelbuilder/goclaw/internal/skills"
"github.com/nextlevelbuilder/goclaw/internal/store"
)
const gatewayOperatorBinaryName = "goclaw"
var (
errGatewayOperatorSecureCLIUnavailable = errors.New("gateway operator securecli unavailable")
errGatewayOperatorTokenMissing = errors.New("gateway operator token missing")
errGatewayOperatorBinaryMissing = errors.New("gateway operator goclaw binary missing")
errGatewayOperatorExistingReview = errors.New("gateway operator existing credential requires review")
errGatewayOperatorRegisterFailed = errors.New("gateway operator register failed")
errGatewayOperatorCredentialFailed = errors.New("gateway operator credential failed")
)
type gatewayOperatorBootstrapResult struct {
Status string `json:"status"`
BinaryID uuid.UUID `json:"binary_id,omitempty"`
GrantID uuid.UUID `json:"grant_id,omitempty"`
Warning string `json:"warning,omitempty"`
}
func (h *AgentsHandler) SetGatewayOperatorBootstrap(secureCLI store.SecureCLIStore, grants store.SecureCLIAgentGrantStore, agentCreds store.SecureCLIAgentCredentialStore, gatewayAddr string) {
h.secureCLI = secureCLI
h.secureCLIGrants = grants
h.secureCLIAgentCreds = agentCreds
h.gatewayAddr = gatewayAddr
if h.findGatewayOperatorBinary == nil {
h.findGatewayOperatorBinary = defaultFindGatewayOperatorBinary
}
}
func (h *AgentsHandler) bootstrapGatewayOperatorForCreatedAgent(ctx context.Context, agentID uuid.UUID, locale string) *gatewayOperatorBootstrapResult {
if h.secureCLI == nil || h.secureCLIGrants == nil || h.secureCLIAgentCreds == nil {
return &gatewayOperatorBootstrapResult{Status: "warning", Warning: i18n.T(locale, i18n.MsgGatewayOperatorSecureCLIUnavailable)}
}
first, err := h.isDeterministicFirstAgent(ctx, agentID)
if err != nil {
slog.Warn("gateway_operator.bootstrap.first_agent_check_failed", "agent_id", agentID, "error", err)
return &gatewayOperatorBootstrapResult{Status: "warning", Warning: i18n.T(locale, i18n.MsgGatewayOperatorEligibilityFailed)}
}
if !first {
return &gatewayOperatorBootstrapResult{Status: "skipped", Warning: i18n.T(locale, i18n.MsgGatewayOperatorNotFirstAgent)}
}
result, err := h.bootstrapGatewayOperatorAccess(ctx, agentID)
if err != nil {
slog.Warn("gateway_operator.bootstrap.failed", "agent_id", agentID, "error", err)
return &gatewayOperatorBootstrapResult{Status: "warning", Warning: gatewayOperatorWarning(locale, err)}
}
result.Status = "granted"
return result
}
func (h *AgentsHandler) isDeterministicFirstAgent(ctx context.Context, agentID uuid.UUID) (bool, error) {
if h.agents == nil || agentID == uuid.Nil {
return false, nil
}
agents, err := h.agents.List(ctx, "")
if err != nil {
return false, err
}
if len(agents) == 0 {
return false, nil
}
firstID := deterministicFirstAgentID(agents)
return firstID == agentID, nil
}
func deterministicFirstAgentID(agents []store.AgentData) uuid.UUID {
sort.SliceStable(agents, func(i, j int) bool {
if !agents[i].CreatedAt.Equal(agents[j].CreatedAt) {
return agents[i].CreatedAt.Before(agents[j].CreatedAt)
}
return agents[i].ID.String() < agents[j].ID.String()
})
if len(agents) == 0 {
return uuid.Nil
}
return agents[0].ID
}
func (h *AgentsHandler) bootstrapGatewayOperatorAccess(ctx context.Context, agentID uuid.UUID) (*gatewayOperatorBootstrapResult, error) {
if strings.TrimSpace(pkgGatewayToken) == "" {
return nil, errGatewayOperatorTokenMissing
}
if h.secureCLI == nil || h.secureCLIGrants == nil || h.secureCLIAgentCreds == nil {
return nil, errGatewayOperatorSecureCLIUnavailable
}
findBinary := h.findGatewayOperatorBinary
if findBinary == nil {
findBinary = defaultFindGatewayOperatorBinary
}
binaryPath, err := findBinary()
if err != nil {
return nil, errGatewayOperatorBinaryMissing
}
binary, err := h.ensureGatewayOperatorBinary(ctx, binaryPath)
if err != nil {
return nil, err
}
grant, err := h.ensureGatewayOperatorGrant(ctx, binary.ID, agentID)
if err != nil {
return nil, err
}
if err := h.setGatewayOperatorAgentCredential(ctx, binary.ID, agentID); err != nil {
return nil, err
}
h.emitCacheInvalidate("secure_cli", binary.ID.String())
return &gatewayOperatorBootstrapResult{BinaryID: binary.ID, GrantID: grant.ID}, nil
}
func (h *AgentsHandler) ensureGatewayOperatorBinary(ctx context.Context, binaryPath string) (*store.SecureCLIBinary, error) {
existing, err := h.findGatewayOperatorBinaryConfig(ctx)
if err != nil {
return nil, errGatewayOperatorCredentialFailed
}
if existing != nil {
return h.applyGatewayOperatorBinaryPolicy(ctx, existing, binaryPath)
}
binary := &store.SecureCLIBinary{
BinaryName: gatewayOperatorBinaryName,
BinaryPath: &binaryPath,
Description: gatewayOperatorDescription(),
DenyArgs: gatewayOperatorDenyArgs(),
DenyVerbose: gatewayOperatorDenyVerbose(),
TimeoutSeconds: 30,
Tips: gatewayOperatorTips(),
IsGlobal: false,
Enabled: true,
CreatedBy: gatewayOperatorActor(ctx),
}
if err := h.secureCLI.Create(ctx, binary); err != nil {
if found, findErr := h.findGatewayOperatorBinaryConfig(ctx); findErr == nil && found != nil {
if updated, updateErr := h.applyGatewayOperatorBinaryPolicy(ctx, found, binaryPath); updateErr == nil {
return updated, nil
}
}
return nil, errGatewayOperatorRegisterFailed
}
return binary, nil
}
func (h *AgentsHandler) applyGatewayOperatorBinaryPolicy(ctx context.Context, binary *store.SecureCLIBinary, binaryPath string) (*store.SecureCLIBinary, error) {
if err := h.secureCLI.Update(ctx, binary.ID, gatewayOperatorBinaryPolicyUpdates(binaryPath)); err != nil {
return nil, errGatewayOperatorCredentialFailed
}
binary.IsGlobal = false
binary.Enabled = true
binary.BinaryPath = &binaryPath
binary.Description = gatewayOperatorDescription()
binary.DenyArgs = gatewayOperatorDenyArgs()
binary.DenyVerbose = gatewayOperatorDenyVerbose()
binary.TimeoutSeconds = 30
binary.Tips = gatewayOperatorTips()
binary.AdapterName = nil
return binary, nil
}
func (h *AgentsHandler) findGatewayOperatorBinaryConfig(ctx context.Context) (*store.SecureCLIBinary, error) {
binaries, err := h.secureCLI.List(ctx)
if err != nil {
return nil, err
}
for i := range binaries {
if strings.EqualFold(strings.TrimSpace(binaries[i].BinaryName), gatewayOperatorBinaryName) {
b := binaries[i]
return &b, nil
}
}
return nil, nil
}
func gatewayOperatorBinaryPolicyUpdates(binaryPath string) map[string]any {
return map[string]any{
"binary_path": binaryPath,
"description": gatewayOperatorDescription(),
"deny_args": gatewayOperatorDenyArgs(),
"deny_verbose": gatewayOperatorDenyVerbose(),
"timeout_seconds": 30,
"tips": gatewayOperatorTips(),
"is_global": false,
"enabled": true,
"adapter_name": nil,
}
}
func gatewayOperatorDescription() string {
return "Local GoClaw gateway operator CLI"
}
func gatewayOperatorTips() string {
return "Use for local gateway operations such as `goclaw agent list`. Auth/setup/migration/backup/restore and verbose/debug commands are blocked."
}
func gatewayOperatorDenyArgs() json.RawMessage {
raw, _ := json.Marshal([]string{
`^auth\b`,
`^onboard\b`,
`^setup\b`,
`^migrate\b`,
`^upgrade\b`,
`^backup\b`,
`^restore\b`,
`^tenant-backup\b`,
`^tenant-restore\b`,
`^config\s+set\b`,
})
return raw
}
func gatewayOperatorDenyVerbose() json.RawMessage {
raw, _ := json.Marshal([]string{`-v`, `--verbose`, `--debug`})
return raw
}
func (h *AgentsHandler) ensureGatewayOperatorGrant(ctx context.Context, binaryID uuid.UUID, agentID uuid.UUID) (*store.SecureCLIAgentGrant, error) {
grants, err := h.secureCLIGrants.ListByAgent(ctx, agentID)
if err != nil {
return nil, errGatewayOperatorCredentialFailed
}
for i := range grants {
if grants[i].BinaryID != binaryID {
continue
}
if !grants[i].Enabled {
if err := h.secureCLIGrants.Update(ctx, grants[i].ID, map[string]any{"enabled": true}); err != nil {
return nil, errGatewayOperatorCredentialFailed
}
grants[i].Enabled = true
}
return &grants[i], nil
}
grant := &store.SecureCLIAgentGrant{
BinaryID: binaryID,
AgentID: agentID,
Enabled: true,
}
if err := h.secureCLIGrants.Create(ctx, grant); err != nil {
return nil, errGatewayOperatorCredentialFailed
}
return grant, nil
}
func (h *AgentsHandler) setGatewayOperatorAgentCredential(ctx context.Context, binaryID uuid.UUID, agentID uuid.UUID) error {
env, err := store.SerializeSecureCLIEnv(map[string]store.SecureCLIEnvEntry{
"GOCLAW_GATEWAY_TOKEN": {
Kind: store.SecureCLIEnvKindSensitive,
Value: pkgGatewayToken,
},
"GOCLAW_SERVER": {
Kind: store.SecureCLIEnvKindSensitive,
Value: gatewayOperatorServerURL(h.gatewayAddr),
},
})
if err != nil {
return errGatewayOperatorCredentialFailed
}
if err := h.secureCLIAgentCreds.SetAgentCredentials(ctx, binaryID, agentID, env, gatewayOperatorActor(ctx)); err != nil {
return errGatewayOperatorCredentialFailed
}
return nil
}
func gatewayOperatorWarning(locale string, err error) string {
switch {
case errors.Is(err, errGatewayOperatorSecureCLIUnavailable):
return i18n.T(locale, i18n.MsgGatewayOperatorSecureCLIUnavailable)
case errors.Is(err, errGatewayOperatorTokenMissing):
return i18n.T(locale, i18n.MsgGatewayOperatorTokenMissing)
case errors.Is(err, errGatewayOperatorBinaryMissing):
return i18n.T(locale, i18n.MsgGatewayOperatorBinaryMissing)
case errors.Is(err, errGatewayOperatorExistingReview):
return i18n.T(locale, i18n.MsgGatewayOperatorExistingReview)
case errors.Is(err, errGatewayOperatorRegisterFailed):
return i18n.T(locale, i18n.MsgGatewayOperatorRegisterFailed)
default:
return i18n.T(locale, i18n.MsgGatewayOperatorCredentialFailed)
}
}
func gatewayOperatorActor(ctx context.Context) string {
if userID := strings.TrimSpace(store.UserIDFromContext(ctx)); userID != "" {
return userID
}
return "system"
}
func gatewayOperatorServerURL(addr string) string {
addr = strings.TrimSpace(addr)
if addr == "" {
return "http://127.0.0.1:18790"
}
if u, err := url.Parse(addr); err == nil && u.Scheme != "" {
return strings.TrimRight(addr, "/")
}
return "http://" + strings.TrimRight(addr, "/")
}
func defaultFindGatewayOperatorBinary() (string, error) {
if exe, err := os.Executable(); err == nil && strings.EqualFold(filepath.Base(exe), gatewayOperatorBinaryName) && skills.IsExecutableFile(exe) {
return exe, nil
}
if path, err := exec.LookPath(gatewayOperatorBinaryName); err == nil && skills.IsExecutableFile(path) {
return path, nil
}
if path, ok := skills.FindRuntimeExecutable(gatewayOperatorBinaryName); ok && skills.IsExecutableFile(path) {
return path, nil
}
return "", fmt.Errorf("%s binary not found", gatewayOperatorBinaryName)
}