package config import ( "fmt" "log/slog" "os" "strings" "time" ) // Config holds all runtime configuration for an Oikos role. // Hierarchy: compiled defaults → config file → env vars → Infisical (secrets only). type Config struct { // Database DatabaseURL string // postgres://user:pass@host:5432/oikos?sslmode=disable // API APIListen string // :8090 APIEnv string // dev, prod // Auth (Phase 2: static bearer tokens + OIDC JWT) APIToken string // operator/CI bearer token for the REST API MCPBearerToken string // shared secret for Nomos→API MCP calls OIDCIssuer string // OIDC issuer URL for JWT validation (e.g. https://authentik.example.com/application/o/oikos/) OIDCClientID string // OIDC client ID (aud claim expected in JWT) OIDCClientSecret string // optional client secret for token endpoint proxy (confidential clients) // CORS (client/server split — see plans/2026-07-12-wails-desktop-app.md // 0.3). Needed for the Wails webview and local dev (Vite on a different // port than the API); a no-op when the SPA and API share an origin. CORSAllowedOrigin string // Observability Debug bool // verbose logging, probe payloads, SQL // Seeds directory (for ingest/export) SeedsDir string // Migrations directory (embedded at build time, but path for fallback) MigrationsDir string // Scheduler (Phase 3) SchedulerInterval time.Duration // check loop interval (default 30s) // Notifier (Phase 3) MatrixHomeserver string // Matrix server URL MatrixUserID string // bot user ID (e.g. @oikos:matrix.hubris.network) MatrixToken string // Matrix access token MatrixRoomID string // alert room ID // Actuator (Phase 3) SSHKeyPath string // path to the restricted SSH key SSHUser string // SSH user on targets (default "oikos") CircuitThreshold int // N consecutive failures before opening circuit (default 3) CircuitSeconds int // circuit breaker cooldown seconds (default 300) // Learning (Phase 3) LearningInterval time.Duration // pattern extraction interval (default 3600s) // Approval HMAC secret (Phase 3) ApprovalHMACSecret string // Nomos agent entity ID (Phase 4) NomosAgentID string NomosAgentSlug string // Infisical (Phase 5) InfisicalSiteURL string InfisicalClientID string InfisicalClientSecret string InfisicalProjectID string InfisicalEnv string SecretsDir string } // Default returns a Config with compiled defaults. func Default() Config { return Config{ DatabaseURL: "postgres://oikos:***@localhost:5432/oikos?sslmode=disable", APIListen: ":8090", APIEnv: "dev", CORSAllowedOrigin: "*", SeedsDir: "seeds", MigrationsDir: "migrations", SchedulerInterval: 30 * time.Second, SSHUser: "oikos", CircuitThreshold: 3, CircuitSeconds: 300, LearningInterval: 3600 * time.Second, } } // FromEnv loads config from environment variables, overlaying defaults. func FromEnv() Config { c := Default() if v := os.Getenv("OIKOS_DATABASE_URL"); v != "" { c.DatabaseURL = v } if v := os.Getenv("OIKOS_API_LISTEN"); v != "" { c.APIListen = v } if v := os.Getenv("OIKOS_ENV"); v != "" { c.APIEnv = v } if v := os.Getenv("OIKOS_OIDC_ISSUER"); v != "" { c.OIDCIssuer = v } if v := os.Getenv("OIKOS_OIDC_CLIENT_ID"); v != "" { c.OIDCClientID = v } if v := os.Getenv("OIKOS_OIDC_CLIENT_SECRET"); v != "" { c.OIDCClientSecret = v } if v := os.Getenv("OIKOS_API_TOKEN"); v != "" { c.APIToken = v } if v := os.Getenv("OIKOS_MCP_BEARER_TOKEN"); v != "" { c.MCPBearerToken = v } if v := os.Getenv("OIKOS_CORS_ORIGIN"); v != "" { c.CORSAllowedOrigin = v } if v := os.Getenv("OIKOS_SEEDS_DIR"); v != "" { c.SeedsDir = v } c.Debug = os.Getenv("OIKOS_DEBUG") == "true" || os.Getenv("OIKOS_DEBUG") == "1" // Phase 3 config if v := os.Getenv("OIKOS_SCHEDULER_INTERVAL"); v != "" { if d, err := time.ParseDuration(v); err == nil { c.SchedulerInterval = d } } if v := os.Getenv("OIKOS_MATRIX_HOMESERVER"); v != "" { c.MatrixHomeserver = v } if v := os.Getenv("OIKOS_MATRIX_USER"); v != "" { c.MatrixUserID = v } if v := os.Getenv("OIKOS_MATRIX_TOKEN"); v != "" { c.MatrixToken = v } if v := os.Getenv("OIKOS_MATRIX_ROOM"); v != "" { c.MatrixRoomID = v } if v := os.Getenv("OIKOS_SSH_KEY_PATH"); v != "" { c.SSHKeyPath = v } if v := os.Getenv("OIKOS_SSH_USER"); v != "" { c.SSHUser = v } if v := os.Getenv("OIKOS_CIRCUIT_THRESHOLD"); v != "" { c.CircuitThreshold = parseInt(v) } if v := os.Getenv("OIKOS_CIRCUIT_SECONDS"); v != "" { c.CircuitSeconds = parseInt(v) } if v := os.Getenv("OIKOS_LEARNING_INTERVAL"); v != "" { if d, err := time.ParseDuration(v); err == nil { c.LearningInterval = d } } if v := os.Getenv("OIKOS_APPROVAL_HMAC_SECRET"); v != "" { c.ApprovalHMACSecret = v } if v := os.Getenv("OIKOS_NOMOS_AGENT_ID"); v != "" { c.NomosAgentID = v } if v := os.Getenv("OIKOS_NOMOS_AGENT_SLUG"); v != "" { c.NomosAgentSlug = v } // Phase 5: Infisical secrets if v := os.Getenv("OIKOS_INFISICAL_SITE_URL"); v != "" { c.InfisicalSiteURL = v } if v := os.Getenv("OIKOS_INFISICAL_CLIENT_ID"); v != "" { c.InfisicalClientID = v } if v := os.Getenv("OIKOS_INFISICAL_CLIENT_SECRET"); v != "" { c.InfisicalClientSecret = v } if v := os.Getenv("OIKOS_INFISICAL_PROJECT_ID"); v != "" { c.InfisicalProjectID = v } if v := os.Getenv("OIKOS_INFISICAL_ENV"); v != "" { c.InfisicalEnv = v } if v := os.Getenv("OIKOS_SECRETS_DIR"); v != "" { c.SecretsDir = v } return c } // parseInt parses a decimal integer from an env var string. Returns 0 on error. func parseInt(s string) int { var n int fmt.Sscanf(s, "%d", &n) return n } // redactedDBURL masks credentials in a postgres:// URL. func (c Config) redactedDBURL() string { dbURL := c.DatabaseURL if i := strings.Index(dbURL, "@"); i >= 0 { if j := strings.Index(dbURL, "://"); j >= 0 && j < i { dbURL = dbURL[:j+3] + "***" + dbURL[i:] } } return dbURL } // String returns a human-safe representation (secrets redacted). func (c Config) String() string { token := "" if c.MCPBearerToken != "" { token = "***" } return fmt.Sprintf("Config{DB=%s Listen=%s Env=%s Debug=%v MCPToken=%s SeedsDir=%s OIDCIssuer=%s OIDCClientID=%s}", c.redactedDBURL(), c.APIListen, c.APIEnv, c.Debug, token, c.SeedsDir, c.OIDCIssuer, c.OIDCClientID) } // LogValue implements slog.LogValuer so structured handlers (JSON) never // serialize raw secrets — without this, slog marshals struct fields // directly and String() is bypassed. func (c Config) LogValue() slog.Value { token := "" if c.MCPBearerToken != "" { token = "***" } return slog.GroupValue( slog.String("db", c.redactedDBURL()), slog.String("listen", c.APIListen), slog.String("env", c.APIEnv), slog.Bool("debug", c.Debug), slog.String("mcp_token", token), slog.String("seeds_dir", c.SeedsDir), slog.String("oidc_issuer", c.OIDCIssuer), slog.String("oidc_client_id", c.OIDCClientID), ) }