mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-07-25 14:22:57 +00:00
334 lines
11 KiB
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)
|
|
}
|