mcpclient.New previously accepted an empty token and silently omitted the Authorization header at request time. When the env var sourcing the token was missing from a Kubernetes Secret (envFrom doesn't warn on missing keys), this surfaced as an opaque 401 from the upstream MCP server with no log trail — see hyperguild #13 and brain entry "mcpclient-empty-token-silent-401-envfrom-missing-key". mcpclient.New now returns ErrTokenRequired when token is empty. The routing pod's project_create init checks the error and exits with a clear message pointing at routing-secrets, turning a runtime 401 storm into a startup crashloop the operator can fix immediately. Tests pass a dummy "test" token (httptest servers don't enforce bearer auth, so any non-empty value works). Added a regression test asserting empty-token construction returns ErrTokenRequired. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
171 lines
5.8 KiB
Go
171 lines
5.8 KiB
Go
package main
|
|
|
|
// The internal/skills/{debug,retrospective,review,trainer} packages imported
|
|
// below are also imported by cmd/supervisor. Plan 7 (supervisor retirement)
|
|
// MUST NOT delete these four packages — the routing pod is their second
|
|
// consumer. Plan 7 deletes only internal/skills/{tdd,spec,tier} (the skills
|
|
// that don't route to local), the supervisor binary, and supervisor manifests.
|
|
// See docs/superpowers/specs/2026-05-04-mode-2-routing-pod-design.md (Constraints).
|
|
|
|
import (
|
|
"context"
|
|
"log/slog"
|
|
"net/http"
|
|
"os"
|
|
"time"
|
|
|
|
"github.com/mathiasbq/supervisor/internal/auth"
|
|
"github.com/mathiasbq/supervisor/internal/config"
|
|
iexec "github.com/mathiasbq/supervisor/internal/exec"
|
|
"github.com/mathiasbq/supervisor/internal/githubclient"
|
|
"github.com/mathiasbq/supervisor/internal/mcp"
|
|
"github.com/mathiasbq/supervisor/internal/mcpclient"
|
|
"github.com/mathiasbq/supervisor/internal/registry"
|
|
"github.com/mathiasbq/supervisor/internal/routing"
|
|
"github.com/mathiasbq/supervisor/internal/skills/debug"
|
|
"github.com/mathiasbq/supervisor/internal/skills/project"
|
|
"github.com/mathiasbq/supervisor/internal/skills/retrospective"
|
|
"github.com/mathiasbq/supervisor/internal/skills/review"
|
|
"github.com/mathiasbq/supervisor/internal/skills/trainer"
|
|
)
|
|
|
|
func main() {
|
|
logger := slog.New(slog.NewTextHandler(os.Stderr, nil))
|
|
slog.SetDefault(logger)
|
|
|
|
cfg, err := config.LoadRouting()
|
|
if err != nil {
|
|
logger.Error("config load failed", "err", err)
|
|
os.Exit(1)
|
|
}
|
|
|
|
configDir := envOr("SUPERVISOR_CONFIG_DIR", "/app/config/supervisor")
|
|
mustRead := func(path string) string {
|
|
b, err := os.ReadFile(configDir + "/" + path)
|
|
if err != nil {
|
|
logger.Error("read prompt failed", "path", path, "err", err)
|
|
os.Exit(1)
|
|
}
|
|
return string(b)
|
|
}
|
|
|
|
llm := iexec.NewLiteLLM(cfg.LiteLLMBaseURL, cfg.LiteLLMAPIKey, 0)
|
|
|
|
router := &routing.Router{
|
|
Fetcher: routing.NewFetcher(cfg.BrainURL, "7d", time.Duration(cfg.PassRateTTLSeconds)*time.Second),
|
|
Logger: routing.NewLogger(cfg.BrainURL),
|
|
Policy: routing.Policy{Floor: cfg.RouteLocalFloor, Ceil: cfg.RouteLocalCeil},
|
|
FastModel: cfg.FastModel,
|
|
ThinkingModel: cfg.ThinkingModel,
|
|
Complete: llm.Complete,
|
|
}
|
|
|
|
// Skill packages call CompleteFunc(ctx, model, system, user) — no session_id
|
|
// or project_root in the signature. Rather than modifying every skill's API
|
|
// (and inflating Plan 6's blast radius), the routing pod logs every decision
|
|
// under a fixed session_id "_routing". Operators query
|
|
// `GET /pass-rate?skill=_routing&window=...` to inspect routing health.
|
|
const routingSessionID = "_routing"
|
|
wrap := func(skillName string) routing.CompleteFunc {
|
|
return func(ctx context.Context, _, system, user string) (string, int64, error) {
|
|
// The model param is ignored: the router picks the model based on policy.
|
|
return router.Run(ctx, routing.RunInput{
|
|
Skill: skillName,
|
|
System: system,
|
|
User: user,
|
|
SessionID: routingSessionID,
|
|
ProjectRoot: "",
|
|
})
|
|
}
|
|
}
|
|
|
|
reg := registry.New()
|
|
reg.Register(review.New(review.Config{
|
|
SkillPrompt: mustRead("review.md"),
|
|
DefaultModel: cfg.FastModel,
|
|
CompleteFunc: review.CompleteFunc(wrap("review")),
|
|
}))
|
|
reg.Register(debug.New(debug.Config{
|
|
SkillPrompt: mustRead("debug.md"),
|
|
DefaultModel: cfg.FastModel,
|
|
CompleteFunc: debug.CompleteFunc(wrap("debug")),
|
|
}))
|
|
reg.Register(retrospective.New(retrospective.Config{
|
|
SkillPrompt: mustRead("retrospective.md"),
|
|
DefaultModel: cfg.FastModel,
|
|
CompleteFunc: retrospective.CompleteFunc(wrap("retrospective")),
|
|
}))
|
|
reg.Register(trainer.New(trainer.Config{
|
|
ReaderPrompt: mustRead("trainer-reader.md"),
|
|
WriterPrompt: mustRead("trainer-writer.md"),
|
|
DefaultModel: cfg.FastModel,
|
|
CompleteFunc: trainer.CompleteFunc(wrap("trainer")),
|
|
}))
|
|
|
|
if cfg.GiteaMCPURL != "" {
|
|
mcpC, err := mcpclient.New(cfg.GiteaMCPURL, cfg.GiteaMCPToken)
|
|
if err != nil {
|
|
logger.Error("mcpclient init for project_create — GITEA_MCP_URL is set but GITEA_MCP_TOKEN is empty (check routing-secrets)", "err", err)
|
|
os.Exit(1)
|
|
}
|
|
var ghClient *githubclient.Client
|
|
if cfg.GitHubPAT != "" {
|
|
ghClient = githubclient.New(cfg.GitHubPAT)
|
|
}
|
|
reg.Register(project.New(project.Config{
|
|
Client: mcpC,
|
|
GitHub: ghClient,
|
|
GiteaOwner: cfg.GiteaOwner,
|
|
GitHubOwner: cfg.GitHubOwner,
|
|
GitHubPAT: cfg.GitHubPAT,
|
|
InfraRepo: cfg.InfraRepo,
|
|
}))
|
|
logger.Info("project_create registered", "gitea_mcp_url", cfg.GiteaMCPURL,
|
|
"gitea_owner", cfg.GiteaOwner, "github_owner", cfg.GitHubOwner,
|
|
"infra_repo", cfg.InfraRepo, "github_pat_set", cfg.GitHubPAT != "")
|
|
} else {
|
|
logger.Info("project_create skipped — GITEA_MCP_URL not set")
|
|
}
|
|
|
|
var validator *auth.Validator
|
|
if dexURL := os.Getenv("DEX_ISSUER_URL"); dexURL != "" {
|
|
audience := os.Getenv("MCP_AUDIENCE")
|
|
v, err := auth.NewValidator(dexURL, audience)
|
|
if err != nil {
|
|
logger.Error("build jwt validator", "err", err)
|
|
os.Exit(1)
|
|
}
|
|
validator = v
|
|
logger.Info("jwt auth enabled", "issuer", dexURL)
|
|
}
|
|
|
|
srv := mcp.NewServer(reg, cfg.MCPAuthToken, validator)
|
|
mux := http.NewServeMux()
|
|
mux.Handle("/mcp", srv)
|
|
mux.HandleFunc("/healthz", func(w http.ResponseWriter, _ *http.Request) {
|
|
w.WriteHeader(http.StatusOK)
|
|
})
|
|
|
|
if dexURL := os.Getenv("DEX_ISSUER_URL"); dexURL != "" {
|
|
resourceURL := os.Getenv("MCP_RESOURCE_URL")
|
|
mux.HandleFunc("GET /.well-known/oauth-protected-resource",
|
|
auth.ProtectedResourceHandler(resourceURL, dexURL))
|
|
}
|
|
|
|
addr := ":" + cfg.Port
|
|
logger.Info("routing pod starting", "addr", addr,
|
|
"fast", cfg.FastModel, "thinking", cfg.ThinkingModel,
|
|
"floor", cfg.RouteLocalFloor, "ceil", cfg.RouteLocalCeil)
|
|
if err := http.ListenAndServe(addr, mux); err != nil { //nolint:gosec
|
|
logger.Error("server stopped", "err", err)
|
|
os.Exit(1)
|
|
}
|
|
}
|
|
|
|
func envOr(key, def string) string {
|
|
if v := os.Getenv(key); v != "" {
|
|
return v
|
|
}
|
|
return def
|
|
}
|