Files
kandev/apps/backend/internal/common/config/config.go
T

994 lines
41 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Package config provides configuration management for Kandev.
// It supports loading configuration from environment variables, config files, and defaults.
package config
import (
"fmt"
"net"
"net/url"
"os"
"path/filepath"
"runtime"
"strings"
"time"
"github.com/kandev/kandev/internal/common/ports"
"github.com/kandev/kandev/internal/profiles"
"github.com/spf13/viper"
)
// kandevHomeSubdir is the hidden directory name used for the Kandev root
// under the user's home directory (e.g. ~/.kandev). This is the single
// source of truth for the dotdir name; derived paths go through
// ResolvedHomeDir / ResolvedDataDir rather than re-constructing it.
const kandevHomeSubdir = ".kandev"
// Config holds all configuration sections for Kandev.
type Config struct {
// HomeDir is the root Kandev directory (e.g. ~/.kandev in prod, or
// <repo>/.kandev-dev during local development). When empty, falls back
// to ~/.kandev. All workspace artifacts (data, tasks, worktrees, repos)
// live under this root.
HomeDir string `mapstructure:"homeDir"`
Server ServerConfig `mapstructure:"server"`
Tasks TasksConfig `mapstructure:"tasks"`
Credentials CredentialsConfig `mapstructure:"credentials"`
Limits LimitsConfig `mapstructure:"limits"`
MessageQueue MessageQueueConfig `mapstructure:"messageQueue"`
Agentctl AgentctlConfig `mapstructure:"agentctl"`
Planning PlanningConfig `mapstructure:"planning"`
Observability ObservabilityConfig `mapstructure:"observability"`
Launcher LauncherConfig `mapstructure:"launcher"`
Database DatabaseConfig `mapstructure:"database"`
NATS NATSConfig `mapstructure:"nats"`
Events EventsConfig `mapstructure:"events"`
Docker DockerConfig `mapstructure:"docker"`
Agent AgentConfig `mapstructure:"agent"`
Auth AuthConfig `mapstructure:"auth"`
Logging LoggingConfig `mapstructure:"logging"`
RepositoryDiscovery RepositoryDiscoveryConfig `mapstructure:"repositoryDiscovery"`
Worktree WorktreeConfig `mapstructure:"worktree"`
RepoClone RepoCloneConfig `mapstructure:"repoClone"`
Debug DebugConfig `mapstructure:"debug"`
Office OfficeConfig `mapstructure:"office"`
Features FeaturesConfig `mapstructure:"features"`
GitHubCredentialBroker GitHubCredentialBrokerConfig `mapstructure:"githubCredentialBroker"`
Executors ExecutorsConfig `mapstructure:"executors"`
Source ConfigSource `mapstructure:"-" json:"-"`
}
// TasksConfig contains task lifecycle startup limits.
type TasksConfig struct {
PreparationTimeout time.Duration `mapstructure:"preparationTimeout"`
// StallDetectionThreshold is the event-silence window after which the
// session reconciliation sweep classifies an active session with no live
// execution as stalled (issue #3712). The orphaned-session healing grace
// window is twice this value.
StallDetectionThreshold time.Duration `mapstructure:"stallDetectionThreshold"`
}
// CredentialsConfig contains operator-managed credential file settings.
type CredentialsConfig struct {
File string `mapstructure:"file"`
}
// ExecutorsConfig contains executor-related startup settings.
type ExecutorsConfig struct {
// SSHReachabilityIntervalSeconds is the raw configured value, not yet
// clamped into the reachability package's own 15-3600 bound: 0 disables
// the poller, and reachability.ClampInterval owns the rest of the
// normalization at construction time.
SSHReachabilityIntervalSeconds int `mapstructure:"sshReachabilityIntervalSeconds"`
}
// LimitsConfig contains process and protocol capacity limits.
type LimitsConfig struct {
GHMaxConcurrent int `mapstructure:"ghMaxConcurrent"`
GitMaxConcurrent int `mapstructure:"gitMaxConcurrent"`
LSPMaxConnections int `mapstructure:"lspMaxConnections"`
}
// MessageQueueConfig contains prompt queue limits.
type MessageQueueConfig struct {
MaxPerSession int `mapstructure:"maxPerSession"`
}
// AgentctlConfig contains settings owned by managed agentctl processes.
type AgentctlConfig struct {
IdleTimeout time.Duration `mapstructure:"idleTimeout"`
IdleReaperInterval time.Duration `mapstructure:"idleReaperInterval"`
NotificationQueueCapacity int `mapstructure:"notificationQueueCapacity"`
// RecoveryDeadline is the single global bound covering adoption,
// enumeration and reconstruction together during standalone agent
// survival re-tracking at backend startup.
RecoveryDeadline time.Duration `mapstructure:"recoveryDeadline"`
// RecoveryReadTimeout and RecoveryReadRetries bound each read of a
// reconstruction value from the adopted control server during re-tracking.
RecoveryReadTimeout time.Duration `mapstructure:"recoveryReadTimeout"`
RecoveryReadRetries int `mapstructure:"recoveryReadRetries"`
// UnownedPeriod is how long a standalone control server tolerates having
// no owning backend before it stops its instances and exits.
UnownedPeriod time.Duration `mapstructure:"unownedPeriod"`
// DetachedEventLimit bounds the per-instance retained-event count while
// a standalone control server has no owning backend attached.
DetachedEventLimit int `mapstructure:"detachedEventLimit"`
}
// PlanningConfig contains planning service timing settings.
type PlanningConfig struct {
CoalesceWindowMs int `mapstructure:"coalesceWindowMs"`
}
// ObservabilityConfig contains process-wide tracing settings.
type ObservabilityConfig struct {
OTLPEndpoint string `mapstructure:"otlpEndpoint"`
}
// LauncherConfig contains settings used by the native launcher. The backend
// reads the same values so a selected file remains one source of truth.
type LauncherConfig struct {
WebPort int `mapstructure:"webPort"`
HealthTimeoutMs int `mapstructure:"healthTimeoutMs"`
NoBrowser bool `mapstructure:"noBrowser"`
}
// GitHubCredentialBrokerConfig holds the externally reachable service URL
// used by managed remote executors. It is independent of GitHub App setup so
// PAT and named gh CLI workspaces can use remote executors.
type GitHubCredentialBrokerConfig struct {
PublicBaseURL string `mapstructure:"publicBaseUrl" json:"-"`
ReissueSigningKey string `mapstructure:"reissueSigningKey" json:"-"`
}
func (c GitHubCredentialBrokerConfig) validate() error {
if strings.TrimSpace(c.PublicBaseURL) == "" {
return nil
}
return validatePublicBaseURL("githubCredentialBroker.publicBaseUrl", c.PublicBaseURL)
}
func validatePublicBaseURL(field, raw string) error {
u, err := url.Parse(raw)
if err != nil || !u.IsAbs() || u.Hostname() == "" {
return fmt.Errorf("%s must be an absolute URL", field)
}
if u.User != nil || u.RawQuery != "" || u.Fragment != "" {
return fmt.Errorf("%s must not include credentials, query, or fragment", field)
}
if u.Scheme != "https" && (u.Scheme != "http" || !IsLoopbackHost(u.Hostname())) {
return fmt.Errorf("%s must use HTTPS outside loopback development", field)
}
return nil
}
// expandTilde expands a leading "~/" to the user's home directory.
// Returns the input unchanged if expansion is unnecessary or fails.
func expandTilde(p string) string {
if !strings.HasPrefix(p, "~/") {
return p
}
home, err := os.UserHomeDir()
if err != nil {
return p
}
return filepath.Join(home, p[2:])
}
// ResolvedHomeDir returns the Kandev root directory — the parent of data,
// tasks, worktrees, repos, sessions, quick-chat and lsp-servers.
//
// Resolution order:
// 1. KANDEV_HOME_DIR (explicit override — e.g. /data in Docker, /kandev in K8s,
// or <repo>/.kandev-dev during local development).
// 2. ~/.kandev (default).
func (c *Config) ResolvedHomeDir() string {
if c.HomeDir != "" {
return expandTilde(c.HomeDir)
}
home, err := os.UserHomeDir()
if err != nil {
return kandevHomeSubdir
}
return filepath.Join(home, kandevHomeSubdir)
}
// ResolvedDataDir returns the base data directory (where the SQLite DB lives).
// Always <ResolvedHomeDir>/data — relocate via KANDEV_HOME_DIR, not a separate knob.
func (c *Config) ResolvedDataDir() string {
return filepath.Join(c.ResolvedHomeDir(), "data")
}
// defaultServerHost is the wildcard host the server binds to when no host is
// configured — every interface.
const defaultServerHost = "0.0.0.0"
// ServerConfig holds HTTP server configuration.
type ServerConfig struct {
// Host is the bind address. A single value (e.g. "127.0.0.1") behaves as
// it always has; a comma-separated list (e.g. "127.0.0.1,100.64.0.1")
// binds one listener per address. Empty means the wildcard default.
//
// Host carries the KANDEV_SERVER_HOST env override, so when it is set it
// wins over Hosts to preserve env-over-file precedence (see ResolvedBinds).
Host string `mapstructure:"host"`
// Hosts is the YAML-array form of Host, for config files that prefer an
// array to a comma-separated string. It is used only when Host is unset.
Hosts []string `mapstructure:"hosts"`
TrustedProxies []string `mapstructure:"trustedProxies"`
Port int `mapstructure:"port"`
ReadTimeout int `mapstructure:"readTimeout"` // in seconds
WriteTimeout int `mapstructure:"writeTimeout"` // in seconds
WebInternalURL string `mapstructure:"webInternalUrl"`
// WebTitlePrefix prefixes the browser tab title as "<prefix> Kandev" (for
// example "TEST") so several Kandev instances are distinguishable in
// adjacent browser tabs. Empty keeps the plain "Kandev" title.
WebTitlePrefix string `mapstructure:"webTitlePrefix"`
}
// splitHosts splits a comma-separated host string, trimming whitespace and
// dropping empty entries.
func splitHosts(s string) []string {
parts := strings.Split(s, ",")
out := make([]string, 0, len(parts))
for _, p := range parts {
if p = strings.TrimSpace(p); p != "" {
out = append(out, p)
}
}
return out
}
// normalizeHost validates h and returns its canonical form plus whether it is a
// wildcard (binds every interface). IP addresses are canonicalized via
// net.ParseIP so equivalent forms dedupe (e.g. 0:0:0:0:0:0:0:1 and ::1); any
// unspecified IP (0.0.0.0, ::, or a longhand form) is treated as a wildcard.
// Hostnames are returned as-is. An invalid entry returns an error.
func normalizeHost(h string) (canonical string, wildcard bool, err error) {
if ip := net.ParseIP(h); ip != nil {
if ip.IsUnspecified() {
return ip.String(), true, nil
}
return ip.String(), false, nil
}
if isValidHostname(h) {
return h, false, nil
}
return "", false, fmt.Errorf("server bind host %q is not a valid IP address or hostname", h)
}
// isValidHostname reports whether h is a syntactically valid RFC 1123 hostname.
func isValidHostname(h string) bool {
if len(h) == 0 || len(h) > 253 {
return false
}
for _, label := range strings.Split(strings.TrimSuffix(h, "."), ".") {
if !isValidHostnameLabel(label) {
return false
}
}
return true
}
// isValidHostnameLabel reports whether label is a valid single hostname label
// (letters, digits, and interior hyphens, 1–63 chars).
func isValidHostnameLabel(label string) bool {
if len(label) == 0 || len(label) > 63 {
return false
}
if label[0] == '-' || label[len(label)-1] == '-' {
return false
}
for i := 0; i < len(label); i++ {
c := label[i]
if (c < 'a' || c > 'z') && (c < 'A' || c > 'Z') &&
(c < '0' || c > '9') && c != '-' {
return false
}
}
return true
}
// IsLoopbackHost reports whether h refers only to the local loopback
// interface. A wildcard (empty/0.0.0.0/::) is NOT loopback because it also
// binds routable interfaces. Unresolvable hostnames are treated as
// non-loopback (fail-closed) so callers gating on "any non-loopback bind"
// don't under-report exposure.
func IsLoopbackHost(h string) bool {
h = strings.TrimSpace(h)
if h == "" {
return false
}
if strings.EqualFold(h, "localhost") {
return true
}
if ip := net.ParseIP(h); ip != nil {
return ip.IsLoopback()
}
return false
}
// ResolvedBinds returns the de-duplicated, validated list of hosts the HTTP
// server should bind to. server.host (comma-separated string) takes precedence
// over server.hosts (YAML array) when set, because Host carries the
// KANDEV_SERVER_HOST env override and env must beat a config-file server.hosts
// (env-over-file precedence, and the desktop loopback contract). server.hosts
// is used only when host is unset. Whitespace is trimmed and empty/duplicate
// entries dropped, and IP addresses are canonicalized so equivalent forms
// dedupe. Every entry is validated before the set is collapsed, so an invalid
// host is rejected regardless of its position relative to a wildcard. If any
// entry is a wildcard (empty/0.0.0.0/:: or an unspecified IP) the whole set
// collapses to that single wildcard, since it already binds every interface. An
// empty result falls back to the wildcard default.
func (c *ServerConfig) ResolvedBinds() ([]string, error) {
raw := splitHosts(c.Host)
if len(raw) == 0 {
for _, h := range c.Hosts {
raw = append(raw, splitHosts(h)...)
}
}
seen := make(map[string]struct{}, len(raw))
out := make([]string, 0, len(raw))
wildcard := ""
for _, h := range raw {
norm, isWild, err := normalizeHost(h)
if err != nil {
return nil, err
}
if isWild {
if wildcard == "" {
wildcard = norm
}
continue
}
if _, dup := seen[norm]; dup {
continue
}
seen[norm] = struct{}{}
out = append(out, norm)
}
if wildcard != "" {
return []string{wildcard}, nil
}
if len(out) == 0 {
return []string{defaultServerHost}, nil
}
return out, nil
}
// NonLoopbackBinds returns the resolved bind hosts that are not loopback-only.
// The sibling app-auth work uses this to decide whether to require auth (fail
// closed when the server is reachable off-box). A wildcard bind counts as
// non-loopback.
func (c *ServerConfig) NonLoopbackBinds() ([]string, error) {
binds, err := c.ResolvedBinds()
if err != nil {
return nil, err
}
out := make([]string, 0, len(binds))
for _, h := range binds {
if !IsLoopbackHost(h) {
out = append(out, h)
}
}
return out, nil
}
// DatabaseConfig holds database connection configuration.
type DatabaseConfig struct {
Driver string `mapstructure:"driver"`
Path string `mapstructure:"path"`
Host string `mapstructure:"host"`
Port int `mapstructure:"port"`
User string `mapstructure:"user"`
Password string `mapstructure:"password"`
DBName string `mapstructure:"dbName"`
SSLMode string `mapstructure:"sslMode"`
MaxConns int `mapstructure:"maxConns"`
MinConns int `mapstructure:"minConns"`
}
// NATSConfig holds NATS messaging configuration.
type NATSConfig struct {
URL string `mapstructure:"url"`
ClusterID string `mapstructure:"clusterId"`
ClientID string `mapstructure:"clientId"`
MaxReconnects int `mapstructure:"maxReconnects"`
}
// EventsConfig holds event bus namespace configuration.
type EventsConfig struct {
// Namespace isolates queue-group subscribers across deployments/instances.
// Empty value means derive from runtime data identity.
Namespace string `mapstructure:"namespace"`
}
// DockerConfig holds Docker client configuration.
type DockerConfig struct {
// Enabled controls whether the Docker runtime is available for task execution.
// When true and Docker is accessible, tasks can use Docker-based executors.
// Default: true (Docker runtime is enabled if Docker is available)
Enabled bool `mapstructure:"enabled"`
Host string `mapstructure:"host"`
APIVersion string `mapstructure:"apiVersion"`
TLSVerify bool `mapstructure:"tlsVerify"`
DefaultNetwork string `mapstructure:"defaultNetwork"`
VolumeBasePath string `mapstructure:"volumeBasePath"`
}
// AuthConfig holds authentication configuration. Whether authentication is
// enforced is controlled by the `features.auth` runtime flag
// (KANDEV_FEATURES_AUTH), not here — these are only session-mechanics knobs.
type AuthConfig struct {
JWTSecret string `mapstructure:"jwtSecret"`
TokenDuration int `mapstructure:"tokenDuration"` // in seconds
// SessionTTLHours is the sliding lifetime of a browser session. A session
// is extended whenever it is used with less than TTL-24h remaining.
SessionTTLHours int `mapstructure:"sessionTTLHours"`
// CookieName is the session cookie base name. Empty (the default) means
// the service uses the "kandev_session" base and port-scopes it from the
// request host, isolating instances on one host (see
// auth.Service.CookieNameForRequest). Only override for unusual
// reverse-proxy setups that need a fixed cookie name; a custom value is
// used verbatim (never port-suffixed) and disables automatic isolation.
CookieName string `mapstructure:"cookieName"`
}
// OfficeConfig holds configuration for the office (autonomous agents) feature.
type OfficeConfig struct {
// JWTSigningKey is the HMAC key used to sign agent runtime JWTs.
// When empty, a random key is generated at startup — fine for dev, but
// means every restart invalidates outstanding agent tokens. Production
// deployments should set a stable value (e.g. via KANDEV_OFFICE_JWTSIGNINGKEY).
JWTSigningKey string `mapstructure:"jwtSigningKey"`
SchedulerTickMs int `mapstructure:"schedulerTickMs"`
// The following back the launch-safety ceilings/budgets
// (REQ-OFFICE-LAUNCH-SAFETY-001/003/004/005) and the backpressure
// gate-failure escalation threshold (REQ-OFFICE-BACKPRESSURE-003.5).
// Every one is boot-time-only, like SchedulerTickMs above: resolved
// once at startup and passed into the owning repository/service via
// its SetXxx method, not polled or overridable at runtime. A value
// below the documented minimum (1, or 1 for PromotionAgeMinutes) is
// replaced by that default and the resolved source is reported as
// SourceDefault (applyPositiveIntEnv's existing behavior), matching
// the "replaced by the default and logged at warn level" language the
// acceptance criteria use for an out-of-range operator override.
MaxConcurrentInstance int `mapstructure:"maxConcurrentInstance"`
MaxConcurrentWorkspace int `mapstructure:"maxConcurrentWorkspace"`
WorkspaceBudgetPerHour int `mapstructure:"workspaceBudgetPerHour"`
RoutineBudgetPerHour int `mapstructure:"routineBudgetPerHour"`
// PromotionAgeMinutes is REQ-OFFICE-BACKPRESSURE-002.1's age-based
// priority promotion period, in minutes (converted to
// runssqlite.ClaimSafetyLimits.PromotionAge, a time.Duration, at the
// wiring site — the catalog only carries plain ints).
PromotionAgeMinutes int `mapstructure:"promotionAgeMinutes"`
MaxCausationDepth int `mapstructure:"maxCausationDepth"`
SelfTriggerAllowance int `mapstructure:"selfTriggerAllowance"`
// SelfTriggerTotalAllowance is the reason-independent sibling of
// SelfTriggerAllowance (AC-OFFICE-LAUNCH-SAFETY-004.8): it may be
// configured below SelfTriggerAllowance, in which case it is the
// binding limit and the per-reason allowance becomes unreachable.
SelfTriggerTotalAllowance int `mapstructure:"selfTriggerTotalAllowance"`
GateFailureThreshold int `mapstructure:"gateFailureThreshold"`
}
// FeaturesConfig is the typed wire/config shape for runtime feature flags.
// Every new release toggle defaults to false so production binaries ship with
// new work hidden until a deployment explicitly opts in (env var, e.g.
// KANDEV_FEATURES_OFFICE=true). The runtimeflags package owns metadata and
// typed config bindings.
//
// The struct doubles as the wire shape for GET /api/v1/features — `json` tags
// keep the field names lowercase and the handler in helpers.go just calls
// `c.JSON(200, p.features)` so new fields are picked up automatically.
//
// See docs/decisions/0007-runtime-feature-flags.md for the pattern and rollout policy.
type FeaturesConfig struct {
// LSPBrowserContinuity gates runtime-owned language-server leases that stay
// connected across browser attachment loss. Off in every embedded profile.
LSPBrowserContinuity bool `mapstructure:"lsp_browser_continuity" json:"lspBrowserContinuity"`
// Office gates the autonomous-agent feature: backend service construction,
// HTTP/WS route registration, and frontend nav/route visibility.
Office bool `mapstructure:"office" json:"office"`
// Auth is the on/off switch for opt-in authentication and per-user
// workspaces. When on, every visitor must sign in (the first becomes the
// admin via a setup wizard) and workspaces are private per user; the
// Users/Account settings surfaces also become visible. Set via the
// runtime feature toggle KANDEV_FEATURES_AUTH (Settings > System > Feature
// Toggles). Off by default so kandev stays single-user until a deployment
// opts in.
Auth bool `mapstructure:"auth" json:"auth"`
// Canvases gates agent-authored plugin web applications and their task and
// workspace surfaces. It remains off in every shipped profile until the
// isolated runtime and lifecycle are ready for opt-in use.
Canvases bool `mapstructure:"canvases" json:"canvases"`
// MultiTenancy gates organizations: a tenant boundary above users, where
// every user belongs to exactly one org and cross-org reach is a bug
// rather than a permission level. It requires Auth; enabling it without
// authentication is refused at startup, because a tenant boundary with no
// identity behind it is not a boundary. Set via the runtime feature toggle
// KANDEV_FEATURES_MULTI_TENANCY. Off in every shipped profile.
MultiTenancy bool `mapstructure:"multi_tenancy" json:"multiTenancy"`
// DynamicAgentRouting gates dynamic profile configuration, execution
// routing, and the shared route health service. It is disabled in every
// embedded profile until the complete feature is ready.
DynamicAgentRouting bool `mapstructure:"dynamic_agent_routing" json:"dynamicAgentRouting"`
// ClaudeBackgroundPromptHandoff gates the high-risk experiment that lets a
// claude-acp session accept a successor prompt after an adapter-attested
// foreground handoff while background work remains live. It is off in every
// embedded profile and must fail closed for every other provider.
ClaudeBackgroundPromptHandoff bool `mapstructure:"claude_background_prompt_handoff" json:"claudeBackgroundPromptHandoff"`
// ClaudeMidTurnSteering gates the high-risk experiment that delivers operator
// input into a turn that is still generating, for an agent that advertised
// prompt queueing. It is independent of ClaudeBackgroundPromptHandoff: that
// flag covers the foreground-idle handoff, this one covers a foreground that
// has not yielded. Off in every embedded profile, and it remains the
// kill-switch after rollout because the agent-side fold is undocumented and
// can regress without notice.
ClaudeMidTurnSteering bool `mapstructure:"claude_mid_turn_steering" json:"claudeMidTurnSteering"`
// OfficeSessionIdentity keys an Office task's session identity on the run's
// own agent instead of the task's runner seat, and binds an agent's
// decision re-evaluation to its own calling session instead of the task's
// most-recently-started session. On in every embedded profile; it remains a
// high-risk, path-scoped change to durable session identity. A live
// (task_id, agent_profile_id) pair is guarded in-transaction on the office
// session creation path, not by a table-level constraint, and pre-existing
// duplicate rows stay safe by selection, not migration. The toggle remains a
// kill switch that restores runner-seat binding and task-active-session
// decision re-evaluation when disabled.
OfficeSessionIdentity bool `mapstructure:"office_session_identity" json:"officeSessionIdentity"`
// NeedsYouInbox gates the Needs-you Inbox: a workspace-scoped sidebar
// destination, independent of Office, listing exactly the answerable
// clarification bundles for the active workspace. Off in prod until the
// feature is user-ready.
NeedsYouInbox bool `mapstructure:"needs_you_inbox" json:"needsYouInbox"`
// AgentSurvival lets a worktree or local-executor agent session survive a
// backend restart by adopting its still-running standalone control server
// instead of killing it. Off in every embedded profile, and unavailable on
// Windows (survival trades the platform's kill-on-job-close safeguard for
// an adoption handshake, which is untested there).
AgentSurvival bool `mapstructure:"agent_survival" json:"agentSurvival"`
}
// LoggingConfig holds logging configuration.
type LoggingConfig struct {
Level string `mapstructure:"level"`
Format string `mapstructure:"format"`
}
// RepositoryDiscoveryConfig holds configuration for local repository scanning.
type RepositoryDiscoveryConfig struct {
Roots []string `mapstructure:"roots"`
MaxDepth int `mapstructure:"maxDepth"`
}
// WorktreeConfig holds Git worktree configuration for concurrent agent execution.
type WorktreeConfig struct {
Enabled bool `mapstructure:"enabled"` // Enable worktree mode
DefaultBranch string `mapstructure:"defaultBranch"` // Default base branch (default: main)
CleanupOnRemove bool `mapstructure:"cleanupOnRemove"` // Remove worktree directory on task deletion
FetchTimeoutSeconds int `mapstructure:"fetchTimeoutSeconds"` // Git fetch timeout before worktree creation
PullTimeoutSeconds int `mapstructure:"pullTimeoutSeconds"` // Git pull timeout before worktree creation
}
// RepoCloneConfig holds configuration for automatic repository cloning.
type RepoCloneConfig struct {
BasePath string `mapstructure:"basePath"` // Base directory for cloned repos (default: ~/.kandev/repos)
}
// DebugConfig holds debug/profiling configuration.
type DebugConfig struct {
// DevMode enables all developer-only endpoints (pprof, memory, debug export).
// Controlled via KANDEV_DEBUG_DEV_MODE env var. Default: false.
DevMode bool `mapstructure:"devMode"`
// PprofEnabled is a legacy alias — if set, it also enables DevMode.
// Controlled via KANDEV_DEBUG_PPROF_ENABLED env var. Default: false.
PprofEnabled bool `mapstructure:"pprofEnabled"`
}
// AgentConfig holds agent runtime configuration.
// Note: Runtime selection is now per-task based on executor type, not global.
// The Standalone runtime (agentctl) always runs as a core service.
// Docker runtime is available when docker.enabled=true.
type AgentConfig struct {
// StandaloneHost is the host where standalone agentctl is running (default: 127.0.0.1)
StandaloneHost string `mapstructure:"standaloneHost"`
// StandalonePort is the control port for standalone agentctl (default: 39429)
StandalonePort int `mapstructure:"standalonePort"`
// StandaloneAuthToken is the single credential that authenticates both
// the agentctl *control server* (instance create/list/delete, health,
// ownership rotate/confirm) and every per-instance agentctl server it
// supervises (/agent/stream, file tree, shell, ...), per design 01
// "Single driver" (AC-EXECUTORS-CONTROL-OWNERSHIP-002.6). After adopting
// a surviving control server it holds the freshly rotated credential
// (AC-EXECUTORS-CONTROL-OWNERSHIP-002); use it for every client built
// against this control server or any instance it supervises. Set at
// runtime after agentctl starts; not persisted in config files.
StandaloneAuthToken string `mapstructure:"-"`
// StandalonePID is the OS process id of the standalone agentctl control-server
// this backend spawned. Set at runtime after agentctl starts (from the
// launcher); not persisted in config files. Used as the host-local liveness
// handle recorded in executors_running.local_pid for local/standalone rows.
StandalonePID int `mapstructure:"-"`
}
// ReadTimeoutDuration returns the read timeout as a time.Duration.
func (s *ServerConfig) ReadTimeoutDuration() time.Duration {
return time.Duration(s.ReadTimeout) * time.Second
}
// WriteTimeoutDuration returns the write timeout as a time.Duration.
func (s *ServerConfig) WriteTimeoutDuration() time.Duration {
return time.Duration(s.WriteTimeout) * time.Second
}
// TokenDurationTime returns the token duration as a time.Duration.
func (a *AuthConfig) TokenDurationTime() time.Duration {
return time.Duration(a.TokenDuration) * time.Second
}
// detectDefaultLogFormat returns the appropriate log format based on environment.
// Returns "json" if running in Kubernetes or other production environments.
// Returns "text" for terminal/development use (human-readable console format).
func detectDefaultLogFormat() string {
// Check if running in Kubernetes
if os.Getenv("KUBERNETES_SERVICE_HOST") != "" {
return "json"
}
// Check for explicit production environment
if env := os.Getenv("KANDEV_ENV"); env == "production" || env == "prod" {
return "json"
}
// Default to text format for terminal use (more readable than JSON)
return "text"
}
// setDefaults configures default values for all configuration options.
func setDefaults(v *viper.Viper) {
// Server defaults. Host defaults to empty (not the wildcard) so an unset
// host is distinguishable from an explicit one: ResolvedBinds falls back to
// server.hosts only when host is unset, and empty resolves to the wildcard
// default. This keeps KANDEV_SERVER_HOST winning over a config-file
// server.hosts.
v.SetDefault("server.host", "")
v.SetDefault("server.port", ports.Backend)
v.SetDefault("server.readTimeout", 30)
v.SetDefault("server.writeTimeout", 30)
v.SetDefault("server.webInternalUrl", "")
v.SetDefault("server.webTitlePrefix", "")
// HomeDir default — empty means resolve from KANDEV_HOME_DIR env or ~/.kandev
v.SetDefault("homeDir", "")
// Database defaults
v.SetDefault("database.driver", "sqlite")
v.SetDefault("database.path", "")
v.SetDefault("database.host", "localhost")
v.SetDefault("database.port", 5432)
v.SetDefault("database.user", "kandev")
v.SetDefault("database.password", "")
v.SetDefault("database.dbName", "kandev")
v.SetDefault("database.sslMode", "disable")
v.SetDefault("database.maxConns", 25)
v.SetDefault("database.minConns", 5)
// NATS defaults - empty URL means use in-memory event bus
v.SetDefault("nats.url", "")
v.SetDefault("nats.clusterId", "kandev-cluster")
v.SetDefault("nats.clientId", "kandev-client")
v.SetDefault("nats.maxReconnects", 10)
// Events defaults
v.SetDefault("events.namespace", "")
// Docker defaults — platform-aware host and volume path
v.SetDefault("docker.enabled", true) // Docker runtime enabled by default if Docker is available
v.SetDefault("docker.host", DefaultDockerHost())
v.SetDefault("docker.apiVersion", "") // Empty = auto-negotiate with daemon
v.SetDefault("docker.tlsVerify", false)
v.SetDefault("docker.defaultNetwork", "kandev-network")
v.SetDefault("docker.volumeBasePath", defaultDockerVolumePath())
// Agent defaults (runtime selection is now per-task based on executor type)
//
// 127.0.0.1 instead of "localhost": localhost resolution can select an IPv6
// loopback address before an IPv4 address. The explicit IPv4 loopback avoids
// address-resolution variance when host utility health checks run against
// agentctl bound IPv4-only and fail with
// "dial tcp [::1]:41001: connect: connection refused" whenever agentctl is
// bound IPv4-only (e.g. auth-disabled loopback binds), spinning the
// "host utility instance unhealthy; recreating" loop. The loopback
// address is explicit and unambiguous; operators with a non-loopback
// control plane can still override via KANDEV_AGENT_STANDALONE_HOST.
v.SetDefault("agent.standaloneHost", "127.0.0.1")
v.SetDefault("agent.standalonePort", ports.AgentCtl)
// Auth defaults. auth.cookieName defaults to empty on purpose: the auth
// service keeps the "kandev_session" base fallback internally and
// port-scopes it from the request host, so an empty default must not be
// seeded with a concrete name or the suffixing would never fire in
// production. A non-empty configured value is used verbatim.
v.SetDefault("auth.jwtSecret", "")
v.SetDefault("auth.tokenDuration", 3600) // 1 hour
v.SetDefault("auth.sessionTTLHours", 720) // 30 days, sliding
v.SetDefault("auth.cookieName", "")
// Office defaults
v.SetDefault("office.jwtSigningKey", "")
// Feature-flag defaults live in ./features.yaml (symlinked to
// apps/backend/internal/features/features.yaml). LoadWithPath applies
// them via features.ApplyDefaults after this function returns so the
// embedded YAML, not a Go literal, is the source of truth. Env vars
// (KANDEV_FEATURES_<NAME>) and the deployment's config.yaml still
// override. See docs/decisions/0007-runtime-feature-flags.md.
// Logging defaults
v.SetDefault("logging.level", "info")
v.SetDefault("logging.format", detectDefaultLogFormat())
// Repository discovery defaults
v.SetDefault("repositoryDiscovery.roots", []string{})
v.SetDefault("repositoryDiscovery.maxDepth", 5)
// Worktree defaults
v.SetDefault("worktree.enabled", true)
v.SetDefault("worktree.defaultBranch", "main")
v.SetDefault("worktree.cleanupOnRemove", true)
v.SetDefault("worktree.fetchTimeoutSeconds", 60)
v.SetDefault("worktree.pullTimeoutSeconds", 60)
// RepoClone defaults
v.SetDefault("repoClone.basePath", "")
// Debug defaults
v.SetDefault("debug.pprofEnabled", false)
}
// DefaultDockerHost returns the platform-appropriate Docker socket path.
// Respects DOCKER_HOST env var as override (standard Docker convention).
func DefaultDockerHost() string {
if host := os.Getenv("DOCKER_HOST"); host != "" {
return host
}
if runtime.GOOS == "windows" {
return "npipe:////./pipe/docker_engine"
}
return "unix:///var/run/docker.sock"
}
// defaultDockerVolumePath returns the platform-appropriate volume base path.
func defaultDockerVolumePath() string {
if runtime.GOOS == "windows" {
localAppData := os.Getenv("LOCALAPPDATA")
if localAppData == "" {
localAppData = filepath.Join(os.Getenv("USERPROFILE"), "AppData", "Local")
}
return filepath.Join(localAppData, "kandev", "volumes")
}
return "/var/lib/kandev/volumes"
}
// Load reads configuration from environment variables, config file, and defaults.
// Environment variables use the prefix KANDEV_ with snake_case naming.
// Config file should be named config.yaml and placed in the current directory or /etc/kandev/.
func Load() (*Config, error) {
return LoadWithHome("")
}
// LoadWithHome reads the selected configuration while using homeDir as the
// bootstrap directory for home-file discovery. The override affects file
// selection only; environment variables still retain their normal precedence
// over values read from the selected file.
func LoadWithHome(homeDir string) (*Config, error) {
if path := strings.TrimSpace(os.Getenv(InternalConfigFileEnv)); path != "" {
return loadWithPath(path, homeDir)
}
return loadWithPath("", homeDir)
}
// LoadWithPath reads configuration from the specified path or default locations.
func LoadWithPath(configPath string) (*Config, error) {
return loadWithPath(configPath, "")
}
func loadWithPath(configPath, homeDir string) (*Config, error) {
v := viper.New()
profileDefaults, err := profiles.EnvironmentDefaults()
if err != nil {
return nil, fmt.Errorf("read profile defaults: %w", err)
}
// Set defaults next. setDefaults seeds non-feature config
// (server, database, logging, …); feature-flag defaults flow
// through EnvironmentDefaults and AutomaticEnv below.
setDefaults(v)
// Seed Viper's features.* keyspace from profiles.yaml so the
// typed Config struct populates correctly even in tests that
// bypass AutomaticEnv. AutomaticEnv still wins at runtime.
flags, err := profiles.FeatureFlagDefaults()
if err != nil {
return nil, fmt.Errorf("read feature flag defaults: %w", err)
}
for name, value := range flags {
v.SetDefault("features."+name, value == "true")
}
setProfileDefaults(v, profileDefaults)
// Configure environment variables
v.SetEnvPrefix("KANDEV")
v.SetEnvKeyReplacer(strings.NewReplacer(".", "_"))
v.AutomaticEnv()
bindCatalogEnvironment(v)
// camelCase key, so AutomaticEnv would expose the undocumented
// KANDEV_AUTH_COOKIENAME; the canonical override is KANDEV_AUTH_COOKIE_NAME.
_ = v.BindEnv("auth.cookieName", "KANDEV_AUTH_COOKIE_NAME")
_ = v.BindEnv(
"githubCredentialBroker.publicBaseUrl",
"KANDEV_GITHUB_CREDENTIAL_BROKER_PUBLIC_BASE_URL",
)
_ = v.BindEnv(
"githubCredentialBroker.reissueSigningKey",
"KANDEV_GITHUB_CREDENTIAL_BROKER_REISSUE_SIGNING_KEY",
)
selection, found, err := selectConfigFile(configPath, homeDir)
if err != nil {
return nil, fmt.Errorf("select configuration file: %w", err)
}
if found {
if err := readSelectedConfig(v, selection); err != nil {
return nil, err
}
}
yamlKeys := configKeys(v)
envSnapshot := environmentSnapshot()
var cfg Config
if err := decodeConfig(v, &cfg); err != nil {
if found {
return nil, fmt.Errorf("error unmarshaling config file %q: %w", selection.path, err)
}
return nil, fmt.Errorf("error unmarshaling config: %w", err)
}
sources := applyStartupDefaultsAndEnvironment(&cfg, yamlKeys, profileDefaults, envSnapshot)
if err := applySurvivalRecoveryEnv(&cfg, envSnapshot, sources); err != nil {
return nil, fmt.Errorf("config validation failed: %w", err)
}
warnings := inspectSecretPermissions(selection, v)
warnings = append(warnings, clampOfficeLaunchSafetyConfig(&cfg)...)
cfg.Source = buildConfigSource(selection, v, sources, warnings)
if err := validateStartupSettings(&cfg); err != nil {
if found {
return nil, fmt.Errorf("config validation failed for %q: %w", selection.path, err)
}
return nil, fmt.Errorf("config validation failed: %w", err)
}
if err := validate(&cfg); err != nil {
if found {
return nil, fmt.Errorf("config validation failed for %q: %w", selection.path, err)
}
return nil, fmt.Errorf("config validation failed: %w", err)
}
return &cfg, nil
}
// validate checks that all required configuration fields are set.
// In development mode (default), most fields are optional.
func validate(cfg *Config) error {
var errs []string
// Server validation - always required
if cfg.Server.Port <= 0 || cfg.Server.Port > 65535 {
errs = append(errs, "server.port must be between 1 and 65535")
}
if _, err := cfg.Server.ResolvedBinds(); err != nil {
errs = append(errs, err.Error())
}
// Database validation. Normalize the driver in place so downstream
// case-sensitive comparisons (and the postgres-only branch below) see
// a canonical value.
cfg.Database.Driver = strings.ToLower(cfg.Database.Driver)
validDrivers := map[string]bool{"sqlite": true, "postgres": true}
if !validDrivers[cfg.Database.Driver] {
errs = append(errs, "database.driver must be one of: sqlite, postgres")
}
if cfg.Database.Driver == "postgres" {
if cfg.Database.Port <= 0 || cfg.Database.Port > 65535 {
errs = append(errs, "database.port must be between 1 and 65535")
}
if cfg.Database.User == "" {
errs = append(errs, "database.user is required for postgres driver")
}
if cfg.Database.DBName == "" {
errs = append(errs, "database.dbName is required for postgres driver")
}
validSSLModes := map[string]bool{
"disable": true, "require": true, "verify-ca": true, "verify-full": true,
}
if !validSSLModes[strings.ToLower(cfg.Database.SSLMode)] {
errs = append(errs, "database.sslMode must be one of: disable, require, verify-ca, verify-full")
}
}
// NATS validation - optional (uses in-memory event bus if not set)
// No validation needed - empty URL means use in-memory
// Docker validation - optional (agent features disabled if not available)
// No validation needed - will gracefully degrade
// Auth validation - generate random secret if not set (dev mode)
if cfg.Auth.JWTSecret == "" {
cfg.Auth.JWTSecret = generateDevSecret()
}
if cfg.Auth.TokenDuration <= 0 {
errs = append(errs, "auth.tokenDuration must be positive")
}
// Logging validation
validLevels := map[string]bool{"debug": true, "info": true, "warn": true, "error": true}
if !validLevels[strings.ToLower(cfg.Logging.Level)] {
errs = append(errs, "logging.level must be one of: debug, info, warn, error")
}
validFormats := map[string]bool{"json": true, "text": true}
if !validFormats[strings.ToLower(cfg.Logging.Format)] {
errs = append(errs, "logging.format must be one of: json, text")
}
if cfg.RepositoryDiscovery.MaxDepth <= 0 {
errs = append(errs, "repositoryDiscovery.maxDepth must be positive")
}
if err := cfg.GitHubCredentialBroker.validate(); err != nil {
errs = append(errs, err.Error())
}
if len(errs) > 0 {
return fmt.Errorf("%s", strings.Join(errs, "; "))
}
return nil
}
// DSN returns the PostgreSQL connection string.
func (d *DatabaseConfig) DSN() string {
return fmt.Sprintf(
"host=%s port=%d user=%s password=%s dbname=%s sslmode=%s",
d.Host, d.Port, d.User, d.Password, d.DBName, d.SSLMode,
)
}
// generateDevSecret generates a random secret for development mode.
func generateDevSecret() string {
// Use a fixed dev secret with a warning prefix
// In production, users should set KANDEV_AUTH_JWTSECRET
return "dev-secret-change-in-production-" + fmt.Sprintf("%d", time.Now().UnixNano())
}