package main import ( "context" "flag" "log" "os" "os/signal" "path/filepath" "slices" "syscall" "time" "github.com/kami/hexis/internal/api" "github.com/kami/hexis/internal/domain" "github.com/kami/hexis/internal/execution" "github.com/kami/hexis/internal/mcp" "github.com/kami/hexis/internal/nexusclient" "github.com/kami/hexis/internal/provider" "github.com/kami/hexis/internal/storage" ) func main() { var httpAddr string var dataDir string var mcpMode bool var workspaceURL string var workspaceAllowlist string var nexusURL string flag.StringVar(&httpAddr, "http", "", "HTTP listen address (default localhost:9741)") flag.StringVar(&dataDir, "data", "", "Data directory for SQLite database") flag.BoolVar(&mcpMode, "mcp", false, "Run in MCP stdio mode") flag.StringVar(&workspaceURL, "workspace-url", "", "Workspace MCP HTTP API URL (e.g. http://localhost:9930)") flag.StringVar(&workspaceAllowlist, "workspace-allowlist", "", "Path to workspace tool allowlist YAML") flag.StringVar(&nexusURL, "nexus", "", "Nexus base URL for hexis.resolve_target (default http://localhost:9740)") flag.Parse() if dataDir == "" { dataDir = filepath.Join(os.Getenv("HOME"), ".local", "share", "hexis") } if httpAddr == "" { httpAddr = "localhost:9741" } if workspaceAllowlist == "" { workspaceAllowlist = filepath.Join(dataDir, "workspace-allowlist.yaml") } if workspaceURL == "" { workspaceURL = os.Getenv("WORKSPACE_MCP_URL") } if nexusURL == "" { nexusURL = os.Getenv("HEXIS_NEXUS_URL") } if nexusURL == "" { nexusURL = "http://localhost:9740" } dbPath := filepath.Join(dataDir, "hexis.db") store, err := storage.Open(dbPath) if err != nil { log.Fatalf("open storage: %v", err) } defer store.Close() reg := provider.NewRegistry() // Register workspace MCP provider if configured if workspaceURL != "" { allowlist, err := provider.LoadToolAllowlist(workspaceAllowlist) if err != nil { log.Fatalf("load workspace allowlist: %v", err) } wsProvider := provider.NewWorkspaceMCPProvider(workspaceURL, allowlist) reg.Register(wsProvider) tools, err := wsProvider.DiscoverTools() if err != nil { log.Printf("warning: workspace MCP discovery failed: %v", err) } else { log.Printf("discovered %d workspace tools, %d in allowlist", len(tools), len(allowlist.Tools)) } // Register workspace capabilities, reconciling existing rows so that // allowlist edits (risk tier, target type, enabled) reach a database // that was populated by an earlier build. for _, cap := range wsProvider.BuildCapabilities() { existing, err := store.GetCapability(cap.ID) if err == nil && existing != nil { if !capabilityMatches(existing, &cap) { merged := cap merged.CreatedAt = existing.CreatedAt merged.UpdatedAt = time.Now().UTC() merged.Attributes = existing.Attributes // UpdateCapability is optimistically concurrent: it matches // on the current version and bumps it. merged.Version = existing.Version if err := store.UpdateCapability(&merged); err != nil { log.Printf("warning: reconcile capability %s: %v", cap.Name, err) } else { log.Printf("reconciled capability: %s -> %s (version %d)", merged.Name, merged.Operation, merged.Version) } } continue } if err := store.CreateCapability(&cap); err != nil { log.Printf("warning: register capability %s: %v", cap.Name, err) } else { log.Printf("registered capability: %s -> %s", cap.Name, cap.Operation) } } } // Nexus is the sole authority on entity identity: Hexis refuses any // target it cannot confirm exists there (ECOSYSTEM-SPEC.md ยง4.3, "Hexis // never accepts a free-text target. Ever."). If Nexus is down, executes // fail closed with 503 rather than accepting the target on trust. nexus := nexusclient.New(nexusURL) engine := execution.New(store, reg, execution.WithEntityLookup(nexus)) if mcpMode { log.Printf("starting MCP stdio adapter") adapter := mcp.New(store, engine, nexus) if err := adapter.ServeStdio(); err != nil { log.Fatalf("MCP error: %v", err) } return } // Shared bearer token for /api/v1/. Required: the HTTP surface can start // and stop containers, and it is reverse-proxied on a public hostname. apiToken := os.Getenv("HEXIS_API_TOKEN") if apiToken == "" { log.Fatalf("HEXIS_API_TOKEN is not set: refusing to serve /api/v1/ unauthenticated") } srv := api.NewServer(store, engine, apiToken) ctx, cancel := context.WithCancel(context.Background()) defer cancel() go func() { log.Printf("listening on http %s", httpAddr) if err := srv.ListenHTTP(httpAddr); err != nil { log.Printf("http error: %v", err) } }() sig := make(chan os.Signal, 1) signal.Notify(sig, syscall.SIGINT, syscall.SIGTERM) <-sig log.Println("shutting down...") srv.Shutdown(ctx) log.Println("stopped") } // capabilityMatches reports whether the stored capability already carries every // derived field, ignoring bookkeeping (timestamps, version, attributes). func capabilityMatches(stored, derived *domain.Capability) bool { return stored.Name == derived.Name && stored.Description == derived.Description && slices.Equal(stored.TargetTypes, derived.TargetTypes) && stored.TargetEntityID == derived.TargetEntityID && stored.Provider == derived.Provider && stored.Operation == derived.Operation && stored.Risk == derived.Risk && stored.ReadOnly == derived.ReadOnly && stored.ExpectedSideEffects == derived.ExpectedSideEffects && stored.RequiresConfirmation == derived.RequiresConfirmation && stored.Enabled == derived.Enabled && stored.TimeoutSeconds == derived.TimeoutSeconds }