mirror of
https://github.com/YspCoder/clawgo.git
synced 2026-04-14 22:09:37 +08:00
853 lines
26 KiB
Go
853 lines
26 KiB
Go
package main
|
|
|
|
import (
|
|
"bufio"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
"os/exec"
|
|
"os/signal"
|
|
"path/filepath"
|
|
"reflect"
|
|
"runtime"
|
|
"strings"
|
|
"syscall"
|
|
"time"
|
|
|
|
"clawgo/pkg/agent"
|
|
"clawgo/pkg/autonomy"
|
|
"clawgo/pkg/bus"
|
|
"clawgo/pkg/channels"
|
|
"clawgo/pkg/config"
|
|
"clawgo/pkg/cron"
|
|
"clawgo/pkg/heartbeat"
|
|
"clawgo/pkg/logger"
|
|
"clawgo/pkg/nodes"
|
|
"clawgo/pkg/providers"
|
|
"clawgo/pkg/runtimecfg"
|
|
"clawgo/pkg/sentinel"
|
|
)
|
|
|
|
func gatewayCmd() {
|
|
args := os.Args[2:]
|
|
if len(args) == 0 {
|
|
if err := gatewayInstallServiceCmd(); err != nil {
|
|
fmt.Printf("Error registering gateway service: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
return
|
|
}
|
|
|
|
switch args[0] {
|
|
case "run":
|
|
case "start", "stop", "restart", "status":
|
|
if err := gatewayServiceControlCmd(args[0]); err != nil {
|
|
fmt.Printf("Error: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
return
|
|
case "autonomy":
|
|
if err := gatewayAutonomyControlCmd(args[1:]); err != nil {
|
|
fmt.Printf("Error: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
return
|
|
default:
|
|
fmt.Printf("Unknown gateway command: %s\n", args[0])
|
|
fmt.Println("Usage: clawgo gateway [run|start|stop|restart|status|autonomy on|off|status]")
|
|
return
|
|
}
|
|
|
|
cfg, err := loadConfig()
|
|
if err != nil {
|
|
fmt.Printf("Error loading config: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
runtimecfg.Set(cfg)
|
|
if strings.EqualFold(strings.TrimSpace(os.Getenv(envRootGranted)), "1") || strings.EqualFold(strings.TrimSpace(os.Getenv(envRootGranted)), "true") {
|
|
applyMaximumPermissionPolicy(cfg)
|
|
}
|
|
|
|
msgBus := bus.NewMessageBus()
|
|
cronStorePath := filepath.Join(filepath.Dir(getConfigPath()), "cron", "jobs.json")
|
|
cronService := cron.NewCronService(cronStorePath, func(job *cron.CronJob) (string, error) {
|
|
if job == nil {
|
|
return "", nil
|
|
}
|
|
|
|
targetChannel := strings.TrimSpace(job.Payload.Channel)
|
|
targetChatID := strings.TrimSpace(job.Payload.To)
|
|
message := strings.TrimSpace(job.Payload.Message)
|
|
|
|
if job.Payload.Deliver && targetChannel != "" && targetChatID != "" && message != "" {
|
|
msgBus.PublishOutbound(bus.OutboundMessage{
|
|
Channel: targetChannel,
|
|
ChatID: targetChatID,
|
|
Content: message,
|
|
})
|
|
return "delivered", nil
|
|
}
|
|
|
|
if message == "" {
|
|
return "", nil
|
|
}
|
|
if targetChannel == "" || targetChatID == "" {
|
|
targetChannel = "internal"
|
|
targetChatID = "cron"
|
|
}
|
|
msgBus.PublishInbound(bus.InboundMessage{
|
|
Channel: "system",
|
|
SenderID: "cron",
|
|
ChatID: fmt.Sprintf("%s:%s", targetChannel, targetChatID),
|
|
Content: message,
|
|
SessionKey: fmt.Sprintf("cron:%s", job.ID),
|
|
Metadata: map[string]string{
|
|
"trigger": "cron",
|
|
"job_id": job.ID,
|
|
},
|
|
})
|
|
return "scheduled", nil
|
|
})
|
|
configureCronServiceRuntime(cronService, cfg)
|
|
heartbeatService := buildHeartbeatService(cfg, msgBus)
|
|
autonomyEngine := buildAutonomyEngine(cfg, msgBus)
|
|
sentinelService := sentinel.NewService(
|
|
getConfigPath(),
|
|
cfg.WorkspacePath(),
|
|
cfg.Sentinel.IntervalSec,
|
|
cfg.Sentinel.AutoHeal,
|
|
func(message string) {
|
|
if cfg.Sentinel.NotifyChannel != "" && cfg.Sentinel.NotifyChatID != "" {
|
|
msgBus.PublishOutbound(bus.OutboundMessage{
|
|
Channel: cfg.Sentinel.NotifyChannel,
|
|
ChatID: cfg.Sentinel.NotifyChatID,
|
|
Content: "[Sentinel] " + message,
|
|
})
|
|
}
|
|
},
|
|
)
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
defer cancel()
|
|
|
|
agentLoop, channelManager, err := buildGatewayRuntime(ctx, cfg, msgBus, cronService)
|
|
if err != nil {
|
|
fmt.Printf("Error initializing gateway runtime: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
sentinelService.SetManager(channelManager)
|
|
|
|
pidFile := filepath.Join(filepath.Dir(getConfigPath()), "gateway.pid")
|
|
if err := os.WriteFile(pidFile, []byte(fmt.Sprintf("%d\n", os.Getpid())), 0644); err != nil {
|
|
fmt.Printf("Warning: failed to write PID file: %v\n", err)
|
|
} else {
|
|
defer os.Remove(pidFile)
|
|
}
|
|
|
|
enabledChannels := channelManager.GetEnabledChannels()
|
|
if len(enabledChannels) > 0 {
|
|
fmt.Printf("✓ Channels enabled: %s\n", enabledChannels)
|
|
} else {
|
|
fmt.Println("⚠ Warning: No channels enabled")
|
|
}
|
|
|
|
fmt.Printf("✓ Gateway started on %s:%d\n", cfg.Gateway.Host, cfg.Gateway.Port)
|
|
fmt.Println("Press Ctrl+C to stop. Send SIGHUP to hot-reload config.")
|
|
|
|
if err := cronService.Start(); err != nil {
|
|
fmt.Printf("Error starting cron service: %v\n", err)
|
|
}
|
|
fmt.Println("✓ Cron service started")
|
|
|
|
if err := heartbeatService.Start(); err != nil {
|
|
fmt.Printf("Error starting heartbeat service: %v\n", err)
|
|
}
|
|
fmt.Println("✓ Heartbeat service started")
|
|
autonomyEngine.Start()
|
|
if cfg.Agents.Defaults.Autonomy.Enabled {
|
|
fmt.Println("✓ Autonomy engine started")
|
|
}
|
|
if cfg.Sentinel.Enabled {
|
|
sentinelService.Start()
|
|
fmt.Println("✓ Sentinel service started")
|
|
}
|
|
|
|
if err := channelManager.StartAll(ctx); err != nil {
|
|
fmt.Printf("Error starting channels: %v\n", err)
|
|
}
|
|
|
|
registryServer := nodes.NewRegistryServer(cfg.Gateway.Host, cfg.Gateway.Port, cfg.Gateway.Token, nodes.DefaultManager())
|
|
registryServer.SetConfigPath(getConfigPath())
|
|
registryServer.SetWebUIDir(filepath.Join(cfg.WorkspacePath(), "webui-dist"))
|
|
registryServer.SetChatHandler(func(cctx context.Context, sessionKey, content string) (string, error) {
|
|
if strings.TrimSpace(content) == "" {
|
|
return "", nil
|
|
}
|
|
return agentLoop.ProcessDirect(cctx, content, sessionKey)
|
|
})
|
|
registryServer.SetConfigAfterHook(func() {
|
|
_ = syscall.Kill(os.Getpid(), syscall.SIGHUP)
|
|
})
|
|
if err := registryServer.Start(ctx); err != nil {
|
|
fmt.Printf("Error starting node registry server: %v\n", err)
|
|
} else {
|
|
fmt.Printf("✓ Node registry server started on %s:%d\n", cfg.Gateway.Host, cfg.Gateway.Port)
|
|
}
|
|
|
|
go agentLoop.Run(ctx)
|
|
go runGatewayStartupCompactionCheck(ctx, agentLoop)
|
|
go runGatewayBootstrapInit(ctx, cfg, agentLoop)
|
|
|
|
sigChan := make(chan os.Signal, 1)
|
|
signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM, syscall.SIGHUP)
|
|
for {
|
|
sig := <-sigChan
|
|
switch sig {
|
|
case syscall.SIGHUP:
|
|
fmt.Println("\n↻ Reloading config...")
|
|
newCfg, err := config.LoadConfig(getConfigPath())
|
|
if err != nil {
|
|
fmt.Printf("✗ Reload failed (load config): %v\n", err)
|
|
continue
|
|
}
|
|
if strings.EqualFold(strings.TrimSpace(os.Getenv(envRootGranted)), "1") || strings.EqualFold(strings.TrimSpace(os.Getenv(envRootGranted)), "true") {
|
|
applyMaximumPermissionPolicy(newCfg)
|
|
}
|
|
configureCronServiceRuntime(cronService, newCfg)
|
|
heartbeatService.Stop()
|
|
autonomyEngine.Stop()
|
|
heartbeatService = buildHeartbeatService(newCfg, msgBus)
|
|
autonomyEngine = buildAutonomyEngine(newCfg, msgBus)
|
|
if err := heartbeatService.Start(); err != nil {
|
|
fmt.Printf("Error starting heartbeat service: %v\n", err)
|
|
}
|
|
autonomyEngine.Start()
|
|
|
|
if reflect.DeepEqual(cfg, newCfg) {
|
|
fmt.Println("✓ Config unchanged, skip reload")
|
|
continue
|
|
}
|
|
|
|
runtimeSame := reflect.DeepEqual(cfg.Agents, newCfg.Agents) &&
|
|
reflect.DeepEqual(cfg.Providers, newCfg.Providers) &&
|
|
reflect.DeepEqual(cfg.Tools, newCfg.Tools) &&
|
|
reflect.DeepEqual(cfg.Channels, newCfg.Channels)
|
|
|
|
templateChanges := summarizeDialogTemplateChanges(cfg, newCfg)
|
|
autonomyChanges := summarizeAutonomyChanges(cfg, newCfg)
|
|
|
|
if runtimeSame {
|
|
configureLogging(newCfg)
|
|
sentinelService.Stop()
|
|
sentinelService = sentinel.NewService(
|
|
getConfigPath(),
|
|
newCfg.WorkspacePath(),
|
|
newCfg.Sentinel.IntervalSec,
|
|
newCfg.Sentinel.AutoHeal,
|
|
func(message string) {
|
|
if newCfg.Sentinel.NotifyChannel != "" && newCfg.Sentinel.NotifyChatID != "" {
|
|
msgBus.PublishOutbound(bus.OutboundMessage{
|
|
Channel: newCfg.Sentinel.NotifyChannel,
|
|
ChatID: newCfg.Sentinel.NotifyChatID,
|
|
Content: "[Sentinel] " + message,
|
|
})
|
|
}
|
|
},
|
|
)
|
|
if newCfg.Sentinel.Enabled {
|
|
sentinelService.SetManager(channelManager)
|
|
sentinelService.Start()
|
|
}
|
|
cfg = newCfg
|
|
runtimecfg.Set(cfg)
|
|
if len(templateChanges) > 0 {
|
|
fmt.Printf("↻ Dialog template changes: %s\n", strings.Join(templateChanges, ", "))
|
|
}
|
|
if len(autonomyChanges) > 0 {
|
|
fmt.Printf("↻ Autonomy changes: %s\n", strings.Join(autonomyChanges, ", "))
|
|
}
|
|
fmt.Println("✓ Config hot-reload applied (logging/metadata only)")
|
|
continue
|
|
}
|
|
|
|
newAgentLoop, newChannelManager, err := buildGatewayRuntime(ctx, newCfg, msgBus, cronService)
|
|
if err != nil {
|
|
fmt.Printf("✗ Reload failed (init runtime): %v\n", err)
|
|
continue
|
|
}
|
|
|
|
channelManager.StopAll(ctx)
|
|
agentLoop.Stop()
|
|
|
|
channelManager = newChannelManager
|
|
agentLoop = newAgentLoop
|
|
cfg = newCfg
|
|
runtimecfg.Set(cfg)
|
|
sentinelService.Stop()
|
|
sentinelService = sentinel.NewService(
|
|
getConfigPath(),
|
|
newCfg.WorkspacePath(),
|
|
newCfg.Sentinel.IntervalSec,
|
|
newCfg.Sentinel.AutoHeal,
|
|
func(message string) {
|
|
if newCfg.Sentinel.NotifyChannel != "" && newCfg.Sentinel.NotifyChatID != "" {
|
|
msgBus.PublishOutbound(bus.OutboundMessage{
|
|
Channel: newCfg.Sentinel.NotifyChannel,
|
|
ChatID: newCfg.Sentinel.NotifyChatID,
|
|
Content: "[Sentinel] " + message,
|
|
})
|
|
}
|
|
},
|
|
)
|
|
if newCfg.Sentinel.Enabled {
|
|
sentinelService.Start()
|
|
}
|
|
sentinelService.SetManager(channelManager)
|
|
|
|
if err := channelManager.StartAll(ctx); err != nil {
|
|
fmt.Printf("✗ Reload failed (start channels): %v\n", err)
|
|
continue
|
|
}
|
|
go agentLoop.Run(ctx)
|
|
if len(templateChanges) > 0 {
|
|
fmt.Printf("↻ Dialog template changes: %s\n", strings.Join(templateChanges, ", "))
|
|
}
|
|
if len(autonomyChanges) > 0 {
|
|
fmt.Printf("↻ Autonomy changes: %s\n", strings.Join(autonomyChanges, ", "))
|
|
}
|
|
fmt.Println("✓ Config hot-reload applied")
|
|
default:
|
|
fmt.Println("\nShutting down...")
|
|
cancel()
|
|
heartbeatService.Stop()
|
|
autonomyEngine.Stop()
|
|
sentinelService.Stop()
|
|
cronService.Stop()
|
|
agentLoop.Stop()
|
|
channelManager.StopAll(ctx)
|
|
fmt.Println("✓ Gateway stopped")
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
func gatewayAutonomyControlCmd(args []string) error {
|
|
if len(args) < 1 {
|
|
return fmt.Errorf("usage: clawgo gateway autonomy [on|off|status]")
|
|
}
|
|
cfg, err := loadConfig()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
memDir := filepath.Join(cfg.WorkspacePath(), "memory")
|
|
if err := os.MkdirAll(memDir, 0755); err != nil {
|
|
return err
|
|
}
|
|
pausePath := filepath.Join(memDir, "autonomy.pause")
|
|
ctrlPath := filepath.Join(memDir, "autonomy.control.json")
|
|
|
|
type autonomyControl struct {
|
|
Enabled bool `json:"enabled"`
|
|
UpdatedAt string `json:"updated_at"`
|
|
Source string `json:"source"`
|
|
}
|
|
|
|
writeControl := func(enabled bool) error {
|
|
c := autonomyControl{Enabled: enabled, UpdatedAt: time.Now().UTC().Format(time.RFC3339), Source: "manual_cli"}
|
|
data, err := json.MarshalIndent(c, "", " ")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return os.WriteFile(ctrlPath, append(data, '\n'), 0644)
|
|
}
|
|
|
|
switch strings.ToLower(strings.TrimSpace(args[0])) {
|
|
case "on":
|
|
_ = os.Remove(pausePath)
|
|
if err := writeControl(true); err != nil {
|
|
return err
|
|
}
|
|
fmt.Println("✓ Autonomy enabled")
|
|
return nil
|
|
case "off":
|
|
if err := writeControl(false); err != nil {
|
|
return err
|
|
}
|
|
if err := os.WriteFile(pausePath, []byte(time.Now().UTC().Format(time.RFC3339)+"\n"), 0644); err != nil {
|
|
return err
|
|
}
|
|
fmt.Println("✓ Autonomy disabled (paused)")
|
|
return nil
|
|
case "status":
|
|
enabled := true
|
|
reason := "default"
|
|
updatedAt := ""
|
|
source := ""
|
|
if data, err := os.ReadFile(ctrlPath); err == nil {
|
|
var c autonomyControl
|
|
if json.Unmarshal(data, &c) == nil {
|
|
enabled = c.Enabled
|
|
updatedAt = c.UpdatedAt
|
|
source = c.Source
|
|
if !c.Enabled {
|
|
reason = "control_file"
|
|
}
|
|
}
|
|
}
|
|
if _, err := os.Stat(pausePath); err == nil {
|
|
enabled = false
|
|
reason = "pause_file"
|
|
}
|
|
fmt.Printf("Autonomy status: %v (%s)\n", enabled, reason)
|
|
if strings.TrimSpace(updatedAt) != "" {
|
|
fmt.Printf("Last switch: %s", updatedAt)
|
|
if strings.TrimSpace(source) != "" {
|
|
fmt.Printf(" via %s", source)
|
|
}
|
|
fmt.Println()
|
|
}
|
|
fmt.Printf("Control file: %s\n", ctrlPath)
|
|
fmt.Printf("Pause file: %s\n", pausePath)
|
|
return nil
|
|
default:
|
|
return fmt.Errorf("usage: clawgo gateway autonomy [on|off|status]")
|
|
}
|
|
}
|
|
|
|
func summarizeAutonomyChanges(oldCfg, newCfg *config.Config) []string {
|
|
if oldCfg == nil || newCfg == nil {
|
|
return nil
|
|
}
|
|
o := oldCfg.Agents.Defaults.Autonomy
|
|
n := newCfg.Agents.Defaults.Autonomy
|
|
changes := make([]string, 0)
|
|
if o.Enabled != n.Enabled {
|
|
changes = append(changes, "enabled")
|
|
}
|
|
if o.TickIntervalSec != n.TickIntervalSec {
|
|
changes = append(changes, "tick_interval_sec")
|
|
}
|
|
if o.MinRunIntervalSec != n.MinRunIntervalSec {
|
|
changes = append(changes, "min_run_interval_sec")
|
|
}
|
|
if o.UserIdleResumeSec != n.UserIdleResumeSec {
|
|
changes = append(changes, "user_idle_resume_sec")
|
|
}
|
|
if o.WaitingResumeDebounceSec != n.WaitingResumeDebounceSec {
|
|
changes = append(changes, "waiting_resume_debounce_sec")
|
|
}
|
|
if strings.TrimSpace(o.QuietHours) != strings.TrimSpace(n.QuietHours) {
|
|
changes = append(changes, "quiet_hours")
|
|
}
|
|
if o.NotifyCooldownSec != n.NotifyCooldownSec {
|
|
changes = append(changes, "notify_cooldown_sec")
|
|
}
|
|
if o.NotifySameReasonCooldownSec != n.NotifySameReasonCooldownSec {
|
|
changes = append(changes, "notify_same_reason_cooldown_sec")
|
|
}
|
|
return changes
|
|
}
|
|
|
|
func summarizeDialogTemplateChanges(oldCfg, newCfg *config.Config) []string {
|
|
if oldCfg == nil || newCfg == nil {
|
|
return nil
|
|
}
|
|
type pair struct {
|
|
name string
|
|
a string
|
|
b string
|
|
}
|
|
oldT := oldCfg.Agents.Defaults.Texts
|
|
newT := newCfg.Agents.Defaults.Texts
|
|
checks := []pair{
|
|
{name: "system_rewrite_template", a: oldT.SystemRewriteTemplate, b: newT.SystemRewriteTemplate},
|
|
{name: "lang_usage", a: oldT.LangUsage, b: newT.LangUsage},
|
|
{name: "lang_invalid", a: oldT.LangInvalid, b: newT.LangInvalid},
|
|
{name: "lang_updated_template", a: oldT.LangUpdatedTemplate, b: newT.LangUpdatedTemplate},
|
|
{name: "runtime_compaction_note", a: oldT.RuntimeCompactionNote, b: newT.RuntimeCompactionNote},
|
|
{name: "startup_compaction_note", a: oldT.StartupCompactionNote, b: newT.StartupCompactionNote},
|
|
{name: "autonomy_completion_template", a: oldT.AutonomyCompletionTemplate, b: newT.AutonomyCompletionTemplate},
|
|
{name: "autonomy_blocked_template", a: oldT.AutonomyBlockedTemplate, b: newT.AutonomyBlockedTemplate},
|
|
}
|
|
out := make([]string, 0)
|
|
for _, c := range checks {
|
|
if strings.TrimSpace(c.a) != strings.TrimSpace(c.b) {
|
|
out = append(out, c.name)
|
|
}
|
|
}
|
|
if strings.Join(oldT.AutonomyImportantKeywords, "|") != strings.Join(newT.AutonomyImportantKeywords, "|") {
|
|
out = append(out, "autonomy_important_keywords")
|
|
}
|
|
if oldCfg.Agents.Defaults.Heartbeat.PromptTemplate != newCfg.Agents.Defaults.Heartbeat.PromptTemplate {
|
|
out = append(out, "heartbeat.prompt_template")
|
|
}
|
|
return out
|
|
}
|
|
|
|
func runGatewayStartupCompactionCheck(parent context.Context, agentLoop *agent.AgentLoop) {
|
|
if agentLoop == nil {
|
|
return
|
|
}
|
|
|
|
checkCtx, cancel := context.WithTimeout(parent, 10*time.Minute)
|
|
defer cancel()
|
|
|
|
report := agentLoop.RunStartupSelfCheckAllSessions(checkCtx)
|
|
logger.InfoCF("gateway", "Startup compaction check completed", map[string]interface{}{
|
|
"sessions_total": report.TotalSessions,
|
|
"sessions_compacted": report.CompactedSessions,
|
|
})
|
|
}
|
|
|
|
func runGatewayBootstrapInit(parent context.Context, cfg *config.Config, agentLoop *agent.AgentLoop) {
|
|
if agentLoop == nil || cfg == nil {
|
|
return
|
|
}
|
|
workspace := cfg.WorkspacePath()
|
|
bootstrapPath := filepath.Join(workspace, "BOOTSTRAP.md")
|
|
if _, err := os.Stat(bootstrapPath); err != nil {
|
|
return
|
|
}
|
|
memDir := filepath.Join(workspace, "memory")
|
|
_ = os.MkdirAll(memDir, 0755)
|
|
markerPath := filepath.Join(memDir, "bootstrap.init.done")
|
|
if _, err := os.Stat(markerPath); err == nil {
|
|
return
|
|
}
|
|
|
|
initCtx, cancel := context.WithTimeout(parent, 90*time.Second)
|
|
defer cancel()
|
|
prompt := "System startup bootstrap: read BOOTSTRAP.md and perform one-time self-initialization checks now. If already initialized, return concise status only."
|
|
resp, err := agentLoop.ProcessDirect(initCtx, prompt, "system:bootstrap:init")
|
|
if err != nil {
|
|
logger.ErrorCF("gateway", "Bootstrap init model call failed", map[string]interface{}{logger.FieldError: err.Error()})
|
|
return
|
|
}
|
|
line := fmt.Sprintf("%s\n%s\n", time.Now().UTC().Format(time.RFC3339), strings.TrimSpace(resp))
|
|
_ = os.WriteFile(markerPath, []byte(line), 0644)
|
|
logger.InfoC("gateway", "Bootstrap init model call completed")
|
|
}
|
|
|
|
func maybePromptAndEscalateRoot(command string) {
|
|
if os.Getenv(envRootPrompted) == "1" {
|
|
return
|
|
}
|
|
if !isInteractiveStdin() {
|
|
return
|
|
}
|
|
|
|
fmt.Printf("Grant root permissions for `clawgo %s`? (yes/no): ", command)
|
|
reader := bufio.NewReader(os.Stdin)
|
|
line, _ := reader.ReadString('\n')
|
|
answer := strings.ToLower(strings.TrimSpace(line))
|
|
if answer != "yes" && answer != "y" {
|
|
_ = os.Setenv(envRootPrompted, "1")
|
|
_ = os.Setenv(envRootGranted, "0")
|
|
return
|
|
}
|
|
|
|
_ = os.Setenv(envRootPrompted, "1")
|
|
_ = os.Setenv(envRootGranted, "1")
|
|
|
|
if os.Geteuid() == 0 {
|
|
return
|
|
}
|
|
|
|
exePath, err := os.Executable()
|
|
if err != nil {
|
|
fmt.Printf("Error resolving executable for sudo re-run: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
exePath, _ = filepath.Abs(exePath)
|
|
|
|
cmdArgs := append([]string{"-E", exePath}, os.Args[1:]...)
|
|
cmd := exec.Command("sudo", cmdArgs...)
|
|
cmd.Stdin = os.Stdin
|
|
cmd.Stdout = os.Stdout
|
|
cmd.Stderr = os.Stderr
|
|
cmd.Env = append(os.Environ(), envRootPrompted+"=1", envRootGranted+"=1")
|
|
|
|
if err := cmd.Run(); err != nil {
|
|
if exitErr, ok := err.(*exec.ExitError); ok {
|
|
os.Exit(exitErr.ExitCode())
|
|
}
|
|
fmt.Printf("Failed to elevate privileges with sudo: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
os.Exit(0)
|
|
}
|
|
|
|
func shouldPromptGatewayRoot(args []string) bool {
|
|
return len(args) == 2 && args[1] == "gateway"
|
|
}
|
|
|
|
func isInteractiveStdin() bool {
|
|
info, err := os.Stdin.Stat()
|
|
if err != nil {
|
|
return false
|
|
}
|
|
return (info.Mode() & os.ModeCharDevice) != 0
|
|
}
|
|
|
|
func applyMaximumPermissionPolicy(cfg *config.Config) {
|
|
cfg.Tools.Shell.Enabled = true
|
|
cfg.Tools.Shell.Sandbox.Enabled = false
|
|
}
|
|
|
|
func gatewayInstallServiceCmd() error {
|
|
scope, unitPath, err := detectGatewayServiceScopeAndPath()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
exePath, err := os.Executable()
|
|
if err != nil {
|
|
return fmt.Errorf("resolve executable path failed: %w", err)
|
|
}
|
|
exePath, _ = filepath.Abs(exePath)
|
|
configPath := getConfigPath()
|
|
workDir := filepath.Dir(exePath)
|
|
|
|
unitContent := buildGatewayUnitContent(scope, exePath, configPath, workDir)
|
|
if err := os.MkdirAll(filepath.Dir(unitPath), 0755); err != nil {
|
|
return fmt.Errorf("create service directory failed: %w", err)
|
|
}
|
|
if err := os.WriteFile(unitPath, []byte(unitContent), 0644); err != nil {
|
|
return fmt.Errorf("write service unit failed: %w", err)
|
|
}
|
|
|
|
if err := runSystemctl(scope, "daemon-reload"); err != nil {
|
|
return err
|
|
}
|
|
if err := runSystemctl(scope, "enable", gatewayServiceName); err != nil {
|
|
return err
|
|
}
|
|
|
|
fmt.Printf("✓ Gateway service registered: %s (%s)\n", gatewayServiceName, scope)
|
|
fmt.Printf(" Unit file: %s\n", unitPath)
|
|
fmt.Println(" Start service: clawgo gateway start")
|
|
fmt.Println(" Restart service: clawgo gateway restart")
|
|
fmt.Println(" Stop service: clawgo gateway stop")
|
|
return nil
|
|
}
|
|
|
|
func gatewayServiceControlCmd(action string) error {
|
|
scope, _, err := detectInstalledGatewayService()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return runSystemctl(scope, action, gatewayServiceName)
|
|
}
|
|
|
|
func detectGatewayServiceScopeAndPath() (string, string, error) {
|
|
if runtime.GOOS != "linux" {
|
|
return "", "", fmt.Errorf("gateway service registration currently supports Linux systemd only")
|
|
}
|
|
if strings.ToLower(strings.TrimSpace(os.Getenv("CLAWGO_GATEWAY_SCOPE"))) == "user" {
|
|
return userGatewayUnitPath()
|
|
}
|
|
if strings.ToLower(strings.TrimSpace(os.Getenv("CLAWGO_GATEWAY_SCOPE"))) == "system" {
|
|
return "system", "/etc/systemd/system/" + gatewayServiceName, nil
|
|
}
|
|
if os.Geteuid() == 0 {
|
|
return "system", "/etc/systemd/system/" + gatewayServiceName, nil
|
|
}
|
|
return userGatewayUnitPath()
|
|
}
|
|
|
|
func userGatewayUnitPath() (string, string, error) {
|
|
home, err := os.UserHomeDir()
|
|
if err != nil {
|
|
return "", "", fmt.Errorf("resolve user home failed: %w", err)
|
|
}
|
|
return "user", filepath.Join(home, ".config", "systemd", "user", gatewayServiceName), nil
|
|
}
|
|
|
|
func detectInstalledGatewayService() (string, string, error) {
|
|
systemPath := "/etc/systemd/system/" + gatewayServiceName
|
|
userScope, userPath, err := userGatewayUnitPath()
|
|
if err != nil {
|
|
return "", "", err
|
|
}
|
|
|
|
systemExists := false
|
|
if info, err := os.Stat(systemPath); err == nil && !info.IsDir() {
|
|
systemExists = true
|
|
}
|
|
|
|
userExists := false
|
|
if info, err := os.Stat(userPath); err == nil && !info.IsDir() {
|
|
userExists = true
|
|
}
|
|
|
|
preferredScope := strings.ToLower(strings.TrimSpace(os.Getenv("CLAWGO_GATEWAY_SCOPE")))
|
|
switch preferredScope {
|
|
case "system":
|
|
if systemExists {
|
|
return "system", systemPath, nil
|
|
}
|
|
return "", "", fmt.Errorf("gateway service unit not found in system scope: %s", systemPath)
|
|
case "user":
|
|
if userExists {
|
|
return userScope, userPath, nil
|
|
}
|
|
return "", "", fmt.Errorf("gateway service unit not found in user scope: %s", userPath)
|
|
}
|
|
|
|
// Auto-pick scope by current privilege to avoid non-root users accidentally
|
|
// selecting system scope when both unit files exist.
|
|
if os.Geteuid() == 0 {
|
|
if systemExists {
|
|
return "system", systemPath, nil
|
|
}
|
|
if userExists {
|
|
return userScope, userPath, nil
|
|
}
|
|
} else {
|
|
if userExists {
|
|
return userScope, userPath, nil
|
|
}
|
|
if systemExists {
|
|
return "system", systemPath, nil
|
|
}
|
|
}
|
|
|
|
return "", "", fmt.Errorf("gateway service not registered. Run: clawgo gateway")
|
|
}
|
|
|
|
func buildGatewayUnitContent(scope, exePath, configPath, workDir string) string {
|
|
quotedExec := fmt.Sprintf("%q gateway run --config %q", exePath, configPath)
|
|
installTarget := "default.target"
|
|
if scope == "system" {
|
|
installTarget = "multi-user.target"
|
|
}
|
|
home, err := os.UserHomeDir()
|
|
if err != nil {
|
|
home = filepath.Dir(configPath)
|
|
}
|
|
|
|
return fmt.Sprintf(`[Unit]
|
|
Description=ClawGo Gateway
|
|
After=network.target
|
|
|
|
[Service]
|
|
Type=simple
|
|
WorkingDirectory=%s
|
|
ExecStart=%s
|
|
Restart=always
|
|
RestartSec=3
|
|
Environment=CLAWGO_CONFIG=%s
|
|
Environment=HOME=%s
|
|
|
|
[Install]
|
|
WantedBy=%s
|
|
`, workDir, quotedExec, configPath, home, installTarget)
|
|
}
|
|
|
|
func runSystemctl(scope string, args ...string) error {
|
|
cmdArgs := make([]string, 0, len(args)+1)
|
|
if scope == "user" {
|
|
cmdArgs = append(cmdArgs, "--user")
|
|
}
|
|
cmdArgs = append(cmdArgs, args...)
|
|
|
|
cmd := exec.Command("systemctl", cmdArgs...)
|
|
cmd.Stdout = os.Stdout
|
|
cmd.Stderr = os.Stderr
|
|
if err := cmd.Run(); err != nil {
|
|
if scope == "user" {
|
|
return fmt.Errorf("systemctl --user %s failed: %w", strings.Join(args, " "), err)
|
|
}
|
|
return fmt.Errorf("systemctl %s failed: %w", strings.Join(args, " "), err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func buildGatewayRuntime(ctx context.Context, cfg *config.Config, msgBus *bus.MessageBus, cronService *cron.CronService) (*agent.AgentLoop, *channels.Manager, error) {
|
|
provider, err := providers.CreateProvider(cfg)
|
|
if err != nil {
|
|
return nil, nil, fmt.Errorf("create provider: %w", err)
|
|
}
|
|
|
|
agentLoop := agent.NewAgentLoop(cfg, msgBus, provider, cronService)
|
|
|
|
startupInfo := agentLoop.GetStartupInfo()
|
|
toolsInfo := startupInfo["tools"].(map[string]interface{})
|
|
skillsInfo := startupInfo["skills"].(map[string]interface{})
|
|
fmt.Println("\n📦 Agent Status:")
|
|
fmt.Printf(" • Tools: %d loaded\n", toolsInfo["count"])
|
|
fmt.Printf(" • Skills: %d/%d available\n", skillsInfo["available"], skillsInfo["total"])
|
|
|
|
logger.InfoCF("agent", "Agent initialized",
|
|
map[string]interface{}{
|
|
"tools_count": toolsInfo["count"],
|
|
"skills_total": skillsInfo["total"],
|
|
"skills_available": skillsInfo["available"],
|
|
})
|
|
|
|
channelManager, err := channels.NewManager(cfg, msgBus)
|
|
if err != nil {
|
|
return nil, nil, fmt.Errorf("create channel manager: %w", err)
|
|
}
|
|
|
|
return agentLoop, channelManager, nil
|
|
}
|
|
|
|
func configureCronServiceRuntime(cs *cron.CronService, cfg *config.Config) {
|
|
if cs == nil || cfg == nil {
|
|
return
|
|
}
|
|
cs.SetRuntimeOptions(cron.RuntimeOptions{
|
|
RunLoopMinSleep: time.Duration(cfg.Cron.MinSleepSec) * time.Second,
|
|
RunLoopMaxSleep: time.Duration(cfg.Cron.MaxSleepSec) * time.Second,
|
|
RetryBackoffBase: time.Duration(cfg.Cron.RetryBackoffBaseSec) * time.Second,
|
|
RetryBackoffMax: time.Duration(cfg.Cron.RetryBackoffMaxSec) * time.Second,
|
|
MaxConsecutiveFailureRetries: int64(cfg.Cron.MaxConsecutiveFailureRetries),
|
|
MaxWorkers: cfg.Cron.MaxWorkers,
|
|
})
|
|
}
|
|
|
|
func buildHeartbeatService(cfg *config.Config, msgBus *bus.MessageBus) *heartbeat.HeartbeatService {
|
|
hbInterval := cfg.Agents.Defaults.Heartbeat.EverySec
|
|
if hbInterval <= 0 {
|
|
hbInterval = 30 * 60
|
|
}
|
|
return heartbeat.NewHeartbeatService(cfg.WorkspacePath(), func(prompt string) (string, error) {
|
|
msgBus.PublishInbound(bus.InboundMessage{
|
|
Channel: "system",
|
|
SenderID: "heartbeat",
|
|
ChatID: "internal:heartbeat",
|
|
Content: prompt,
|
|
SessionKey: "heartbeat:default",
|
|
Metadata: map[string]string{
|
|
"trigger": "heartbeat",
|
|
},
|
|
})
|
|
return "queued", nil
|
|
}, hbInterval, cfg.Agents.Defaults.Heartbeat.Enabled, cfg.Agents.Defaults.Heartbeat.PromptTemplate)
|
|
}
|
|
|
|
func buildAutonomyEngine(cfg *config.Config, msgBus *bus.MessageBus) *autonomy.Engine {
|
|
a := cfg.Agents.Defaults.Autonomy
|
|
return autonomy.NewEngine(autonomy.Options{
|
|
Enabled: a.Enabled,
|
|
TickIntervalSec: a.TickIntervalSec,
|
|
MinRunIntervalSec: a.MinRunIntervalSec,
|
|
MaxPendingDurationSec: a.MaxPendingDurationSec,
|
|
MaxConsecutiveStalls: a.MaxConsecutiveStalls,
|
|
MaxDispatchPerTick: a.MaxDispatchPerTick,
|
|
NotifyCooldownSec: a.NotifyCooldownSec,
|
|
NotifySameReasonCooldownSec: a.NotifySameReasonCooldownSec,
|
|
QuietHours: a.QuietHours,
|
|
UserIdleResumeSec: a.UserIdleResumeSec,
|
|
WaitingResumeDebounceSec: a.WaitingResumeDebounceSec,
|
|
ImportantKeywords: cfg.Agents.Defaults.Texts.AutonomyImportantKeywords,
|
|
CompletionTemplate: cfg.Agents.Defaults.Texts.AutonomyCompletionTemplate,
|
|
BlockedTemplate: cfg.Agents.Defaults.Texts.AutonomyBlockedTemplate,
|
|
Workspace: cfg.WorkspacePath(),
|
|
DefaultNotifyChannel: a.NotifyChannel,
|
|
DefaultNotifyChatID: a.NotifyChatID,
|
|
}, msgBus)
|
|
}
|