// 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 // /.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 /.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 /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 " 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_) 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()) }