diff --git a/AGENTS.md b/AGENTS.md index af04055727..25c1cf0c0e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -240,6 +240,15 @@ Runtime feature toggles add a SQLite-backed override tier managed through `Setti Profile selection: `KANDEV_E2E_MOCK=true` → `e2e`, `KANDEV_DEBUG_DEV_MODE=true` or `KANDEV_DEBUG_PPROF_ENABLED=true` → `dev`, otherwise `prod`. The Go dev launcher (`apps/backend/internal/launcher/dev.go`) and `apps/web/e2e/fixtures/backend.ts` set only the selector — they no longer hardcode the underlying values. +Stable operator startup settings have a separate typed source contract in +`apps/backend/internal/common/config/catalog.go` and `source.go`. The catalog +owns each canonical YAML key, compatible environment alias, default, source +provenance, sensitivity, and reviewed exclusion. Configuration discovery uses +the first existing `config.yaml` candidate in working-directory, home, and +`/etc/kandev` order; it does not merge files. Environment values override YAML, +and consumers receive typed values or explicit child-process contracts instead +of relying on YAML-to-public-environment copying. + For any task that adds, rolls out, promotes, graduates, or removes a release toggle, use `/runtime-feature-flags`. That skill contains the file-by-file checklist, disabled-path requirements, test commands, promotion procedure, and retired-identity removal steps; do not rely on an agent discovering an ADR or public docs. In brief: merge risky features off in every shipped profile, enable a selected install with an admin override or explicit environment, restart when required by registry metadata, then test. Change `prod:` to `"true"` for the all-user release while retaining the registry entry as a kill-switch. Remove the live flag after the feature is permanent, move its key and environment variable to the append-only retired identities in `runtimeflags/registry.go`, and never reuse either identity. Completeness tests cover the registry/profile/frontend contracts. Runtime overrides and restart support are documented in `docs/decisions/0018-runtime-settings-overrides.md` and `docs/decisions/0019-restart-supervisor.md`. --- diff --git a/apps/backend/AGENTS.md b/apps/backend/AGENTS.md index c510ce1153..53a60f4dc2 100644 --- a/apps/backend/AGENTS.md +++ b/apps/backend/AGENTS.md @@ -216,6 +216,7 @@ Client (WS) ← Orchestrator ← Lifecycle Manager ←──── stream update - **Dependency direction:** Shared admission limits used by task models and orchestrator/messagequeue belong in a neutral internal package; task/models must not import the higher-level orchestrator/messagequeue package. - Production Git commands must use `subproc.NewGitCommand` with a classified `subproc.RunGit*` helper, or hold a classified admission slot across streaming `Start`/`Wait`. Do not construct raw Git commands outside `internal/common/subproc`; choose `interactive`, `lifecycle`, or `background`. - **Cross-tier shared code belongs in `internal/common/`.** agentctl ships as a standalone binary uploaded into containers, so neither it nor the backend may own code the other imports — put shared logic in a neutral `internal/common/*` package instead of importing across the boundary or forking a copy (`ptyexec` is the worked example; `subproc`, `gitref`, `securityutil` are older ones). Duplicating to "avoid the dependency" is how the two PTY copies drifted. +- **Startup configuration ownership:** `internal/common/config/catalog.go` and `source.go` own the stable operator catalog, discovery, precedence, provenance, and typed values; managed agentctl children receive a private subset contract. Do not reparse stable env in executors or copy YAML into public child environments. - **Pure computation that only matters on Windows should not carry `//go:build windows`.** One CI job runs on Windows and it tests a package allowlist, so tagged code is easily unverified — loginpty's copy of the Win32 quoting was compiled by no job at all. Keep platform API calls behind the tag, leave string/path helpers untagged, and add packages needing native coverage to the `test-windows` job in `.github/workflows/backend-tests.yml`. - **Event-bus wildcard parity:** New NATS wildcard subscriptions must verify equivalent `MemoryEventBus` semantics in `go test ./internal/events/bus`. - **Repository provider identity:** Provider-backed repositories are keyed by workspace, provider, normalized `provider_host` origin, full owner/namespace, and name. Persist `provider_host` when importing or resolving a remote; do not infer self-managed GitLab rows from owner/name alone. Legacy rows with an empty host have unknown identity and must fail closed for provider write/link operations. @@ -283,8 +284,6 @@ Enforced by `apps/backend/.golangci.yml` (errors on new code only): - Functions: ≤80 lines, ≤50 statements · Cyclomatic complexity: ≤15 · Cognitive complexity: ≤30 - Nesting depth: ≤5 · Naked returns only in functions ≤30 lines · No duplicated blocks (≥150 tokens) · Repeated strings → constants (≥3 occurrences) · Revive's 800-effective-line file limit also applies to test files; put new tests in a new file instead of appending to an already-large test file. -When you hit a limit, extract a helper function. Prefer composition over growing a single function. - When a PR fixup touches backend code, run the CI-style changed-file linter locally from `apps/backend` with the PR base SHA before pushing, because CI enforces changed-file complexity thresholds: ```bash diff --git a/apps/backend/cmd/agentctl/main.go b/apps/backend/cmd/agentctl/main.go index 9bbd59a703..09e800f45a 100644 --- a/apps/backend/cmd/agentctl/main.go +++ b/apps/backend/cmd/agentctl/main.go @@ -27,9 +27,11 @@ import ( "github.com/kandev/kandev/internal/agentctl/server/config" "github.com/kandev/kandev/internal/agentctl/server/instance" "github.com/kandev/kandev/internal/agentctl/server/process" + "github.com/kandev/kandev/internal/agentctl/tracing" "github.com/kandev/kandev/internal/common/logger" "github.com/kandev/kandev/internal/githubauth" mcpserver "github.com/kandev/kandev/internal/mcp/server" + "github.com/kandev/kandev/internal/profiles" "github.com/kandev/kandev/pkg/agent" "go.uber.org/zap" ) @@ -43,28 +45,54 @@ var ( ) func main() { + os.Exit(runMain()) +} + +func runMain() int { if code, handled := runGitHubUtilityCommand(); handled { - os.Exit(code) + return code } // Dispatch kandev CLI subcommands before flag parsing or server startup. // When invoked as "agentctl kandev ", this runs the CLI and exits // without starting the HTTP server. if len(os.Args) > 1 && os.Args[1] == "kandev" { - os.Exit(runKandevCLI(os.Args[2:])) + return runKandevCLI(os.Args[2:]) } cleanupGitHubCLIShim, err := prepareGitHubCLIShim() if err != nil { fmt.Fprintln(os.Stderr, err) - os.Exit(1) + return 1 } defer cleanupGitHubCLIShim() flag.Parse() - // Load configuration - cfg := config.Load() + // Load configuration. Managed launches receive a validated, resolved child + // contract from the backend. Direct invocations retain legacy environment + // compatibility when that private contract is absent. + startup, managed, err := config.StartupConfigFromEnv() + if err != nil { + fmt.Fprintf(os.Stderr, "failed to load managed agentctl configuration: %v\n", err) + return 1 + } + var cfg *config.Config + if managed { + cfg, err = config.LoadWithStartup(startup) + if err != nil { + fmt.Fprintf(os.Stderr, "failed to load managed agentctl configuration: %v\n", err) + return 1 + } + tracing.ConfigureEndpoint(startup.OTLPEndpoint) + } else { + cfg = config.Load() + tracing.ConfigureEndpoint(cfg.OTLPEndpoint) + } + if _, _, err := profiles.ApplyProfile(); err != nil { + fmt.Fprintf(os.Stderr, "failed to apply profile defaults: %v\n", err) + return 1 + } // Override with CLI flags if provided if *protocolFlag != "" { @@ -88,7 +116,7 @@ func main() { }) if err != nil { fmt.Fprintf(os.Stderr, "failed to initialize logger: %v\n", err) - os.Exit(1) + return 1 } defer func() { _ = log.Sync() @@ -99,6 +127,7 @@ func main() { zap.String("log_level", cfg.LogLevel)) run(cfg, log) + return 0 } func runGitHubUtilityCommand() (int, bool) { diff --git a/apps/backend/internal/agent/runtime/agentctl/launcher/launcher.go b/apps/backend/internal/agent/runtime/agentctl/launcher/launcher.go index deb74efafe..7f58f3d4fe 100644 --- a/apps/backend/internal/agent/runtime/agentctl/launcher/launcher.go +++ b/apps/backend/internal/agent/runtime/agentctl/launcher/launcher.go @@ -20,6 +20,7 @@ import ( "time" agentctlclient "github.com/kandev/kandev/internal/agent/runtime/agentctl" + commonconfig "github.com/kandev/kandev/internal/common/config" "github.com/kandev/kandev/internal/common/logger" "go.uber.org/zap" ) @@ -31,6 +32,7 @@ type Launcher struct { port int logger *logger.Logger onUnexpectedExit func() + startupConfig commonconfig.AgentctlStartupConfig cmd *exec.Cmd exited chan struct{} @@ -65,6 +67,7 @@ type Config struct { Host string // Host to bind to (default: localhost) Port int // Control port (default: 39429) OnUnexpectedExit func() // Called once when the child exits without Stop. + StartupConfig commonconfig.AgentctlStartupConfig } // New creates a new Launcher. @@ -84,6 +87,7 @@ func New(cfg Config, log *logger.Logger) *Launcher { host: cfg.Host, port: cfg.Port, onUnexpectedExit: cfg.OnUnexpectedExit, + startupConfig: cfg.StartupConfig, logger: log.WithFields(zap.String("component", "agentctl-launcher")), exited: make(chan struct{}), } @@ -230,8 +234,17 @@ func (l *Launcher) buildAndStartProcess(nonce string) error { // CommandContext sends SIGKILL on cancellation, preventing graceful shutdown. l.cmd = exec.Command(l.binaryPath, fmt.Sprintf("-port=%d", l.port)) - // Inject bootstrap nonce; agentctl generates its own auth token. - l.cmd.Env = append(os.Environ(), "AGENTCTL_BOOTSTRAP_NONCE="+nonce) + // Inject bootstrap nonce and the resolved child contract. Remove inherited + // copies first so a managed child cannot observe a conflicting host value. + overrides := []string{"AGENTCTL_BOOTSTRAP_NONCE=" + nonce} + if l.startupConfig.Configured { + encoded, err := commonconfig.EncodeAgentctlStartupConfig(l.startupConfig) + if err != nil { + return err + } + overrides = append(overrides, commonconfig.InternalAgentctlStartupConfigEnv+"="+encoded) + } + l.cmd.Env = environmentWithOverrides(os.Environ(), overrides...) l.cmd.SysProcAttr = buildSysProcAttr() pipeWrite, err := setupLivenessPipe(l.cmd) @@ -274,6 +287,27 @@ func (l *Launcher) buildAndStartProcess(nonce string) error { return nil } +func environmentWithOverrides(base []string, overrides ...string) []string { + keys := make(map[string]struct{}, len(overrides)) + for _, entry := range overrides { + key, _, ok := strings.Cut(entry, "=") + if ok && key != "" { + keys[key] = struct{}{} + } + } + result := make([]string, 0, len(base)+len(overrides)) + for _, entry := range base { + key, _, ok := strings.Cut(entry, "=") + if ok { + if _, overridden := keys[key]; overridden { + continue + } + } + result = append(result, entry) + } + return append(result, overrides...) +} + // performHandshake retrieves agentctl's self-generated auth token using the nonce. // On failure it kills the process and closes the liveness pipe. func (l *Launcher) performHandshake(ctx context.Context, nonce string) (string, error) { diff --git a/apps/backend/internal/agent/runtime/agentctl/launcher/launcher_test.go b/apps/backend/internal/agent/runtime/agentctl/launcher/launcher_test.go index 0025b97a2f..ab86f4d369 100644 --- a/apps/backend/internal/agent/runtime/agentctl/launcher/launcher_test.go +++ b/apps/backend/internal/agent/runtime/agentctl/launcher/launcher_test.go @@ -8,6 +8,7 @@ import ( "reflect" "runtime" "strconv" + "strings" "testing" "time" @@ -24,6 +25,22 @@ func TestLauncherConfigSupportsUnexpectedExitCallback(t *testing.T) { } } +func TestEnvironmentWithOverridesRemovesInheritedValues(t *testing.T) { + got := environmentWithOverrides( + []string{"KANDEV_ACP_IDLE_TIMEOUT=host", "PATH=/bin", "DUP=old"}, + "KANDEV_ACP_IDLE_TIMEOUT=resolved", + "DUP=new", + ) + + joined := strings.Join(got, "\n") + if strings.Count(joined, "KANDEV_ACP_IDLE_TIMEOUT=") != 1 || !strings.Contains(joined, "KANDEV_ACP_IDLE_TIMEOUT=resolved") { + t.Fatalf("resolved timeout override = %v", got) + } + if strings.Count(joined, "DUP=") != 1 || !strings.Contains(joined, "DUP=new") { + t.Fatalf("duplicate override = %v", got) + } +} + func TestLauncherUnexpectedExitCallbackRunsForCleanAndFailedExit(t *testing.T) { for _, exitCode := range []int{0, 7} { t.Run(fmt.Sprintf("exit-%d", exitCode), func(t *testing.T) { diff --git a/apps/backend/internal/agent/runtime/lifecycle/agentctl_startup.go b/apps/backend/internal/agent/runtime/lifecycle/agentctl_startup.go new file mode 100644 index 0000000000..d493d34894 --- /dev/null +++ b/apps/backend/internal/agent/runtime/lifecycle/agentctl_startup.go @@ -0,0 +1,36 @@ +package lifecycle + +import ( + commonconfig "github.com/kandev/kandev/internal/common/config" +) + +func validateAgentctlStartupConfig(startup commonconfig.AgentctlStartupConfig) error { + if !startup.Configured { + return nil + } + return startup.Validate() +} + +func agentctlStartupEnvironment(startup commonconfig.AgentctlStartupConfig) map[string]string { + if !startup.Configured { + return nil + } + raw, err := commonconfig.EncodeAgentctlStartupConfig(startup) + if err != nil { + // Executor entry points validate the contract before calling this helper. + // Keep the helper total for test-only direct environment builders. + return nil + } + return map[string]string{commonconfig.InternalAgentctlStartupConfigEnv: raw} +} + +func mergeAgentctlStartupEnvironment(base map[string]string, startup commonconfig.AgentctlStartupConfig) map[string]string { + result := make(map[string]string, len(base)+1) + for key, value := range base { + result[key] = value + } + for key, value := range agentctlStartupEnvironment(startup) { + result[key] = value + } + return result +} diff --git a/apps/backend/internal/agent/runtime/lifecycle/agentctl_startup_test.go b/apps/backend/internal/agent/runtime/lifecycle/agentctl_startup_test.go new file mode 100644 index 0000000000..f5f5790a79 --- /dev/null +++ b/apps/backend/internal/agent/runtime/lifecycle/agentctl_startup_test.go @@ -0,0 +1,85 @@ +package lifecycle + +import ( + "strings" + "testing" + "time" + + "github.com/kandev/kandev/internal/agent/agents" + commonconfig "github.com/kandev/kandev/internal/common/config" +) + +func testAgentctlStartupConfig() commonconfig.AgentctlStartupConfig { + return commonconfig.AgentctlStartupConfig{ + Configured: true, + IdleTimeout: 2 * time.Hour, + IdleReaperInterval: 3 * time.Minute, + NotificationQueueCapacity: 4096, + OTLPEndpoint: "http://collector:4318", + } +} + +func TestAgentctlStartupContractReachesContainerSpriteAndSSHEnvironments(t *testing.T) { + startup := testAgentctlStartupConfig() + encoded, err := commonconfig.EncodeAgentctlStartupConfig(startup) + if err != nil { + t.Fatalf("EncodeAgentctlStartupConfig: %v", err) + } + + containerEnv, err := (&ContainerManager{}).buildEnvVars(ContainerConfig{ + AgentConfig: agents.NewMockAgent(), + AgentctlStartupConfig: startup, + }) + if err != nil { + t.Fatalf("buildEnvVars: %v", err) + } + if !containsEnvValue(containerEnv, commonconfig.InternalAgentctlStartupConfigEnv, encoded) { + t.Fatalf("container environment did not carry the resolved contract: %v", containerEnv) + } + if got := countEnvKey(containerEnv, commonconfig.InternalAgentctlStartupConfigEnv); got != 1 { + t.Fatalf("container environment contains %d startup contracts, want exactly one: %v", got, containerEnv) + } + + spriteEnv := (&SpritesExecutor{}).buildSpriteEnv(map[string]string{ + commonconfig.InternalAgentctlStartupConfigEnv: "host-value", + }, startup) + if !containsEnvValue(spriteEnv, commonconfig.InternalAgentctlStartupConfigEnv, encoded) { + t.Fatalf("sprite environment did not override the host contract: %v", spriteEnv) + } + + sshEnv := sshAgentctlLaunchEnv(map[string]string{ + commonconfig.InternalAgentctlStartupConfigEnv: "host-value", + }, "nonce", startup) + if sshEnv[commonconfig.InternalAgentctlStartupConfigEnv] != encoded { + t.Fatalf("SSH environment contract = %q, want %q", sshEnv[commonconfig.InternalAgentctlStartupConfigEnv], encoded) + } +} + +func TestAgentctlStartupContractRejectsInvalidLaunchValues(t *testing.T) { + startup := testAgentctlStartupConfig() + startup.NotificationQueueCapacity = 1 + if err := validateAgentctlStartupConfig(startup); err == nil { + t.Fatal("validateAgentctlStartupConfig accepted an invalid queue capacity") + } +} + +func containsEnvValue(env []string, key, want string) bool { + prefix := key + "=" + for _, entry := range env { + if strings.HasPrefix(entry, prefix) && strings.TrimPrefix(entry, prefix) == want { + return true + } + } + return false +} + +func countEnvKey(env []string, key string) int { + prefix := key + "=" + count := 0 + for _, entry := range env { + if strings.HasPrefix(entry, prefix) { + count++ + } + } + return count +} diff --git a/apps/backend/internal/agent/runtime/lifecycle/container.go b/apps/backend/internal/agent/runtime/lifecycle/container.go index d2e06e05b8..4f4726498b 100644 --- a/apps/backend/internal/agent/runtime/lifecycle/container.go +++ b/apps/backend/internal/agent/runtime/lifecycle/container.go @@ -17,6 +17,7 @@ import ( "github.com/kandev/kandev/internal/agent/agents" "github.com/kandev/kandev/internal/agent/docker" agentctl "github.com/kandev/kandev/internal/agent/runtime/agentctl" + commonconfig "github.com/kandev/kandev/internal/common/config" "github.com/kandev/kandev/internal/common/constants" "github.com/kandev/kandev/internal/common/logger" "github.com/kandev/kandev/internal/gitconfigenv" @@ -56,10 +57,11 @@ type ContainerConfig struct { McpMode string McpProviders []string McpProfile *mcpprofile.Context - PrepareScript string // Script to run inside container before agent starts (e.g., clone repo) - ImageTagOverride string // If set, replaces the agent runtime's default image (e.g. profile.config.image_tag) - LocalClonePath string // Host path for file:// repository clone URLs; mounted read-only at the same path. - BootstrapNonce string // one-time nonce for agentctl handshake (set internally) + PrepareScript string // Script to run inside container before agent starts (e.g., clone repo) + ImageTagOverride string // If set, replaces the agent runtime's default image (e.g. profile.config.image_tag) + LocalClonePath string // Host path for file:// repository clone URLs; mounted read-only at the same path. + BootstrapNonce string // one-time nonce for agentctl handshake (set internally) + AgentctlStartupConfig commonconfig.AgentctlStartupConfig Metadata map[string]interface{} // Optional metadata (e.g., office runtime dir) // BaseBranches maps RepositoryName → base branch ref; forwarded into // agentctl's CreateInstanceRequest so each WorkspaceTracker resolves @@ -655,6 +657,9 @@ func (cm *ContainerManager) expandMountSource(source, workspacePath string) stri // buildEnvVars builds environment variables for the container func (cm *ContainerManager) buildEnvVars(config ContainerConfig) ([]string, error) { + if err := validateAgentctlStartupConfig(config.AgentctlStartupConfig); err != nil { + return nil, fmt.Errorf("invalid agentctl startup configuration: %w", err) + } ag := config.AgentConfig rt := ag.Runtime() env := make([]string, 0) @@ -733,10 +738,25 @@ func (cm *ContainerManager) buildEnvVars(config ContainerConfig) ([]string, erro if config.BootstrapNonce != "" { env = append(env, "AGENTCTL_BOOTSTRAP_NONCE="+config.BootstrapNonce) } + env = removeEnvKey(env, commonconfig.InternalAgentctlStartupConfigEnv) + for key, value := range agentctlStartupEnvironment(config.AgentctlStartupConfig) { + env = append(env, key+"="+value) + } return env, nil } +func removeEnvKey(env []string, key string) []string { + prefix := key + "=" + filtered := env[:0] + for _, entry := range env { + if !strings.HasPrefix(entry, prefix) { + filtered = append(filtered, entry) + } + } + return filtered +} + // generateBootstrapNonce creates a cryptographically random 32-byte hex-encoded nonce. func generateBootstrapNonce() (string, error) { b := make([]byte, 32) diff --git a/apps/backend/internal/agent/runtime/lifecycle/executor_backend.go b/apps/backend/internal/agent/runtime/lifecycle/executor_backend.go index 25d849f42e..e4e51419ac 100644 --- a/apps/backend/internal/agent/runtime/lifecycle/executor_backend.go +++ b/apps/backend/internal/agent/runtime/lifecycle/executor_backend.go @@ -13,6 +13,7 @@ import ( agentctl "github.com/kandev/kandev/internal/agent/runtime/agentctl" "github.com/kandev/kandev/internal/agentctl/server/process" "github.com/kandev/kandev/internal/agentruntime" + commonconfig "github.com/kandev/kandev/internal/common/config" mcpprofile "github.com/kandev/kandev/internal/mcp/profile" "github.com/kandev/kandev/internal/task/models" v1 "github.com/kandev/kandev/pkg/api/v1" @@ -411,6 +412,7 @@ type ExecutorCreateRequest struct { McpProfile *mcpprofile.Context AuthToken string // Previously handshaken agentctl token for reconnects BootstrapNonce string // Stored nonce for re-handshake after container restart + AgentctlStartupConfig commonconfig.AgentctlStartupConfig // OnProgress is an optional callback for streaming preparation progress. // Executors that perform multi-step setup (e.g. Sprites, remote Docker) can diff --git a/apps/backend/internal/agent/runtime/lifecycle/executor_docker.go b/apps/backend/internal/agent/runtime/lifecycle/executor_docker.go index 91431999e1..a0ca3e2b3a 100644 --- a/apps/backend/internal/agent/runtime/lifecycle/executor_docker.go +++ b/apps/backend/internal/agent/runtime/lifecycle/executor_docker.go @@ -163,6 +163,9 @@ func (r *DockerExecutor) HealthCheck(_ context.Context) error { func (r *DockerExecutor) CreateInstance(ctx context.Context, req *ExecutorCreateRequest) (instance *ExecutorInstance, err error) { baseCtx := preparationContext(ctx) + if err := validateAgentctlStartupConfig(req.AgentctlStartupConfig); err != nil { + return nil, fmt.Errorf("invalid agentctl startup configuration: %w", err) + } if _, err := validateRemoteContributions(req.RemoteContributions); err != nil { return nil, err } @@ -288,6 +291,7 @@ func (r *DockerExecutor) buildContainerLaunchConfig(req *ExecutorCreateRequest) BaseBranches: getMetadataStringMap(req.Metadata, MetadataKeyBaseBranches), RemoteContributions: req.RemoteContributions, ContributionDestinations: req.ContributionDestinations, + AgentctlStartupConfig: req.AgentctlStartupConfig, }, nil } diff --git a/apps/backend/internal/agent/runtime/lifecycle/executor_sprites.go b/apps/backend/internal/agent/runtime/lifecycle/executor_sprites.go index e33fbc1629..10d01f8bed 100644 --- a/apps/backend/internal/agent/runtime/lifecycle/executor_sprites.go +++ b/apps/backend/internal/agent/runtime/lifecycle/executor_sprites.go @@ -122,6 +122,9 @@ func (r *SpritesExecutor) ResumeRemoteInstance(_ context.Context, req *ExecutorC func (r *SpritesExecutor) CreateInstance(ctx context.Context, req *ExecutorCreateRequest) (*ExecutorInstance, error) { baseCtx := preparationContext(ctx) + if err := validateAgentctlStartupConfig(req.AgentctlStartupConfig); err != nil { + return nil, fmt.Errorf("invalid agentctl startup configuration: %w", err) + } if _, err := validateRemoteContributions(req.RemoteContributions); err != nil { return nil, err } @@ -232,7 +235,7 @@ func (r *SpritesExecutor) preflightGitHubCredentialBroker( stepCtx, cancel := context.WithTimeout(ctx, 15*time.Second) defer cancel() cmd := sprite.CommandContext(stepCtx, "sh", "-c", command) - cmd.Env = r.buildSpriteEnv(env) + cmd.Env = r.buildSpriteEnv(env, req.AgentctlStartupConfig) return cmd.CombinedOutput() }) } diff --git a/apps/backend/internal/agent/runtime/lifecycle/executor_sprites_credentials.go b/apps/backend/internal/agent/runtime/lifecycle/executor_sprites_credentials.go index 626dc8be38..8465f36e8a 100644 --- a/apps/backend/internal/agent/runtime/lifecycle/executor_sprites_credentials.go +++ b/apps/backend/internal/agent/runtime/lifecycle/executor_sprites_credentials.go @@ -103,7 +103,7 @@ func (r *SpritesExecutor) runAuthSetupScripts( stepCtx, cancel := context.WithTimeout(ctx, 30*time.Second) cmd := sprite.CommandContext(stepCtx, "sh", "-c", method.SetupScript) - cmd.Env = r.buildSpriteEnv(req.Env) + cmd.Env = r.buildSpriteEnv(req.Env, req.AgentctlStartupConfig) out, err := cmd.CombinedOutput() cancel() diff --git a/apps/backend/internal/agent/runtime/lifecycle/executor_sprites_helpers.go b/apps/backend/internal/agent/runtime/lifecycle/executor_sprites_helpers.go index 7ae8c2e8b7..a0686a0575 100644 --- a/apps/backend/internal/agent/runtime/lifecycle/executor_sprites_helpers.go +++ b/apps/backend/internal/agent/runtime/lifecycle/executor_sprites_helpers.go @@ -13,6 +13,7 @@ import ( sprites "github.com/superfly/sprites-go" "go.uber.org/zap" + commonconfig "github.com/kandev/kandev/internal/common/config" "github.com/kandev/kandev/internal/githubauth" ) @@ -128,9 +129,13 @@ func extractUploadHTTPStatus(msg string) int { return code } -func (r *SpritesExecutor) buildSpriteEnv(env map[string]string) []string { - result := make([]string, 0, len(env)) - for k, v := range env { +func (r *SpritesExecutor) buildSpriteEnv(env map[string]string, startup ...commonconfig.AgentctlStartupConfig) []string { + merged := env + if len(startup) > 0 { + merged = mergeAgentctlStartupEnvironment(env, startup[0]) + } + result := make([]string, 0, len(merged)) + for k, v := range merged { if k == githubauth.CredentialHelperPathEnv { continue } diff --git a/apps/backend/internal/agent/runtime/lifecycle/executor_sprites_operations.go b/apps/backend/internal/agent/runtime/lifecycle/executor_sprites_operations.go index 6170163f25..91a497549a 100644 --- a/apps/backend/internal/agent/runtime/lifecycle/executor_sprites_operations.go +++ b/apps/backend/internal/agent/runtime/lifecycle/executor_sprites_operations.go @@ -234,7 +234,7 @@ func (r *SpritesExecutor) runPrepareScript( r.logger.Debug("running prepare script") cmd := sprite.CommandContext(stepCtx, "bash", "-c", script) - cmd.Env = r.buildSpriteEnv(req.Env) + cmd.Env = r.buildSpriteEnv(req.Env, req.AgentctlStartupConfig) stdout, err := cmd.StdoutPipe() if err != nil { diff --git a/apps/backend/internal/agent/runtime/lifecycle/executor_ssh.go b/apps/backend/internal/agent/runtime/lifecycle/executor_ssh.go index b5c3b0d954..a6dafe12fa 100644 --- a/apps/backend/internal/agent/runtime/lifecycle/executor_ssh.go +++ b/apps/backend/internal/agent/runtime/lifecycle/executor_ssh.go @@ -17,6 +17,7 @@ import ( agentctl "github.com/kandev/kandev/internal/agent/runtime/agentctl" "github.com/kandev/kandev/internal/agentctl/server/process" "github.com/kandev/kandev/internal/agentruntime" + commonconfig "github.com/kandev/kandev/internal/common/config" "github.com/kandev/kandev/internal/common/logger" "github.com/kandev/kandev/internal/secrets" ) @@ -192,6 +193,9 @@ func (r *SSHExecutor) workdirRoot(md map[string]interface{}) string { // instead of starting a second remote agentctl on top of the live one. func (r *SSHExecutor) CreateInstance(ctx context.Context, req *ExecutorCreateRequest) (*ExecutorInstance, error) { baseCtx := preparationContext(ctx) + if err := validateAgentctlStartupConfig(req.AgentctlStartupConfig); err != nil { + return nil, fmt.Errorf("invalid agentctl startup configuration: %w", err) + } resumed, ok := r.resumedStateForCreate(req) if ok { return r.buildResumedInstance(req, resumed), nil @@ -384,6 +388,7 @@ func (r *SSHExecutor) startAndForwardAgentctl( env := sshAgentctlLaunchEnv( managedGitCredentialBrokerEnv(sshRemoteContributionEnv(req, agentctlBin)), nonce, + req.AgentctlStartupConfig, ) shell := sshShellForRemote(req.Metadata, platform) controlPort, pid, err := startRemoteAgentctl(ctx, client, shell, agentctlBin, taskDir, sessionDir, env, r.logger) @@ -428,7 +433,7 @@ func (r *SSHExecutor) startAndForwardAgentctl( return instancePort, pid, fwd, authToken, nil } -func sshAgentctlLaunchEnv(base map[string]string, nonce string) map[string]string { +func sshAgentctlLaunchEnv(base map[string]string, nonce string, startup ...commonconfig.AgentctlStartupConfig) map[string]string { env := make(map[string]string, len(base)) for key, value := range base { env[key] = value @@ -439,6 +444,11 @@ func sshAgentctlLaunchEnv(base map[string]string, nonce string) map[string]strin delete(env, "AGENTCTL_AUTH_TOKEN") env["AGENTCTL_BOOTSTRAP_NONCE"] = nonce env["AGENTCTL_LISTEN_HOST"] = sshAgentctlLoopbackHost + if len(startup) > 0 { + for key, value := range agentctlStartupEnvironment(startup[0]) { + env[key] = value + } + } return env } diff --git a/apps/backend/internal/agent/runtime/lifecycle/manager.go b/apps/backend/internal/agent/runtime/lifecycle/manager.go index 3c9726d2fd..2320d8111f 100644 --- a/apps/backend/internal/agent/runtime/lifecycle/manager.go +++ b/apps/backend/internal/agent/runtime/lifecycle/manager.go @@ -18,6 +18,7 @@ import ( "github.com/kandev/kandev/internal/agent/runtime/activity" agentctl "github.com/kandev/kandev/internal/agent/runtime/agentctl" "github.com/kandev/kandev/internal/agent/runtime/routingerr" + commonconfig "github.com/kandev/kandev/internal/common/config" "github.com/kandev/kandev/internal/common/logger" "github.com/kandev/kandev/internal/events/bus" "github.com/kandev/kandev/internal/secrets" @@ -157,6 +158,10 @@ type Manager struct { // SSH/remote rows — their process lives on another host. standaloneHostPID atomic.Int64 + // agentctlStartupConfig is the resolved child contract applied to every + // managed agentctl launch path. + agentctlStartupConfig commonconfig.AgentctlStartupConfig + // managedGoCache provides the opt-in GOCACHE for host-local executions. // System storage wiring installs it after settings persistence is ready. managedGoCache ManagedGoCacheEnvironmentProvider @@ -240,6 +245,17 @@ func (m *Manager) SetStandaloneHostPID(pid int) { m.standaloneHostPID.Store(int64(pid)) } +// SetAgentctlStartupConfig wires the resolved backend-owned agentctl values +// into every executor request. Remote and container executors serialize this +// contract explicitly instead of inheriting the backend environment. +func (m *Manager) SetAgentctlStartupConfig(startup commonconfig.AgentctlStartupConfig) error { + if err := startup.Validate(); err != nil { + return err + } + m.agentctlStartupConfig = startup + return nil +} + // NewManager creates a new lifecycle manager. // The executorRegistry manages multiple runtimes (Docker, Standalone, etc.) for task-specific execution. // The fallbackPolicy controls behavior when a requested runtime is unavailable. diff --git a/apps/backend/internal/agent/runtime/lifecycle/manager_execution.go b/apps/backend/internal/agent/runtime/lifecycle/manager_execution.go index 73a9c4d97e..b023bb16e4 100644 --- a/apps/backend/internal/agent/runtime/lifecycle/manager_execution.go +++ b/apps/backend/internal/agent/runtime/lifecycle/manager_execution.go @@ -734,6 +734,7 @@ func (m *Manager) prepareExecutionCreateRequest( PreviousExecutionID: info.AgentExecutionID, AuthToken: m.revealRuntimeSecret(ctx, info.Metadata, MetadataKeyAuthTokenSecret), BootstrapNonce: m.revealRuntimeSecret(ctx, info.Metadata, MetadataKeyBootstrapNonceSecret), + AgentctlStartupConfig: m.agentctlStartupConfig, RemoteContributions: remoteContributions, ContributionDestinations: contributionDestinations, }, diff --git a/apps/backend/internal/agent/runtime/lifecycle/manager_launch.go b/apps/backend/internal/agent/runtime/lifecycle/manager_launch.go index e5bad9bcdc..ee2d48bc44 100644 --- a/apps/backend/internal/agent/runtime/lifecycle/manager_launch.go +++ b/apps/backend/internal/agent/runtime/lifecycle/manager_launch.go @@ -801,6 +801,7 @@ func (m *Manager) launchBuildExecutorRequest(ctx context.Context, executionID st McpProfile: reqWithWorktree.McpProfile, AuthToken: m.revealRuntimeSecret(ctx, metadata, MetadataKeyAuthTokenSecret), BootstrapNonce: m.revealRuntimeSecret(ctx, metadata, MetadataKeyBootstrapNonceSecret), + AgentctlStartupConfig: m.agentctlStartupConfig, OnProgress: onProgress, RemoteContributions: remoteContributions, ContributionDestinations: contributionDestinations, diff --git a/apps/backend/internal/agentctl/server/adapter/adapter.go b/apps/backend/internal/agentctl/server/adapter/adapter.go index 56e0d83953..b677e15a53 100644 --- a/apps/backend/internal/agentctl/server/adapter/adapter.go +++ b/apps/backend/internal/agentctl/server/adapter/adapter.go @@ -300,6 +300,9 @@ type Config struct { // (opencode acp). The process manager uses the adapter's return value to // decide whether to kill the entire process group on shutdown. RequiresProcessKill bool + + // NotificationQueueCapacity is the server-resolved ACP inbound queue size. + NotificationQueueCapacity int } // ToSharedConfig converts this Config to the shared.Config used by transport adapters. @@ -317,20 +320,21 @@ func (c *Config) ToSharedConfig() *shared.Config { } } return &shared.Config{ - WorkDir: c.WorkDir, - AutoApprove: c.AutoApprove, - ApprovalPolicy: c.ApprovalPolicy, - McpServers: mcpServers, - AgentID: c.AgentID, - AgentName: c.AgentName, - BaseURL: c.BaseURL, - AuthHeader: c.AuthHeader, - AuthValue: c.AuthValue, - Headers: c.Headers, - Extra: c.Extra, - AssumeMcpSse: c.AssumeMcpSse, - AssumeMcpHttp: c.AssumeMcpHttp, - RequiresProcessKill: c.RequiresProcessKill, + WorkDir: c.WorkDir, + AutoApprove: c.AutoApprove, + ApprovalPolicy: c.ApprovalPolicy, + McpServers: mcpServers, + AgentID: c.AgentID, + AgentName: c.AgentName, + BaseURL: c.BaseURL, + AuthHeader: c.AuthHeader, + AuthValue: c.AuthValue, + Headers: c.Headers, + Extra: c.Extra, + AssumeMcpSse: c.AssumeMcpSse, + AssumeMcpHttp: c.AssumeMcpHttp, + RequiresProcessKill: c.RequiresProcessKill, + NotificationQueueCapacity: c.NotificationQueueCapacity, } } diff --git a/apps/backend/internal/agentctl/server/adapter/transport/acp/adapter.go b/apps/backend/internal/agentctl/server/adapter/transport/acp/adapter.go index a33ec4f049..14fe13752d 100644 --- a/apps/backend/internal/agentctl/server/adapter/transport/acp/adapter.go +++ b/apps/backend/internal/agentctl/server/adapter/transport/acp/adapter.go @@ -76,8 +76,12 @@ const ( ) // acpNotifQueueCapacity returns the per-connection inbound notification queue -// capacity, honoring KANDEV_ACP_NOTIF_QUEUE when set and parseable. -func acpNotifQueueCapacity() int { +// capacity. Managed servers pass the resolved value explicitly; the legacy +// environment fallback remains for directly constructed adapters. +func acpNotifQueueCapacity(configured ...int) int { + if len(configured) > 0 && configured[0] != 0 { + return clampACPNotifQueueCapacity(configured[0]) + } raw := os.Getenv("KANDEV_ACP_NOTIF_QUEUE") if raw == "" { return acpNotifQueueDefault @@ -86,6 +90,10 @@ func acpNotifQueueCapacity() int { if err != nil || n <= 0 { return acpNotifQueueDefault } + return clampACPNotifQueueCapacity(n) +} + +func clampACPNotifQueueCapacity(n int) int { if n < acpNotifQueueMin { return acpNotifQueueMin } @@ -454,7 +462,7 @@ func (a *Adapter) Initialize(ctx context.Context) error { // down. The internal notifQueueCapacity channel sits in front of this // queue and is drained by our update worker. Requires a coder/acp-go-sdk // fork with WithMaxQueuedNotifications; see go.mod replace directive. - notifQueueCap := acpNotifQueueCapacity() + notifQueueCap := acpNotifQueueCapacity(a.cfg.NotificationQueueCapacity) a.acpConn = acp.NewClientSideConnection(a.acpClient, a.stdin, a.stdout, acp.WithMaxQueuedNotifications(notifQueueCap)) a.acpConn.SetLogger(slog.Default().With("component", "acp-conn")) diff --git a/apps/backend/internal/agentctl/server/adapter/transport/acp/adapter_queue_test.go b/apps/backend/internal/agentctl/server/adapter/transport/acp/adapter_queue_test.go index 6ae5667d6a..c5010924cf 100644 --- a/apps/backend/internal/agentctl/server/adapter/transport/acp/adapter_queue_test.go +++ b/apps/backend/internal/agentctl/server/adapter/transport/acp/adapter_queue_test.go @@ -54,3 +54,10 @@ func TestACPNotifQueueCapacity(t *testing.T) { } }) } + +func TestACPNotifQueueCapacityUsesExplicitStartupValue(t *testing.T) { + t.Setenv("KANDEV_ACP_NOTIF_QUEUE", "1024") + if got := acpNotifQueueCapacity(4096); got != 4096 { + t.Fatalf("explicit queue capacity = %d, want 4096", got) + } +} diff --git a/apps/backend/internal/agentctl/server/adapter/transport/shared/config.go b/apps/backend/internal/agentctl/server/adapter/transport/shared/config.go index e31558f833..9196d84d6e 100644 --- a/apps/backend/internal/agentctl/server/adapter/transport/shared/config.go +++ b/apps/backend/internal/agentctl/server/adapter/transport/shared/config.go @@ -59,6 +59,10 @@ type Config struct { // adapter's return value to decide whether to kill the entire process // group on shutdown so MCP child processes don't leak. RequiresProcessKill bool + + // NotificationQueueCapacity is the resolved ACP inbound notification queue + // capacity. Zero keeps compatibility for directly constructed test configs. + NotificationQueueCapacity int } // GetPermissionTimeout returns the configured permission timeout or the default. diff --git a/apps/backend/internal/agentctl/server/config/config.go b/apps/backend/internal/agentctl/server/config/config.go index 987add00aa..64e80e4fcb 100644 --- a/apps/backend/internal/agentctl/server/config/config.go +++ b/apps/backend/internal/agentctl/server/config/config.go @@ -23,6 +23,7 @@ import ( "sync" "time" + commonconfig "github.com/kandev/kandev/internal/common/config" "github.com/kandev/kandev/internal/gitconfigenv" "github.com/kandev/kandev/internal/githubauth" mcpprofile "github.com/kandev/kandev/internal/mcp/profile" @@ -88,6 +89,13 @@ type Config struct { // rely on the default. IdleReaperInterval time.Duration + // NotificationQueueCapacity is the resolved ACP inbound notification + // queue capacity for every instance created by this server. + NotificationQueueCapacity int + + // OTLPEndpoint is the resolved endpoint used by agentctl transport tracing. + OTLPEndpoint string + // mu protects BootstrapNonce from concurrent access during handshake. mu sync.Mutex } @@ -202,6 +210,10 @@ type InstanceConfig struct { // ProcessBufferMaxBytes caps per-process output buffer size ProcessBufferMaxBytes int64 + // NotificationQueueCapacity is the ACP inbound notification queue size + // inherited from the server startup contract. + NotificationQueueCapacity int + // SessionID is the session ID for this agent instance (used in MCP tool calls) SessionID string @@ -269,8 +281,50 @@ type InstanceConfig struct { WorkspaceSourceRoots []string } -// Load loads the configuration from environment variables. +// Load loads the configuration from environment variables. Managed launches +// should use LoadWithStartup so their resolved child contract is explicit. func Load() *Config { + return load(nil) +} + +// LoadWithStartup loads the configuration and applies the resolved backend +// startup contract. The contract is validated before it is used so a managed +// child cannot silently fall back to a different timeout or queue size. +func LoadWithStartup(startup commonconfig.AgentctlStartupConfig) (*Config, error) { + if err := startup.Validate(); err != nil { + return nil, err + } + return load(&startup), nil +} + +// StartupConfigFromEnv reads the private managed-child contract. A missing +// variable means agentctl was started directly and should retain legacy +// environment compatibility. A present but malformed variable is fatal to +// that managed launch. +func StartupConfigFromEnv() (commonconfig.AgentctlStartupConfig, bool, error) { + raw, found := os.LookupEnv(commonconfig.InternalAgentctlStartupConfigEnv) + if !found { + return commonconfig.AgentctlStartupConfig{}, false, nil + } + startup, err := commonconfig.DecodeAgentctlStartupConfig(raw) + if err != nil { + return commonconfig.AgentctlStartupConfig{}, true, err + } + return startup, true, nil +} + +func load(startup *commonconfig.AgentctlStartupConfig) *Config { + idleTimeout := getEnvDuration("KANDEV_ACP_IDLE_TIMEOUT", time.Hour) + idleReaperInterval := getEnvDuration("KANDEV_ACP_IDLE_REAPER_INTERVAL", time.Minute) + notificationQueueCapacity := getEnvInt("KANDEV_ACP_NOTIF_QUEUE", 131072) + otlpEndpoint := getEnv("OTEL_EXPORTER_OTLP_ENDPOINT", "") + if startup != nil { + idleTimeout = startup.IdleTimeout + idleReaperInterval = startup.IdleReaperInterval + notificationQueueCapacity = startup.NotificationQueueCapacity + otlpEndpoint = startup.OTLPEndpoint + } + cfg := &Config{ Port: getEnvInt("AGENTCTL_PORT", 39429), Ports: PortConfig{ @@ -286,14 +340,16 @@ func Load() *Config { HealthCheckInterval: getEnvInt("AGENTCTL_HEALTH_CHECK_INTERVAL", 5), ProcessBufferMaxBytes: getEnvInt64("AGENTCTL_PROCESS_BUFFER_MAX_BYTES", 2*1024*1024), }, - ShellEnabled: getEnvBool("AGENTCTL_SHELL_ENABLED", true), - LogLevel: getEnvWithFallback("AGENTCTL_LOG_LEVEL", "KANDEV_LOG_LEVEL", "info"), - LogFormat: getEnv("AGENTCTL_LOG_FORMAT", "json"), - McpLogFile: getEnv("KANDEV_MCP_LOG_FILE", ""), - VscodeCommand: getEnv("AGENTCTL_VSCODE_COMMAND", "code-server"), - ListenHostOverride: getEnv("AGENTCTL_LISTEN_HOST", ""), - IdleTimeout: getEnvDuration("KANDEV_ACP_IDLE_TIMEOUT", time.Hour), - IdleReaperInterval: getEnvDuration("KANDEV_ACP_IDLE_REAPER_INTERVAL", time.Minute), + ShellEnabled: getEnvBool("AGENTCTL_SHELL_ENABLED", true), + LogLevel: getEnvWithFallback("AGENTCTL_LOG_LEVEL", "KANDEV_LOG_LEVEL", "info"), + LogFormat: getEnv("AGENTCTL_LOG_FORMAT", "json"), + McpLogFile: getEnv("KANDEV_MCP_LOG_FILE", ""), + VscodeCommand: getEnv("AGENTCTL_VSCODE_COMMAND", "code-server"), + ListenHostOverride: getEnv("AGENTCTL_LISTEN_HOST", ""), + IdleTimeout: idleTimeout, + IdleReaperInterval: idleReaperInterval, + NotificationQueueCapacity: notificationQueueCapacity, + OTLPEndpoint: otlpEndpoint, } // Bootstrap nonce mode: agentctl generates its own token and the backend @@ -360,19 +416,20 @@ func generateSelfToken() string { // If port is 0, it should be allocated by the caller. func (c *Config) NewInstanceConfig(port int, overrides *InstanceOverrides) *InstanceConfig { cfg := &InstanceConfig{ - Port: port, - Protocol: c.Defaults.Protocol, - AgentCommand: c.Defaults.AgentCommand, - WorkDir: c.Defaults.WorkDir, - AutoStart: c.Defaults.AutoStart, - AutoApprovePermissions: c.Defaults.AutoApprovePermissions, - ShellEnabled: c.ShellEnabled, - LogLevel: c.LogLevel, - LogFormat: c.LogFormat, - ProcessBufferMaxBytes: c.Defaults.ProcessBufferMaxBytes, - VscodeCommand: c.VscodeCommand, - McpMode: "task", - AuthToken: c.AuthToken, + Port: port, + Protocol: c.Defaults.Protocol, + AgentCommand: c.Defaults.AgentCommand, + WorkDir: c.Defaults.WorkDir, + AutoStart: c.Defaults.AutoStart, + AutoApprovePermissions: c.Defaults.AutoApprovePermissions, + ShellEnabled: c.ShellEnabled, + LogLevel: c.LogLevel, + LogFormat: c.LogFormat, + ProcessBufferMaxBytes: c.Defaults.ProcessBufferMaxBytes, + NotificationQueueCapacity: c.NotificationQueueCapacity, + VscodeCommand: c.VscodeCommand, + McpMode: "task", + AuthToken: c.AuthToken, } applyOverrides(cfg, overrides) diff --git a/apps/backend/internal/agentctl/server/config/config_test.go b/apps/backend/internal/agentctl/server/config/config_test.go index 76b65d7eca..0404d29152 100644 --- a/apps/backend/internal/agentctl/server/config/config_test.go +++ b/apps/backend/internal/agentctl/server/config/config_test.go @@ -8,8 +8,39 @@ import ( "runtime" "strings" "testing" + "time" + + commonconfig "github.com/kandev/kandev/internal/common/config" ) +func TestLoadWithStartupUsesExplicitManagedValues(t *testing.T) { + t.Setenv("KANDEV_ACP_IDLE_TIMEOUT", "bad") + t.Setenv("KANDEV_ACP_IDLE_REAPER_INTERVAL", "bad") + t.Setenv("KANDEV_ACP_NOTIF_QUEUE", "1024") + t.Setenv("OTEL_EXPORTER_OTLP_ENDPOINT", "http://inherited:4318") + + startup := commonconfig.AgentctlStartupConfig{ + Configured: true, + IdleTimeout: 2 * time.Hour, + IdleReaperInterval: 3 * time.Minute, + NotificationQueueCapacity: 4096, + OTLPEndpoint: "http://configured:4318", + } + cfg, err := LoadWithStartup(startup) + if err != nil { + t.Fatalf("LoadWithStartup: %v", err) + } + if cfg.IdleTimeout != startup.IdleTimeout || cfg.IdleReaperInterval != startup.IdleReaperInterval { + t.Fatalf("managed durations = %s/%s, want %s/%s", cfg.IdleTimeout, cfg.IdleReaperInterval, startup.IdleTimeout, startup.IdleReaperInterval) + } + if cfg.NotificationQueueCapacity != startup.NotificationQueueCapacity { + t.Fatalf("managed queue capacity = %d, want %d", cfg.NotificationQueueCapacity, startup.NotificationQueueCapacity) + } + if cfg.OTLPEndpoint != startup.OTLPEndpoint { + t.Fatalf("managed OTLP endpoint = %q, want %q", cfg.OTLPEndpoint, startup.OTLPEndpoint) + } +} + func TestNewInstanceConfigNormalizesMcpProviders(t *testing.T) { cfg := (&Config{}).NewInstanceConfig(0, &InstanceOverrides{ McpProviders: []string{" GITLAB ", "unsupported", "github", "github"}, diff --git a/apps/backend/internal/agentctl/server/process/manager.go b/apps/backend/internal/agentctl/server/process/manager.go index 713d4acec6..4d91902ee4 100644 --- a/apps/backend/internal/agentctl/server/process/manager.go +++ b/apps/backend/internal/agentctl/server/process/manager.go @@ -1209,14 +1209,15 @@ func (m *Manager) buildAdapterConfig() error { } } m.adapterCfg = &adapter.Config{ - WorkDir: m.cfg.WorkDir, - AutoApprove: m.cfg.AutoApprovePermissions, - ApprovalPolicy: m.cfg.ApprovalPolicy, - McpServers: mcpServers, - AgentID: m.cfg.AgentType, // From registry (e.g., "auggie", "amp", "claude-code") - AssumeMcpSse: m.cfg.AssumeMcpSse, - AssumeMcpHttp: m.cfg.AssumeMcpHttp, - RequiresProcessKill: m.cfg.RequiresProcessKill, + WorkDir: m.cfg.WorkDir, + AutoApprove: m.cfg.AutoApprovePermissions, + ApprovalPolicy: m.cfg.ApprovalPolicy, + McpServers: mcpServers, + AgentID: m.cfg.AgentType, // From registry (e.g., "auggie", "amp", "claude-code") + AssumeMcpSse: m.cfg.AssumeMcpSse, + AssumeMcpHttp: m.cfg.AssumeMcpHttp, + RequiresProcessKill: m.cfg.RequiresProcessKill, + NotificationQueueCapacity: m.cfg.NotificationQueueCapacity, } // Configure one-shot mode when a continue command is provided. diff --git a/apps/backend/internal/agentctl/server/process/manager_test.go b/apps/backend/internal/agentctl/server/process/manager_test.go index 4584440187..7f44277f25 100644 --- a/apps/backend/internal/agentctl/server/process/manager_test.go +++ b/apps/backend/internal/agentctl/server/process/manager_test.go @@ -425,6 +425,28 @@ func TestBuildAdapterConfig_StripEnvRemovesDeclaredVars(t *testing.T) { } } +func TestBuildAdapterConfigForwardsNotificationQueueCapacity(t *testing.T) { + m := &Manager{ + cfg: &config.InstanceConfig{ + AgentArgs: []string{"cat"}, + WorkDir: t.TempDir(), + AgentEnv: []string{"PATH=/usr/bin"}, + Protocol: agent.ProtocolACP, + NotificationQueueCapacity: 4096, + }, + logger: newTestLogger(t), + } + + if err := m.buildAdapterConfig(); err != nil { + t.Fatalf("buildAdapterConfig: %v", err) + } + t.Cleanup(func() { _ = m.adapter.Close() }) + + if got := m.adapterCfg.NotificationQueueCapacity; got != 4096 { + t.Fatalf("adapter notification queue capacity = %d, want 4096", got) + } +} + func TestStartOneShotPreservesConfiguredTempEnvironment(t *testing.T) { log := newTestLogger(t) workDir := t.TempDir() diff --git a/apps/backend/internal/agentctl/tracing/otel.go b/apps/backend/internal/agentctl/tracing/otel.go index 0a95c6425b..d7b689ea38 100644 --- a/apps/backend/internal/agentctl/tracing/otel.go +++ b/apps/backend/internal/agentctl/tracing/otel.go @@ -25,11 +25,33 @@ import ( const serviceName = "kandev-agentctl" var ( - initOnce sync.Once - tracerProvider trace.TracerProvider = noop.NewTracerProvider() - sdkProvider *sdktrace.TracerProvider + initOnce sync.Once + tracerProvider trace.TracerProvider = noop.NewTracerProvider() + sdkProvider *sdktrace.TracerProvider + endpointMu sync.RWMutex + configuredEndpoint string + configuredSet bool ) +// ConfigureEndpoint sets the resolved startup endpoint before the first +// tracer is requested. Managed backend and agentctl processes use this to +// avoid depending on inherited environment values. +func ConfigureEndpoint(endpoint string) { + endpointMu.Lock() + configuredEndpoint = endpoint + configuredSet = true + endpointMu.Unlock() +} + +func resolvedEndpoint() string { + endpointMu.RLock() + defer endpointMu.RUnlock() + if configuredSet { + return configuredEndpoint + } + return os.Getenv("OTEL_EXPORTER_OTLP_ENDPOINT") +} + func initTracing() { // Always register the W3C propagator so Inject/Extract work // even if this process doesn't export spans. @@ -40,7 +62,7 @@ func initTracing() { ), ) - endpoint := os.Getenv("OTEL_EXPORTER_OTLP_ENDPOINT") + endpoint := resolvedEndpoint() if endpoint == "" { return } diff --git a/apps/backend/internal/backendapp/agentctl.go b/apps/backend/internal/backendapp/agentctl.go index 8f4a8d44fd..81c2a288b7 100644 --- a/apps/backend/internal/backendapp/agentctl.go +++ b/apps/backend/internal/backendapp/agentctl.go @@ -31,6 +31,7 @@ func provideAgentctlLauncher( l, cleanup, err := launcher.Provide(ctx, launcher.Config{ Host: cfg.Agent.StandaloneHost, Port: cfg.Agent.StandalonePort, + StartupConfig: cfg.ManagedAgentctlStartupConfig(), OnUnexpectedExit: availability.MarkUnavailable, }, log) if err != nil { diff --git a/apps/backend/internal/backendapp/agents.go b/apps/backend/internal/backendapp/agents.go index 1ab2c481fa..aa70750229 100644 --- a/apps/backend/internal/backendapp/agents.go +++ b/apps/backend/internal/backendapp/agents.go @@ -2,7 +2,9 @@ package backendapp import ( "context" + "fmt" "os" + "strings" "go.uber.org/zap" @@ -90,7 +92,7 @@ func provideLifecycleManager( } credsMgr.AddProvider(credentials.NewEnvProvider("KANDEV_")) credsMgr.AddProvider(credentials.NewAugmentSessionProvider()) - if credsFile := os.Getenv("KANDEV_CREDENTIALS_FILE"); credsFile != "" { + if credsFile := credentialFilePath(cfg); credsFile != "" { credsMgr.AddProvider(credentials.NewFileProvider(credsFile)) } @@ -122,6 +124,9 @@ func provideLifecycleManager( preparerRegistry.Register(models.ExecutorTypeSSH, lifecycle.NewSSHPreparer(log)) lifecycleMgr.SetPreparerRegistry(preparerRegistry) lifecycleMgr.SetSecretStore(secretStore) + if err := lifecycleMgr.SetAgentctlStartupConfig(cfg.ManagedAgentctlStartupConfig()); err != nil { + return nil, fmt.Errorf("configure managed agentctl startup: %w", err) + } // Record the standalone agentctl control-server PID (populated by // provideAgentctlLauncher, which runs before this) so local/standalone // executor rows carry a real host-local liveness handle. @@ -148,3 +153,10 @@ func provideLifecycleManager( zap.Int("agent_types", len(agentRegistry.List()))) return lifecycleMgr, nil } + +func credentialFilePath(cfg *config.Config) string { + if cfg != nil { + return strings.TrimSpace(cfg.Credentials.File) + } + return strings.TrimSpace(os.Getenv("KANDEV_CREDENTIALS_FILE")) +} diff --git a/apps/backend/internal/backendapp/agents_startup_config_test.go b/apps/backend/internal/backendapp/agents_startup_config_test.go new file mode 100644 index 0000000000..a70b5548eb --- /dev/null +++ b/apps/backend/internal/backendapp/agents_startup_config_test.go @@ -0,0 +1,17 @@ +package backendapp + +import ( + "testing" + + "github.com/kandev/kandev/internal/common/config" +) + +func TestCredentialFilePathUsesTypedStartupConfig(t *testing.T) { + cfg := &config.Config{} + cfg.Credentials.File = "/srv/kandev/credentials.json" + t.Setenv("KANDEV_CREDENTIALS_FILE", "/tmp/legacy.json") + + if got := credentialFilePath(cfg); got != "/srv/kandev/credentials.json" { + t.Fatalf("credential file = %q, want typed startup path", got) + } +} diff --git a/apps/backend/internal/backendapp/gateway.go b/apps/backend/internal/backendapp/gateway.go index 41250e34e7..def5ffdd35 100644 --- a/apps/backend/internal/backendapp/gateway.go +++ b/apps/backend/internal/backendapp/gateway.go @@ -118,6 +118,7 @@ func provideGateway( gitlabSvc *gitlab.Service, referenceValidator entityrefs.SubmissionValidator, dataDir string, + lspMaxConnections ...int, ) (*gateways.Gateway, *notificationservice.Service, *notificationcontroller.Controller, *terminalservice.Service, error) { gateway, err := gateways.Provide(log) if err != nil { @@ -142,7 +143,7 @@ func provideGateway( scriptSvc := &scriptServiceAdapter{taskSvc: taskSvc} if lifecycleMgr != nil { gateway.SetLifecycleManager(lifecycleMgr, userSvc, scriptSvc) - gateway.SetLSPHandler(lifecycleMgr, userSvc) + gateway.SetLSPHandler(lifecycleMgr, userSvc, lspMaxConnections...) gateway.SetVscodeProxy(lifecycleMgr) gateway.SetPortProxy(lifecycleMgr) gateway.SetPortTunnel(lifecycleMgr) diff --git a/apps/backend/internal/backendapp/helpers.go b/apps/backend/internal/backendapp/helpers.go index 481edd3399..29ad04e0f1 100644 --- a/apps/backend/internal/backendapp/helpers.go +++ b/apps/backend/internal/backendapp/helpers.go @@ -572,6 +572,8 @@ type routeParams struct { devMode bool httpPort int features config.FeaturesConfig + planCoalesceWindow time.Duration + planCoalesceWindowConfigured bool homeDir string interimSettingsInterlockToken string log *logger.Logger @@ -580,7 +582,12 @@ type routeParams struct { // registerRoutes sets up all HTTP and WebSocket routes on the given router. func registerRoutes(p routeParams) { workflowCtrl := workflowcontroller.NewController(p.services.Workflow) - planService := taskservice.NewPlanService(p.taskRepo, p.eventBus, p.log) + var planService *taskservice.PlanService + if p.planCoalesceWindowConfigured { + planService = taskservice.NewPlanService(p.taskRepo, p.eventBus, p.log, p.planCoalesceWindow) + } else { + planService = taskservice.NewPlanService(p.taskRepo, p.eventBus, p.log) + } // Per-user task scoping for plan reads/writes (opt-in auth). planService.SetTaskAuthorizer(p.taskSvc.AuthorizeTaskAccess) clarificationStore := clarification.NewStore(2 * time.Hour) diff --git a/apps/backend/internal/backendapp/main.go b/apps/backend/internal/backendapp/main.go index 8180169953..47759ac506 100644 --- a/apps/backend/internal/backendapp/main.go +++ b/apps/backend/internal/backendapp/main.go @@ -28,7 +28,10 @@ import ( // Common packages "github.com/kandev/kandev/internal/backendapp/ownershiplock" "github.com/kandev/kandev/internal/common/config" + "github.com/kandev/kandev/internal/common/constants" "github.com/kandev/kandev/internal/common/logger" + "github.com/kandev/kandev/internal/common/subproc" + "github.com/kandev/kandev/internal/profiles" // Event bus "github.com/kandev/kandev/internal/events" @@ -54,6 +57,7 @@ import ( runtimeskill "github.com/kandev/kandev/internal/agent/runtime/lifecycle/skill" agentsettingscontroller "github.com/kandev/kandev/internal/agent/settings/controller" settingsstore "github.com/kandev/kandev/internal/agent/settings/store" + agentctltracing "github.com/kandev/kandev/internal/agentctl/tracing" "github.com/kandev/kandev/internal/utility/profilebinding" // WebSocket gateway @@ -205,6 +209,12 @@ func Run(args []string, build BuildInfo) int { fmt.Fprintf(os.Stderr, "Failed to load configuration: %v\n", err) return 1 } + if _, _, err := profiles.ApplyProfile(); err != nil { + fmt.Fprintf(os.Stderr, "Failed to apply profile defaults: %v\n", err) + return 1 + } + constants.ApplyPreparationTimeout(cfg.Tasks.PreparationTimeout) + subproc.ConfigureCaps(cfg.Limits.GHMaxConcurrent, cfg.Limits.GitMaxConcurrent) // Apply command-line flag overrides (flags take precedence over config/env) if parsedFlags.Port > 0 { @@ -213,6 +223,7 @@ func Run(args []string, build BuildInfo) int { if parsedFlags.LogLevel != "" { cfg.Logging.Level = parsedFlags.LogLevel } + agentctltracing.ConfigureEndpoint(cfg.Observability.OTLPEndpoint) // Acquire runtime-state ownership before any shared-state initialization. // The lock remains held through logger and service cleanup, so a second @@ -776,6 +787,7 @@ func startGatewayAndServe( repos.Notification, repos.Task, repos.Terminal, services.GitHub, services.GitLab, referenceValidator, cfg.ResolvedHomeDir(), + cfg.Limits.LSPMaxConnections, ) if terminalSvc != nil { services.Terminal = terminalSvc @@ -904,6 +916,7 @@ func startGatewayAndServe( } scheduling := startSchedulingRuntime( ctx, repos, services, eventBus, orchestratorSvc, runProcessorSvc, log, + runsscheduler.TickIntervalFromConfig(cfg.Office.SchedulerTickMs), ) addCleanup(scheduling.Stop) var restoreQuiesceOnce sync.Once @@ -951,6 +964,7 @@ func startGatewayAndServe( OrchestratorShutdown: func() { _ = orchestratorSvc.Stop() }, RestoreQuiesce: restoreQuiesce, MessageQueue: orchestratorSvc.GetMessageQueue(), + MessageQueueConfig: queueConfiguration(cfg), TaskSessions: repos.Task, }) storageComposition, err := provideStorageComposition( @@ -1429,10 +1443,11 @@ func startSchedulingRuntime( orchestratorSvc *orchestrator.Service, runProcessorSvc *officeservice.Service, log *logger.Logger, + tickInterval time.Duration, ) *schedulingRuntime { log.Info("Global run processor wired to orchestrator StartTask") orchScheduler := officeservice.NewSchedulerIntegration( - runProcessorSvc, runsscheduler.TickIntervalFromEnv(), + runProcessorSvc, tickInterval, ) // Office task-handoffs prompt enrichment. The HandoffService is // constructed alongside the HTTP routes (helpers.go); we stash the @@ -1458,11 +1473,11 @@ func startSchedulingRuntime( // orchScheduler.Tick on both periodic ticks and event-driven signals. runScheduler := runsscheduler.New( orchScheduler, runsSvc.SubscribeSignal(), - runsscheduler.TickIntervalFromEnv(), log, + tickInterval, log, ) runScheduler.Start(ctx) log.Info("Runs scheduler started", - zap.Duration("tick", runsscheduler.TickIntervalFromEnv())) + zap.Duration("tick", tickInterval)) // Phase 5 (ADR-0004): start the shared cron loop. The routines handler // degrades to a no-op when routineSvc is nil, so omitting Office's // scheduler is safe when features.office is off. @@ -1986,7 +2001,7 @@ func buildHTTPServer( // X-Forwarded-Host feeds the port-scoped cookie-name resolver; honor it // only from the same trusted proxies that may rewrite it (an untrusted // value is stripped with a warning, so the resolver falls back to Host). - trusted := configureTrustedProxies(router, log) + trusted := configureTrustedProxies(router, log, cfg.Server.TrustedProxies) router.Use(authhttpmw.StripUntrustedForwardedHost(trusted, log)) router.Use(httpmw.RequestLogger(log, kandevName)) router.Use(httpmw.OtelTracing(kandevName)) @@ -2080,6 +2095,8 @@ func buildHTTPServer( devMode: cfg.Debug.DevMode || cfg.Debug.PprofEnabled, httpPort: resolvedHTTPPort(cfg), features: cfg.Features, + planCoalesceWindow: time.Duration(cfg.Planning.CoalesceWindowMs) * time.Millisecond, + planCoalesceWindowConfigured: true, homeDir: cfg.ResolvedHomeDir(), interimSettingsInterlockToken: interimSettingsInterlockToken, log: log, diff --git a/apps/backend/internal/backendapp/message_queue_settings_test.go b/apps/backend/internal/backendapp/message_queue_settings_test.go index 04088adf6c..791559fbe9 100644 --- a/apps/backend/internal/backendapp/message_queue_settings_test.go +++ b/apps/backend/internal/backendapp/message_queue_settings_test.go @@ -7,6 +7,7 @@ import ( "github.com/jmoiron/sqlx" _ "github.com/mattn/go-sqlite3" + commonconfig "github.com/kandev/kandev/internal/common/config" "github.com/kandev/kandev/internal/db" "github.com/kandev/kandev/internal/system/queuesettings" systemsettings "github.com/kandev/kandev/internal/system/settings" @@ -46,6 +47,61 @@ func TestResolveQueueMaxPerSessionPrecedence(t *testing.T) { } } +func TestResolveQueueMaxPerSessionUsesYAMLConfigurationBeforePersistedSetting(t *testing.T) { + pool := newMessageQueueSettingsTestPool(t) + raw, err := systemsettings.NewStore(pool) + if err != nil { + t.Fatalf("new system settings store: %v", err) + } + if err := queuesettings.NewStore(raw).Save(context.Background(), queuesettings.Settings{ + MaxPerSession: 6, MergeEnabled: true, AutoMergeEnabled: true, + }); err != nil { + t.Fatalf("save setting: %v", err) + } + + cfg := &commonconfig.Config{ + MessageQueue: commonconfig.MessageQueueConfig{MaxPerSession: 14}, + Source: commonconfig.ConfigSource{Values: map[string]commonconfig.SettingSource{ + "messageQueue.maxPerSession": commonconfig.SourceConfiguration, + }}, + } + t.Setenv(queuesettings.EnvironmentVariable, "") + startup := queueConfiguration(cfg) + resolution := resolveQueueSettings(pool, testLogger(t), startup) + if resolution.Effective.MaxPerSession != 14 || + resolution.Effective.Source != queuesettings.SourceConfiguration || + !resolution.Effective.Locked { + t.Fatalf("resolution = %+v, want locked YAML configuration value", resolution.Effective) + } +} + +func TestResolveQueueMaxPerSessionYAMLZeroLocksUnlimitedCapacity(t *testing.T) { + pool := newMessageQueueSettingsTestPool(t) + raw, err := systemsettings.NewStore(pool) + if err != nil { + t.Fatalf("new system settings store: %v", err) + } + if err := queuesettings.NewStore(raw).Save(context.Background(), queuesettings.Settings{ + MaxPerSession: 6, MergeEnabled: true, AutoMergeEnabled: true, + }); err != nil { + t.Fatalf("save setting: %v", err) + } + + cfg := &commonconfig.Config{ + MessageQueue: commonconfig.MessageQueueConfig{MaxPerSession: 0}, + Source: commonconfig.ConfigSource{Values: map[string]commonconfig.SettingSource{ + "messageQueue.maxPerSession": commonconfig.SourceConfiguration, + }}, + } + t.Setenv(queuesettings.EnvironmentVariable, "") + resolution := resolveQueueSettings(pool, testLogger(t), queueConfiguration(cfg)) + if resolution.Effective.MaxPerSession != 0 || + resolution.Effective.Source != queuesettings.SourceConfiguration || + !resolution.Effective.Locked { + t.Fatalf("resolution = %+v, want locked YAML unlimited value", resolution.Effective) + } +} + func TestResolveQueueMergeEnabledPrecedence(t *testing.T) { tests := []struct { name string diff --git a/apps/backend/internal/backendapp/orchestrator.go b/apps/backend/internal/backendapp/orchestrator.go index 67c69bfdb6..a12d6d1987 100644 --- a/apps/backend/internal/backendapp/orchestrator.go +++ b/apps/backend/internal/backendapp/orchestrator.go @@ -97,7 +97,7 @@ func provideOrchestrator( if err != nil { return nil, nil, fmt.Errorf("init message queue repo: %w", err) } - queueSettings := resolveQueueSettings(pool, log).Effective + queueSettings := resolveQueueSettings(pool, log, queueConfiguration(cfg)).Effective maxPerSession := queueSettings.MaxPerSession mergeEnabled := queueSettings.MergeEnabled autoMergeEnabled := queueSettings.AutoMergeEnabled @@ -287,8 +287,8 @@ func githubCredentialBrokerEndpoint(cfg *config.Config) string { // falling back to messagequeue.DefaultMaxPerSession (10) when unset or invalid. // Values <= 0 disable the cap entirely (callers can still flood queues — only // useful in tests / specialized deployments). -func resolveQueueMaxPerSession(pool *db.Pool, log *logger.Logger) int { - return resolveQueueSettings(pool, log).Effective.MaxPerSession +func resolveQueueMaxPerSession(pool *db.Pool, log *logger.Logger, startup ...queuesettings.Configuration) int { + return resolveQueueSettings(pool, log, startup...).Effective.MaxPerSession } // resolveQueueMergeEnabled honors the persisted message queue setting, @@ -308,7 +308,11 @@ func resolveQueueAutoMergeEnabled(pool *db.Pool, log *logger.Logger) bool { // back to defaults when unset, invalid, or the store is unavailable — and // resolves them against the KANDEV_QUEUE_MAX_PER_SESSION environment // override. -func resolveQueueSettings(pool *db.Pool, log *logger.Logger) queuesettings.Resolution { +func resolveQueueSettings( + pool *db.Pool, + log *logger.Logger, + startup ...queuesettings.Configuration, +) queuesettings.Resolution { var configured *queuesettings.Settings if pool != nil { rawStore, err := systemsettings.NewStore(pool) @@ -322,7 +326,7 @@ func resolveQueueSettings(pool *db.Pool, log *logger.Logger) queuesettings.Resol } } } - resolution, err := queuesettings.Resolve(configured, queuesettings.ReadEnvironment()) + resolution, err := queuesettings.Resolve(configured, queuesettings.ReadEnvironment(), startup...) if err != nil { log.Warn("Failed to resolve message queue settings, using defaults", zap.Error(err)) return queuesettings.Resolution{Response: queuesettings.Response{ @@ -341,6 +345,13 @@ func resolveQueueSettings(pool *db.Pool, log *logger.Logger) queuesettings.Resol return resolution } +func queueConfiguration(cfg *config.Config) queuesettings.Configuration { + if cfg == nil || cfg.SourceFor("messageQueue.maxPerSession") != config.SourceConfiguration { + return queuesettings.Configuration{} + } + return queuesettings.Configuration{Value: cfg.MessageQueue.MaxPerSession, Present: true} +} + func resolveEventNamespace(cfg *config.Config) string { if cfg == nil { return defaultEventNamespace diff --git a/apps/backend/internal/backendapp/trustedproxies.go b/apps/backend/internal/backendapp/trustedproxies.go index f6c31ddf9a..7bf815a61b 100644 --- a/apps/backend/internal/backendapp/trustedproxies.go +++ b/apps/backend/internal/backendapp/trustedproxies.go @@ -11,8 +11,9 @@ import ( "github.com/kandev/kandev/internal/common/logger" ) -// trustedProxiesEnv lists the reverse proxies whose X-Forwarded-For header -// the backend trusts. Comma-separated IPs and CIDRs. +// trustedProxiesEnv is the legacy environment alias for the trusted-proxy +// setting. It lists reverse proxies whose X-Forwarded-For header the backend +// trusts as comma-separated IPs and CIDRs. const trustedProxiesEnv = "KANDEV_TRUSTED_PROXIES" // emptyTrustedProxyEntry is the placeholder name used when a blank component @@ -21,11 +22,11 @@ const trustedProxiesEnv = "KANDEV_TRUSTED_PROXIES" // closed like any other unparsable entry. const emptyTrustedProxyEntry = "" -const trustedProxiesWarningMsg = "KANDEV_TRUSTED_PROXIES contains invalid entries; ignoring X-Forwarded-For entirely" +const trustedProxiesWarningMsg = "configured trusted-proxy setting contains invalid entries; ignoring X-Forwarded-For entirely" -// resolveTrustedProxies parses the comma-separated KANDEV_TRUSTED_PROXIES -// value into gin-compatible proxy origins (bare IPs or CIDRs). An entirely -// blank value means "no trusted proxies" (trusted nil, no invalid entries). +// resolveTrustedProxies parses the comma-separated environment override into +// gin-compatible proxy origins (bare IPs or CIDRs). An entirely blank value +// means "no trusted proxies" (trusted nil, no invalid entries). // Any unparsable entry, including a blank component inside a nonblank value, // makes the whole list untrustworthy: trusted is nil then (fail closed) and // invalid names the bad values for the startup warning. The returned strings @@ -35,7 +36,11 @@ func resolveTrustedProxies(raw string) (trusted []string, invalid []string) { if strings.TrimSpace(raw) == "" { return nil, nil } - for _, part := range strings.Split(raw, ",") { + return resolveTrustedProxyEntries(strings.Split(raw, ",")) +} + +func resolveTrustedProxyEntries(entries []string) (trusted []string, invalid []string) { + for _, part := range entries { entry := strings.TrimSpace(part) if entry == "" { invalid = append(invalid, emptyTrustedProxyEntry) @@ -58,7 +63,7 @@ func resolveTrustedProxies(raw string) (trusted []string, invalid []string) { return trusted, nil } -// configureTrustedProxies reads KANDEV_TRUSTED_PROXIES and applies it to the +// configureTrustedProxies applies the configured trusted-proxy list to the // router. Unset, empty, or fully invalid lists disable forwarded-header trust // (SetTrustedProxies(nil)); an invalid entry logs a warning naming the bad // value(s). gin trusts all proxies out of the box, so without this call a @@ -66,8 +71,13 @@ func resolveTrustedProxies(raw string) (trusted []string, invalid []string) { // defeat the ClientIP-keyed login rate limiter. It returns the effective // trusted list (nil when nothing is trusted) so callers can apply the same // trust decision to other forwarded headers. -func configureTrustedProxies(router *gin.Engine, log *logger.Logger) []string { - trusted, invalid := resolveTrustedProxies(os.Getenv(trustedProxiesEnv)) +func configureTrustedProxies(router *gin.Engine, log *logger.Logger, configured ...[]string) []string { + var trusted, invalid []string + if len(configured) > 0 { + trusted, invalid = resolveTrustedProxyEntries(configured[0]) + } else { + trusted, invalid = resolveTrustedProxies(os.Getenv(trustedProxiesEnv)) + } if len(invalid) > 0 { log.Warn(trustedProxiesWarningMsg, zap.Strings("invalid_entries", invalid)) trusted = nil diff --git a/apps/backend/internal/backendapp/trustedproxies_test.go b/apps/backend/internal/backendapp/trustedproxies_test.go index 4ebe52f2df..4e8cfa4d90 100644 --- a/apps/backend/internal/backendapp/trustedproxies_test.go +++ b/apps/backend/internal/backendapp/trustedproxies_test.go @@ -4,6 +4,8 @@ import ( "fmt" "net/http" "net/http/httptest" + "os" + "path/filepath" "reflect" "strings" "testing" @@ -13,9 +15,37 @@ import ( "go.uber.org/zap/zapcore" "go.uber.org/zap/zaptest/observer" + "github.com/kandev/kandev/internal/common/config" "github.com/kandev/kandev/internal/common/logger" ) +func TestConfigureTrustedProxiesUsesTypedStartupConfig(t *testing.T) { + dir := t.TempDir() + configPath := filepath.Join(dir, "config.yaml") + if err := os.WriteFile(configPath, []byte("server:\n trustedProxies:\n - 10.0.0.0/8\n"), 0o600); err != nil { + t.Fatal(err) + } + t.Setenv("KANDEV_SERVER_PORT", "") + t.Setenv("KANDEV_TRUSTED_PROXIES", "") + cfg, err := config.LoadWithPath(configPath) + if err != nil { + t.Fatal(err) + } + + gin.SetMode(gin.TestMode) + router := gin.New() + configureTrustedProxies(router, testLogger(t), cfg.Server.TrustedProxies) + router.GET("/", func(c *gin.Context) { c.String(http.StatusOK, c.ClientIP()) }) + req := httptest.NewRequest(http.MethodGet, "/", nil) + req.RemoteAddr = "10.0.0.5:1234" + req.Header.Set("X-Forwarded-For", "203.0.113.7") + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + if got := rec.Body.String(); got != "203.0.113.7" { + t.Fatalf("typed config ClientIP = %q, want 203.0.113.7", got) + } +} + func TestResolveTrustedProxies(t *testing.T) { tests := []struct { name string @@ -117,7 +147,7 @@ func TestConfigureTrustedProxiesInvalidEntryWarnsAndFallsBack(t *testing.T) { } warned := false for _, entry := range logs.All() { - if !strings.Contains(entry.Message, "KANDEV_TRUSTED_PROXIES") { + if !strings.Contains(entry.Message, "trusted-proxy setting") { continue } for _, field := range entry.Context { diff --git a/apps/backend/internal/common/config/agentctl.go b/apps/backend/internal/common/config/agentctl.go new file mode 100644 index 0000000000..dcca562067 --- /dev/null +++ b/apps/backend/internal/common/config/agentctl.go @@ -0,0 +1,84 @@ +package config + +import ( + "encoding/json" + "fmt" + "strings" + "time" +) + +// InternalAgentctlStartupConfigEnv carries the resolved agentctl-owned startup +// settings from the backend to a managed agentctl process. It is a private +// child-process contract, not an operator-facing configuration variable. +const InternalAgentctlStartupConfigEnv = "KANDEV_INTERNAL_AGENTCTL_STARTUP_CONFIG" + +// AgentctlStartupConfig is the resolved configuration contract for a managed +// agentctl process. Configured distinguishes the backend's resolved contract +// from zero-valued structs used by embedded callers and tests. +type AgentctlStartupConfig struct { + Configured bool `json:"configured"` + IdleTimeout time.Duration `json:"idleTimeout"` + IdleReaperInterval time.Duration `json:"idleReaperInterval"` + NotificationQueueCapacity int `json:"notificationQueueCapacity"` + OTLPEndpoint string `json:"otlpEndpoint"` +} + +// ManagedAgentctlStartupConfig returns the agentctl settings resolved by the +// common startup configuration loader. +func (c *Config) ManagedAgentctlStartupConfig() AgentctlStartupConfig { + if c == nil { + return AgentctlStartupConfig{} + } + return AgentctlStartupConfig{ + Configured: true, + IdleTimeout: c.Agentctl.IdleTimeout, + IdleReaperInterval: c.Agentctl.IdleReaperInterval, + NotificationQueueCapacity: c.Agentctl.NotificationQueueCapacity, + OTLPEndpoint: c.Observability.OTLPEndpoint, + } +} + +// Validate checks the child contract before it crosses a process boundary. +func (c AgentctlStartupConfig) Validate() error { + if !c.Configured { + return fmt.Errorf("agentctl startup configuration is not resolved") + } + if c.IdleTimeout < 0 { + return fmt.Errorf("agentctl idle timeout must be zero or greater") + } + if c.IdleReaperInterval <= 0 { + return fmt.Errorf("agentctl idle reaper interval must be positive") + } + if c.NotificationQueueCapacity < 1024 || c.NotificationQueueCapacity > 131072 { + return fmt.Errorf("agentctl notification queue capacity must be between 1024 and 131072") + } + return nil +} + +// EncodeAgentctlStartupConfig serializes a validated child contract for the +// private environment handoff used by local, container, and remote launches. +func EncodeAgentctlStartupConfig(c AgentctlStartupConfig) (string, error) { + if err := c.Validate(); err != nil { + return "", err + } + data, err := json.Marshal(c) + if err != nil { + return "", fmt.Errorf("encode agentctl startup configuration: %w", err) + } + return string(data), nil +} + +// DecodeAgentctlStartupConfig parses and validates a private child contract. +func DecodeAgentctlStartupConfig(raw string) (AgentctlStartupConfig, error) { + if strings.TrimSpace(raw) == "" { + return AgentctlStartupConfig{}, fmt.Errorf("agentctl startup configuration is empty") + } + var c AgentctlStartupConfig + if err := json.Unmarshal([]byte(raw), &c); err != nil { + return AgentctlStartupConfig{}, fmt.Errorf("decode agentctl startup configuration: %w", err) + } + if err := c.Validate(); err != nil { + return AgentctlStartupConfig{}, err + } + return c, nil +} diff --git a/apps/backend/internal/common/config/catalog.go b/apps/backend/internal/common/config/catalog.go new file mode 100644 index 0000000000..f5cb5e9137 --- /dev/null +++ b/apps/backend/internal/common/config/catalog.go @@ -0,0 +1,226 @@ +package config + +import ( + "fmt" + "sort" + "strings" +) + +// SettingSource identifies the source that supplied a resolved startup value. +type SettingSource string + +const ( + SourceDefault SettingSource = "default" + SourceProfile SettingSource = "profile" + SourceConfiguration SettingSource = "configuration" + SourceEnvironment SettingSource = "environment" + SourceDatabase SettingSource = "database" + SourceFlag SettingSource = "flag" +) + +// CatalogEntry describes one stable operator-facing startup setting. +// +// EnvVars is ordered by compatibility priority. The first non-empty variable +// wins when more than one alias is present. Internal aliases are included when +// a launcher or child process must use the same typed setting. +type CatalogEntry struct { + Key string + EnvVars []string + Owner string + Default string + Sensitive bool + Description string +} + +// CatalogExclusion records an environment variable that is intentionally not +// exposed as a public YAML setting. +type CatalogExclusion struct { + EnvVar string + Class string + Reason string +} + +var startupCatalog = []CatalogEntry{ + {Key: "homeDir", EnvVars: []string{"KANDEV_HOME_DIR"}, Owner: "common/config", Default: "~/.kandev"}, + {Key: "server.host", EnvVars: []string{"KANDEV_SERVER_HOST"}, Owner: "backend", Default: "0.0.0.0"}, + {Key: "server.port", EnvVars: []string{"KANDEV_SERVER_PORT", "KANDEV_BACKEND_PORT", "KANDEV_PORT"}, Owner: "launcher/backend", Default: "38429"}, + {Key: "server.readTimeout", EnvVars: []string{"KANDEV_SERVER_READTIMEOUT"}, Owner: "backend", Default: "30"}, + {Key: "server.writeTimeout", EnvVars: []string{"KANDEV_SERVER_WRITETIMEOUT"}, Owner: "backend", Default: "30"}, + {Key: "server.webInternalUrl", EnvVars: []string{"KANDEV_WEB_INTERNAL_URL"}, Owner: "launcher/backend", Default: ""}, + {Key: "server.webTitlePrefix", EnvVars: []string{"KANDEV_WEB_TITLE_PREFIX"}, Owner: "backend", Default: ""}, + {Key: "server.trustedProxies", EnvVars: []string{"KANDEV_TRUSTED_PROXIES"}, Owner: "backend", Default: ""}, + {Key: "database.driver", EnvVars: []string{"KANDEV_DATABASE_DRIVER"}, Owner: "database", Default: "sqlite"}, + {Key: "database.path", EnvVars: []string{"KANDEV_DATABASE_PATH"}, Owner: "launcher/backend", Default: "/data/kandev.db"}, + {Key: "database.host", EnvVars: []string{"KANDEV_DATABASE_HOST"}, Owner: "database", Default: "localhost"}, + {Key: "database.port", EnvVars: []string{"KANDEV_DATABASE_PORT"}, Owner: "database", Default: "5432"}, + {Key: "database.user", EnvVars: []string{"KANDEV_DATABASE_USER"}, Owner: "database", Default: "kandev"}, + {Key: "database.password", EnvVars: []string{"KANDEV_DATABASE_PASSWORD"}, Owner: "database", Default: "", Sensitive: true}, + {Key: "database.dbName", EnvVars: []string{"KANDEV_DATABASE_DBNAME"}, Owner: "database", Default: "kandev"}, + {Key: "database.sslMode", EnvVars: []string{"KANDEV_DATABASE_SSLMODE"}, Owner: "database", Default: "disable"}, + {Key: "database.maxConns", EnvVars: []string{"KANDEV_DATABASE_MAXCONNS"}, Owner: "database", Default: "25"}, + {Key: "database.minConns", EnvVars: []string{"KANDEV_DATABASE_MINCONNS"}, Owner: "database", Default: "5"}, + {Key: "nats.url", EnvVars: []string{"KANDEV_NATS_URL"}, Owner: "events", Default: "", Sensitive: true}, + {Key: "nats.clusterId", EnvVars: []string{"KANDEV_NATS_CLUSTERID"}, Owner: "events", Default: "kandev-cluster"}, + {Key: "nats.clientId", EnvVars: []string{"KANDEV_NATS_CLIENTID"}, Owner: "events", Default: "kandev-client"}, + {Key: "nats.maxReconnects", EnvVars: []string{"KANDEV_NATS_MAXRECONNECTS"}, Owner: "events", Default: "10"}, + {Key: "events.namespace", EnvVars: []string{"KANDEV_EVENTS_NAMESPACE"}, Owner: "events", Default: ""}, + {Key: "docker.enabled", EnvVars: []string{"KANDEV_DOCKER_ENABLED"}, Owner: "docker", Default: "true"}, + {Key: "docker.host", EnvVars: []string{"KANDEV_DOCKER_HOST", "DOCKER_HOST"}, Owner: "docker", Default: "platform socket"}, + {Key: "docker.apiVersion", EnvVars: []string{"KANDEV_DOCKER_APIVERSION"}, Owner: "docker", Default: ""}, + {Key: "docker.tlsVerify", EnvVars: []string{"KANDEV_DOCKER_TLSVERIFY"}, Owner: "docker", Default: "false"}, + {Key: "docker.defaultNetwork", EnvVars: []string{"KANDEV_DOCKER_DEFAULTNETWORK"}, Owner: "docker", Default: "kandev-network"}, + {Key: "docker.volumeBasePath", EnvVars: []string{"KANDEV_DOCKER_VOLUMEBASEPATH"}, Owner: "docker", Default: "platform default"}, + {Key: "agent.standaloneHost", EnvVars: []string{"KANDEV_AGENT_STANDALONE_HOST"}, Owner: "agentctl", Default: "localhost"}, + {Key: "agent.standalonePort", EnvVars: []string{"AGENTCTL_PORT", "KANDEV_AGENT_STANDALONE_PORT"}, Owner: "agentctl", Default: "39429"}, + {Key: "auth.jwtSecret", EnvVars: []string{"KANDEV_AUTH_JWTSECRET"}, Owner: "auth", Default: "generated", Sensitive: true}, + {Key: "auth.tokenDuration", EnvVars: []string{"KANDEV_AUTH_TOKENDURATION"}, Owner: "auth", Default: "3600"}, + {Key: "auth.sessionTTLHours", EnvVars: []string{"KANDEV_AUTH_SESSIONTTLHOURS"}, Owner: "auth", Default: "720"}, + {Key: "auth.cookieName", EnvVars: []string{"KANDEV_AUTH_COOKIE_NAME"}, Owner: "auth", Default: ""}, + {Key: "logging.level", EnvVars: []string{"KANDEV_LOG_LEVEL"}, Owner: "logging", Default: "info"}, + {Key: "logging.format", EnvVars: []string{"KANDEV_LOGGING_FORMAT"}, Owner: "logging", Default: "text"}, + {Key: "repositoryDiscovery.roots", EnvVars: []string{"KANDEV_REPOSITORYDISCOVERY_ROOTS"}, Owner: "repository discovery", Default: "[]"}, + {Key: "repositoryDiscovery.maxDepth", EnvVars: []string{"KANDEV_REPOSITORYDISCOVERY_MAXDEPTH"}, Owner: "repository discovery", Default: "5"}, + {Key: "worktree.enabled", EnvVars: []string{"KANDEV_WORKTREE_ENABLED"}, Owner: "worktree", Default: "true"}, + {Key: "worktree.defaultBranch", EnvVars: []string{"KANDEV_WORKTREE_DEFAULTBRANCH"}, Owner: "worktree", Default: "main"}, + {Key: "worktree.cleanupOnRemove", EnvVars: []string{"KANDEV_WORKTREE_CLEANUPONREMOVE"}, Owner: "worktree", Default: "true"}, + {Key: "worktree.fetchTimeoutSeconds", EnvVars: []string{"KANDEV_WORKTREE_FETCHTIMEOUTSECONDS"}, Owner: "worktree", Default: "60"}, + {Key: "worktree.pullTimeoutSeconds", EnvVars: []string{"KANDEV_WORKTREE_PULLTIMEOUTSECONDS"}, Owner: "worktree", Default: "60"}, + {Key: "repoClone.basePath", EnvVars: []string{"KANDEV_REPOCLONE_BASEPATH"}, Owner: "repository clone", Default: "/repos"}, + {Key: "debug.devMode", EnvVars: []string{"KANDEV_DEBUG_DEV_MODE"}, Owner: "debug", Default: "false"}, + {Key: "debug.pprofEnabled", EnvVars: []string{"KANDEV_DEBUG_PPROF_ENABLED"}, Owner: "debug", Default: "false"}, + {Key: "office.jwtSigningKey", EnvVars: []string{"KANDEV_OFFICE_JWTSIGNINGKEY"}, Owner: "office", Default: "random per start", Sensitive: true}, + {Key: "office.schedulerTickMs", EnvVars: []string{"KANDEV_OFFICE_SCHEDULER_TICK_MS"}, Owner: "office", Default: "5000"}, + {Key: "githubCredentialBroker.publicBaseUrl", EnvVars: []string{"KANDEV_GITHUB_CREDENTIAL_BROKER_PUBLIC_BASE_URL"}, Owner: "github credential broker", Default: ""}, + {Key: "tasks.preparationTimeout", EnvVars: []string{"KANDEV_TASK_PREPARATION_TIMEOUT"}, Owner: "task lifecycle", Default: "10m"}, + {Key: "credentials.file", EnvVars: []string{"KANDEV_CREDENTIALS_FILE"}, Owner: "credentials", Default: ""}, + {Key: "limits.ghMaxConcurrent", EnvVars: []string{"KANDEV_GH_MAX_CONCURRENT"}, Owner: "subprocess admission", Default: "8"}, + {Key: "limits.gitMaxConcurrent", EnvVars: []string{"KANDEV_GIT_MAX_CONCURRENT"}, Owner: "subprocess admission", Default: "12"}, + {Key: "limits.lspMaxConnections", EnvVars: []string{"KANDEV_LSP_MAX_CONNECTIONS"}, Owner: "LSP gateway", Default: "8"}, + {Key: "messageQueue.maxPerSession", EnvVars: []string{"KANDEV_QUEUE_MAX_PER_SESSION"}, Owner: "message queue", Default: "10"}, + {Key: "agentctl.idleTimeout", EnvVars: []string{"KANDEV_ACP_IDLE_TIMEOUT"}, Owner: "agentctl", Default: "1h"}, + {Key: "agentctl.idleReaperInterval", EnvVars: []string{"KANDEV_ACP_IDLE_REAPER_INTERVAL"}, Owner: "agentctl", Default: "1m"}, + {Key: "agentctl.notificationQueueCapacity", EnvVars: []string{"KANDEV_ACP_NOTIF_QUEUE"}, Owner: "agentctl", Default: "131072"}, + {Key: "planning.coalesceWindowMs", EnvVars: []string{"KANDEV_PLAN_COALESCE_WINDOW_MS"}, Owner: "planning", Default: "300000"}, + {Key: "observability.otlpEndpoint", EnvVars: []string{"OTEL_EXPORTER_OTLP_ENDPOINT"}, Owner: "observability", Default: "", Sensitive: true}, + {Key: "launcher.webPort", EnvVars: []string{"KANDEV_WEB_PORT"}, Owner: "launcher", Default: "automatic"}, + {Key: "launcher.healthTimeoutMs", EnvVars: []string{"KANDEV_HEALTH_TIMEOUT_MS"}, Owner: "launcher", Default: "45000"}, + {Key: "launcher.noBrowser", EnvVars: []string{"KANDEV_NO_BROWSER"}, Owner: "launcher", Default: "false"}, +} + +var startupExclusions = []CatalogExclusion{ + {EnvVar: InternalConfigFileEnv, Class: "internal wiring", Reason: "launcher-selected configuration file handoff"}, + {EnvVar: InternalConfigHomeFileEnv, Class: "internal wiring", Reason: "launcher-selected home-file handoff"}, + {EnvVar: InternalAgentctlStartupConfigEnv, Class: "internal wiring", Reason: "resolved agentctl child configuration handoff"}, + {EnvVar: "KANDEV_LAUNCHER_PARENT_PID", Class: "internal wiring", Reason: "launcher parent-watchdog handoff"}, + {EnvVar: "KANDEV_BACKEND_PID_FILE", Class: "internal wiring", Reason: "supervisor-owned process state"}, + {EnvVar: "KANDEV_DESKTOP_HEALTH_TOKEN", Class: "generated", Reason: "per-launch health authentication token"}, + {EnvVar: "KANDEV_DESKTOP_NATIVE_NOTIFICATIONS", Class: "internal wiring", Reason: "desktop shell capability handoff"}, + {EnvVar: "KANDEV_BUNDLE_DIR", Class: "packaging", Reason: "runtime bundle discovery"}, + {EnvVar: "KANDEV_WEB_DIST_DIR", Class: "packaging", Reason: "embedded web asset override"}, + {EnvVar: "KANDEV_TASK_ID", Class: "workspace injection", Reason: "task-owned child process context"}, + {EnvVar: "KANDEV_SESSION_ID", Class: "workspace injection", Reason: "session-owned child process context"}, + {EnvVar: "KANDEV_WORKSPACE_ID", Class: "workspace injection", Reason: "workspace-owned child process context"}, + {EnvVar: "KANDEV_E2E_MOCK", Class: "test", Reason: "profile selector and test harness"}, + {EnvVar: "KANDEV_MOCK_AGENT", Class: "test", Reason: "profile-selected mock provider"}, + {EnvVar: "KANDEV_MOCK_GITHUB", Class: "test", Reason: "test-only provider replacement"}, + {EnvVar: "KANDEV_MOCK_GITLAB", Class: "test", Reason: "test-only provider replacement"}, + {EnvVar: "KANDEV_MOCK_JIRA", Class: "test", Reason: "test-only provider replacement"}, + {EnvVar: "KANDEV_MOCK_LINEAR", Class: "test", Reason: "test-only provider replacement"}, + {EnvVar: "KANDEV_FEATURES_OFFICE", Class: "profile", Reason: "runtime feature flag registry"}, + {EnvVar: "KANDEV_FEATURES_AUTH", Class: "profile", Reason: "runtime feature flag registry"}, + {EnvVar: "KANDEV_FEATURES_CLAUDE_BACKGROUND_PROMPT_HANDOFF", Class: "debug", Reason: "runtime feature flag registry"}, + {EnvVar: "KANDEV_FEATURES_CLAUDE_MID_TURN_STEERING", Class: "debug", Reason: "runtime feature flag registry"}, + {EnvVar: "KANDEV_DEBUG_AGENT_MESSAGES", Class: "debug", Reason: "ACP frame diagnostics"}, + {EnvVar: "KANDEV_DEBUG_ACP_MAX_FILES", Class: "debug", Reason: "ACP debug retention"}, + {EnvVar: "KANDEV_DEBUG_ACP_RETENTION_HOURS", Class: "debug", Reason: "ACP debug retention"}, + {EnvVar: "KANDEV_DEBUG_ACP_MAX_FILE_BYTES", Class: "debug", Reason: "ACP debug retention"}, + {EnvVar: "KANDEV_MCP_LOG_FILE", Class: "debug", Reason: "agentctl MCP debug logging"}, + {EnvVar: "KANDEV_DEBUG_LOG_DIR", Class: "debug", Reason: "ACP debug logging directory"}, + {EnvVar: "AGENTCTL_AUTO_APPROVE_PERMISSIONS", Class: "test", Reason: "profile-selected E2E behavior"}, +} + +// ConfigurationCatalog returns a defensive copy of the stable startup +// catalog. Callers may sort or edit the returned slices. +func ConfigurationCatalog() []CatalogEntry { + out := make([]CatalogEntry, len(startupCatalog)) + for i, entry := range startupCatalog { + out[i] = entry + out[i].EnvVars = append([]string(nil), entry.EnvVars...) + } + return out +} + +// ConfigurationExclusions returns a defensive copy of the reviewed +// environment-variable exclusions. +func ConfigurationExclusions() []CatalogExclusion { + return append([]CatalogExclusion(nil), startupExclusions...) +} + +// CatalogEntryForKey returns the catalog entry for a canonical YAML key. +func CatalogEntryForKey(key string) (CatalogEntry, bool) { + for _, entry := range startupCatalog { + if entry.Key == key { + entry.EnvVars = append([]string(nil), entry.EnvVars...) + return entry, true + } + } + return CatalogEntry{}, false +} + +// ValidateCatalog checks that canonical keys and environment aliases are +// unique and that every audited environment variable has an owner. +func ValidateCatalog() error { + keys := make(map[string]struct{}, len(startupCatalog)) + envs := make(map[string]string) + for _, entry := range startupCatalog { + if entry.Key == "" || entry.Owner == "" { + return fmt.Errorf("catalog entry has empty key or owner: %#v", entry) + } + if _, exists := keys[entry.Key]; exists { + return fmt.Errorf("duplicate catalog key %q", entry.Key) + } + keys[entry.Key] = struct{}{} + for _, envVar := range entry.EnvVars { + if envVar == "" { + return fmt.Errorf("catalog key %q has an empty environment alias", entry.Key) + } + if previous, exists := envs[envVar]; exists { + return fmt.Errorf("environment alias %q belongs to both %q and %q", envVar, previous, entry.Key) + } + envs[envVar] = entry.Key + } + } + for _, exclusion := range startupExclusions { + if exclusion.EnvVar == "" || exclusion.Class == "" || strings.TrimSpace(exclusion.Reason) == "" { + return fmt.Errorf("incomplete catalog exclusion: %#v", exclusion) + } + if owner, exists := envs[exclusion.EnvVar]; exists { + return fmt.Errorf("environment variable %q is both cataloged as %q and excluded", exclusion.EnvVar, owner) + } + envs[exclusion.EnvVar] = "excluded" + } + return nil +} + +// CatalogEnvironmentVariables returns all cataloged and excluded variables in +// stable order. It is used by inventory and documentation completeness tests. +func CatalogEnvironmentVariables() []string { + seen := make(map[string]struct{}) + var out []string + for _, entry := range startupCatalog { + for _, envVar := range entry.EnvVars { + if _, ok := seen[envVar]; !ok { + seen[envVar] = struct{}{} + out = append(out, envVar) + } + } + } + for _, exclusion := range startupExclusions { + if _, ok := seen[exclusion.EnvVar]; !ok { + seen[exclusion.EnvVar] = struct{}{} + out = append(out, exclusion.EnvVar) + } + } + sort.Strings(out) + return out +} diff --git a/apps/backend/internal/common/config/catalog_test.go b/apps/backend/internal/common/config/catalog_test.go new file mode 100644 index 0000000000..2599933db1 --- /dev/null +++ b/apps/backend/internal/common/config/catalog_test.go @@ -0,0 +1,248 @@ +package config + +import ( + "fmt" + "strings" + "testing" + "time" +) + +func TestConfigurationCatalogIsComplete(t *testing.T) { + if err := ValidateCatalog(); err != nil { + t.Fatalf("ValidateCatalog: %v", err) + } + entries := ConfigurationCatalog() + if len(entries) < 50 { + t.Fatalf("catalog has %d entries, want the stable startup inventory", len(entries)) + } + for _, key := range []string{ + "server.trustedProxies", + "tasks.preparationTimeout", + "credentials.file", + "limits.ghMaxConcurrent", + "messageQueue.maxPerSession", + "agentctl.notificationQueueCapacity", + "launcher.noBrowser", + } { + if _, ok := CatalogEntryForKey(key); !ok { + t.Errorf("catalog is missing %q", key) + } + } +} + +func TestConfigurationCatalogMatchesAuditedEnvironmentInventory(t *testing.T) { + if err := validateCatalogAgainstAuditedInventory(ConfigurationCatalog(), ConfigurationExclusions(), auditedStartupEnvironmentInventory()); err != nil { + t.Fatal(err) + } +} + +func TestConfigurationCatalogAuditRejectsUncatalogedEnvironmentVariable(t *testing.T) { + err := validateCatalogAgainstAuditedInventory( + []CatalogEntry{{Key: "test.setting", EnvVars: []string{"KANDEV_UNCATALOGED_TEST"}}}, + nil, + nil, + ) + if err == nil { + t.Fatal("catalog audit accepted an environment variable absent from the audited inventory") + } + if !strings.Contains(err.Error(), "KANDEV_UNCATALOGED_TEST") { + t.Fatalf("catalog audit error = %v, want the uncataloged variable", err) + } +} + +type auditedStartupEnvironment struct { + envVar string + class string +} + +// auditedStartupEnvironmentInventory is intentionally independent of +// startupCatalog. Update it only after auditing every stable environment +// consumer and every reviewed exclusion. +func auditedStartupEnvironmentInventory() []auditedStartupEnvironment { + return []auditedStartupEnvironment{ + {envVar: "KANDEV_HOME_DIR", class: "catalog"}, + {envVar: "KANDEV_SERVER_HOST", class: "catalog"}, + {envVar: "KANDEV_SERVER_PORT", class: "catalog"}, + {envVar: "KANDEV_BACKEND_PORT", class: "catalog"}, + {envVar: "KANDEV_PORT", class: "catalog"}, + {envVar: "KANDEV_SERVER_READTIMEOUT", class: "catalog"}, + {envVar: "KANDEV_SERVER_WRITETIMEOUT", class: "catalog"}, + {envVar: "KANDEV_WEB_INTERNAL_URL", class: "catalog"}, + {envVar: "KANDEV_WEB_TITLE_PREFIX", class: "catalog"}, + {envVar: "KANDEV_TRUSTED_PROXIES", class: "catalog"}, + {envVar: "KANDEV_DATABASE_DRIVER", class: "catalog"}, + {envVar: "KANDEV_DATABASE_PATH", class: "catalog"}, + {envVar: "KANDEV_DATABASE_HOST", class: "catalog"}, + {envVar: "KANDEV_DATABASE_PORT", class: "catalog"}, + {envVar: "KANDEV_DATABASE_USER", class: "catalog"}, + {envVar: "KANDEV_DATABASE_PASSWORD", class: "catalog"}, + {envVar: "KANDEV_DATABASE_DBNAME", class: "catalog"}, + {envVar: "KANDEV_DATABASE_SSLMODE", class: "catalog"}, + {envVar: "KANDEV_DATABASE_MAXCONNS", class: "catalog"}, + {envVar: "KANDEV_DATABASE_MINCONNS", class: "catalog"}, + {envVar: "KANDEV_NATS_URL", class: "catalog"}, + {envVar: "KANDEV_NATS_CLUSTERID", class: "catalog"}, + {envVar: "KANDEV_NATS_CLIENTID", class: "catalog"}, + {envVar: "KANDEV_NATS_MAXRECONNECTS", class: "catalog"}, + {envVar: "KANDEV_EVENTS_NAMESPACE", class: "catalog"}, + {envVar: "KANDEV_DOCKER_ENABLED", class: "catalog"}, + {envVar: "KANDEV_DOCKER_HOST", class: "catalog"}, + {envVar: "DOCKER_HOST", class: "catalog"}, + {envVar: "KANDEV_DOCKER_APIVERSION", class: "catalog"}, + {envVar: "KANDEV_DOCKER_TLSVERIFY", class: "catalog"}, + {envVar: "KANDEV_DOCKER_DEFAULTNETWORK", class: "catalog"}, + {envVar: "KANDEV_DOCKER_VOLUMEBASEPATH", class: "catalog"}, + {envVar: "KANDEV_AGENT_STANDALONE_HOST", class: "catalog"}, + {envVar: "AGENTCTL_PORT", class: "catalog"}, + {envVar: "KANDEV_AGENT_STANDALONE_PORT", class: "catalog"}, + {envVar: "KANDEV_AUTH_JWTSECRET", class: "catalog"}, + {envVar: "KANDEV_AUTH_TOKENDURATION", class: "catalog"}, + {envVar: "KANDEV_AUTH_SESSIONTTLHOURS", class: "catalog"}, + {envVar: "KANDEV_AUTH_COOKIE_NAME", class: "catalog"}, + {envVar: "KANDEV_LOG_LEVEL", class: "catalog"}, + {envVar: "KANDEV_LOGGING_FORMAT", class: "catalog"}, + {envVar: "KANDEV_REPOSITORYDISCOVERY_ROOTS", class: "catalog"}, + {envVar: "KANDEV_REPOSITORYDISCOVERY_MAXDEPTH", class: "catalog"}, + {envVar: "KANDEV_WORKTREE_ENABLED", class: "catalog"}, + {envVar: "KANDEV_WORKTREE_DEFAULTBRANCH", class: "catalog"}, + {envVar: "KANDEV_WORKTREE_CLEANUPONREMOVE", class: "catalog"}, + {envVar: "KANDEV_WORKTREE_FETCHTIMEOUTSECONDS", class: "catalog"}, + {envVar: "KANDEV_WORKTREE_PULLTIMEOUTSECONDS", class: "catalog"}, + {envVar: "KANDEV_REPOCLONE_BASEPATH", class: "catalog"}, + {envVar: "KANDEV_DEBUG_DEV_MODE", class: "catalog"}, + {envVar: "KANDEV_DEBUG_PPROF_ENABLED", class: "catalog"}, + {envVar: "KANDEV_OFFICE_JWTSIGNINGKEY", class: "catalog"}, + {envVar: "KANDEV_OFFICE_SCHEDULER_TICK_MS", class: "catalog"}, + {envVar: "KANDEV_GITHUB_CREDENTIAL_BROKER_PUBLIC_BASE_URL", class: "catalog"}, + {envVar: "KANDEV_TASK_PREPARATION_TIMEOUT", class: "catalog"}, + {envVar: "KANDEV_CREDENTIALS_FILE", class: "catalog"}, + {envVar: "KANDEV_GH_MAX_CONCURRENT", class: "catalog"}, + {envVar: "KANDEV_GIT_MAX_CONCURRENT", class: "catalog"}, + {envVar: "KANDEV_LSP_MAX_CONNECTIONS", class: "catalog"}, + {envVar: "KANDEV_QUEUE_MAX_PER_SESSION", class: "catalog"}, + {envVar: "KANDEV_ACP_IDLE_TIMEOUT", class: "catalog"}, + {envVar: "KANDEV_ACP_IDLE_REAPER_INTERVAL", class: "catalog"}, + {envVar: "KANDEV_ACP_NOTIF_QUEUE", class: "catalog"}, + {envVar: "KANDEV_PLAN_COALESCE_WINDOW_MS", class: "catalog"}, + {envVar: "OTEL_EXPORTER_OTLP_ENDPOINT", class: "catalog"}, + {envVar: "KANDEV_WEB_PORT", class: "catalog"}, + {envVar: "KANDEV_HEALTH_TIMEOUT_MS", class: "catalog"}, + {envVar: "KANDEV_NO_BROWSER", class: "catalog"}, + {envVar: InternalConfigFileEnv, class: "exclusion"}, + {envVar: InternalConfigHomeFileEnv, class: "exclusion"}, + {envVar: InternalAgentctlStartupConfigEnv, class: "exclusion"}, + {envVar: "KANDEV_LAUNCHER_PARENT_PID", class: "exclusion"}, + {envVar: "KANDEV_BACKEND_PID_FILE", class: "exclusion"}, + {envVar: "KANDEV_DESKTOP_HEALTH_TOKEN", class: "exclusion"}, + {envVar: "KANDEV_DESKTOP_NATIVE_NOTIFICATIONS", class: "exclusion"}, + {envVar: "KANDEV_BUNDLE_DIR", class: "exclusion"}, + {envVar: "KANDEV_WEB_DIST_DIR", class: "exclusion"}, + {envVar: "KANDEV_TASK_ID", class: "exclusion"}, + {envVar: "KANDEV_SESSION_ID", class: "exclusion"}, + {envVar: "KANDEV_WORKSPACE_ID", class: "exclusion"}, + {envVar: "KANDEV_E2E_MOCK", class: "exclusion"}, + {envVar: "KANDEV_MOCK_AGENT", class: "exclusion"}, + {envVar: "KANDEV_MOCK_GITHUB", class: "exclusion"}, + {envVar: "KANDEV_MOCK_GITLAB", class: "exclusion"}, + {envVar: "KANDEV_MOCK_JIRA", class: "exclusion"}, + {envVar: "KANDEV_MOCK_LINEAR", class: "exclusion"}, + {envVar: "KANDEV_FEATURES_OFFICE", class: "exclusion"}, + {envVar: "KANDEV_FEATURES_AUTH", class: "exclusion"}, + {envVar: "KANDEV_FEATURES_CLAUDE_BACKGROUND_PROMPT_HANDOFF", class: "exclusion"}, + {envVar: "KANDEV_FEATURES_CLAUDE_MID_TURN_STEERING", class: "exclusion"}, + {envVar: "KANDEV_DEBUG_AGENT_MESSAGES", class: "exclusion"}, + {envVar: "KANDEV_DEBUG_ACP_MAX_FILES", class: "exclusion"}, + {envVar: "KANDEV_DEBUG_ACP_RETENTION_HOURS", class: "exclusion"}, + {envVar: "KANDEV_DEBUG_ACP_MAX_FILE_BYTES", class: "exclusion"}, + {envVar: "KANDEV_MCP_LOG_FILE", class: "exclusion"}, + {envVar: "KANDEV_DEBUG_LOG_DIR", class: "exclusion"}, + {envVar: "AGENTCTL_AUTO_APPROVE_PERMISSIONS", class: "exclusion"}, + } +} + +func validateCatalogAgainstAuditedInventory(entries []CatalogEntry, exclusions []CatalogExclusion, audited []auditedStartupEnvironment) error { + tracked := make(map[string]string, len(entries)+len(exclusions)) + for _, entry := range entries { + for _, envVar := range entry.EnvVars { + if previous, exists := tracked[envVar]; exists { + return fmt.Errorf("environment variable %q is tracked more than once (%s and catalog)", envVar, previous) + } + tracked[envVar] = "catalog" + } + } + for _, exclusion := range exclusions { + if previous, exists := tracked[exclusion.EnvVar]; exists { + return fmt.Errorf("environment variable %q is tracked more than once (%s and exclusion)", exclusion.EnvVar, previous) + } + tracked[exclusion.EnvVar] = "exclusion" + } + + want := make(map[string]string, len(audited)) + for _, item := range audited { + if previous, exists := want[item.envVar]; exists { + return fmt.Errorf("audited environment variable %q is listed more than once (%s and %s)", item.envVar, previous, item.class) + } + want[item.envVar] = item.class + } + for envVar, class := range tracked { + if auditedClass, exists := want[envVar]; !exists { + return fmt.Errorf("environment variable %q is %s but absent from the audited inventory", envVar, class) + } else if auditedClass != class { + return fmt.Errorf("environment variable %q is %s, audited as %s", envVar, class, auditedClass) + } + } + for envVar, class := range want { + if trackedClass, exists := tracked[envVar]; !exists { + return fmt.Errorf("audited environment variable %q (%s) is neither cataloged nor excluded", envVar, class) + } else if trackedClass != class { + return fmt.Errorf("audited environment variable %q is %s, tracked as %s", envVar, class, trackedClass) + } + } + return nil +} + +func TestConfigurationCatalogMarksSecretsAndExclusions(t *testing.T) { + for _, key := range []string{"database.password", "auth.jwtSecret", "office.jwtSigningKey"} { + entry, ok := CatalogEntryForKey(key) + if !ok || !entry.Sensitive { + t.Errorf("catalog entry %q is not marked sensitive: %#v", key, entry) + } + } + exclusions := ConfigurationExclusions() + foundGenerated := false + for _, exclusion := range exclusions { + if exclusion.EnvVar == "KANDEV_DESKTOP_HEALTH_TOKEN" && exclusion.Class == "generated" && strings.TrimSpace(exclusion.Reason) != "" { + foundGenerated = true + } + } + if !foundGenerated { + t.Fatal("generated health token exclusion is missing") + } +} + +func TestAgentctlStartupConfigRoundTripsAndRejectsInvalidValues(t *testing.T) { + want := AgentctlStartupConfig{ + Configured: true, + IdleTimeout: 2 * time.Hour, + IdleReaperInterval: 3 * time.Minute, + NotificationQueueCapacity: 4096, + OTLPEndpoint: "http://collector:4318", + } + raw, err := EncodeAgentctlStartupConfig(want) + if err != nil { + t.Fatalf("EncodeAgentctlStartupConfig: %v", err) + } + got, err := DecodeAgentctlStartupConfig(raw) + if err != nil { + t.Fatalf("DecodeAgentctlStartupConfig: %v", err) + } + if got != want { + t.Fatalf("decoded contract = %#v, want %#v", got, want) + } + + invalid := want + invalid.NotificationQueueCapacity = 1 + if _, err := EncodeAgentctlStartupConfig(invalid); err == nil { + t.Fatal("EncodeAgentctlStartupConfig accepted an invalid queue capacity") + } +} diff --git a/apps/backend/internal/common/config/config.go b/apps/backend/internal/common/config/config.go index 6ba8fdbc6f..983c64b4f1 100644 --- a/apps/backend/internal/common/config/config.go +++ b/apps/backend/internal/common/config/config.go @@ -31,6 +31,14 @@ type Config struct { // 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"` @@ -45,6 +53,54 @@ type Config struct { Office OfficeConfig `mapstructure:"office"` Features FeaturesConfig `mapstructure:"features"` GitHubCredentialBroker GitHubCredentialBrokerConfig `mapstructure:"githubCredentialBroker"` + Source ConfigSource `mapstructure:"-" json:"-"` +} + +// TasksConfig contains task lifecycle startup limits. +type TasksConfig struct { + PreparationTimeout time.Duration `mapstructure:"preparationTimeout"` +} + +// CredentialsConfig contains operator-managed credential file settings. +type CredentialsConfig struct { + File string `mapstructure:"file"` +} + +// 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"` +} + +// 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 @@ -129,6 +185,7 @@ type ServerConfig struct { // 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 @@ -356,7 +413,8 @@ type OfficeConfig struct { // 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"` + JWTSigningKey string `mapstructure:"jwtSigningKey"` + SchedulerTickMs int `mapstructure:"schedulerTickMs"` } // FeaturesConfig is the typed wire/config shape for runtime feature flags. @@ -612,31 +670,36 @@ func defaultDockerVolumePath() string { // 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 LoadWithPath("") + 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() - // Apply the active runtime profile (prod / dev / e2e) from the - // embedded profiles.yaml. This writes env vars onto our own - // process so the subsequent AutomaticEnv and the rest of the - // codebase's os.Getenv reads see the YAML-declared values. - // Vars already set by the launcher / shell / per-spec override - // are left alone, giving precedence: - // - // shell env / launcher env > profiles.yaml > Go zero values - // - // A parse error here means someone committed a malformed - // profiles.yaml; fail loud so CI catches it before a release ships. - if _, _, err := profiles.ApplyProfile(); err != nil { - return nil, fmt.Errorf("apply profile defaults: %w", err) + 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 env via ApplyProfile + AutomaticEnv below. + // through EnvironmentDefaults and AutomaticEnv below. setDefaults(v) // Seed Viper's features.* keyspace from profiles.yaml so the @@ -649,24 +712,14 @@ func LoadWithPath(configPath string) (*Config, error) { 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() - // Explicit bindings for snake_case env vars (camelCase config keys) - // AutomaticEnv does not handle camelCase to SNAKE_CASE conversion, - // so we explicitly bind keys where env var naming differs from config key naming. - _ = v.BindEnv("agent.standalonePort", "AGENTCTL_PORT", "KANDEV_AGENT_STANDALONE_PORT") - _ = v.BindEnv("agent.standaloneHost", "KANDEV_AGENT_STANDALONE_HOST") - _ = v.BindEnv("server.webInternalUrl", "KANDEV_WEB_INTERNAL_URL") - _ = v.BindEnv("server.webTitlePrefix", "KANDEV_WEB_TITLE_PREFIX") - _ = v.BindEnv("homeDir", "KANDEV_HOME_DIR") - _ = v.BindEnv("logging.level", "KANDEV_LOG_LEVEL") - _ = v.BindEnv("events.namespace", "KANDEV_EVENTS_NAMESPACE") - _ = v.BindEnv("debug.devMode", "KANDEV_DEBUG_DEV_MODE") - _ = v.BindEnv("debug.pprofEnabled", "KANDEV_DEBUG_PPROF_ENABLED") + 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") @@ -679,29 +732,40 @@ func LoadWithPath(configPath string) (*Config, error) { "KANDEV_GITHUB_CREDENTIAL_BROKER_REISSUE_SIGNING_KEY", ) - // Configure config file - v.SetConfigName("config") - v.SetConfigType("yaml") - - if configPath != "" { - v.AddConfigPath(configPath) + selection, found, err := selectConfigFile(configPath, homeDir) + if err != nil { + return nil, fmt.Errorf("select configuration file: %w", err) } - v.AddConfigPath(".") - v.AddConfigPath("/etc/kandev/") - - // Read config file (ignore if not found) - if err := v.ReadInConfig(); err != nil { - if _, ok := err.(viper.ConfigFileNotFoundError); !ok { - return nil, fmt.Errorf("error reading config 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 := v.Unmarshal(&cfg); err != nil { + 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) + warnings := inspectSecretPermissions(selection, v) + 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) } diff --git a/apps/backend/internal/common/config/source.go b/apps/backend/internal/common/config/source.go new file mode 100644 index 0000000000..169cb2e856 --- /dev/null +++ b/apps/backend/internal/common/config/source.go @@ -0,0 +1,508 @@ +package config + +import ( + "errors" + "fmt" + "log" + "os" + "path/filepath" + "runtime" + "strconv" + "strings" + "time" + + "github.com/kandev/kandev/internal/profiles" + "github.com/mitchellh/mapstructure" + "github.com/spf13/viper" +) + +// InternalConfigFileEnv pins a managed child process to the configuration +// file selected by its launcher. It is process wiring, not an operator-facing +// configuration variable, and is intentionally excluded from the catalog. +const ( + InternalConfigFileEnv = "KANDEV_INTERNAL_CONFIG_FILE" + InternalConfigHomeFileEnv = "KANDEV_INTERNAL_CONFIG_HOME_FILE" + windowsOS = "windows" +) + +// ConfigSource records configuration provenance without retaining any decoded +// secret values. +type ConfigSource struct { + FilePath string + HomeFile bool + Values map[string]SettingSource + Warnings []string +} + +// Value returns the source for key. Unknown keys are treated as built-in +// defaults so callers can safely use the result in source-reporting APIs. +func (s ConfigSource) Value(key string) SettingSource { + if source, ok := s.Values[key]; ok { + return source + } + return SourceDefault +} + +// SourceFor returns the source for a canonical YAML key. +func (c *Config) SourceFor(key string) SettingSource { + if c == nil { + return SourceDefault + } + return c.Source.Value(key) +} + +type configCandidate struct { + path string + isHome bool +} + +type configSelection struct { + configCandidate +} + +// yamlOnlyStartupKeys are parsed from config.yaml and resolved from the +// compatible environment aliases below. They intentionally do not use Viper's +// AutomaticEnv during Unmarshal: legacy invalid environment values must retain +// their fallback behavior instead of turning into typed-config startup errors. +var yamlOnlyStartupKeys = map[string]struct{}{ + "server.trustedProxies": {}, + "tasks.preparationTimeout": {}, + "credentials.file": {}, + "limits.ghMaxConcurrent": {}, + "limits.gitMaxConcurrent": {}, + "limits.lspMaxConnections": {}, + "messageQueue.maxPerSession": {}, + "agentctl.idleTimeout": {}, + "agentctl.idleReaperInterval": {}, + "agentctl.notificationQueueCapacity": {}, + "planning.coalesceWindowMs": {}, + "office.schedulerTickMs": {}, + "observability.otlpEndpoint": {}, + "launcher.webPort": {}, + "launcher.healthTimeoutMs": {}, + "launcher.noBrowser": {}, +} + +func isYAMLOnlyStartupKey(key string) bool { + _, ok := yamlOnlyStartupKeys[key] + return ok +} + +func configCandidates(configPath, homeDir string) ([]configCandidate, error) { + if configPath != "" { + path := explicitConfigFile(configPath) + return []configCandidate{{path: path, isHome: isHomeConfigFile(path, homeDir) || os.Getenv(InternalConfigHomeFileEnv) == "1"}}, nil + } + + workingDir, err := os.Getwd() + if err != nil { + return nil, fmt.Errorf("resolve config working directory: %w", err) + } + homeDir = bootstrapHomeDirFor(homeDir) + return []configCandidate{ + {path: filepath.Join(workingDir, "config.yaml")}, + {path: filepath.Join(homeDir, "config.yaml"), isHome: true}, + {path: "/etc/kandev/config.yaml"}, + }, nil +} + +func isHomeConfigFile(path, homeDir string) bool { + absPath, err := filepath.Abs(path) + if err != nil { + return false + } + absHome, err := filepath.Abs(bootstrapHomeDirFor(homeDir)) + if err != nil { + return false + } + return filepath.Clean(absPath) == filepath.Join(filepath.Clean(absHome), "config.yaml") +} + +func explicitConfigFile(path string) string { + info, err := os.Stat(path) + if err == nil && info.IsDir() { + return filepath.Join(path, "config.yaml") + } + if filepath.Base(path) == "config.yaml" || strings.HasSuffix(strings.ToLower(path), ".yaml") { + return path + } + return filepath.Join(path, "config.yaml") +} + +func bootstrapHomeDir() string { + if raw := strings.TrimSpace(os.Getenv("KANDEV_HOME_DIR")); raw != "" { + return expandTilde(raw) + } + home, err := os.UserHomeDir() + if err != nil { + return kandevHomeSubdir + } + return filepath.Join(home, kandevHomeSubdir) +} + +func bootstrapHomeDirFor(homeDir string) string { + if raw := strings.TrimSpace(homeDir); raw != "" { + return expandTilde(raw) + } + return bootstrapHomeDir() +} + +func selectConfigFile(configPath, homeDir string) (configSelection, bool, error) { + candidates, err := configCandidates(configPath, homeDir) + if err != nil { + return configSelection{}, false, err + } + for _, candidate := range candidates { + if _, err := os.Stat(candidate.path); err != nil { + if errors.Is(err, os.ErrNotExist) { + continue + } + // The candidate exists from the operator's point of view, but + // cannot be inspected. Select it so the read error names it + // instead of silently falling through to a lower-priority file. + return configSelection{configCandidate: candidate}, true, nil + } + return configSelection{configCandidate: candidate}, true, nil + } + return configSelection{}, false, nil +} + +func readSelectedConfig(v *viper.Viper, selection configSelection) error { + v.SetConfigFile(selection.path) + if err := v.ReadInConfig(); err != nil { + return fmt.Errorf("read configuration file %q: %w", selection.path, err) + } + if selection.isHome && v.InConfig("homeDir") { + return fmt.Errorf("configuration file %q cannot set homeDir because a home configuration file cannot relocate itself", selection.path) + } + return nil +} + +func bindCatalogEnvironment(v *viper.Viper) { + for _, entry := range startupCatalog { + if isYAMLOnlyStartupKey(entry.Key) || len(entry.EnvVars) == 0 { + continue + } + args := append([]string{entry.Key}, entry.EnvVars...) + _ = v.BindEnv(args...) + } +} + +func setProfileDefaults(v *viper.Viper, profileDefaults map[string]string) { + for _, entry := range startupCatalog { + if isYAMLOnlyStartupKey(entry.Key) { + continue + } + for _, envVar := range entry.EnvVars { + if value := profileDefaults[envVar]; value != "" { + v.SetDefault(entry.Key, value) + break + } + } + } +} + +func environmentSnapshot() map[string]string { + out := make(map[string]string) + for _, entry := range startupCatalog { + for _, envVar := range entry.EnvVars { + if value, ok := os.LookupEnv(envVar); ok { + out[envVar] = value + } + } + } + return out +} + +func nonEmptyEnv(snapshot map[string]string, names ...string) (string, bool) { + for _, name := range names { + if value, ok := snapshot[name]; ok && strings.TrimSpace(value) != "" { + return value, true + } + } + return "", false +} + +func envValue(snapshot map[string]string, names ...string) (string, bool) { + for _, name := range names { + if value, ok := snapshot[name]; ok { + return value, true + } + } + return "", false +} + +func applyStartupDefaultsAndEnvironment( + cfg *Config, + yamlKeys map[string]bool, + profileDefaults map[string]string, + envSnapshot map[string]string, +) map[string]SettingSource { + sources := make(map[string]SettingSource) + for _, entry := range startupCatalog { + sources[entry.Key] = sourceForEntry(entry, yamlKeys, profileDefaults, envSnapshot) + } + applyStartupDefaults(cfg, yamlKeys, profileDefaults, sources) + applyStartupEnvironment(cfg, envSnapshot, sources) + return sources +} + +func applyStartupDefaults(cfg *Config, yamlKeys map[string]bool, profileDefaults map[string]string, sources map[string]SettingSource) { + setDefaultDuration := func(key string, target *time.Duration, builtin time.Duration) { + if yamlKeys[key] { + return + } + *target = builtin + if entry, ok := CatalogEntryForKey(key); ok { + if raw, found := nonEmptyEnv(profileDefaults, entry.EnvVars...); found { + if parsed, err := time.ParseDuration(raw); err == nil { + *target = parsed + sources[key] = SourceProfile + } + } + } + } + setDefaultInt := func(key string, target *int, builtin int) { + if yamlKeys[key] { + return + } + *target = builtin + if entry, ok := CatalogEntryForKey(key); ok { + if raw, found := nonEmptyEnv(profileDefaults, entry.EnvVars...); found { + if parsed, err := strconv.Atoi(strings.TrimSpace(raw)); err == nil { + *target = parsed + sources[key] = SourceProfile + } + } + } + } + + if !yamlKeys["server.trustedProxies"] { + cfg.Server.TrustedProxies = nil + } + setDefaultDuration("tasks.preparationTimeout", &cfg.Tasks.PreparationTimeout, 10*time.Minute) + if !yamlKeys["credentials.file"] { + cfg.Credentials.File = "" + } + setDefaultInt("limits.ghMaxConcurrent", &cfg.Limits.GHMaxConcurrent, 8) + setDefaultInt("limits.gitMaxConcurrent", &cfg.Limits.GitMaxConcurrent, 12) + setDefaultInt("limits.lspMaxConnections", &cfg.Limits.LSPMaxConnections, 8) + setDefaultInt("messageQueue.maxPerSession", &cfg.MessageQueue.MaxPerSession, 10) + setDefaultDuration("agentctl.idleTimeout", &cfg.Agentctl.IdleTimeout, time.Hour) + setDefaultDuration("agentctl.idleReaperInterval", &cfg.Agentctl.IdleReaperInterval, time.Minute) + setDefaultInt("agentctl.notificationQueueCapacity", &cfg.Agentctl.NotificationQueueCapacity, 131072) + setDefaultInt("planning.coalesceWindowMs", &cfg.Planning.CoalesceWindowMs, 300000) + setDefaultInt("office.schedulerTickMs", &cfg.Office.SchedulerTickMs, 5000) + if !yamlKeys["observability.otlpEndpoint"] { + cfg.Observability.OTLPEndpoint = "" + } + setDefaultInt("launcher.webPort", &cfg.Launcher.WebPort, 0) + setDefaultInt("launcher.healthTimeoutMs", &cfg.Launcher.HealthTimeoutMs, launcherHealthTimeoutDefault()) + if !yamlKeys["launcher.noBrowser"] { + cfg.Launcher.NoBrowser = false + } +} + +func applyStartupEnvironment(cfg *Config, envSnapshot map[string]string, sources map[string]SettingSource) { + applyTrustedProxiesEnv(cfg, envSnapshot, sources) + applyDurationEnv("tasks.preparationTimeout", &cfg.Tasks.PreparationTimeout, 10*time.Minute, envSnapshot, sources) + applyStringEnvAllowEmpty("credentials.file", &cfg.Credentials.File, envSnapshot, sources) + applyPositiveIntEnv("limits.ghMaxConcurrent", &cfg.Limits.GHMaxConcurrent, 8, envSnapshot, sources) + applyPositiveIntEnv("limits.gitMaxConcurrent", &cfg.Limits.GitMaxConcurrent, 12, envSnapshot, sources) + applyPositiveIntEnv("limits.lspMaxConnections", &cfg.Limits.LSPMaxConnections, 8, envSnapshot, sources) + applyNonNegativeIntEnv("messageQueue.maxPerSession", &cfg.MessageQueue.MaxPerSession, 10, envSnapshot, sources) + applyNonNegativeDurationEnv("agentctl.idleTimeout", &cfg.Agentctl.IdleTimeout, time.Hour, envSnapshot, sources) + applyDurationEnv("agentctl.idleReaperInterval", &cfg.Agentctl.IdleReaperInterval, time.Minute, envSnapshot, sources) + applyBoundedIntEnv("agentctl.notificationQueueCapacity", &cfg.Agentctl.NotificationQueueCapacity, 131072, 1024, 131072, envSnapshot, sources) + applyNonNegativeIntEnv("planning.coalesceWindowMs", &cfg.Planning.CoalesceWindowMs, 300000, envSnapshot, sources) + applyPositiveIntEnv("office.schedulerTickMs", &cfg.Office.SchedulerTickMs, 5000, envSnapshot, sources) + applyStringEnvAllowEmpty("observability.otlpEndpoint", &cfg.Observability.OTLPEndpoint, envSnapshot, sources) + applyBoundedIntEnv("launcher.webPort", &cfg.Launcher.WebPort, 0, 1, 65535, envSnapshot, sources) + applyPositiveIntEnv("launcher.healthTimeoutMs", &cfg.Launcher.HealthTimeoutMs, launcherHealthTimeoutDefault(), envSnapshot, sources) + applyBoolEnv("launcher.noBrowser", &cfg.Launcher.NoBrowser, false, envSnapshot, sources) +} + +func launcherHealthTimeoutDefault() int { + if profiles.DetectEnvironment() == profiles.EnvDev || profiles.DetectEnvironment() == profiles.EnvE2E { + return 600000 + } + return 45000 +} + +func sourceForEntry(entry CatalogEntry, yamlKeys map[string]bool, profileDefaults, envSnapshot map[string]string) SettingSource { + if _, found := nonEmptyEnv(envSnapshot, entry.EnvVars...); found { + return SourceEnvironment + } + if yamlKeys[entry.Key] { + return SourceConfiguration + } + if _, found := nonEmptyEnv(profileDefaults, entry.EnvVars...); found { + return SourceProfile + } + return SourceDefault +} + +func applyTrustedProxiesEnv(cfg *Config, env map[string]string, sources map[string]SettingSource) { + entry, ok := CatalogEntryForKey("server.trustedProxies") + if !ok { + return + } + raw, found := nonEmptyEnv(env, entry.EnvVars...) + if !found { + return + } + parts := strings.Split(raw, ",") + cfg.Server.TrustedProxies = make([]string, 0, len(parts)) + for _, part := range parts { + cfg.Server.TrustedProxies = append(cfg.Server.TrustedProxies, strings.TrimSpace(part)) + } + sources[entry.Key] = SourceEnvironment +} + +func applyDurationEnv(key string, target *time.Duration, fallback time.Duration, env map[string]string, sources map[string]SettingSource) { + entry, ok := CatalogEntryForKey(key) + if !ok { + return + } + raw, found := nonEmptyEnv(env, entry.EnvVars...) + if !found { + return + } + parsed, err := time.ParseDuration(strings.TrimSpace(raw)) + if err != nil || parsed <= 0 { + *target = fallback + sources[key] = SourceDefault + return + } + *target = parsed + sources[key] = SourceEnvironment +} + +func applyNonNegativeDurationEnv(key string, target *time.Duration, fallback time.Duration, env map[string]string, sources map[string]SettingSource) { + entry, ok := CatalogEntryForKey(key) + if !ok { + return + } + raw, found := nonEmptyEnv(env, entry.EnvVars...) + if !found { + return + } + parsed, err := time.ParseDuration(strings.TrimSpace(raw)) + if err != nil || parsed < 0 { + *target = fallback + sources[key] = SourceDefault + return + } + *target = parsed + sources[key] = SourceEnvironment +} + +func applyStringEnvAllowEmpty(key string, target *string, env map[string]string, sources map[string]SettingSource) { + entry, ok := CatalogEntryForKey(key) + if !ok { + return + } + if raw, found := envValue(env, entry.EnvVars...); found { + *target = strings.TrimSpace(raw) + sources[key] = SourceEnvironment + } +} + +func applyPositiveIntEnv(key string, target *int, fallback int, env map[string]string, sources map[string]SettingSource) { + applyBoundedIntEnv(key, target, fallback, 1, int(^uint(0)>>1), env, sources) +} + +func applyNonNegativeIntEnv(key string, target *int, fallback int, env map[string]string, sources map[string]SettingSource) { + applyBoundedIntEnv(key, target, fallback, 0, int(^uint(0)>>1), env, sources) +} + +func applyBoundedIntEnv(key string, target *int, fallback, minimum, maximum int, env map[string]string, sources map[string]SettingSource) { + entry, ok := CatalogEntryForKey(key) + if !ok { + return + } + raw, found := nonEmptyEnv(env, entry.EnvVars...) + if !found { + return + } + parsed, err := strconv.Atoi(strings.TrimSpace(raw)) + if err != nil || parsed < minimum || parsed > maximum { + *target = fallback + sources[key] = SourceDefault + return + } + *target = parsed + sources[key] = SourceEnvironment +} + +func applyBoolEnv(key string, target *bool, fallback bool, env map[string]string, sources map[string]SettingSource) { + entry, ok := CatalogEntryForKey(key) + if !ok { + return + } + raw, found := nonEmptyEnv(env, entry.EnvVars...) + if !found { + return + } + parsed, err := strconv.ParseBool(strings.TrimSpace(raw)) + if err != nil { + *target = fallback + sources[key] = SourceDefault + return + } + *target = parsed + sources[key] = SourceEnvironment +} + +func configKeys(v *viper.Viper) map[string]bool { + keys := make(map[string]bool) + for _, entry := range startupCatalog { + keys[entry.Key] = v.InConfig(entry.Key) + } + return keys +} + +func buildConfigSource(selection configSelection, v *viper.Viper, sources map[string]SettingSource, warnings []string) ConfigSource { + if sources == nil { + sources = make(map[string]SettingSource) + } + for _, entry := range startupCatalog { + if _, ok := sources[entry.Key]; !ok { + if v.InConfig(entry.Key) { + sources[entry.Key] = SourceConfiguration + } else { + sources[entry.Key] = SourceDefault + } + } + } + path := "" + if selection.path != "" { + path = selection.path + } + return ConfigSource{FilePath: path, HomeFile: selection.isHome, Values: sources, Warnings: warnings} +} + +func inspectSecretPermissions(selection configSelection, v *viper.Viper) []string { + if selection.path == "" || runtime.GOOS == windowsOS { + return nil + } + info, err := os.Stat(selection.path) + if err != nil || info.Mode().Perm()&0o077 == 0 { + return nil + } + for _, entry := range startupCatalog { + if entry.Sensitive && v.InConfig(entry.Key) { + warning := fmt.Sprintf("configuration file %q contains a secret setting readable by group or other users; use mode 0600", selection.path) + log.Print(warning) + return []string{warning} + } + } + return nil +} + +func decodeConfig(v *viper.Viper, cfg *Config) error { + return v.Unmarshal(cfg, viper.DecodeHook(mapstructure.ComposeDecodeHookFunc( + mapstructure.StringToTimeDurationHookFunc(), + mapstructure.StringToSliceHookFunc(","), + ))) +} diff --git a/apps/backend/internal/common/config/source_home_override_test.go b/apps/backend/internal/common/config/source_home_override_test.go new file mode 100644 index 0000000000..8c26cc3cc6 --- /dev/null +++ b/apps/backend/internal/common/config/source_home_override_test.go @@ -0,0 +1,66 @@ +package config + +import ( + "os" + "path/filepath" + "testing" +) + +func TestLoadWithHomeUsesOverrideForHomeFileDiscovery(t *testing.T) { + t.Setenv("KANDEV_HOME_DIR", "") + t.Setenv("KANDEV_SERVER_PORT", "") + t.Setenv("KANDEV_BACKEND_PORT", "") + t.Setenv("KANDEV_PORT", "") + workingDir := t.TempDir() + homeDir := t.TempDir() + t.Chdir(workingDir) + configPath := filepath.Join(homeDir, "config.yaml") + if err := os.WriteFile(configPath, []byte("server:\n port: 40128\n"), 0o600); err != nil { + t.Fatalf("write home config: %v", err) + } + + cfg, err := LoadWithHome(homeDir) + if err != nil { + t.Fatalf("LoadWithHome: %v", err) + } + if cfg.Source.FilePath != configPath { + t.Fatalf("selected config = %q, want %q", cfg.Source.FilePath, configPath) + } + if !cfg.Source.HomeFile { + t.Fatal("selected override config is not marked as a home file") + } + if cfg.Server.Port != 40128 { + t.Fatalf("server.port = %d, want 40128", cfg.Server.Port) + } +} + +func TestLoadWithHomePreservesWorkingDirectoryPriority(t *testing.T) { + t.Setenv("KANDEV_HOME_DIR", "") + t.Setenv("KANDEV_SERVER_PORT", "") + t.Setenv("KANDEV_BACKEND_PORT", "") + t.Setenv("KANDEV_PORT", "") + workingDir := t.TempDir() + homeDir := t.TempDir() + t.Chdir(workingDir) + workingConfig := filepath.Join(workingDir, "config.yaml") + if err := os.WriteFile(workingConfig, []byte("server:\n port: 40129\n"), 0o600); err != nil { + t.Fatalf("write working-directory config: %v", err) + } + if err := os.WriteFile(filepath.Join(homeDir, "config.yaml"), []byte("server:\n port: 40130\n"), 0o600); err != nil { + t.Fatalf("write home config: %v", err) + } + + cfg, err := LoadWithHome(homeDir) + if err != nil { + t.Fatalf("LoadWithHome: %v", err) + } + if cfg.Source.FilePath != workingConfig { + t.Fatalf("selected config = %q, want %q", cfg.Source.FilePath, workingConfig) + } + if cfg.Source.HomeFile { + t.Fatal("working-directory config was marked as a home file") + } + if cfg.Server.Port != 40129 { + t.Fatalf("server.port = %d, want 40129", cfg.Server.Port) + } +} diff --git a/apps/backend/internal/common/config/source_test.go b/apps/backend/internal/common/config/source_test.go new file mode 100644 index 0000000000..562964eb7a --- /dev/null +++ b/apps/backend/internal/common/config/source_test.go @@ -0,0 +1,350 @@ +package config + +import ( + "os" + "path/filepath" + "reflect" + "runtime" + "strings" + "testing" + "time" +) + +func TestConfigSourceLoadsStableYAMLFields(t *testing.T) { + dir := t.TempDir() + configFile := filepath.Join(dir, "config.yaml") + contents := `server: + trustedProxies: + - 10.0.0.0/8 +tasks: + preparationTimeout: 17m +credentials: + file: /run/secrets/credentials +limits: + ghMaxConcurrent: 11 + gitMaxConcurrent: 13 + lspMaxConnections: 21 +messageQueue: + maxPerSession: 14 +agentctl: + idleTimeout: 2h + idleReaperInterval: 3m + notificationQueueCapacity: 2048 +planning: + coalesceWindowMs: 2400 +office: + schedulerTickMs: 7000 +observability: + otlpEndpoint: https://otel.example.test/v1/traces +launcher: + webPort: 37430 + healthTimeoutMs: 12345 + noBrowser: true +` + if err := os.WriteFile(configFile, []byte(contents), 0o600); err != nil { + t.Fatalf("write config: %v", err) + } + + cfg, err := LoadWithPath(dir) + if err != nil { + t.Fatalf("LoadWithPath: %v", err) + } + + if got := nestedField(t, cfg, "Server", "TrustedProxies"); !reflect.DeepEqual(got.Interface(), []string{"10.0.0.0/8"}) { + t.Fatalf("server.trustedProxies = %#v", got.Interface()) + } + if got := nestedField(t, cfg, "Tasks", "PreparationTimeout"); got.Interface() != 17*time.Minute { + t.Fatalf("tasks.preparationTimeout = %#v", got.Interface()) + } + if got := nestedField(t, cfg, "Credentials", "File"); got.String() != "/run/secrets/credentials" { + t.Fatalf("credentials.file = %q", got.String()) + } + if got := nestedField(t, cfg, "Limits", "GHMaxConcurrent"); got.Int() != 11 { + t.Fatalf("limits.ghMaxConcurrent = %d", got.Int()) + } + if got := nestedField(t, cfg, "Limits", "GitMaxConcurrent"); got.Int() != 13 { + t.Fatalf("limits.gitMaxConcurrent = %d", got.Int()) + } + if got := nestedField(t, cfg, "Limits", "LSPMaxConnections"); got.Int() != 21 { + t.Fatalf("limits.lspMaxConnections = %d", got.Int()) + } + if got := nestedField(t, cfg, "MessageQueue", "MaxPerSession"); got.Int() != 14 { + t.Fatalf("messageQueue.maxPerSession = %d", got.Int()) + } + if got := nestedField(t, cfg, "Agentctl", "IdleTimeout"); got.Interface() != 2*time.Hour { + t.Fatalf("agentctl.idleTimeout = %#v", got.Interface()) + } + if got := nestedField(t, cfg, "Agentctl", "IdleReaperInterval"); got.Interface() != 3*time.Minute { + t.Fatalf("agentctl.idleReaperInterval = %#v", got.Interface()) + } + if got := nestedField(t, cfg, "Agentctl", "NotificationQueueCapacity"); got.Int() != 2048 { + t.Fatalf("agentctl.notificationQueueCapacity = %d", got.Int()) + } + if got := nestedField(t, cfg, "Planning", "CoalesceWindowMs"); got.Int() != 2400 { + t.Fatalf("planning.coalesceWindowMs = %d", got.Int()) + } + if got := nestedField(t, cfg, "Office", "SchedulerTickMs"); got.Int() != 7000 { + t.Fatalf("office.schedulerTickMs = %d", got.Int()) + } + if got := nestedField(t, cfg, "Observability", "OTLPEndpoint"); got.String() != "https://otel.example.test/v1/traces" { + t.Fatalf("observability.otlpEndpoint = %q", got.String()) + } + if got := nestedField(t, cfg, "Launcher", "WebPort"); got.Int() != 37430 { + t.Fatalf("launcher.webPort = %d", got.Int()) + } + if got := nestedField(t, cfg, "Launcher", "HealthTimeoutMs"); got.Int() != 12345 { + t.Fatalf("launcher.healthTimeoutMs = %d", got.Int()) + } + if got := nestedField(t, cfg, "Launcher", "NoBrowser"); !got.Bool() { + t.Fatal("launcher.noBrowser = false, want true") + } +} + +func TestHomeConfigDiscovery(t *testing.T) { + t.Setenv("KANDEV_SERVER_PORT", "") + homeDir := t.TempDir() + t.Chdir(t.TempDir()) + if err := os.WriteFile(filepath.Join(homeDir, "config.yaml"), []byte("server:\n port: 40123\n"), 0o600); err != nil { + t.Fatalf("write home config: %v", err) + } + t.Setenv("KANDEV_HOME_DIR", homeDir) + + cfg, err := Load() + if err != nil { + t.Fatalf("Load: %v", err) + } + if cfg.Server.Port != 40123 { + t.Fatalf("server.port = %d, want 40123", cfg.Server.Port) + } +} + +func TestHomeConfigCannotRelocateItself(t *testing.T) { + t.Setenv("KANDEV_SERVER_PORT", "") + homeDir := t.TempDir() + t.Chdir(t.TempDir()) + if err := os.WriteFile(filepath.Join(homeDir, "config.yaml"), []byte("homeDir: /different\n"), 0o600); err != nil { + t.Fatalf("write home config: %v", err) + } + t.Setenv("KANDEV_HOME_DIR", homeDir) + + _, err := Load() + if err == nil || !strings.Contains(err.Error(), "homeDir") || !strings.Contains(err.Error(), homeDir) { + t.Fatalf("Load error = %v, want a homeDir relocation error naming %s", err, homeDir) + } +} + +func TestConfigSourceMetadataReportsSelectedFileAndYAMLValues(t *testing.T) { + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "config.yaml"), []byte("server:\n port: 40124\n"), 0o600); err != nil { + t.Fatalf("write config: %v", err) + } + t.Setenv("KANDEV_SERVER_PORT", "40125") + + cfg, err := LoadWithPath(dir) + if err != nil { + t.Fatalf("LoadWithPath: %v", err) + } + source := reflect.ValueOf(cfg).Elem().FieldByName("Source") + if !source.IsValid() { + t.Fatal("Config.Source is missing") + } + file := source.FieldByName("FilePath") + if !file.IsValid() || file.String() != filepath.Join(dir, "config.yaml") { + t.Fatalf("selected config file = %#v, want %s", file.Interface(), filepath.Join(dir, "config.yaml")) + } + values := source.FieldByName("Values") + if !values.IsValid() { + t.Fatal("Config.Source.Values is missing") + } + got := values.MapIndex(reflect.ValueOf("server.port")) + if !got.IsValid() || got.String() != "environment" { + t.Fatalf("server.port source = %#v, want environment", got.Interface()) + } +} + +func TestConfigPrecedenceEnvironmentOverYAML(t *testing.T) { + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "config.yaml"), []byte("messageQueue:\n maxPerSession: 4\n"), 0o600); err != nil { + t.Fatalf("write config: %v", err) + } + t.Setenv("KANDEV_QUEUE_MAX_PER_SESSION", "9") + t.Setenv("KANDEV_SERVER_PORT", "") + + cfg, err := LoadWithPath(dir) + if err != nil { + t.Fatalf("LoadWithPath: %v", err) + } + if cfg.MessageQueue.MaxPerSession != 9 { + t.Fatalf("messageQueue.maxPerSession = %d, want 9", cfg.MessageQueue.MaxPerSession) + } + if got := cfg.SourceFor("messageQueue.maxPerSession"); got != SourceEnvironment { + t.Fatalf("messageQueue.maxPerSession source = %q, want %q", got, SourceEnvironment) + } +} + +func TestEmptyCredentialsEnvironmentClearsYAML(t *testing.T) { + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "config.yaml"), []byte("credentials:\n file: /run/secrets/credentials\n"), 0o600); err != nil { + t.Fatalf("write config: %v", err) + } + t.Setenv("KANDEV_CREDENTIALS_FILE", "") + + cfg, err := LoadWithPath(dir) + if err != nil { + t.Fatalf("LoadWithPath: %v", err) + } + if cfg.Credentials.File != "" { + t.Fatalf("credentials.file = %q, want empty environment override", cfg.Credentials.File) + } + if got := cfg.SourceFor("credentials.file"); got != SourceEnvironment { + t.Fatalf("credentials.file source = %q, want environment", got) + } +} + +func TestRepositoryDiscoveryRootsEnvironmentUsesCommaSeparatedValues(t *testing.T) { + t.Setenv("KANDEV_REPOSITORYDISCOVERY_ROOTS", "/one,/two") + + cfg, err := LoadWithPath(t.TempDir()) + if err != nil { + t.Fatalf("LoadWithPath: %v", err) + } + if !reflect.DeepEqual(cfg.RepositoryDiscovery.Roots, []string{"/one", "/two"}) { + t.Fatalf("repositoryDiscovery.roots = %#v, want comma-separated paths", cfg.RepositoryDiscovery.Roots) + } +} + +func TestInvalidYAMLValueNamesSelectedFileAndKey(t *testing.T) { + dir := t.TempDir() + configFile := filepath.Join(dir, "config.yaml") + if err := os.WriteFile(configFile, []byte("tasks:\n preparationTimeout: not-a-duration\n"), 0o600); err != nil { + t.Fatalf("write config: %v", err) + } + t.Setenv("KANDEV_SERVER_PORT", "") + + _, err := LoadWithPath(dir) + if err == nil || !strings.Contains(err.Error(), configFile) || !strings.Contains(err.Error(), "preparationTimeout") { + t.Fatalf("Load error = %v, want selected file and preparationTimeout", err) + } +} + +func TestSecretConfigPermissionsWarningOmitsValue(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("Unix permission warning is not applicable") + } + dir := t.TempDir() + configFile := filepath.Join(dir, "config.yaml") + secret := "do-not-log-this-secret" + if err := os.WriteFile(configFile, []byte("auth:\n jwtSecret: "+secret+"\n"), 0o644); err != nil { + t.Fatalf("write config: %v", err) + } + t.Setenv("KANDEV_SERVER_PORT", "") + + cfg, err := LoadWithPath(dir) + if err != nil { + t.Fatalf("LoadWithPath: %v", err) + } + if len(cfg.Source.Warnings) != 1 || !strings.Contains(cfg.Source.Warnings[0], configFile) { + t.Fatalf("warnings = %#v, want one warning naming %s", cfg.Source.Warnings, configFile) + } + if strings.Contains(cfg.Source.Warnings[0], secret) { + t.Fatalf("warning exposed secret: %q", cfg.Source.Warnings[0]) + } +} + +func TestInvalidYAMLRangeNamesSettingAndFile(t *testing.T) { + dir := t.TempDir() + configFile := filepath.Join(dir, "config.yaml") + if err := os.WriteFile(configFile, []byte("limits:\n ghMaxConcurrent: 0\n"), 0o600); err != nil { + t.Fatalf("write config: %v", err) + } + t.Setenv("KANDEV_SERVER_PORT", "") + + _, err := LoadWithPath(dir) + if err == nil || !strings.Contains(err.Error(), configFile) || !strings.Contains(err.Error(), "ghMaxConcurrent") { + t.Fatalf("Load error = %v, want selected file and ghMaxConcurrent", err) + } +} + +func TestHomeConfigDiscoveryOrderUsesOneFirstExistingFile(t *testing.T) { + workingDir := t.TempDir() + homeDir := t.TempDir() + if err := os.WriteFile(filepath.Join(workingDir, "config.yaml"), []byte("server:\n port: 40126\n"), 0o600); err != nil { + t.Fatalf("write working-directory config: %v", err) + } + if err := os.WriteFile(filepath.Join(homeDir, "config.yaml"), []byte("server:\n port: 40127\n"), 0o600); err != nil { + t.Fatalf("write home config: %v", err) + } + t.Setenv("KANDEV_HOME_DIR", homeDir) + t.Setenv("KANDEV_SERVER_PORT", "") + t.Chdir(workingDir) + + cfg, err := Load() + if err != nil { + t.Fatalf("Load: %v", err) + } + if cfg.Server.Port != 40126 || cfg.Source.FilePath != filepath.Join(workingDir, "config.yaml") { + t.Fatalf("selected config = %q, port = %d; want working-directory file and port 40126", cfg.Source.FilePath, cfg.Server.Port) + } +} + +func TestHomeConfigInvalidFirstDoesNotFallThrough(t *testing.T) { + workingDir := t.TempDir() + homeDir := t.TempDir() + configFile := filepath.Join(workingDir, "config.yaml") + if err := os.WriteFile(configFile, []byte("limits:\n ghMaxConcurrent: 0\n"), 0o600); err != nil { + t.Fatalf("write invalid config: %v", err) + } + if err := os.WriteFile(filepath.Join(homeDir, "config.yaml"), []byte("limits:\n ghMaxConcurrent: 22\n"), 0o600); err != nil { + t.Fatalf("write home config: %v", err) + } + t.Setenv("KANDEV_HOME_DIR", homeDir) + t.Setenv("KANDEV_SERVER_PORT", "") + t.Chdir(workingDir) + + _, err := Load() + if err == nil || !strings.Contains(err.Error(), configFile) { + t.Fatalf("Load error = %v, want invalid first file and no fallback", err) + } +} + +func nestedField(t *testing.T, value any, names ...string) reflect.Value { + t.Helper() + current := reflect.ValueOf(value) + if current.Kind() == reflect.Pointer { + current = current.Elem() + } + for _, name := range names { + if current.Kind() != reflect.Struct { + t.Fatalf("cannot find %s in %s", name, current.Type()) + } + current = current.FieldByName(name) + if !current.IsValid() { + t.Fatalf("configuration field %s is missing", strings.Join(names, ".")) + } + } + return current +} + +func TestHomeConfigDefaultDotDirectory(t *testing.T) { + userHome := t.TempDir() + defaultHome := filepath.Join(userHome, ".kandev") + if err := os.MkdirAll(defaultHome, 0o700); err != nil { + t.Fatalf("create default home: %v", err) + } + configFile := filepath.Join(defaultHome, "config.yaml") + if err := os.WriteFile(configFile, []byte("server:\n port: 40128\n"), 0o600); err != nil { + t.Fatalf("write default home config: %v", err) + } + t.Setenv("HOME", userHome) + t.Setenv("KANDEV_HOME_DIR", "") + t.Setenv("KANDEV_SERVER_PORT", "") + t.Chdir(t.TempDir()) + + cfg, err := Load() + if err != nil { + t.Fatalf("Load: %v", err) + } + if cfg.Server.Port != 40128 || cfg.Source.FilePath != configFile { + t.Fatalf("selected config = %q, port = %d; want %s and port 40128", cfg.Source.FilePath, cfg.Server.Port, configFile) + } +} diff --git a/apps/backend/internal/common/config/validation.go b/apps/backend/internal/common/config/validation.go new file mode 100644 index 0000000000..df12c405e9 --- /dev/null +++ b/apps/backend/internal/common/config/validation.go @@ -0,0 +1,51 @@ +package config + +import ( + "fmt" + "net" + "strings" +) + +func validateStartupSettings(cfg *Config) error { + errs := validateTrustedProxies(cfg) + errs = append(errs, configuredValidation(cfg, "tasks.preparationTimeout", cfg.Tasks.PreparationTimeout <= 0, "tasks.preparationTimeout must be positive")...) + errs = append(errs, configuredValidation(cfg, "limits.ghMaxConcurrent", cfg.Limits.GHMaxConcurrent <= 0, "limits.ghMaxConcurrent must be positive")...) + errs = append(errs, configuredValidation(cfg, "limits.gitMaxConcurrent", cfg.Limits.GitMaxConcurrent <= 0, "limits.gitMaxConcurrent must be positive")...) + errs = append(errs, configuredValidation(cfg, "limits.lspMaxConnections", cfg.Limits.LSPMaxConnections <= 0, "limits.lspMaxConnections must be positive")...) + errs = append(errs, configuredValidation(cfg, "messageQueue.maxPerSession", cfg.MessageQueue.MaxPerSession < 0, "messageQueue.maxPerSession must be zero or greater")...) + errs = append(errs, configuredValidation(cfg, "agentctl.idleTimeout", cfg.Agentctl.IdleTimeout < 0, "agentctl.idleTimeout must be zero or greater")...) + errs = append(errs, configuredValidation(cfg, "agentctl.idleReaperInterval", cfg.Agentctl.IdleReaperInterval <= 0, "agentctl.idleReaperInterval must be positive")...) + errs = append(errs, configuredValidation(cfg, "agentctl.notificationQueueCapacity", cfg.Agentctl.NotificationQueueCapacity < 1024 || cfg.Agentctl.NotificationQueueCapacity > 131072, "agentctl.notificationQueueCapacity must be between 1024 and 131072")...) + errs = append(errs, configuredValidation(cfg, "planning.coalesceWindowMs", cfg.Planning.CoalesceWindowMs < 0, "planning.coalesceWindowMs must be zero or greater")...) + errs = append(errs, configuredValidation(cfg, "office.schedulerTickMs", cfg.Office.SchedulerTickMs <= 0, "office.schedulerTickMs must be positive")...) + errs = append(errs, configuredValidation(cfg, "launcher.webPort", cfg.Launcher.WebPort < 1 || cfg.Launcher.WebPort > 65535, "launcher.webPort must be between 1 and 65535")...) + errs = append(errs, configuredValidation(cfg, "launcher.healthTimeoutMs", cfg.Launcher.HealthTimeoutMs <= 0, "launcher.healthTimeoutMs must be positive")...) + if len(errs) > 0 { + return fmt.Errorf("%s", strings.Join(errs, "; ")) + } + return nil +} + +func configuredValidation(cfg *Config, key string, invalid bool, message string) []string { + if cfg.SourceFor(key) == SourceConfiguration && invalid { + return []string{message} + } + return nil +} + +func validateTrustedProxies(cfg *Config) []string { + if cfg.SourceFor("server.trustedProxies") != SourceConfiguration { + return nil + } + var errs []string + for _, raw := range cfg.Server.TrustedProxies { + value := strings.TrimSpace(raw) + if net.ParseIP(value) != nil { + continue + } + if _, _, err := net.ParseCIDR(value); err != nil { + errs = append(errs, fmt.Sprintf("server.trustedProxies contains invalid IP or CIDR %q", raw)) + } + } + return errs +} diff --git a/apps/backend/internal/common/constants/timeouts.go b/apps/backend/internal/common/constants/timeouts.go index 2ae837fbfd..e0ccb2bb15 100644 --- a/apps/backend/internal/common/constants/timeouts.go +++ b/apps/backend/internal/common/constants/timeouts.go @@ -3,7 +3,6 @@ package constants import ( "math" - "os" "time" ) @@ -38,13 +37,25 @@ const ( var ( // SetupScriptTimeout is the maximum time to wait for a setup script to complete. - SetupScriptTimeout = resolveSetupScriptTimeout(os.Getenv("KANDEV_TASK_PREPARATION_TIMEOUT")) + SetupScriptTimeout = defaultSetupScriptTimeout // AgentLaunchTimeout is the maximum time to wait for agent launch, // including worktree creation and setup script execution. AgentLaunchTimeout = SetupScriptTimeout + setupLaunchAllowance ) +// ApplyPreparationTimeout applies the resolved typed startup setting to the +// process-wide launch budgets. It is called once after config.Load, before any +// task or agent services are constructed. Keeping the default here preserves +// callers that use the constants package without a backend startup sequence. +func ApplyPreparationTimeout(timeout time.Duration) { + if timeout <= 0 || timeout > maxSetupScriptTimeout { + timeout = defaultSetupScriptTimeout + } + SetupScriptTimeout = timeout + AgentLaunchTimeout = timeout + setupLaunchAllowance +} + func resolveSetupScriptTimeout(value string) time.Duration { if value == "" { return defaultSetupScriptTimeout diff --git a/apps/backend/internal/common/constants/timeouts_test.go b/apps/backend/internal/common/constants/timeouts_test.go index c0cfe76640..236b80672a 100644 --- a/apps/backend/internal/common/constants/timeouts_test.go +++ b/apps/backend/internal/common/constants/timeouts_test.go @@ -60,3 +60,20 @@ func TestResolveSetupScriptTimeoutRejectsLaunchBudgetOverflow(t *testing.T) { }) } } + +func TestApplyPreparationTimeoutUsesTypedStartupValue(t *testing.T) { + previousSetup := SetupScriptTimeout + previousLaunch := AgentLaunchTimeout + t.Cleanup(func() { + SetupScriptTimeout = previousSetup + AgentLaunchTimeout = previousLaunch + }) + + ApplyPreparationTimeout(17 * time.Minute) + if SetupScriptTimeout != 17*time.Minute { + t.Fatalf("SetupScriptTimeout = %s, want 17m", SetupScriptTimeout) + } + if AgentLaunchTimeout != 22*time.Minute { + t.Fatalf("AgentLaunchTimeout = %s, want 22m", AgentLaunchTimeout) + } +} diff --git a/apps/backend/internal/common/subproc/admission.go b/apps/backend/internal/common/subproc/admission.go index 8f0eb2811c..14e9555576 100644 --- a/apps/backend/internal/common/subproc/admission.go +++ b/apps/backend/internal/common/subproc/admission.go @@ -282,6 +282,16 @@ func (a *classAdmission) setCapForTest(newCap int) func() { } } +func (a *classAdmission) setCap(newCap int) { + a.mu.Lock() + a.cap = newCap + if newCap > 0 { + a.dispatchLocked() + } + a.mu.Unlock() + a.owner.publishCap(newCap) +} + func (a *classAdmission) capacity() int { a.mu.Lock() defer a.mu.Unlock() diff --git a/apps/backend/internal/common/subproc/shared.go b/apps/backend/internal/common/subproc/shared.go index 94f8d8c2bf..e9e923a243 100644 --- a/apps/backend/internal/common/subproc/shared.go +++ b/apps/backend/internal/common/subproc/shared.go @@ -44,10 +44,23 @@ var ( // subproc_acquire_total, subproc_acquire_wait_millis_total). Unit // tests that swap the pool via SetCapForTest reuse the same names // so the published gauges stay coherent across cap changes. - ghThrottle = NewNamedThrottle("gh", resolveCap(ghMaxConcurrentEnv, defaultGHMaxConcurrent)) - gitThrottle = NewNamedClassThrottle("git", resolveCap(gitMaxConcurrentEnv, defaultGitMaxConcurrent)) + ghThrottle = NewNamedThrottle("gh", defaultGHMaxConcurrent) + gitThrottle = NewNamedClassThrottle("git", defaultGitMaxConcurrent) ) +// ConfigureCaps applies the typed startup capacities to the process-wide +// throttles. Invalid values are reduced to the existing safe defaults. +func ConfigureCaps(ghMax, gitMax int) { + if ghMax <= 0 { + ghMax = defaultGHMaxConcurrent + } + if gitMax <= 0 { + gitMax = defaultGitMaxConcurrent + } + ghThrottle.SetCap(ghMax) + gitThrottle.SetCap(gitMax) +} + // GH returns the process-wide throttle gating gh subprocess execs. // All gh callers across the codebase share this single semaphore so the // total host fork pressure stays bounded regardless of caller count. diff --git a/apps/backend/internal/common/subproc/shared_test.go b/apps/backend/internal/common/subproc/shared_test.go index 57a29a0196..779ffa8444 100644 --- a/apps/backend/internal/common/subproc/shared_test.go +++ b/apps/backend/internal/common/subproc/shared_test.go @@ -67,6 +67,23 @@ func TestResolveGitMaxConcurrent(t *testing.T) { } } +func TestConfigureCapsUsesTypedStartupValues(t *testing.T) { + previousGH := GH().SetCapForTest(defaultGHMaxConcurrent) + previousGit := Git().SetCapForTest(defaultGitMaxConcurrent) + t.Cleanup(func() { + previousGH() + previousGit() + }) + + ConfigureCaps(3, 4) + if got := GH().Capacity(); got != 3 { + t.Fatalf("GH capacity = %d, want 3", got) + } + if got := GitCapacity(); got != 4 { + t.Fatalf("Git capacity = %d, want 4", got) + } +} + // TestGHGitAreDistinctThrottles verifies the two singletons aren't // accidentally aliased. A regression where both helpers return the same // pool would let a gh storm starve git ops (or vice-versa) — exactly diff --git a/apps/backend/internal/common/subproc/throttle.go b/apps/backend/internal/common/subproc/throttle.go index b0dcaf770b..eb2f1f0a8f 100644 --- a/apps/backend/internal/common/subproc/throttle.go +++ b/apps/backend/internal/common/subproc/throttle.go @@ -181,6 +181,37 @@ func (t *Throttle) currentSem() chan struct{} { return t.sem } +// Capacity reports the current admission capacity. A non-positive value means +// throttling is disabled. +func (t *Throttle) Capacity() int { + if t.admission != nil { + return t.admission.capacity() + } + sem := t.currentSem() + if sem == nil { + return 0 + } + return cap(sem) +} + +// SetCap applies a process startup capacity. Unlike SetCapForTest it does not +// return a restore function because startup configuration is immutable for the +// lifetime of the backend process. +func (t *Throttle) SetCap(newCap int) { + if t.admission != nil { + t.admission.setCap(newCap) + return + } + t.mu.Lock() + if newCap <= 0 { + t.sem = nil + } else { + t.sem = make(chan struct{}, newCap) + } + t.mu.Unlock() + t.publishCap(newCap) +} + // SetCapForTest replaces the slot pool with one of the given capacity and // returns a restore function. Test-only — production code MUST NOT call // this. Use cap=0 to disable throttling for a test. diff --git a/apps/backend/internal/gateway/websocket/lsp_capacity_test.go b/apps/backend/internal/gateway/websocket/lsp_capacity_test.go index 175a35d345..d9de7b65f6 100644 --- a/apps/backend/internal/gateway/websocket/lsp_capacity_test.go +++ b/apps/backend/internal/gateway/websocket/lsp_capacity_test.go @@ -23,3 +23,10 @@ func TestLSPCapacityLimiterFallsBackToDefaultForInvalidEnv(t *testing.T) { t.Fatalf("max = %d, want %d", limiter.max, defaultLSPMaxConnections) } } + +func TestNewLSPHandlerUsesTypedCapacity(t *testing.T) { + handler := NewLSPHandler(nil, nil, testLogger(), 21) + if handler.capacity.max != 21 { + t.Fatalf("LSP capacity = %d, want 21", handler.capacity.max) + } +} diff --git a/apps/backend/internal/gateway/websocket/lsp_handler.go b/apps/backend/internal/gateway/websocket/lsp_handler.go index 01522add80..92e5e9742a 100644 --- a/apps/backend/internal/gateway/websocket/lsp_handler.go +++ b/apps/backend/internal/gateway/websocket/lsp_handler.go @@ -69,11 +69,15 @@ var lspUpgrader = gorillaws.Upgrader{ } // NewLSPHandler creates a new LSPHandler. -func NewLSPHandler(lifecycleMgr *lifecycle.Manager, userService LSPUserService, log *logger.Logger) *LSPHandler { +func NewLSPHandler(lifecycleMgr *lifecycle.Manager, userService LSPUserService, log *logger.Logger, configuredMax ...int) *LSPHandler { + capacity := newLSPCapacityLimiterFromEnv() + if len(configuredMax) > 0 { + capacity = newLSPCapacityLimiter(configuredMax[0]) + } return &LSPHandler{ lifecycleMgr: lifecycleMgr, userService: userService, - capacity: newLSPCapacityLimiterFromEnv(), + capacity: capacity, logger: log.WithFields(zap.String("component", "lsp_handler")), } } diff --git a/apps/backend/internal/gateway/websocket/setup.go b/apps/backend/internal/gateway/websocket/setup.go index 9371edbc2d..49dabdf357 100644 --- a/apps/backend/internal/gateway/websocket/setup.go +++ b/apps/backend/internal/gateway/websocket/setup.go @@ -47,8 +47,8 @@ func (g *Gateway) SetLifecycleManager(lifecycleMgr *lifecycle.Manager, userServi } // SetLSPHandler enables the LSP WebSocket handler. -func (g *Gateway) SetLSPHandler(lifecycleMgr *lifecycle.Manager, userService LSPUserService) { - g.LSPHandler = NewLSPHandler(lifecycleMgr, userService, g.logger) +func (g *Gateway) SetLSPHandler(lifecycleMgr *lifecycle.Manager, userService LSPUserService, configuredMax ...int) { + g.LSPHandler = NewLSPHandler(lifecycleMgr, userService, g.logger, configuredMax...) } // SetVscodeProxy enables the VS Code reverse proxy handler. diff --git a/apps/backend/internal/launcher/bootstrap.go b/apps/backend/internal/launcher/bootstrap.go new file mode 100644 index 0000000000..d047563e4b --- /dev/null +++ b/apps/backend/internal/launcher/bootstrap.go @@ -0,0 +1,96 @@ +package launcher + +import ( + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/kandev/kandev/internal/common/config" + "github.com/kandev/kandev/internal/profiles" +) + +func loadBootstrapConfig() (*config.Config, error) { + return loadBootstrapConfigWithHome("") +} + +func loadBootstrapConfigWithHome(homeDir string) (*config.Config, error) { + cfg, err := config.LoadWithHome(homeDir) + if err != nil { + return nil, fmt.Errorf("load startup configuration: %w", err) + } + if _, _, err := profiles.ApplyProfile(); err != nil { + return nil, fmt.Errorf("apply profile defaults: %w", err) + } + return cfg, nil +} + +func loadServiceBootstrapConfig(args serviceArgs) (*config.Config, error) { + return loadBootstrapConfigWithHome(serviceBootstrapHomeDir(args)) +} + +func serviceBootstrapHomeDir(args serviceArgs) string { + if strings.TrimSpace(args.HomeDir) != "" { + return args.HomeDir + } + if raw := strings.TrimSpace(os.Getenv("KANDEV_HOME_DIR")); raw != "" { + return expandHome(raw) + } + if args.System { + return defaultSystemServiceHomeDir + } + return resolveHomeDir() +} + +func configSourceIsExplicit(cfg *config.Config, key string) bool { + if cfg == nil { + return false + } + source := cfg.SourceFor(key) + return source == config.SourceConfiguration || source == config.SourceEnvironment || source == config.SourceProfile +} + +func resolveHomeDirForConfig(cfg *config.Config) string { + if cfg == nil { + return resolveHomeDir() + } + return cfg.ResolvedHomeDir() +} + +func resolveDataDirForConfig(cfg *config.Config) string { + if cfg == nil { + return resolveDataDir() + } + return cfg.ResolvedDataDir() +} + +func resolveDatabasePathForConfig(cfg *config.Config) string { + if cfg == nil { + return resolveDatabasePath() + } + if path := strings.TrimSpace(cfg.Database.Path); path != "" { + return path + } + return filepath.Join(resolveDataDirForConfig(cfg), "kandev.db") +} + +func ensureDataDirForConfig(cfg *config.Config) error { + dir := resolveDataDirForConfig(cfg) + if err := rejectSymlinkComponents(dir); err != nil { + return err + } + if err := os.MkdirAll(dir, 0o700); err != nil { + return err + } + if err := os.Chmod(dir, 0o700); err != nil { + return err + } + return nil +} + +func configFileForChild(cfg *config.Config) string { + if cfg == nil { + return "" + } + return cfg.Source.FilePath +} diff --git a/apps/backend/internal/launcher/bootstrap_test.go b/apps/backend/internal/launcher/bootstrap_test.go new file mode 100644 index 0000000000..370ca5b4fd --- /dev/null +++ b/apps/backend/internal/launcher/bootstrap_test.go @@ -0,0 +1,192 @@ +package launcher + +import ( + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/kandev/kandev/internal/common/config" +) + +func TestLauncherBootstrapUsesYAMLValues(t *testing.T) { + clearLauncherConfigurationEnvironment(t) + dir := t.TempDir() + t.Chdir(dir) + dbPath := filepath.Join(dir, "state", "custom.db") + configPath := filepath.Join(dir, "config.yaml") + writeLauncherConfig(t, configPath, "homeDir: /srv/kandev\nserver:\n host: 127.0.0.1\n port: 4321\ndatabase:\n path: "+dbPath+"\nlogging:\n level: error\nlauncher:\n webPort: 4322\n healthTimeoutMs: 12345\n noBrowser: true\n") + + cfg, err := loadBootstrapConfig() + if err != nil { + t.Fatal(err) + } + if cfg.Source.FilePath != configPath { + t.Fatalf("selected config = %q, want %q", cfg.Source.FilePath, configPath) + } + if got := resolveHomeDirForConfig(cfg); got != "/srv/kandev" { + t.Fatalf("home directory = %q, want /srv/kandev", got) + } + if got := resolveDatabasePathForConfig(cfg); got != dbPath { + t.Fatalf("database path = %q, want %q", got, dbPath) + } + if got, err := resolvePortsForConfig(Options{}, cfg); err != nil || got != 4321 { + t.Fatalf("backend port = %d, %v, want 4321", got, err) + } + if got, err := resolveWebPortForConfig(Options{}, cfg); err != nil || got != 4322 { + t.Fatalf("web port = %d, %v, want 4322", got, err) + } + if got := resolveLogLevelForConfig(Options{}, cfg); got != "error" { + t.Fatalf("log level = %q, want error", got) + } + if got := healthTimeoutForConfig(45000, cfg); got != 12345*time.Millisecond { + t.Fatalf("health timeout = %s, want 12.345s", got) + } + if shouldOpenBrowser(Options{}, cfg) { + t.Fatal("YAML launcher.noBrowser did not suppress the browser") + } +} + +func TestLauncherBootstrapEnvironmentOverridesYAMLAndFlagsOverrideEnvironment(t *testing.T) { + clearLauncherConfigurationEnvironment(t) + dir := t.TempDir() + t.Chdir(dir) + writeLauncherConfig(t, filepath.Join(dir, "config.yaml"), "server:\n port: 4321\nlogging:\n level: error\nlauncher:\n webPort: 4322\n healthTimeoutMs: 12345\n noBrowser: false\n") + t.Setenv("KANDEV_BACKEND_PORT", "4323") + t.Setenv("KANDEV_PORT", "4324") + t.Setenv("KANDEV_WEB_PORT", "4325") + t.Setenv("KANDEV_LOG_LEVEL", "warn") + t.Setenv("KANDEV_HEALTH_TIMEOUT_MS", "23456") + t.Setenv("KANDEV_NO_BROWSER", "true") + + cfg, err := loadBootstrapConfig() + if err != nil { + t.Fatal(err) + } + if got, err := resolvePortsForConfig(Options{}, cfg); err != nil || got != 4323 { + t.Fatalf("environment backend port = %d, %v, want 4323", got, err) + } + if got, err := resolvePortsForConfig(Options{BackendPort: 4326}, cfg); err != nil || got != 4326 { + t.Fatalf("flag backend port = %d, %v, want 4326", got, err) + } + if got, err := resolveWebPortForConfig(Options{}, cfg); err != nil || got != 4325 { + t.Fatalf("environment web port = %d, %v, want 4325", got, err) + } + if got, err := resolveWebPortForConfig(Options{WebPort: 4327}, cfg); err != nil || got != 4327 { + t.Fatalf("flag web port = %d, %v, want 4327", got, err) + } + if got := resolveLogLevelForConfig(Options{}, cfg); got != "warn" { + t.Fatalf("environment log level = %q, want warn", got) + } + if got := healthTimeoutForConfig(45000, cfg); got != 23456*time.Millisecond { + t.Fatalf("environment health timeout = %s, want 23.456s", got) + } + if shouldOpenBrowser(Options{}, cfg) { + t.Fatal("environment no-browser override did not suppress the browser") + } +} + +func TestLauncherBackendEnvironmentHandsOffSelectedConfigFile(t *testing.T) { + clearLauncherConfigurationEnvironment(t) + dir := t.TempDir() + configPath := filepath.Join(dir, "config.yaml") + writeLauncherConfig(t, configPath, "database:\n path: /srv/custom.db\n") + cfg, err := config.LoadWithPath(configPath) + if err != nil { + t.Fatal(err) + } + + env := backendEnvForConfig( + portConfig{BackendPort: 4321, AgentctlPort: 4322}, + "info", + "warn", + false, + "health-token", + nil, + cfg, + ) + if got := envValue(env, config.InternalConfigFileEnv); got != configPath { + t.Fatalf("selected config handoff = %q, want %q", got, configPath) + } + if got := envValue(env, "KANDEV_DATABASE_PATH"); got != "" { + t.Fatalf("YAML database path was copied into public environment as %q", got) + } +} + +func TestLauncherSelectedConfigHandoffSurvivesWorkingDirectoryChange(t *testing.T) { + clearLauncherConfigurationEnvironment(t) + sourceDir := t.TempDir() + otherDir := t.TempDir() + configPath := filepath.Join(sourceDir, "config.yaml") + writeLauncherConfig(t, configPath, "server:\n port: 4321\n") + t.Setenv(config.InternalConfigFileEnv, configPath) + t.Chdir(otherDir) + + cfg, err := config.Load() + if err != nil { + t.Fatal(err) + } + if cfg.Source.FilePath != configPath { + t.Fatalf("child selected config = %q, want %q", cfg.Source.FilePath, configPath) + } + if cfg.Server.Port != 4321 { + t.Fatalf("child server port = %d, want 4321", cfg.Server.Port) + } +} + +func TestDevBootstrapUsesConfiguredWebPort(t *testing.T) { + clearLauncherConfigurationEnvironment(t) + repo := makeRepoTree(t) + t.Chdir(repo) + writeLauncherConfig(t, filepath.Join(repo, "config.yaml"), "launcher:\n webPort: 4328\n") + + cfg, code := devLaunchConfigFor(Options{Command: CommandDev}) + if code != 0 { + t.Fatalf("devLaunchConfigFor() code = %d, want 0", code) + } + if cfg.ports.WebPort != 4328 { + t.Fatalf("dev web port = %d, want 4328", cfg.ports.WebPort) + } +} + +func TestLauncherInvalidBootstrapReportsSelectedFile(t *testing.T) { + clearLauncherConfigurationEnvironment(t) + dir := t.TempDir() + t.Chdir(dir) + configPath := filepath.Join(dir, "config.yaml") + writeLauncherConfig(t, configPath, "launcher:\n healthTimeoutMs: invalid\n") + + _, err := loadBootstrapConfig() + if err == nil { + t.Fatal("loadBootstrapConfig() succeeded for invalid launcher configuration") + } + if !strings.Contains(err.Error(), configPath) { + t.Fatalf("error = %v, want selected file %q", err, configPath) + } +} + +func clearLauncherConfigurationEnvironment(t *testing.T) { + t.Helper() + for _, name := range []string{ + "KANDEV_HOME_DIR", + "KANDEV_SERVER_HOST", + "KANDEV_DATABASE_PATH", + "KANDEV_LOG_LEVEL", + "KANDEV_HEALTH_TIMEOUT_MS", + "KANDEV_NO_BROWSER", + config.InternalConfigFileEnv, + } { + t.Setenv(name, "") + } + for _, name := range []string{"KANDEV_SERVER_PORT", "KANDEV_BACKEND_PORT", "KANDEV_PORT", "KANDEV_WEB_PORT"} { + unsetEnvForTest(t, name) + } +} + +func writeLauncherConfig(t *testing.T, path, contents string) { + t.Helper() + if err := os.WriteFile(path, []byte(contents), 0o600); err != nil { + t.Fatalf("write config: %v", err) + } +} diff --git a/apps/backend/internal/launcher/dev.go b/apps/backend/internal/launcher/dev.go index e5c4e07ac8..d371a978e3 100644 --- a/apps/backend/internal/launcher/dev.go +++ b/apps/backend/internal/launcher/dev.go @@ -6,6 +6,8 @@ import ( "os" "path/filepath" "time" + + "github.com/kandev/kandev/internal/common/config" ) var ( @@ -27,7 +29,7 @@ func runDev(ctx context.Context, opts Options) int { fmt.Fprintln(os.Stderr, "[kandev] "+err.Error()) return 1 } - logStartup("dev mode: using local repo", cfg.ports, cfg.dbPath, cfg.logLevel) + logStartup("dev mode: using local repo", cfg.ports, cfg.dbPath, cfg.logLevel, serverHostForConfig(cfg.startup)) ignoreBrokenPipeSignal() setLauncherShutdownDebug(cfg.debug || os.Getenv("KANDEV_SHUTDOWN_DEBUG") == "1") @@ -40,7 +42,7 @@ func runDev(ctx context.Context, opts Options) int { fmt.Fprintln(os.Stderr, "[kandev] "+err.Error()) return 1 } - env := backendEnv(cfg.ports, cfg.logLevel, resolveConsoleLogLevel(opts), opts.Debug, healthToken, cfg.extra) + env := backendEnvForConfig(cfg.ports, cfg.logLevel, resolveConsoleLogLevel(opts), opts.Debug, healthToken, cfg.extra, cfg.startup) env = upsertEnv(env, backendPIDFileEnv, filepath.Join(devKandevHome(cfg.repoRoot), "supervisor", "backend.pid")) backend, dumpLogs, err := launchBackendFn(backendLaunchConfig{ Command: "make", @@ -50,7 +52,7 @@ func runDev(ctx context.Context, opts Options) int { Quiet: false, Ports: cfg.ports, Mode: "dev", - HomeDir: devKandevHome(cfg.repoRoot), + HomeDir: cfg.homeDir, Supervisor: supervisor, }) if err != nil { @@ -60,7 +62,7 @@ func runDev(ctx context.Context, opts Options) int { } fmt.Println("[kandev] starting backend...") - if err := waitForHealthFn(ctx, cfg.ports.BackendURL, backend, healthTimeout(healthTimeoutDevMS), healthToken, dumpLogs); err != nil { + if err := waitForHealthFn(ctx, cfg.ports.BackendURL, backend, healthTimeoutForConfig(healthTimeoutDevMS, cfg.startup), healthToken, dumpLogs); err != nil { supervisor.shutdown("backend health failure") fmt.Fprintln(os.Stderr, "[kandev] "+err.Error()) return 1 @@ -75,13 +77,13 @@ func runDev(ctx context.Context, opts Options) int { fmt.Fprintln(os.Stderr, "[kandev] "+err.Error()) return 1 } - if err := waitForURLFn(ctx, webURL, webProc, healthTimeout(healthTimeoutDevMS)); err != nil { + if err := waitForURLFn(ctx, webURL, webProc, healthTimeoutForConfig(healthTimeoutDevMS, cfg.startup)); err != nil { supervisor.shutdown("web readiness failure") fmt.Fprintln(os.Stderr, "[kandev] "+err.Error()) return 1 } - if opts.Headless { + if !shouldOpenBrowser(opts, cfg.startup) { fmt.Printf("[kandev] ready (headless) at %s\n", cfg.ports.BackendURL) return waitForAppExit(supervisor, backend, webProc) } @@ -97,12 +99,18 @@ type devLaunchConfig struct { extra []string logLevel string debug bool + homeDir string + startup *config.Config } // devLaunchConfigFor resolves everything runDev needs before any process is // spawned: repo root, dev ports, and the dev backend env. It returns a // nonzero exit code (having printed the error) when any step fails. -func devLaunchConfigFor(opts Options) (devLaunchConfig, int) { +func devLaunchConfigFor(opts Options, configs ...*config.Config) (devLaunchConfig, int) { + startupConfig, exitCode := devStartupConfig(configs...) + if exitCode != 0 { + return devLaunchConfig{}, exitCode + } cwd, err := os.Getwd() if err != nil { fmt.Fprintln(os.Stderr, "[kandev] "+err.Error()) @@ -113,12 +121,12 @@ func devLaunchConfigFor(opts Options) (devLaunchConfig, int) { fmt.Fprintln(os.Stderr, "[kandev] "+err.Error()) return devLaunchConfig{}, 2 } - backendPort, err := resolvePorts(opts) + backendPort, err := resolvePorts(opts, startupConfig) if err != nil { fmt.Fprintln(os.Stderr, "[kandev] "+err.Error()) return devLaunchConfig{}, 2 } - webPort, err := resolveWebPort(opts) + webPort, err := resolveWebPort(opts, startupConfig) if err != nil { fmt.Fprintln(os.Stderr, "[kandev] "+err.Error()) return devLaunchConfig{}, 2 @@ -128,17 +136,63 @@ func devLaunchConfigFor(opts Options) (devLaunchConfig, int) { fmt.Fprintln(os.Stderr, "[kandev] "+err.Error()) return devLaunchConfig{}, 1 } - dbPath, extra := resolveDevBackendEnv(repoRoot) + dbPath, extra := resolveDevBackendEnv(repoRoot, startupConfig) + homeDir := devKandevHome(repoRoot) + if !isInsideKandevTask(repoRoot) && configSourceIsExplicit(startupConfig, "homeDir") { + homeDir = startupConfig.ResolvedHomeDir() + } return devLaunchConfig{ repoRoot: repoRoot, ports: ports, dbPath: dbPath, extra: extra, - logLevel: resolveLogLevel(opts), + logLevel: resolveLogLevelForConfig(opts, startupConfig), debug: opts.Debug, + homeDir: homeDir, + startup: startupConfig, }, 0 } +func devStartupConfig(configs ...*config.Config) (*config.Config, int) { + if len(configs) > 0 { + return configs[0], 0 + } + // Load dev defaults with the same selector that the child receives. + if os.Getenv("KANDEV_E2E_MOCK") == "" { + if err := os.Setenv("KANDEV_DEBUG_DEV_MODE", "true"); err != nil { + fmt.Fprintln(os.Stderr, "[kandev] "+err.Error()) + return nil, 1 + } + } + startupConfig, err := loadDevBootstrapConfig() + if err != nil { + fmt.Fprintln(os.Stderr, "[kandev] "+err.Error()) + return nil, 1 + } + return startupConfig, 0 +} + +func loadDevBootstrapConfig() (*config.Config, error) { + if os.Getenv("KANDEV_E2E_MOCK") != "" || os.Getenv("KANDEV_DEBUG_DEV_MODE") != "" { + return loadBootstrapConfig() + } + + const selector = "KANDEV_DEBUG_DEV_MODE" + previous, present := os.LookupEnv(selector) + if err := os.Setenv(selector, "true"); err != nil { + return nil, err + } + cfg, err := loadBootstrapConfig() + if present { + if restoreErr := os.Setenv(selector, previous); err == nil { + err = restoreErr + } + } else if restoreErr := os.Unsetenv(selector); err == nil { + err = restoreErr + } + return cfg, err +} + // backupProductionDBIfNeeded snapshots the resolved DB before dev mode runs // against it, unless it already lives under a .kandev-dev segment. func backupProductionDBIfNeeded(dbPath string) error { diff --git a/apps/backend/internal/launcher/dev_test.go b/apps/backend/internal/launcher/dev_test.go index aa27731570..811c9d6b33 100644 --- a/apps/backend/internal/launcher/dev_test.go +++ b/apps/backend/internal/launcher/dev_test.go @@ -150,6 +150,61 @@ func TestRunDevLaunchesBackendThenWebAndOpensBrowser(t *testing.T) { } } +func TestRunDevUsesConfiguredHealthTimeoutForBackendAndWeb(t *testing.T) { + repo := makeRepoTree(t) + t.Chdir(repo) + writeLauncherConfig(t, filepath.Join(repo, "config.yaml"), "launcher:\n healthTimeoutMs: 12345\n") + t.Setenv("KANDEV_TASK_ID", "") + t.Setenv("KANDEV_DATABASE_PATH", "") + t.Setenv("KANDEV_HOME_DIR", "") + + oldNewSupervisor := newSupervisorFn + oldAttachSignals := attachSignalsFn + oldLaunchBackend := launchBackendFn + oldWaitForHealth := waitForHealthFn + oldWaitForURL := waitForURLFn + oldOpenBrowser := openBrowserFn + oldStartProcess := startProcessFn + t.Cleanup(func() { + newSupervisorFn = oldNewSupervisor + attachSignalsFn = oldAttachSignals + launchBackendFn = oldLaunchBackend + waitForHealthFn = oldWaitForHealth + waitForURLFn = oldWaitForURL + openBrowserFn = oldOpenBrowser + startProcessFn = oldStartProcess + }) + + newSupervisorFn = newSupervisor + attachSignalsFn = func(_ *processSupervisor) {} + launchBackendFn = func(_ backendLaunchConfig) (*restartableBackend, func(), error) { + exitCh := make(chan int, 1) + exitCh <- 0 + return &restartableBackend{exitCh: exitCh}, func() {}, nil + } + var backendTimeout, webTimeout time.Duration + waitForHealthFn = func(_ context.Context, _ string, _ childState, timeout time.Duration, _ string, _ func()) error { + backendTimeout = timeout + return nil + } + waitForURLFn = func(_ context.Context, _ string, _ childState, timeout time.Duration) error { + webTimeout = timeout + return nil + } + openBrowserFn = func(string) {} + startProcessFn = func(_ string, _ []string, _ string, _ []string, _ bool, _ string, _ *processSupervisor) (*managedProcess, func(), error) { + return &managedProcess{}, func() {}, nil + } + + if code := runDev(context.Background(), Options{Command: CommandDev}); code != 0 { + t.Fatalf("runDev() = %d, want 0", code) + } + want := 12345 * time.Millisecond + if backendTimeout != want || webTimeout != want { + t.Fatalf("health timeouts = backend %s, web %s; want %s for both", backendTimeout, webTimeout, want) + } +} + func TestRunDevHeadlessSkipsBrowser(t *testing.T) { repo := makeRepoTree(t) t.Chdir(repo) diff --git a/apps/backend/internal/launcher/devpaths.go b/apps/backend/internal/launcher/devpaths.go index 483e9074a1..957c526f84 100644 --- a/apps/backend/internal/launcher/devpaths.go +++ b/apps/backend/internal/launcher/devpaths.go @@ -5,6 +5,8 @@ import ( "os" "path/filepath" "strings" + + "github.com/kandev/kandev/internal/common/config" ) // findRepoRoot returns the nearest ancestor of startDir that contains both @@ -61,9 +63,13 @@ func isInsideKandevTask(repoRoot string) bool { // The returned dbPath is display-only; the backend derives its own DB path // from KANDEV_HOME_DIR via resolveDatabasePath(). Both resolve to the same // location. -func resolveDevBackendEnv(repoRoot string) (dbPath string, extra []string) { +func resolveDevBackendEnv(repoRoot string, configs ...*config.Config) (dbPath string, extra []string) { devHome := devKandevHome(repoRoot) devDBPath := filepath.Join(devHome, "data", "kandev.db") + var startupConfig *config.Config + if len(configs) > 0 { + startupConfig = configs[0] + } // Profile-selector only: the backend reads profiles.yaml at startup and // applies the matching dev: values (mock agent, pprof, feature flags, @@ -81,6 +87,15 @@ func resolveDevBackendEnv(repoRoot string) (dbPath string, extra []string) { ) } + if startupConfig != nil && startupConfig.SourceFor("database.path") == config.SourceConfiguration && + strings.TrimSpace(startupConfig.Database.Path) != "" { + return startupConfig.Database.Path, baseExtra + } + if startupConfig != nil && (startupConfig.SourceFor("homeDir") == config.SourceConfiguration || + startupConfig.SourceFor("homeDir") == config.SourceEnvironment) { + return filepath.Join(startupConfig.ResolvedDataDir(), "kandev.db"), baseExtra + } + if override := strings.TrimSpace(os.Getenv("KANDEV_DATABASE_PATH")); override != "" { return override, append(baseExtra, "KANDEV_DATABASE_PATH="+override) } diff --git a/apps/backend/internal/launcher/devpaths_test.go b/apps/backend/internal/launcher/devpaths_test.go index bde739abbc..1defa62b77 100644 --- a/apps/backend/internal/launcher/devpaths_test.go +++ b/apps/backend/internal/launcher/devpaths_test.go @@ -5,6 +5,8 @@ import ( "path/filepath" "strings" "testing" + + commonconfig "github.com/kandev/kandev/internal/common/config" ) func makeRepoTree(t *testing.T) string { @@ -172,6 +174,28 @@ func TestResolveDevBackendEnvIgnoresWhitespaceOnlyOverride(t *testing.T) { } } +func TestResolveDevBackendEnvHonorsEnvironmentConfiguredHome(t *testing.T) { + repo := makeRepoTree(t) + customHome := filepath.Join(t.TempDir(), "kandev") + t.Setenv("KANDEV_HOME_DIR", customHome) + t.Setenv("KANDEV_TASK_ID", "") + t.Setenv("KANDEV_DATABASE_PATH", "") + + cfg := &commonconfig.Config{ + HomeDir: customHome, + Source: commonconfig.ConfigSource{Values: map[string]commonconfig.SettingSource{ + "homeDir": commonconfig.SourceEnvironment, + }}, + } + dbPath, extra := resolveDevBackendEnv(repo, cfg) + if want := filepath.Join(customHome, "data", "kandev.db"); dbPath != want { + t.Fatalf("dbPath = %q, want %q", dbPath, want) + } + if env := devEnvToMap(extra); env["KANDEV_HOME_DIR"] != "" { + t.Fatalf("dev extra environment overwrote configured home: %q", env["KANDEV_HOME_DIR"]) + } +} + func TestResolveDevBackendEnvClearsLeakedPathInTaskWorkspace(t *testing.T) { home := t.TempDir() t.Setenv("HOME", home) diff --git a/apps/backend/internal/launcher/env.go b/apps/backend/internal/launcher/env.go index cde482d53b..f2cb986d12 100644 --- a/apps/backend/internal/launcher/env.go +++ b/apps/backend/internal/launcher/env.go @@ -6,6 +6,9 @@ import ( "fmt" "os" "strings" + + "github.com/kandev/kandev/internal/common/config" + "github.com/kandev/kandev/internal/profiles" ) const healthTokenBytes = 32 @@ -29,16 +32,31 @@ func launchHealthToken() (string, error) { return newHealthToken() } -func backendEnv(ports portConfig, logLevel, consoleLogLevel string, debug bool, healthToken string, extra []string) []string { - env := os.Environ() +func backendEnv(ports portConfig, logLevel, consoleLogLevel string, debug bool, healthToken string, extra []string, configs ...*config.Config) []string { + if len(configs) > 0 { + return backendEnvForConfig(ports, logLevel, consoleLogLevel, debug, healthToken, extra, configs[0]) + } + return backendEnvForConfig(ports, logLevel, consoleLogLevel, debug, healthToken, extra, nil) +} + +func backendEnvForConfig(ports portConfig, logLevel, consoleLogLevel string, debug bool, healthToken string, extra []string, cfg *config.Config) []string { + env := stripAppliedProfileEnvironment(os.Environ()) env = upsertEnv(env, "KANDEV_SERVER_PORT", fmt.Sprint(ports.BackendPort)) env = upsertEnv(env, "KANDEV_AGENT_STANDALONE_PORT", fmt.Sprint(ports.AgentctlPort)) - env = upsertEnv(env, "KANDEV_DATABASE_PATH", resolveDatabasePath()) + if cfg == nil || cfg.SourceFor("database.path") != config.SourceConfiguration || strings.TrimSpace(cfg.Database.Path) == "" { + env = upsertEnv(env, "KANDEV_DATABASE_PATH", resolveDatabasePathForConfig(cfg)) + } + if configPath := configFileForChild(cfg); configPath != "" { + env = upsertEnv(env, config.InternalConfigFileEnv, configPath) + if cfg.Source.HomeFile { + env = upsertEnv(env, config.InternalConfigHomeFileEnv, "1") + } + } env = upsertEnv(env, "KANDEV_DESKTOP_HEALTH_TOKEN", healthToken) if ports.WebPort != 0 { env = upsertEnv(env, "KANDEV_WEB_INTERNAL_URL", fmt.Sprintf("http://localhost:%d", ports.WebPort)) } - if logLevel != "" { + if logLevel != "" && (cfg == nil || cfg.SourceFor("logging.level") != config.SourceConfiguration || cfg.Logging.Level != logLevel) { env = upsertEnv(env, "KANDEV_LOG_LEVEL", logLevel) } env = upsertEnv(env, "KANDEV_CONSOLE_LOG_LEVEL", consoleLogLevel) @@ -56,6 +74,22 @@ func backendEnv(ports portConfig, logLevel, consoleLogLevel string, debug bool, return env } +func stripAppliedProfileEnvironment(env []string) []string { + defaults, err := profiles.EnvironmentDefaults() + if err != nil { + return env + } + filtered := make([]string, 0, len(env)) + for _, item := range env { + key, value, ok := strings.Cut(item, "=") + if ok && profiles.WasApplied(key) && defaults[key] == value { + continue + } + filtered = append(filtered, item) + } + return filtered +} + func upsertEnv(env []string, key, value string) []string { prefix := key + "=" for i, item := range env { diff --git a/apps/backend/internal/launcher/health.go b/apps/backend/internal/launcher/health.go index 9ffc9bc859..239ab09905 100644 --- a/apps/backend/internal/launcher/health.go +++ b/apps/backend/internal/launcher/health.go @@ -9,7 +9,10 @@ import ( "net/url" "os" "strconv" + "strings" "time" + + "github.com/kandev/kandev/internal/common/config" ) const ( @@ -38,6 +41,18 @@ func healthTimeout(defaultMS int) time.Duration { return time.Duration(n) * time.Millisecond } +func healthTimeoutForConfig(defaultMS int, cfg *config.Config) time.Duration { + if raw, ok := os.LookupEnv("KANDEV_HEALTH_TIMEOUT_MS"); ok && strings.TrimSpace(raw) != "" { + return healthTimeout(defaultMS) + } + if configSourceIsExplicit(cfg, "launcher.healthTimeoutMs") || cfg != nil { + if cfg != nil && cfg.Launcher.HealthTimeoutMs > 0 { + return time.Duration(cfg.Launcher.HealthTimeoutMs) * time.Millisecond + } + } + return healthTimeout(defaultMS) +} + func waitForHealth(ctx context.Context, baseURL string, proc childState, timeout time.Duration, expectedToken string, onFailure func()) error { ctx, cancel := context.WithTimeout(ctx, timeout) defer cancel() diff --git a/apps/backend/internal/launcher/ports.go b/apps/backend/internal/launcher/ports.go index b58ec499f7..577efc7508 100644 --- a/apps/backend/internal/launcher/ports.go +++ b/apps/backend/internal/launcher/ports.go @@ -7,6 +7,8 @@ import ( "os" "strconv" "time" + + "github.com/kandev/kandev/internal/common/config" ) type portConfig struct { @@ -24,7 +26,10 @@ const ( webPortEnv = "KANDEV_WEB_PORT" ) -func resolvePorts(opts Options) (int, error) { +func resolvePorts(opts Options, configs ...*config.Config) (int, error) { + if len(configs) > 0 { + return resolvePortsForConfig(opts, configs[0]) + } backend := opts.BackendPort if backend == 0 { if p, err := envPort(backendPortEnv); err != nil { @@ -40,15 +45,62 @@ func resolvePorts(opts Options) (int, error) { return backend, nil } +func resolvePortsForConfig(opts Options, cfg *config.Config) (int, error) { + if opts.BackendPort != 0 { + return opts.BackendPort, nil + } + if _, ok := os.LookupEnv(backendPortEnv); ok { + return resolvePorts(opts) + } + if _, ok := os.LookupEnv(legacyBackendPortEnv); ok { + return resolvePorts(opts) + } + // KANDEV_SERVER_PORT is the child-backend handoff variable. The launcher + // has historically resolved its own port from the flag, KANDEV_BACKEND_PORT, + // or KANDEV_PORT, so an inherited child value must not pin a dev launcher to + // an already-running parent backend. + if cfg != nil && (cfg.SourceFor("server.port") == config.SourceConfiguration || + (cfg.SourceFor("server.port") == config.SourceEnvironment && serviceEnvironmentActive())) { + if cfg.Server.Port < 1 || cfg.Server.Port > 65535 { + return 0, ParseError{Message: fmt.Sprintf("server.port must be an integer between 1 and 65535, got %d", cfg.Server.Port)} + } + return cfg.Server.Port, nil + } + return 0, nil +} + +func serviceEnvironmentActive() bool { + return os.Getenv("KANDEV_SERVICE_MODE") != "" || os.Getenv("KANDEV_RUNNING_AS_SERVICE") != "" +} + // resolveWebPort resolves the dev-mode web port: the --web-internal-port flag // beats KANDEV_WEB_PORT, and 0 means "unset". -func resolveWebPort(opts Options) (int, error) { +func resolveWebPort(opts Options, configs ...*config.Config) (int, error) { + if len(configs) > 0 { + return resolveWebPortForConfig(opts, configs[0]) + } if opts.WebPort != 0 { return opts.WebPort, nil } return envPort(webPortEnv) } +func resolveWebPortForConfig(opts Options, cfg *config.Config) (int, error) { + if opts.WebPort != 0 { + return opts.WebPort, nil + } + if _, ok := os.LookupEnv(webPortEnv); ok { + return envPort(webPortEnv) + } + if cfg != nil && cfg.SourceFor("launcher.webPort") == config.SourceConfiguration { + if cfg.Launcher.WebPort < 1 || cfg.Launcher.WebPort > 65535 { + return 0, ParseError{Message: fmt.Sprintf("launcher.webPort must be an integer between 1 and 65535, got %d", cfg.Launcher.WebPort)} + } + return cfg.Launcher.WebPort, nil + } + return 0, nil +} + func backendPortSource(opts Options) string { if opts.BackendPort != 0 { return backendPortFlag diff --git a/apps/backend/internal/launcher/run.go b/apps/backend/internal/launcher/run.go index 429bc6ff07..0395da9c2b 100644 --- a/apps/backend/internal/launcher/run.go +++ b/apps/backend/internal/launcher/run.go @@ -8,7 +8,12 @@ import ( ) func runInstalled(ctx context.Context, opts Options) int { - backendPort, err := resolvePorts(opts) + startupConfig, err := loadBootstrapConfig() + if err != nil { + fmt.Fprintln(os.Stderr, "[kandev] "+err.Error()) + return 1 + } + backendPort, err := resolvePorts(opts, startupConfig) if err != nil { fmt.Fprintln(os.Stderr, "[kandev] "+err.Error()) return 2 @@ -23,12 +28,12 @@ func runInstalled(ctx context.Context, opts Options) int { fmt.Fprintln(os.Stderr, "[kandev] "+err.Error()) return 1 } - if err := ensureDataDir(); err != nil { + if err := ensureDataDirForConfig(startupConfig); err != nil { fmt.Fprintln(os.Stderr, "[kandev] "+err.Error()) return 1 } - logLevel := resolveLogLevel(opts) + logLevel := resolveLogLevelForConfig(opts, startupConfig) releaseTag := os.Getenv("KANDEV_VERSION") if releaseTag == "" { releaseTag = "(" + bundle.Source + ")" @@ -41,5 +46,6 @@ func runInstalled(ctx context.Context, opts Options) int { Ports: ports, LogLevel: logLevel, Opts: opts, + Startup: startupConfig, }) } diff --git a/apps/backend/internal/launcher/service.go b/apps/backend/internal/launcher/service.go index 2423590323..3d913df0fe 100644 --- a/apps/backend/internal/launcher/service.go +++ b/apps/backend/internal/launcher/service.go @@ -7,6 +7,8 @@ import ( "path/filepath" "runtime" "strings" + + "github.com/kandev/kandev/internal/common/config" ) type serviceArgs struct { @@ -20,22 +22,25 @@ type serviceArgs struct { Intent string DryRun bool ShowHelp bool + Startup *config.Config } const ( - actionInstall = "install" - actionUninstall = "uninstall" - actionRestart = "restart" - actionConfig = "config" - actionLogs = "logs" - actionStatus = "status" - actionStop = "stop" - actionSelfUpdate = "self-update" - flagHelp = "--help" - goosLinux = "linux" - goosDarwin = "darwin" - managedMarker = "managed by kandev" - serviceUnitName = "kandev.service" + actionInstall = "install" + actionUninstall = "uninstall" + actionRestart = "restart" + actionConfig = "config" + actionLogs = "logs" + actionStatus = "status" + actionStop = "stop" + actionSelfUpdate = "self-update" + flagHelp = "--help" + goosLinux = "linux" + goosDarwin = "darwin" + managedMarker = "managed by kandev" + serviceUnitName = "kandev.service" + defaultServiceLogLevel = "info" + defaultSystemServiceHomeDir = "/var/lib/kandev" ) const serviceHelp = `kandev service — install kandev as an OS-managed service @@ -74,6 +79,20 @@ func runService(argv []string, build BuildInfo) int { } return 0 } + if args.Action == actionInstall { + startupConfig, err := loadServiceBootstrapConfig(args) + if err != nil { + fmt.Fprintln(os.Stderr, "[kandev] "+err.Error()) + return 1 + } + args.Startup = startupConfig + port, err := resolvePortsForConfig(Options{BackendPort: args.Port}, startupConfig) + if err != nil { + fmt.Fprintln(os.Stderr, "[kandev] "+err.Error()) + return 2 + } + args.Port = port + } switch runtime.GOOS { case goosLinux: return runLinuxService(args, build) @@ -250,7 +269,7 @@ func installSystemd(args serviceArgs, build BuildInfo, unitPath string) int { fmt.Fprintln(os.Stderr, "[kandev] "+err.Error()) return 1 } - homeDir := serviceHomeDir(args) + homeDir, homeSource := resolveServiceHome(args) logDir := filepath.Join(homeDir, "logs") bundleDir := serviceBundleDir(self) systemUser, err := resolveAndValidateSystemIdentity(args, unitPath, nativeServiceManagerSystemd, homeDir) @@ -259,16 +278,20 @@ func installSystemd(args serviceArgs, build BuildInfo, unitPath string) int { return 1 } input := nativeServiceUnitInput{ - Executable: self, - HomeDir: homeDir, - LogDir: logDir, - Port: args.Port, - System: args.System, - SystemUser: systemUser, - NoBootStart: args.NoBootStart, - BundleDir: bundleDir, - Version: serviceVersion(build.Version), - PathPrefixes: serviceNodeToolBinDirs(), + Executable: self, + HomeDir: homeDir, + LogDir: logDir, + Port: args.Port, + System: args.System, + SystemUser: systemUser, + NoBootStart: args.NoBootStart, + BundleDir: bundleDir, + Version: serviceVersion(build.Version), + PathPrefixes: serviceNodeToolBinDirs(), + ConfigFile: configFileForChild(args.Startup), + HomeDirFromConfig: homeSource == config.SourceConfiguration, + ConfigHomeFile: args.Startup != nil && args.Startup.Source.HomeFile, + LogLevel: serviceLogLevel(args.Startup), } metadata := buildNativeServiceMetadata(nativeServiceManagerSystemd, args, input, unitPath) prepared, err := prepareNativeServiceMetadata(metadata) @@ -363,7 +386,7 @@ func installLaunchd(args serviceArgs, build BuildInfo, plistPath, target, domain fmt.Fprintln(os.Stderr, "[kandev] "+err.Error()) return 1 } - homeDir := serviceHomeDir(args) + homeDir, homeSource := resolveServiceHome(args) logDir := filepath.Join(homeDir, "logs") bundleDir := serviceBundleDir(self) systemUser, err := resolveAndValidateSystemIdentity(args, plistPath, nativeServiceManagerLaunchd, homeDir) @@ -372,16 +395,20 @@ func installLaunchd(args serviceArgs, build BuildInfo, plistPath, target, domain return 1 } input := nativeServiceUnitInput{ - Executable: self, - HomeDir: homeDir, - LogDir: logDir, - Port: args.Port, - System: args.System, - SystemUser: systemUser, - NoBootStart: args.NoBootStart, - BundleDir: bundleDir, - Version: serviceVersion(build.Version), - PathPrefixes: serviceNodeToolBinDirs(), + Executable: self, + HomeDir: homeDir, + LogDir: logDir, + Port: args.Port, + System: args.System, + SystemUser: systemUser, + NoBootStart: args.NoBootStart, + BundleDir: bundleDir, + Version: serviceVersion(build.Version), + PathPrefixes: serviceNodeToolBinDirs(), + ConfigFile: configFileForChild(args.Startup), + HomeDirFromConfig: homeSource == config.SourceConfiguration, + ConfigHomeFile: args.Startup != nil && args.Startup.Source.HomeFile, + LogLevel: serviceLogLevel(args.Startup), } metadata := buildNativeServiceMetadata(nativeServiceManagerLaunchd, args, input, plistPath) prepared, err := prepareNativeServiceMetadata(metadata) @@ -436,16 +463,20 @@ func installLaunchd(args serviceArgs, build BuildInfo, plistPath, target, domain } type nativeServiceUnitInput struct { - Executable string - HomeDir string - LogDir string - Port int - System bool - SystemUser string - NoBootStart bool - BundleDir string - Version string - PathPrefixes []string + Executable string + HomeDir string + LogDir string + Port int + System bool + SystemUser string + NoBootStart bool + BundleDir string + Version string + PathPrefixes []string + ConfigFile string + HomeDirFromConfig bool + ConfigHomeFile bool + LogLevel string } const ( @@ -543,8 +574,6 @@ func cleanServicePathDirs(dirs []string) []string { func renderSystemdUnit(input nativeServiceUnitInput) string { pathValue := servicePathWithPrefixes(systemdServicePath, input.PathPrefixes) env := []string{ - serviceEnvLine("KANDEV_HOME_DIR", input.HomeDir), - serviceEnvLine("KANDEV_LOG_LEVEL", "info"), serviceEnvLineAllowSpecifiers("PATH", pathValue), serviceEnvLine("KANDEV_RUNNING_AS_SERVICE", "true"), serviceEnvLine("KANDEV_SERVICE_MODE", nativeServiceMode(input.System)), @@ -552,6 +581,22 @@ func renderSystemdUnit(input nativeServiceUnitInput) string { serviceEnvLine("KANDEV_INSTALL_KIND", nativeInstallKind(input.Executable, input.BundleDir)), serviceEnvLine("KANDEV_SERVICE_METADATA", nativeServiceMetadataPath(input.HomeDir)), } + if !input.HomeDirFromConfig { + env = append([]string{serviceEnvLine("KANDEV_HOME_DIR", input.HomeDir)}, env...) + } + if input.LogLevel != "" || input.ConfigFile == "" { + level := input.LogLevel + if level == "" { + level = defaultServiceLogLevel + } + env = append([]string{serviceEnvLine("KANDEV_LOG_LEVEL", level)}, env...) + } + if input.ConfigFile != "" { + env = append(env, serviceEnvLine(config.InternalConfigFileEnv, input.ConfigFile)) + if input.ConfigHomeFile { + env = append(env, serviceEnvLine(config.InternalConfigHomeFileEnv, "1")) + } + } if input.Port != 0 { env = append(env, serviceEnvLine("KANDEV_SERVER_PORT", fmt.Sprint(input.Port))) } @@ -579,8 +624,6 @@ func renderSystemdUnit(input nativeServiceUnitInput) string { func renderLaunchdPlist(input nativeServiceUnitInput) string { pathValue := servicePathWithPrefixes(launchdServicePath, input.PathPrefixes) envEntries := [][2]string{ - {"KANDEV_HOME_DIR", input.HomeDir}, - {"KANDEV_LOG_LEVEL", "info"}, {"PATH", pathValue}, {"KANDEV_RUNNING_AS_SERVICE", "true"}, {"KANDEV_SERVICE_MODE", nativeServiceMode(input.System)}, @@ -588,6 +631,22 @@ func renderLaunchdPlist(input nativeServiceUnitInput) string { {"KANDEV_INSTALL_KIND", nativeInstallKind(input.Executable, input.BundleDir)}, {"KANDEV_SERVICE_METADATA", nativeServiceMetadataPath(input.HomeDir)}, } + if !input.HomeDirFromConfig { + envEntries = append([][2]string{{"KANDEV_HOME_DIR", input.HomeDir}}, envEntries...) + } + if input.LogLevel != "" || input.ConfigFile == "" { + level := input.LogLevel + if level == "" { + level = defaultServiceLogLevel + } + envEntries = append([][2]string{{"KANDEV_LOG_LEVEL", level}}, envEntries...) + } + if input.ConfigFile != "" { + envEntries = append(envEntries, [2]string{config.InternalConfigFileEnv, input.ConfigFile}) + if input.ConfigHomeFile { + envEntries = append(envEntries, [2]string{config.InternalConfigHomeFileEnv, "1"}) + } + } if input.Port != 0 { envEntries = append(envEntries, [2]string{"KANDEV_SERVER_PORT", fmt.Sprint(input.Port)}) } @@ -741,13 +800,41 @@ func systemctlScope(system bool) []string { } func serviceHomeDir(args serviceArgs) string { - if args.HomeDir != "" { - return args.HomeDir + dir, _ := resolveServiceHome(args) + return dir +} + +func resolveServiceHome(args serviceArgs) (string, config.SettingSource) { + if strings.TrimSpace(args.HomeDir) != "" { + return args.HomeDir, config.SourceFlag + } + if args.Startup != nil { + source := args.Startup.SourceFor("homeDir") + switch source { + case config.SourceConfiguration, config.SourceEnvironment, config.SourceProfile: + return args.Startup.ResolvedHomeDir(), source + } + } + if raw := strings.TrimSpace(os.Getenv("KANDEV_HOME_DIR")); raw != "" { + return expandHome(raw), config.SourceEnvironment } if args.System { - return "/var/lib/kandev" + return defaultSystemServiceHomeDir, config.SourceDefault } - return resolveHomeDir() + return resolveHomeDir(), config.SourceDefault +} + +func serviceLogLevel(cfg *config.Config) string { + if cfg == nil { + return defaultServiceLogLevel + } + if cfg.Source.FilePath != "" && cfg.SourceFor("logging.level") == config.SourceConfiguration { + return "" + } + if cfg.Logging.Level != "" { + return cfg.Logging.Level + } + return defaultServiceLogLevel } func printServiceConfig(args serviceArgs) { diff --git a/apps/backend/internal/launcher/service_configuration_test.go b/apps/backend/internal/launcher/service_configuration_test.go new file mode 100644 index 0000000000..27d40bc678 --- /dev/null +++ b/apps/backend/internal/launcher/service_configuration_test.go @@ -0,0 +1,287 @@ +package launcher + +import ( + "errors" + "os" + "os/user" + "path/filepath" + "runtime" + "strings" + "testing" +) + +func TestServiceBootstrapHomeResolutionPrecedence(t *testing.T) { + flagHome := filepath.Join(t.TempDir(), "flag-home") + envHome := filepath.Join(t.TempDir(), "env-home") + + tests := []struct { + name string + args serviceArgs + env string + want string + }{ + {name: "flag", args: serviceArgs{HomeDir: flagHome}, env: envHome, want: flagHome}, + {name: "environment", args: serviceArgs{}, env: envHome, want: envHome}, + {name: "system default", args: serviceArgs{System: true}, want: "/var/lib/kandev"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Setenv("KANDEV_HOME_DIR", tt.env) + if got := serviceBootstrapHomeDir(tt.args); got != tt.want { + t.Fatalf("serviceBootstrapHomeDir() = %q, want %q", got, tt.want) + } + }) + } +} + +func TestInstallSystemdDiscoversFlagHomeConfiguration(t *testing.T) { + t.Setenv("KANDEV_HOME_DIR", "") + tmp := t.TempDir() + if err := os.MkdirAll(filepath.Join(tmp, "working"), 0o700); err != nil { + t.Fatal(err) + } + t.Chdir(filepath.Join(tmp, "working")) + homeDir := filepath.Join(tmp, "flag-home") + configPath := filepath.Join(homeDir, "config.yaml") + writeServiceConfiguration(t, configPath, "server:\n port: 40131\n") + + startup, err := loadServiceBootstrapConfig(serviceArgs{HomeDir: homeDir}) + if err != nil { + t.Fatalf("loadServiceBootstrapConfig() = %v", err) + } + if startup.Source.FilePath != configPath { + t.Fatalf("selected config = %q, want %q", startup.Source.FilePath, configPath) + } + + restoreServiceInstallHooks(t) + executablePath = func() (string, error) { return "/opt/kandev/bin/kandev", nil } + executeServiceCommand = func(string, ...string) error { return nil } + servicePrintln = func(string) {} + + unitPath := filepath.Join(tmp, "kandev.service") + code := installSystemd(serviceArgs{ + Action: actionInstall, + HomeDir: homeDir, + NoBootStart: true, + Startup: startup, + }, BuildInfo{Version: "test"}, unitPath) + if code != 0 { + t.Fatalf("installSystemd() = %d, want 0", code) + } + unit, err := os.ReadFile(unitPath) + if err != nil { + t.Fatal(err) + } + unitText := string(unit) + if !strings.Contains(unitText, "Environment=KANDEV_INTERNAL_CONFIG_FILE="+configPath) { + t.Fatalf("unit did not pin the flag-selected config file:\n%s", unitText) + } +} + +func TestInstallSystemdDiscoversSystemHomeConfiguration(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("system service installation is unsupported on Windows") + } + t.Setenv("KANDEV_HOME_DIR", "") + tmp := t.TempDir() + workingDir := filepath.Join(tmp, "working") + if err := os.MkdirAll(workingDir, 0o700); err != nil { + t.Fatal(err) + } + t.Chdir(workingDir) + homeDir := filepath.Join(tmp, "system-home") + configPath := filepath.Join(homeDir, "config.yaml") + writeServiceConfiguration(t, configPath, "server:\n port: 40132\n") + currentUser, err := user.Current() + if err != nil { + t.Fatal(err) + } + + startup, err := loadServiceBootstrapConfig(serviceArgs{System: true, HomeDir: homeDir}) + if err != nil { + t.Fatalf("loadServiceBootstrapConfig() = %v", err) + } + if startup.Source.FilePath != configPath { + t.Fatalf("selected config = %q, want %q", startup.Source.FilePath, configPath) + } + + restoreServiceInstallHooks(t) + executablePath = func() (string, error) { return "/opt/kandev/bin/kandev", nil } + executeServiceCommand = func(string, ...string) error { return nil } + servicePrintln = func(string) {} + + unitPath := filepath.Join(tmp, "kandev.service") + code := installSystemd(serviceArgs{ + Action: actionInstall, + System: true, + RunAs: currentUser.Username, + HomeDir: homeDir, + NoBootStart: true, + Startup: startup, + }, BuildInfo{Version: "test"}, unitPath) + if code != 0 { + t.Fatalf("installSystemd() = %d, want 0", code) + } + unit, err := os.ReadFile(unitPath) + if err != nil { + t.Fatal(err) + } + unitText := string(unit) + if !strings.Contains(unitText, "Environment=KANDEV_INTERNAL_CONFIG_FILE="+configPath) { + t.Fatalf("system unit did not pin the system-selected config file:\n%s", unitText) + } + if !strings.Contains(unitText, "Environment=KANDEV_SERVICE_MODE=system") { + t.Fatalf("system unit did not retain system mode:\n%s", unitText) + } +} + +func TestServiceInstallHomeSourceWinsOverYAMLForBothManagers(t *testing.T) { + tests := []struct { + name string + kind string + }{ + {name: "systemd", kind: nativeServiceManagerSystemd}, + {name: "launchd", kind: nativeServiceManagerLaunchd}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Setenv("KANDEV_HOME_DIR", "") + tmp := t.TempDir() + workingDir := filepath.Join(tmp, "working") + if err := os.MkdirAll(workingDir, 0o700); err != nil { + t.Fatal(err) + } + t.Chdir(workingDir) + writeServiceConfiguration(t, filepath.Join(workingDir, "config.yaml"), "homeDir: /yaml-home\n") + flagHome := filepath.Join(tmp, "flag-home") + startup, err := loadServiceBootstrapConfig(serviceArgs{HomeDir: flagHome}) + if err != nil { + t.Fatalf("loadServiceBootstrapConfig() = %v", err) + } + + restoreServiceInstallHooks(t) + executablePath = func() (string, error) { return "/opt/kandev/bin/kandev", nil } + executeServiceCommand = func(name string, args ...string) error { + if tt.kind == nativeServiceManagerLaunchd && name == "launchctl" && len(args) > 0 && args[0] == "print" { + return errors.New("not loaded") + } + return nil + } + servicePrintln = func(string) {} + + output := installServiceUnitForTest(t, tt.kind, tmp, serviceArgs{Action: actionInstall, HomeDir: flagHome, NoBootStart: true, Startup: startup}) + if !strings.Contains(output, expectedServiceHomeEntry(tt.kind, flagHome)) { + t.Fatalf("%s service definition omitted flag home:\n%s", tt.name, output) + } + if strings.Contains(output, "yaml-home") { + t.Fatalf("service metadata used YAML home despite the flag:\n%s", output) + } + }) + } +} + +func TestServiceInstallEnvironmentHomeWinsOverYAMLForBothManagers(t *testing.T) { + tests := []struct { + name string + kind string + }{ + {name: "systemd", kind: nativeServiceManagerSystemd}, + {name: "launchd", kind: nativeServiceManagerLaunchd}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tmp := t.TempDir() + workingDir := filepath.Join(tmp, "working") + if err := os.MkdirAll(workingDir, 0o700); err != nil { + t.Fatal(err) + } + t.Chdir(workingDir) + envHome := filepath.Join(tmp, "env-home") + t.Setenv("KANDEV_HOME_DIR", envHome) + writeServiceConfiguration(t, filepath.Join(workingDir, "config.yaml"), "homeDir: /yaml-home\n") + startup, err := loadServiceBootstrapConfig(serviceArgs{}) + if err != nil { + t.Fatalf("loadServiceBootstrapConfig() = %v", err) + } + + restoreServiceInstallHooks(t) + executablePath = func() (string, error) { return "/opt/kandev/bin/kandev", nil } + executeServiceCommand = func(name string, args ...string) error { + if tt.kind == nativeServiceManagerLaunchd && name == "launchctl" && len(args) > 0 && args[0] == "print" { + return errors.New("not loaded") + } + return nil + } + servicePrintln = func(string) {} + + output := installServiceUnitForTest(t, tt.kind, tmp, serviceArgs{Action: actionInstall, NoBootStart: true, Startup: startup}) + if !strings.Contains(output, expectedServiceHomeEntry(tt.kind, envHome)) { + t.Fatalf("%s service definition omitted environment home:\n%s", tt.name, output) + } + if strings.Contains(output, "yaml-home") { + t.Fatalf("service metadata used YAML home despite the environment:\n%s", output) + } + }) + } +} + +func installServiceUnitForTest(t *testing.T, kind, tmp string, args serviceArgs) string { + t.Helper() + switch kind { + case nativeServiceManagerSystemd: + path := filepath.Join(tmp, "kandev.service") + if code := installSystemd(args, BuildInfo{}, path); code != 0 { + t.Fatalf("installSystemd() = %d, want 0", code) + } + data, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + return string(data) + case nativeServiceManagerLaunchd: + path := filepath.Join(tmp, "com.kdlbs.kandev.plist") + if code := installLaunchd(args, BuildInfo{}, path, "gui/501/com.kdlbs.kandev", "gui/501"); code != 0 { + t.Fatalf("installLaunchd() = %d, want 0", code) + } + data, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + return string(data) + default: + t.Fatalf("unsupported test service manager %q", kind) + return "" + } +} + +func expectedServiceHomeEntry(kind, homeDir string) string { + if kind == nativeServiceManagerSystemd { + return "Environment=KANDEV_HOME_DIR=" + homeDir + } + return "KANDEV_HOME_DIR\n " + homeDir + "" +} + +func restoreServiceInstallHooks(t *testing.T) { + t.Helper() + originalExecutablePath := executablePath + originalExecuteServiceCommand := executeServiceCommand + originalServicePrintln := servicePrintln + t.Cleanup(func() { + executablePath = originalExecutablePath + executeServiceCommand = originalExecuteServiceCommand + servicePrintln = originalServicePrintln + }) +} + +func writeServiceConfiguration(t *testing.T, path, contents string) { + t.Helper() + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte(contents), 0o600); err != nil { + t.Fatalf("write config: %v", err) + } +} diff --git a/apps/backend/internal/launcher/service_test.go b/apps/backend/internal/launcher/service_test.go index cbc8cf2d44..b0a5076468 100644 --- a/apps/backend/internal/launcher/service_test.go +++ b/apps/backend/internal/launcher/service_test.go @@ -8,6 +8,8 @@ import ( "slices" "strings" "testing" + + commonconfig "github.com/kandev/kandev/internal/common/config" ) func TestRenderSystemdUnitExecsNativeKandev(t *testing.T) { @@ -28,6 +30,29 @@ func TestRenderSystemdUnitExecsNativeKandev(t *testing.T) { } } +func TestRenderSystemdUnitPinsSelectedConfigWithoutOverridingYAML(t *testing.T) { + unit := renderSystemdUnit(nativeServiceUnitInput{ + Executable: "/opt/kandev/bin/kandev", + HomeDir: "/srv/kandev", + LogDir: "/srv/kandev/logs", + ConfigFile: "/etc/kandev/config.yaml", + HomeDirFromConfig: true, + ConfigHomeFile: true, + }) + if !strings.Contains(unit, "Environment=KANDEV_INTERNAL_CONFIG_FILE=/etc/kandev/config.yaml") { + t.Fatalf("unit missing selected config handoff:\n%s", unit) + } + if !strings.Contains(unit, "Environment="+commonconfig.InternalConfigHomeFileEnv+"=1") { + t.Fatalf("unit missing home-config handoff:\n%s", unit) + } + if strings.Contains(unit, "Environment=KANDEV_HOME_DIR=") { + t.Fatalf("unit copied YAML homeDir into public environment:\n%s", unit) + } + if strings.Contains(unit, "Environment=KANDEV_LOG_LEVEL=info") { + t.Fatalf("unit replaced YAML logging.level with the built-in default:\n%s", unit) + } +} + func TestRenderSystemdUnitIncludesBundleMetadata(t *testing.T) { unit := renderSystemdUnit(nativeServiceUnitInput{ Executable: "/opt/kandev/bin/kandev", diff --git a/apps/backend/internal/launcher/start.go b/apps/backend/internal/launcher/start.go index c4b2760d41..eb7e38fa2e 100644 --- a/apps/backend/internal/launcher/start.go +++ b/apps/backend/internal/launcher/start.go @@ -7,6 +7,8 @@ import ( "os/exec" "path/filepath" "runtime" + + "github.com/kandev/kandev/internal/common/config" ) var ( @@ -26,7 +28,12 @@ var ( ) func runStart(ctx context.Context, opts Options) int { - backendPort, err := resolvePorts(opts) + startupConfig, err := loadBootstrapConfig() + if err != nil { + fmt.Fprintln(os.Stderr, "[kandev] "+err.Error()) + return 1 + } + backendPort, err := resolvePorts(opts, startupConfig) if err != nil { fmt.Fprintln(os.Stderr, "[kandev] "+err.Error()) return 2 @@ -36,12 +43,12 @@ func runStart(ctx context.Context, opts Options) int { fmt.Fprintln(os.Stderr, "[kandev] "+err.Error()) return 1 } - if err := ensureDataDir(); err != nil { + if err := ensureDataDirForConfig(startupConfig); err != nil { fmt.Fprintln(os.Stderr, "[kandev] "+err.Error()) return 1 } - logLevel := resolveLogLevel(opts) + logLevel := resolveLogLevelForConfig(opts, startupConfig) self, err := executablePath() if err != nil { @@ -56,6 +63,7 @@ func runStart(ctx context.Context, opts Options) int { Ports: ports, LogLevel: logLevel, Opts: opts, + Startup: startupConfig, }) } @@ -67,12 +75,23 @@ type managedAppConfig struct { Ports portConfig LogLevel string Opts Options + Startup *config.Config } -func resolveLogLevel(opts Options) string { +func resolveLogLevel(opts Options, configs ...*config.Config) string { + if len(configs) > 0 { + return resolveLogLevelForConfig(opts, configs[0]) + } + return resolveLogLevelForConfig(opts, nil) +} + +func resolveLogLevelForConfig(opts Options, cfg *config.Config) string { if logLevel := os.Getenv("KANDEV_LOG_LEVEL"); logLevel != "" { return logLevel } + if configSourceIsExplicit(cfg, "logging.level") && cfg.Logging.Level != "" { + return cfg.Logging.Level + } switch { case opts.Debug: return "debug" @@ -90,7 +109,7 @@ func resolveConsoleLogLevel(opts Options) string { func runManagedApp(ctx context.Context, cfg managedAppConfig) int { ignoreBrokenPipeSignal() - logStartup(cfg.Header, cfg.Ports, resolveDatabasePath(), cfg.LogLevel) + logStartup(cfg.Header, cfg.Ports, resolveDatabasePathForConfig(cfg.Startup), cfg.LogLevel, serverHostForConfig(cfg.Startup)) setLauncherShutdownDebug(cfg.Opts.Debug || os.Getenv("KANDEV_SHUTDOWN_DEBUG") == "1") shutdownDebugf("runManagedApp start mode=%q backend=%q backend_cwd=%q debug=%t", cfg.Mode, cfg.Backend, cfg.BackendCWD, cfg.Opts.Debug) @@ -106,7 +125,7 @@ func runManagedApp(ctx context.Context, cfg managedAppConfig) int { fmt.Fprintln(os.Stderr, "[kandev] "+err.Error()) return 1 } - env := backendEnv(cfg.Ports, cfg.LogLevel, resolveConsoleLogLevel(cfg.Opts), cfg.Opts.Debug, healthToken, nil) + env := backendEnvForConfig(cfg.Ports, cfg.LogLevel, resolveConsoleLogLevel(cfg.Opts), cfg.Opts.Debug, healthToken, nil, cfg.Startup) backend, dumpLogs, err := launchBackendFn(backendLaunchConfig{ Command: cfg.Backend, Args: []string{"__backend"}, @@ -115,7 +134,7 @@ func runManagedApp(ctx context.Context, cfg managedAppConfig) int { Quiet: false, Ports: cfg.Ports, Mode: cfg.Mode, - HomeDir: resolveHomeDir(), + HomeDir: resolveHomeDirForConfig(cfg.Startup), Supervisor: supervisor, }) if err != nil { @@ -124,14 +143,14 @@ func runManagedApp(ctx context.Context, cfg managedAppConfig) int { } shutdownDebugf("runManagedApp backend launched") fmt.Println("[kandev] starting backend...") - if err := waitForHealthFn(ctx, cfg.Ports.BackendURL, backend, healthTimeout(healthTimeoutReleaseMS), healthToken, dumpLogs); err != nil { + if err := waitForHealthFn(ctx, cfg.Ports.BackendURL, backend, healthTimeoutForConfig(healthTimeoutReleaseMS, cfg.Startup), healthToken, dumpLogs); err != nil { supervisor.shutdown("backend health failure") fmt.Fprintln(os.Stderr, "[kandev] "+err.Error()) return 1 } fmt.Printf("[kandev] backend ready at %s\n", cfg.Ports.BackendURL) - if cfg.Opts.Headless { + if !shouldOpenBrowser(cfg.Opts, cfg.Startup) { fmt.Printf("[kandev] ready (headless) at %s\n", cfg.Ports.BackendURL) return waitForAppExit(supervisor, backend) } @@ -140,10 +159,14 @@ func runManagedApp(ctx context.Context, cfg managedAppConfig) int { return waitForAppExit(supervisor, backend) } -func logStartup(header string, ports portConfig, dbPath, logLevel string) { +func logStartup(header string, ports portConfig, dbPath, logLevel string, serverHosts ...string) { fmt.Println("[kandev] " + header) fmt.Println("[kandev] url:", ports.BackendURL) - hosts := networkAddressesForBindHost(listHostNetworkAddresses(), os.Getenv("KANDEV_SERVER_HOST")) + serverHost := os.Getenv("KANDEV_SERVER_HOST") + if len(serverHosts) > 0 && serverHosts[0] != "" { + serverHost = serverHosts[0] + } + hosts := networkAddressesForBindHost(listHostNetworkAddresses(), serverHost) for _, url := range networkURLsForPort(ports.BackendPort, hosts) { fmt.Println("[kandev] network:", url) } @@ -172,6 +195,23 @@ func openBrowser(url string) { _ = cmd.Start() } +func serverHostForConfig(cfg *config.Config) string { + if cfg != nil && configSourceIsExplicit(cfg, "server.host") { + return cfg.Server.Host + } + return os.Getenv("KANDEV_SERVER_HOST") +} + +func shouldOpenBrowser(opts Options, cfg *config.Config) bool { + if opts.Headless { + return false + } + if cfg != nil { + return !cfg.Launcher.NoBrowser + } + return os.Getenv("KANDEV_NO_BROWSER") != "1" +} + func exists(path string) bool { _, err := os.Stat(path) return err == nil diff --git a/apps/backend/internal/office/service/scheduler_integration.go b/apps/backend/internal/office/service/scheduler_integration.go index 83b0195603..218012ea97 100644 --- a/apps/backend/internal/office/service/scheduler_integration.go +++ b/apps/backend/internal/office/service/scheduler_integration.go @@ -4,8 +4,6 @@ import ( "context" "encoding/json" "fmt" - "os" - "strconv" "time" "go.uber.org/zap" @@ -23,19 +21,20 @@ const DefaultTickInterval = 5 * time.Second // if no agent lifecycle event returned it to a terminal queue state. const staleClaimedRunAge = 30 * time.Minute -// TickIntervalFromEnv reads KANDEV_OFFICE_SCHEDULER_TICK_MS and returns the -// corresponding duration. Falls back to DefaultTickInterval when the variable -// is unset or invalid. +// TickIntervalFromConfig converts the resolved typed millisecond setting into +// the office scheduler duration. Invalid values retain the safe default. +func TickIntervalFromConfig(milliseconds int) time.Duration { + if milliseconds <= 0 { + return DefaultTickInterval + } + return time.Duration(milliseconds) * time.Millisecond +} + +// TickIntervalFromEnv remains as a compatibility helper for package callers. +// Backend startup resolves the setting through common/config and passes the +// result to NewSchedulerIntegration. func TickIntervalFromEnv() time.Duration { - raw := os.Getenv("KANDEV_OFFICE_SCHEDULER_TICK_MS") - if raw == "" { - return DefaultTickInterval - } - ms, err := strconv.Atoi(raw) - if err != nil || ms <= 0 { - return DefaultTickInterval - } - return time.Duration(ms) * time.Millisecond + return DefaultTickInterval } // SchedulerIntegration runs the run processing tick loop. diff --git a/apps/backend/internal/profiles/profiles.go b/apps/backend/internal/profiles/profiles.go index e171994f34..b6c647cf3a 100644 --- a/apps/backend/internal/profiles/profiles.go +++ b/apps/backend/internal/profiles/profiles.go @@ -94,35 +94,52 @@ type profilesFile map[string]map[string]map[string]string // already set. func ApplyProfile() (count int, env Environment, err error) { env = DetectEnvironment() - file, err := parse(profilesYAML) + defaults, err := EnvironmentDefaults() if err != nil { return 0, env, err } - for _, vars := range file { - for name, perProfile := range vars { - value := resolve(perProfile, env) - if value == "" { - // Empty means "leave unset". Skip even if the var is - // also currently unset — we don't want to introduce a - // noisy empty-string entry that os.LookupEnv would - // then report as set. - continue - } - if _, alreadySet := os.LookupEnv(name); alreadySet { - continue - } - if err := os.Setenv(name, value); err != nil { - return count, env, fmt.Errorf("setenv %q: %w", name, err) - } - appliedEnvVars.Lock() - appliedEnvVars.names[name] = true - appliedEnvVars.Unlock() - count++ + for name, value := range defaults { + if value == "" { + // Empty means "leave unset". Skip even if the var is + // also currently unset — we don't want to introduce a + // noisy empty-string entry that os.LookupEnv would + // then report as set. + continue } + if _, alreadySet := os.LookupEnv(name); alreadySet { + continue + } + if err := os.Setenv(name, value); err != nil { + return count, env, fmt.Errorf("setenv %q: %w", name, err) + } + appliedEnvVars.Lock() + appliedEnvVars.names[name] = true + appliedEnvVars.Unlock() + count++ } return count, env, nil } +// EnvironmentDefaults returns the resolved profile values without mutating the +// process environment. Startup configuration uses this projection as typed +// defaults so a selected config.yaml can override a profile value. Callers +// that need the historical child-process environment should still call +// ApplyProfile. +func EnvironmentDefaults() (map[string]string, error) { + file, err := parse(profilesYAML) + if err != nil { + return nil, err + } + env := DetectEnvironment() + out := make(map[string]string) + for _, vars := range file { + for name, perProfile := range vars { + out[name] = resolve(perProfile, env) + } + } + return out, nil +} + // WasApplied reports whether ApplyProfile wrote name into the process // environment. Callers use this to distinguish a profile-supplied default from // a true launcher/shell env var, because DB-backed runtime overrides should diff --git a/apps/backend/internal/profiles/profiles_test.go b/apps/backend/internal/profiles/profiles_test.go index bf7cf9313d..db7071807d 100644 --- a/apps/backend/internal/profiles/profiles_test.go +++ b/apps/backend/internal/profiles/profiles_test.go @@ -54,6 +54,37 @@ func TestApplyProfile_DefaultsToProd(t *testing.T) { } } +func TestEnvironmentDefaultsSelectsProfileWithoutMutatingEnvironment(t *testing.T) { + clearProfileSelectors(t) + clearProfilesYAMLVars(t) + t.Setenv("KANDEV_FEATURES_OFFICE", "explicit") + + prodBefore, prodPresent := os.LookupEnv("KANDEV_FEATURES_OFFICE") + prod, err := EnvironmentDefaults() + if err != nil { + t.Fatalf("prod EnvironmentDefaults: %v", err) + } + if prod["KANDEV_FEATURES_OFFICE"] != "false" { + t.Fatalf("prod office default = %q, want false", prod["KANDEV_FEATURES_OFFICE"]) + } + prodAfter, prodAfterPresent := os.LookupEnv("KANDEV_FEATURES_OFFICE") + if prodPresent != prodAfterPresent || prodBefore != prodAfter { + t.Fatalf("prod EnvironmentDefaults mutated KANDEV_FEATURES_OFFICE: before=%q/%v after=%q/%v", prodBefore, prodPresent, prodAfter, prodAfterPresent) + } + + t.Setenv("KANDEV_E2E_MOCK", "true") + e2e, err := EnvironmentDefaults() + if err != nil { + t.Fatalf("e2e EnvironmentDefaults: %v", err) + } + if e2e["KANDEV_FEATURES_OFFICE"] != "true" { + t.Fatalf("e2e office default = %q, want true", e2e["KANDEV_FEATURES_OFFICE"]) + } + if e2e["KANDEV_PLAN_COALESCE_WINDOW_MS"] != "2000" { + t.Fatalf("e2e plan coalesce default = %q, want 2000", e2e["KANDEV_PLAN_COALESCE_WINDOW_MS"]) + } +} + // TestApplyProfile_DevUsesDevelopmentDefaults verifies the mixed dev profile. func TestApplyProfile_DevUsesDevelopmentDefaults(t *testing.T) { clearProfileSelectors(t) diff --git a/apps/backend/internal/runs/scheduler/scheduler.go b/apps/backend/internal/runs/scheduler/scheduler.go index 1f443fd4c0..137ec354ca 100644 --- a/apps/backend/internal/runs/scheduler/scheduler.go +++ b/apps/backend/internal/runs/scheduler/scheduler.go @@ -17,8 +17,6 @@ package scheduler import ( "context" - "os" - "strconv" "sync" "time" @@ -42,21 +40,20 @@ const DefaultTickInterval = 5 * time.Second // has elapsed. const SignalCoalesceWindow = 10 * time.Millisecond -// TickIntervalFromEnv reads KANDEV_OFFICE_SCHEDULER_TICK_MS and returns -// the corresponding duration. Falls back to DefaultTickInterval when -// the variable is unset or invalid. The env var name keeps the -// "office" prefix for backward compatibility with existing -// deployments. +// TickIntervalFromConfig converts the resolved typed millisecond setting into +// the scheduler duration. Invalid values retain the safe default. +func TickIntervalFromConfig(milliseconds int) time.Duration { + if milliseconds <= 0 { + return DefaultTickInterval + } + return time.Duration(milliseconds) * time.Millisecond +} + +// TickIntervalFromEnv remains as a compatibility helper for package callers. +// Backend startup resolves the setting through common/config and passes the +// result to New, so this helper no longer performs a package-local env read. func TickIntervalFromEnv() time.Duration { - raw := os.Getenv("KANDEV_OFFICE_SCHEDULER_TICK_MS") - if raw == "" { - return DefaultTickInterval - } - ms, err := strconv.Atoi(raw) - if err != nil || ms <= 0 { - return DefaultTickInterval - } - return time.Duration(ms) * time.Millisecond + return DefaultTickInterval } // RunProcessor performs one claim-and-dispatch pass. The scheduler diff --git a/apps/backend/internal/system/queuesettings/handler.go b/apps/backend/internal/system/queuesettings/handler.go index a076c36239..315670d805 100644 --- a/apps/backend/internal/system/queuesettings/handler.go +++ b/apps/backend/internal/system/queuesettings/handler.go @@ -42,6 +42,8 @@ func handleUpdate(service *Service) gin.HandlerFunc { ctx.JSON(http.StatusBadRequest, gin.H{responseErrorKey: err.Error()}) case errors.Is(err, ErrEnvironmentLocked): ctx.JSON(http.StatusConflict, gin.H{responseErrorKey: err.Error()}) + case errors.Is(err, ErrConfigurationLocked): + ctx.JSON(http.StatusConflict, gin.H{responseErrorKey: err.Error()}) default: ctx.JSON(http.StatusInternalServerError, gin.H{responseErrorKey: "failed to save message queue settings"}) } diff --git a/apps/backend/internal/system/queuesettings/queuesettings_test.go b/apps/backend/internal/system/queuesettings/queuesettings_test.go index 05755f6bfa..db26774ee8 100644 --- a/apps/backend/internal/system/queuesettings/queuesettings_test.go +++ b/apps/backend/internal/system/queuesettings/queuesettings_test.go @@ -59,6 +59,29 @@ func TestResolveRejectsNegativePersistedValue(t *testing.T) { } } +func TestResolveConfigurationPrecedence(t *testing.T) { + configured := &Settings{MaxPerSession: 6, MergeEnabled: true, AutoMergeEnabled: true} + startup := Configuration{Value: 14, Present: true} + + resolution, err := Resolve(configured, Environment{}, startup) + if err != nil { + t.Fatalf("resolve configuration: %v", err) + } + if resolution.Effective.MaxPerSession != 14 || + resolution.Effective.Source != SourceConfiguration || + !resolution.Effective.Locked { + t.Fatalf("configuration resolution = %+v, want locked configuration value", resolution.Effective) + } + + resolution, err = Resolve(configured, Environment{Value: "20", Present: true}, startup) + if err != nil { + t.Fatalf("resolve environment over configuration: %v", err) + } + if resolution.Effective.MaxPerSession != 20 || resolution.Effective.Source != SourceEnvironment { + t.Fatalf("environment resolution = %+v, want environment value", resolution.Effective) + } +} + func TestStoreRoundTripAndValidation(t *testing.T) { raw := &fakeRawStore{} store := NewStore(raw) @@ -311,6 +334,32 @@ func TestServiceEnvironmentLockRejectsUpdate(t *testing.T) { } } +func TestServiceConfigurationLockRejectsUpdate(t *testing.T) { + raw := &fakeRawStore{} + target := &fakeTarget{max: 14} + service := NewService( + NewStore(raw), target, func() Environment { return Environment{} }, testLogger(t), + Configuration{Value: 14, Present: true}, + ) + + response, err := service.Get(context.Background()) + if err != nil { + t.Fatalf("get: %v", err) + } + if response.Effective.Source != SourceConfiguration || !response.Effective.Locked || + response.Effective.MaxPerSession != 14 { + t.Fatalf("configuration response = %+v, want locked configuration source", response) + } + + _, err = service.Update(context.Background(), SettingsPatch{MaxPerSession: new(4)}) + if !errors.Is(err, ErrConfigurationLocked) { + t.Fatalf("update error = %v, want configuration lock", err) + } + if raw.saveCalls != 0 || target.max != 14 { + t.Fatalf("locked configuration update mutated state: saves=%d target=%d", raw.saveCalls, target.max) + } +} + // TestServiceEnvironmentLockAllowsMergeOnlyUpdate asserts the environment // lock only blocks a patch that touches max_per_session — merge_enabled has // no environment override and must stay editable. diff --git a/apps/backend/internal/system/queuesettings/resolver.go b/apps/backend/internal/system/queuesettings/resolver.go index f2794ecfc9..23a0b51f94 100644 --- a/apps/backend/internal/system/queuesettings/resolver.go +++ b/apps/backend/internal/system/queuesettings/resolver.go @@ -1,16 +1,26 @@ package queuesettings import ( + "fmt" "strconv" "strings" ) -// Resolve combines persisted configuration with an optional environment -// override. A malformed environment value is reported but ignored so the -// persisted/default setting remains effective. -func Resolve(configured *Settings, environment Environment) (Resolution, error) { +// Resolve combines persisted settings with startup configuration and an +// optional environment override. A malformed environment value is reported +// but ignored so the configuration/persisted/default setting remains +// effective. Environment takes precedence over startup configuration, which +// takes precedence over the persisted setting. +func Resolve(configured *Settings, environment Environment, startup ...Configuration) (Resolution, error) { settings := DefaultSettings() source := SourceDefault + var configuration Configuration + if len(startup) > 0 { + configuration = startup[0] + } + if configuration.Present && configuration.Value < 0 { + return Resolution{}, fmt.Errorf("%w: max_per_session must be zero or greater", ErrValidation) + } if configured != nil { if err := Validate(*configured); err != nil { return Resolution{}, err @@ -29,12 +39,12 @@ func Resolve(configured *Settings, environment Environment) (Resolution, error) }} raw := strings.TrimSpace(environment.Value) if !environment.Present || raw == "" { - return resolution, nil + return resolveConfiguration(resolution, configuration), nil } value, err := strconv.Atoi(raw) if err != nil { resolution.InvalidEnvironment = true - return resolution, nil + return resolveConfiguration(resolution, configuration), nil } if value <= 0 { value = 0 @@ -48,3 +58,17 @@ func Resolve(configured *Settings, environment Environment) (Resolution, error) } return resolution, nil } + +func resolveConfiguration(resolution Resolution, configuration Configuration) Resolution { + if !configuration.Present { + return resolution + } + resolution.Effective = Effective{ + MaxPerSession: configuration.Value, + Source: SourceConfiguration, + Locked: true, + MergeEnabled: resolution.Settings.MergeEnabled, + AutoMergeEnabled: resolution.Settings.AutoMergeEnabled, + } + return resolution +} diff --git a/apps/backend/internal/system/queuesettings/service.go b/apps/backend/internal/system/queuesettings/service.go index 2ba61f2837..c5967a2966 100644 --- a/apps/backend/internal/system/queuesettings/service.go +++ b/apps/backend/internal/system/queuesettings/service.go @@ -26,14 +26,28 @@ type Service struct { store *Store target Target readEnvironment EnvironmentReader + configuration Configuration logger *logger.Logger } -func NewService(store *Store, target Target, readEnvironment EnvironmentReader, log *logger.Logger) *Service { +func NewService( + store *Store, + target Target, + readEnvironment EnvironmentReader, + log *logger.Logger, + startup ...Configuration, +) *Service { if readEnvironment == nil { readEnvironment = ReadEnvironment } - return &Service{store: store, target: target, readEnvironment: readEnvironment, logger: log} + var configuration Configuration + if len(startup) > 0 { + configuration = startup[0] + } + return &Service{ + store: store, target: target, readEnvironment: readEnvironment, + configuration: configuration, logger: log, + } } func ReadEnvironment() Environment { @@ -49,7 +63,7 @@ func (s *Service) Get(ctx context.Context) (Response, error) { if err != nil { return Response{}, err } - resolution, err := Resolve(configured, s.readEnvironment()) + resolution, err := Resolve(configured, s.readEnvironment(), s.configuration) if err != nil { return Response{}, err } @@ -71,12 +85,15 @@ func (s *Service) Update(ctx context.Context, patch SettingsPatch) (Response, er if err != nil { return Response{}, err } - resolution, err := Resolve(current, environment) + resolution, err := Resolve(current, environment, s.configuration) if err != nil { return Response{}, err } s.warnInvalidEnvironment(resolution) if patch.MaxPerSession != nil && resolution.Effective.Locked { + if resolution.Effective.Source == SourceConfiguration { + return Response{}, ErrConfigurationLocked + } return Response{}, ErrEnvironmentLocked } settings := patch.Apply(resolution.Settings) @@ -89,7 +106,7 @@ func (s *Service) Update(ctx context.Context, patch SettingsPatch) (Response, er if err := s.store.Save(ctx, settings); err != nil { return Response{}, err } - updated, err := Resolve(&settings, environment) + updated, err := Resolve(&settings, environment, s.configuration) if err != nil { return Response{}, err } diff --git a/apps/backend/internal/system/queuesettings/types.go b/apps/backend/internal/system/queuesettings/types.go index 2764c93786..a59f713af0 100644 --- a/apps/backend/internal/system/queuesettings/types.go +++ b/apps/backend/internal/system/queuesettings/types.go @@ -16,16 +16,18 @@ const ( type Source string const ( - SourceDefault Source = "default" - SourceSetting Source = "setting" - SourceEnvironment Source = "environment" + SourceDefault Source = "default" + SourceSetting Source = "setting" + SourceConfiguration Source = "configuration" + SourceEnvironment Source = "environment" ) var ( - ErrValidation = errors.New("message queue settings validation") - ErrInvalidPersisted = errors.New("invalid persisted message queue settings") - ErrEnvironmentLocked = errors.New("message queue capacity is controlled by the environment") - ErrTargetUnavailable = errors.New("message queue live target is unavailable") + ErrValidation = errors.New("message queue settings validation") + ErrInvalidPersisted = errors.New("invalid persisted message queue settings") + ErrEnvironmentLocked = errors.New("message queue capacity is controlled by the environment") + ErrConfigurationLocked = errors.New("message queue capacity is controlled by configuration") + ErrTargetUnavailable = errors.New("message queue live target is unavailable") ) type Settings struct { @@ -81,6 +83,13 @@ type Environment struct { Present bool } +// Configuration is the startup value for max_per_session. Present is kept +// separate from Value because zero is a valid, explicit unlimited setting. +type Configuration struct { + Value int + Present bool +} + type Resolution struct { Response InvalidEnvironment bool diff --git a/apps/backend/internal/system/system.go b/apps/backend/internal/system/system.go index fa5377788a..7dd433fa42 100644 --- a/apps/backend/internal/system/system.go +++ b/apps/backend/internal/system/system.go @@ -59,6 +59,7 @@ type Wiring struct { OrchestratorShutdown func() RestoreQuiesce func() error MessageQueue queuesettings.Target + MessageQueueConfig queuesettings.Configuration TaskSessions sleepinhibition.SessionReader } @@ -125,6 +126,7 @@ func Provide(cfg *config.Config, log *logger.Logger, pool *db.Pool, eventBus bus if wiring.MessageQueue != nil { queueSettingsSvc = queuesettings.NewService( queuesettings.NewStore(settingsStore), wiring.MessageQueue, nil, log, + wiring.MessageQueueConfig, ) } if wiring.TaskSessions != nil { diff --git a/apps/backend/internal/task/service/plan_service.go b/apps/backend/internal/task/service/plan_service.go index f38a5ac0b9..96c0bdea46 100644 --- a/apps/backend/internal/task/service/plan_service.go +++ b/apps/backend/internal/task/service/plan_service.go @@ -3,8 +3,6 @@ package service import ( "context" "errors" - "os" - "strconv" "time" "go.uber.org/zap" @@ -31,7 +29,6 @@ const ( createdByAgent = "agent" createdByUser = "user" defaultCoalesceWindow = 5 * time.Minute - coalesceWindowEnvVar = "KANDEV_PLAN_COALESCE_WINDOW_MS" defaultAgentAuthorFallback = "Agent" defaultUserAuthorFallback = "User" ) @@ -61,12 +58,17 @@ type PlanService struct { // NewPlanService creates a new task plan service. The concrete repository // passed by callers must implement both PlanRepository and the session-lookup // methods on planRepo (the SQLite repository does both). -func NewPlanService(repo planRepo, eventBus bus.EventBus, log *logger.Logger) *PlanService { + +func NewPlanService(repo planRepo, eventBus bus.EventBus, log *logger.Logger, configuredWindow ...time.Duration) *PlanService { + coalesceWindow := defaultCoalesceWindow + if len(configuredWindow) > 0 { + coalesceWindow = resolveCoalesceWindow(configuredWindow[0]) + } return &PlanService{ repo: repo, eventBus: eventBus, logger: log.WithFields(zap.String("component", "plan-service")), - coalesceWindow: readCoalesceWindow(), + coalesceWindow: coalesceWindow, } } @@ -83,16 +85,11 @@ func (s *PlanService) authorize(ctx context.Context, taskID string) error { return s.authorizeTask(ctx, taskID) } -func readCoalesceWindow() time.Duration { - raw := os.Getenv(coalesceWindowEnvVar) - if raw == "" { +func resolveCoalesceWindow(window time.Duration) time.Duration { + if window < 0 { return defaultCoalesceWindow } - ms, err := strconv.Atoi(raw) - if err != nil || ms < 0 { - return defaultCoalesceWindow - } - return time.Duration(ms) * time.Millisecond + return window } // CreatePlanRequest contains parameters for creating/updating a task plan. diff --git a/apps/backend/internal/task/service/plan_service_test.go b/apps/backend/internal/task/service/plan_service_test.go index 3eb2219f9e..ab06c5adbb 100644 --- a/apps/backend/internal/task/service/plan_service_test.go +++ b/apps/backend/internal/task/service/plan_service_test.go @@ -51,6 +51,12 @@ func createTestPlanService(t *testing.T) (*PlanService, *MockEventBus, *sqlitere return svc, eventBus, repo } +func TestResolveCoalesceWindowUsesTypedStartupValue(t *testing.T) { + if got := resolveCoalesceWindow(2400 * time.Millisecond); got != 2400*time.Millisecond { + t.Fatalf("coalesce window = %s, want 2.4s", got) + } +} + type nilMarkPlanRepo struct { *sqliterepo.Repository } diff --git a/apps/backend/internal/workflow/repository/phase2_sqlite.go b/apps/backend/internal/workflow/repository/phase2_sqlite.go index e4196e2825..677d62ec67 100644 --- a/apps/backend/internal/workflow/repository/phase2_sqlite.go +++ b/apps/backend/internal/workflow/repository/phase2_sqlite.go @@ -731,6 +731,24 @@ func (r *Repository) recordStepDecisionTx(ctx context.Context, d *models.Workflo } defer func() { _ = tx.Rollback() }() + // PostgreSQL's READ COMMITTED isolation does not serialize the initial + // supersede UPDATE when no active row exists yet. Lock the identity before + // that UPDATE so concurrent writers observe the prior commit instead of + // racing into the partial unique index. SQLite's single-writer lock already + // provides this serialization. + lockIdentity := d.ParticipantID + if d.DeciderID != "" && d.Role != "" { + lockIdentity = d.DeciderID + "\x1f" + d.Role + } + if dialect.IsPostgres(r.db.DriverName()) { + lockKey := "workflow-step-decision:" + d.TaskID + "\x1f" + d.StepID + "\x1f" + lockIdentity + if _, err := tx.ExecContext(ctx, tx.Rebind(` + SELECT pg_advisory_xact_lock(hashtextextended(?, 0)) + `), lockKey); err != nil { + return fmt.Errorf("lock step decision identity: %w", err) + } + } + if d.DeciderID != "" && d.Role != "" { if _, err := tx.ExecContext(ctx, tx.Rebind(` UPDATE workflow_step_decisions diff --git a/apps/web/components/settings/system/message-queue-settings.test.tsx b/apps/web/components/settings/system/message-queue-settings.test.tsx index 8f9d34f0dd..60dc2457ea 100644 --- a/apps/web/components/settings/system/message-queue-settings.test.tsx +++ b/apps/web/components/settings/system/message-queue-settings.test.tsx @@ -196,6 +196,25 @@ describe("MessageQueueSettings — per-session limit", () => { expect(screen.getByTestId("message-queue-source").textContent).toBe("Environment"); expect(screen.getByText(/KANDEV_QUEUE_MAX_PER_SESSION/)).toBeTruthy(); }); + + it("shows a configuration value as a locked configuration source", async () => { + fetchSettingsMock.mockResolvedValueOnce( + response({ + configured: 25, + effective: 41, + source: "configuration" as MessageQueueSettingsResponse["effective"]["source"], + locked: true, + }), + ); + render(); + + const input = await screen.findByLabelText(MAXIMUM_LABEL); + expect(input).toHaveProperty("disabled", true); + expect(screen.getByTestId(EFFECTIVE_VALUE_ID).textContent).toBe("41"); + expect(screen.getByTestId("message-queue-source").textContent).toBe("Configuration"); + expect(screen.getByText(/Managed by configuration/)).toBeTruthy(); + expect(screen.queryByText(/KANDEV_QUEUE_MAX_PER_SESSION/)).toBeNull(); + }); }); describe("MessageQueueSettings — permissions and recovery", () => { diff --git a/apps/web/components/settings/system/message-queue-settings.tsx b/apps/web/components/settings/system/message-queue-settings.tsx index d42c5e9d17..2e3f33141d 100644 --- a/apps/web/components/settings/system/message-queue-settings.tsx +++ b/apps/web/components/settings/system/message-queue-settings.tsx @@ -10,6 +10,7 @@ import { Label } from "@kandev/ui/label"; import { Spinner } from "@kandev/ui/spinner"; import { Switch } from "@kandev/ui/switch"; import { IconAlertCircle, IconInfoCircle, IconLock } from "@tabler/icons-react"; +import type { TFunction } from "i18next"; import { useTranslation } from "react-i18next"; import { useAppStore } from "@/components/state-provider"; import { SettingsCard } from "@/components/settings/settings-card"; @@ -36,6 +37,8 @@ function sourceLabelKey(source: MessageQueueSettingsSource): string { switch (source) { case "setting": return "system:messageQueueSourceSetting"; + case "configuration": + return "system:messageQueueSourceConfiguration"; case "environment": return "system:messageQueueSourceEnvironment"; default: @@ -43,6 +46,32 @@ function sourceLabelKey(source: MessageQueueSettingsSource): string { } } +function queueCapacityLockMessage( + t: TFunction, + source: MessageQueueSettingsSource | undefined, +): string { + if (source === "configuration") return t("system:messageQueueConfigurationLocked"); + return t("system:messageQueueEnvironmentLocked", { variable: ENVIRONMENT_VARIABLE }); +} + +type QueueSettingsValidationOptions = { + parsed: number | null; + isAdmin: boolean; + isLocked: boolean; + isMaxDirty: boolean; + lockSource: MessageQueueSettingsSource | undefined; +}; + +function queueSettingsInvalidReason( + t: TFunction, + { parsed, isAdmin, isLocked, isMaxDirty, lockSource }: QueueSettingsValidationOptions, +): string | undefined { + if (parsed === null) return t("system:messageQueueValidation"); + if (!isAdmin) return t("system:messageQueueAdminOnly"); + if (isLocked && isMaxDirty) return queueCapacityLockMessage(t, lockSource); + return undefined; +} + /** Loads the persisted snapshot and owns the editable queue-setting drafts. * Split out of * `useMessageQueueSettingsDraft` so neither function needs to grow past the @@ -194,12 +223,14 @@ function useMessageQueueSettingsDraft() { const isAutoMergeDirty = autoMergeBaseline !== undefined && autoMergeDraft !== autoMergeBaseline; const isAdmin = role === undefined || role === "admin"; const isLocked = snapshot?.effective.locked === true; - let invalidReason: string | undefined; - if (parsed === null) invalidReason = t("system:messageQueueValidation"); - else if (!isAdmin) invalidReason = t("system:messageQueueAdminOnly"); - else if (isLocked && isMaxDirty) { - invalidReason = t("system:messageQueueEnvironmentLocked", { variable: ENVIRONMENT_VARIABLE }); - } + const lockSource = snapshot?.effective.source; + const invalidReason = queueSettingsInvalidReason(t, { + parsed, + isAdmin, + isLocked, + isMaxDirty, + lockSource, + }); useQueueSettingsContributor({ load, @@ -227,6 +258,7 @@ function useMessageQueueSettingsDraft() { isDirty: isMaxDirty || isMergeDirty || isAutoMergeDirty, isAdmin, isLocked, + lockSource, reload: load.reload, }; } @@ -392,6 +424,7 @@ export function MessageQueueSettings() { if (!state.snapshot) return null; const { settings, effective } = state.snapshot; + const isConfigurationLocked = state.lockSource === "configuration"; const effectiveValue = effective.max_per_session === 0 ? t("system:messageQueueUnlimited") @@ -420,10 +453,14 @@ export function MessageQueueSettings() { {state.isLocked && ( - {t("system:messageQueueEnvironmentLockTitle")} - - {t("system:messageQueueEnvironmentLocked", { variable: ENVIRONMENT_VARIABLE })} - + + {t( + isConfigurationLocked + ? "system:messageQueueConfigurationLockTitle" + : "system:messageQueueEnvironmentLockTitle", + )} + + {queueCapacityLockMessage(t, state.lockSource)} )} {!state.isAdmin && ( diff --git a/apps/web/e2e/tests/system/message-queue-settings.spec.ts b/apps/web/e2e/tests/system/message-queue-settings.spec.ts index 4822aba4d3..68ba8a705d 100644 --- a/apps/web/e2e/tests/system/message-queue-settings.spec.ts +++ b/apps/web/e2e/tests/system/message-queue-settings.spec.ts @@ -172,6 +172,39 @@ test.describe.serial("Message Queue task behavior settings", () => { await expect(testPage.getByTestId("message-queue-auto-merge-enabled")).toBeEnabled(); }); + test("configuration override is visible and read-only", async ({ testPage }) => { + await testPage.route(`**${MESSAGE_QUEUE_SETTINGS_PATH}`, async (route) => { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + settings: { + max_per_session: baseline?.max_per_session ?? 10, + merge_enabled: true, + auto_merge_enabled: true, + }, + effective: { + max_per_session: 43, + source: "configuration", + locked: true, + merge_enabled: true, + auto_merge_enabled: true, + }, + } satisfies MessageQueueSettingsResponse), + }); + }); + + await testPage.goto("/settings/preferences/task-behavior"); + await expect(testPage.getByLabel("Maximum messages per session")).toBeDisabled(); + await expect(testPage.getByTestId("message-queue-effective-value")).toHaveText("43"); + await expect(testPage.getByTestId("message-queue-source")).toHaveText("Configuration"); + await expect(testPage.getByText(/Managed by configuration/)).toBeVisible(); + await expect(testPage.getByText(/KANDEV_QUEUE_MAX_PER_SESSION/)).toHaveCount(0); + await expect(testPage.getByTestId("settings-floating-save")).toHaveCount(0); + await expect(testPage.getByTestId("message-queue-merge-enabled")).toBeEnabled(); + await expect(testPage.getByTestId("message-queue-auto-merge-enabled")).toBeEnabled(); + }); + test("member can navigate to the setting but cannot edit it", async ({ testPage }) => { await testPage.goto("/settings/preferences/appearance"); await testPage.waitForFunction(() => Boolean(window.__KANDEV_E2E_STORE__)); diff --git a/apps/web/e2e/tests/system/mobile-message-queue-settings.spec.ts b/apps/web/e2e/tests/system/mobile-message-queue-settings.spec.ts index f1adecfc7b..979d6408fd 100644 --- a/apps/web/e2e/tests/system/mobile-message-queue-settings.spec.ts +++ b/apps/web/e2e/tests/system/mobile-message-queue-settings.spec.ts @@ -88,3 +88,49 @@ test("mobile navigation reaches the Message Queue section with touch-safe shared String(expected), ); }); + +test("mobile configuration lock keeps the source and accessible controls consistent", async ({ + testPage, +}) => { + await testPage.route(`**${MESSAGE_QUEUE_SETTINGS_PATH}`, async (route) => { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + settings: { + max_per_session: baseline?.max_per_session ?? 10, + merge_enabled: true, + auto_merge_enabled: true, + }, + effective: { + max_per_session: 44, + source: "configuration", + locked: true, + merge_enabled: true, + auto_merge_enabled: true, + }, + }), + }); + }); + + const mobile = new MobileKanbanPage(testPage); + await mobile.goto(); + await mobile.mobileMenuButton.click(); + await testPage + .getByTestId("mobile-home-menu-card") + .getByRole("link", { name: "Settings" }) + .click(); + await testPage + .getByTestId("settings-index") + .getByRole("link", { name: /^Task Behavior/ }) + .click(); + + const input = testPage.getByLabel("Maximum messages per session"); + await expect(input).toBeDisabled(); + await expect(testPage.getByTestId("message-queue-source")).toHaveText("Configuration"); + await expect(testPage.getByText(/Managed by configuration/)).toBeVisible(); + await expect(testPage.getByText(/KANDEV_QUEUE_MAX_PER_SESSION/)).toHaveCount(0); + const inputBox = await input.boundingBox(); + expect(inputBox).not.toBeNull(); + expect(inputBox!.height).toBeGreaterThanOrEqual(44); +}); diff --git a/apps/web/lib/types/system.ts b/apps/web/lib/types/system.ts index 3a96cb82b1..34f46be767 100644 --- a/apps/web/lib/types/system.ts +++ b/apps/web/lib/types/system.ts @@ -195,7 +195,7 @@ export interface MessageQueueSettingsValue { /** Partial PATCH payload: omitted fields are left unchanged server-side. */ export type MessageQueueSettingsPatch = Partial; -export type MessageQueueSettingsSource = "default" | "setting" | "environment"; +export type MessageQueueSettingsSource = "default" | "setting" | "configuration" | "environment"; export interface MessageQueueEffectiveSettings extends MessageQueueSettingsValue { source: MessageQueueSettingsSource; diff --git a/apps/web/src/locales/en/system.json b/apps/web/src/locales/en/system.json index 1a0a695afe..ad08e18e08 100644 --- a/apps/web/src/locales/en/system.json +++ b/apps/web/src/locales/en/system.json @@ -171,6 +171,8 @@ "messageQueueDescription": "Control how many pending messages each session can hold before new work is rejected.", "messageQueueEffective": "Effective:", "messageQueueEffectiveHelp": "The effective value is used for new admissions now. Existing queue entries are not pruned when this value is lowered.", + "messageQueueConfigurationLockTitle": "Managed by configuration", + "messageQueueConfigurationLocked": "The YAML configuration value overrides the saved setting. Change the value in config.yaml and restart Kandev to edit it here again.", "messageQueueEnvironmentLockTitle": "Managed by environment", "messageQueueEnvironmentLocked": "{{variable}} overrides the saved setting. Change the environment value and restart Kandev to edit it here again.", "messageQueueLimitDescription": "This limit applies immediately to newly admitted messages. Lowering it never removes messages already waiting, and accepted delivery retries remain safe.", @@ -184,6 +186,7 @@ "messageQueueRetry": "Retry", "messageQueueSaveFailed": "Failed to save message queue settings.", "messageQueueSourceDefault": "Default", + "messageQueueSourceConfiguration": "Configuration", "messageQueueSourceEnvironment": "Environment", "messageQueueSourceSetting": "Saved setting", "messageQueueTitle": "Message Queue", diff --git a/apps/web/src/locales/pseudo/system.json b/apps/web/src/locales/pseudo/system.json index 389fe07bd5..32907f6cc5 100644 --- a/apps/web/src/locales/pseudo/system.json +++ b/apps/web/src/locales/pseudo/system.json @@ -171,6 +171,8 @@ "messageQueueDescription": "Ćōńţŕōĺ ĥōŵ ḿàńŷ ƥēńďĩńĝ ḿēśśàĝēś ēàćĥ śēśśĩōń ćàń ĥōĺď ƀēƒōŕē ńēŵ ŵōŕķ ĩś ŕēĵēćţēď.", "messageQueueEffective": "Ēƒƒēćţĩvē:", "messageQueueEffectiveHelp": "Ţĥē ēƒƒēćţĩvē vàĺũē ĩś ũśēď ƒōŕ ńēŵ àďḿĩśśĩōńś ńōŵ. Ēxĩśţĩńĝ qũēũē ēńţŕĩēś àŕē ńōţ ƥŕũńēď ŵĥēń ţĥĩś vàĺũē ĩś ĺōŵēŕēď.", + "messageQueueConfigurationLockTitle": "Ḿàńàĝēď ƀŷ ćōńƒĩĝũŕàţĩōń", + "messageQueueConfigurationLocked": "Ţĥē ŶÀḾĹ ćōńƒĩĝũŕàţĩōń vàĺũē ōvēŕŕĩďēś ţĥē śàvēď śēţţĩńĝ. Ćĥàńĝē ţĥē vàĺũē ĩń ćōńƒĩĝ.ŷàḿĺ àńď ŕēśţàŕţ Ķàńďēv ţō ēďĩţ ĩţ ĥēŕē àĝàĩń.", "messageQueueEnvironmentLockTitle": "Ḿàńàĝēď ƀŷ ēńvĩŕōńḿēńţ", "messageQueueEnvironmentLocked": "{{variable}} ōvēŕŕĩďēś ţĥē śàvēď śēţţĩńĝ. Ćĥàńĝē ţĥē ēńvĩŕōńḿēńţ vàĺũē àńď ŕēśţàŕţ Ķàńďēv ţō ēďĩţ ĩţ ĥēŕē àĝàĩń.", "messageQueueLimitDescription": "Ţĥĩś ĺĩḿĩţ àƥƥĺĩēś ĩḿḿēďĩàţēĺŷ ţō ńēŵĺŷ àďḿĩţţēď ḿēśśàĝēś. Ĺōŵēŕĩńĝ ĩţ ńēvēŕ ŕēḿōvēś ḿēśśàĝēś àĺŕēàďŷ ŵàĩţĩńĝ, àńď àććēƥţēď ďēĺĩvēŕŷ ŕēţŕĩēś ŕēḿàĩń śàƒē.", @@ -184,6 +186,7 @@ "messageQueueRetry": "Ŕēţŕŷ", "messageQueueSaveFailed": "Ƒàĩĺēď ţō śàvē ḿēśśàĝē qũēũē śēţţĩńĝś.", "messageQueueSourceDefault": "Ďēƒàũĺţ", + "messageQueueSourceConfiguration": "Ćōńƒĩĝũŕàţĩōń", "messageQueueSourceEnvironment": "Ēńvĩŕōńḿēńţ", "messageQueueSourceSetting": "Śàvēď śēţţĩńĝ", "messageQueueTitle": "Ḿēśśàĝē Qũēũē", diff --git a/apps/web/src/locales/pt-pt/system.json b/apps/web/src/locales/pt-pt/system.json index 4a75293f3f..88014c8f8f 100644 --- a/apps/web/src/locales/pt-pt/system.json +++ b/apps/web/src/locales/pt-pt/system.json @@ -167,6 +167,8 @@ "messageQueueDescription": "Controle quantas mensagens pendentes cada sessão pode ter antes de o novo trabalho ser rejeitado.", "messageQueueEffective": "Efetivo:", "messageQueueEffectiveHelp": "O valor efetivo é usado nas novas admissões a partir de agora. As entradas já em fila não são removidas quando este valor é reduzido.", + "messageQueueConfigurationLockTitle": "Gerido pela configuração", + "messageQueueConfigurationLocked": "O valor de configuração YAML substitui a definição guardada. Altere o valor em config.yaml e reinicie o Kandev para o voltar a editar aqui.", "messageQueueEnvironmentLockTitle": "Gerido pelo ambiente", "messageQueueEnvironmentLocked": "{{variable}} substitui a definição guardada. Altere o valor no ambiente e reinicie o Kandev para o voltar a editar aqui.", "messageQueueLimitDescription": "Este limite aplica-se de imediato às mensagens recém-admitidas. Reduzi-lo nunca remove mensagens já em espera e as tentativas de entrega aceites mantêm-se seguras.", @@ -180,6 +182,7 @@ "messageQueueRetry": "Tentar novamente", "messageQueueSaveFailed": "Não foi possível guardar as definições da fila de mensagens.", "messageQueueSourceDefault": "Pré-definição", + "messageQueueSourceConfiguration": "Configuração", "messageQueueSourceEnvironment": "Ambiente", "messageQueueSourceSetting": "Definição guardada", "messageQueueTitle": "Fila de mensagens", diff --git a/apps/web/src/locales/zh-cn/system.json b/apps/web/src/locales/zh-cn/system.json index d170e79b21..32245881c8 100644 --- a/apps/web/src/locales/zh-cn/system.json +++ b/apps/web/src/locales/zh-cn/system.json @@ -168,6 +168,8 @@ "messageQueueDescription": "控制每个会话在新工作被拒绝前可以容纳多少条待处理消息。", "messageQueueEffective": "当前生效:", "messageQueueEffectiveHelp": "当前生效值会立即用于新的接纳操作。降低此值不会清理已有的排队条目。", + "messageQueueConfigurationLockTitle": "由配置管理", + "messageQueueConfigurationLocked": "YAML 配置值会覆盖已保存的设置。请更改 config.yaml 中的值并重启 Kandev,之后才能在此处再次编辑。", "messageQueueEnvironmentLockTitle": "由环境管理", "messageQueueEnvironmentLocked": "{{variable}} 会覆盖已保存的设置。请更改环境值并重启 Kandev,之后才能在此处再次编辑。", "messageQueueLimitDescription": "此限制会立即应用于新接纳的消息。降低限制不会移除已在等待的消息,已接受的投递重试仍然安全。", @@ -181,6 +183,7 @@ "messageQueueRetry": "重试", "messageQueueSaveFailed": "保存消息队列设置失败。", "messageQueueSourceDefault": "默认值", + "messageQueueSourceConfiguration": "配置", "messageQueueSourceEnvironment": "环境", "messageQueueSourceSetting": "已保存的设置", "messageQueueTitle": "消息队列", diff --git a/apps/web/src/locales/zh-hk/system.json b/apps/web/src/locales/zh-hk/system.json index 9183f89e68..4502608fc9 100644 --- a/apps/web/src/locales/zh-hk/system.json +++ b/apps/web/src/locales/zh-hk/system.json @@ -168,6 +168,8 @@ "messageQueueDescription": "控制每個工作階段在新工作被拒絕前可以容納多少條待處理訊息。", "messageQueueEffective": "目前生效:", "messageQueueEffectiveHelp": "目前生效值會立即用於新的接納操作。降低此值不會清理已有的排隊條目。", + "messageQueueConfigurationLockTitle": "由設定管理", + "messageQueueConfigurationLocked": "YAML 設定值會覆蓋已儲存的設定。請更改 config.yaml 中的值並重啓 Kandev,之後才能在此處再次編輯。", "messageQueueEnvironmentLockTitle": "由環境管理", "messageQueueEnvironmentLocked": "{{variable}} 會覆蓋已儲存的設定。請更改環境值並重啓 Kandev,之後才能在此處再次編輯。", "messageQueueLimitDescription": "此限制會立即應用於新接納的訊息。降低限制不會移除已在等待的訊息,已接受的投遞重試仍然安全。", @@ -181,6 +183,7 @@ "messageQueueRetry": "重試", "messageQueueSaveFailed": "儲存訊息佇列設定失敗。", "messageQueueSourceDefault": "預設值", + "messageQueueSourceConfiguration": "設定", "messageQueueSourceEnvironment": "環境", "messageQueueSourceSetting": "已儲存的設定", "messageQueueTitle": "訊息佇列", diff --git a/apps/web/src/locales/zh-tw/system.json b/apps/web/src/locales/zh-tw/system.json index 14d9176558..0a6b65966d 100644 --- a/apps/web/src/locales/zh-tw/system.json +++ b/apps/web/src/locales/zh-tw/system.json @@ -168,6 +168,8 @@ "messageQueueDescription": "控制每個工作階段在新工作被拒絕前可以容納多少條待處理訊息。", "messageQueueEffective": "目前生效:", "messageQueueEffectiveHelp": "目前生效值會立即用於新的接納操作。降低此值不會清理已有的排隊條目。", + "messageQueueConfigurationLockTitle": "由設定管理", + "messageQueueConfigurationLocked": "YAML 設定值會覆蓋已儲存的設定。請更改 config.yaml 中的值並重啟 Kandev,之後才能在此處再次編輯。", "messageQueueEnvironmentLockTitle": "由環境管理", "messageQueueEnvironmentLocked": "{{variable}} 會覆蓋已儲存的設定。請更改環境值並重啟 Kandev,之後才能在此處再次編輯。", "messageQueueLimitDescription": "此限制會立即應用於新接納的訊息。降低限制不會移除已在等待的訊息,已接受的投遞重試仍然安全。", @@ -181,6 +183,7 @@ "messageQueueRetry": "重試", "messageQueueSaveFailed": "儲存訊息佇列設定失敗。", "messageQueueSourceDefault": "預設值", + "messageQueueSourceConfiguration": "設定", "messageQueueSourceEnvironment": "環境", "messageQueueSourceSetting": "已儲存的設定", "messageQueueTitle": "訊息佇列", diff --git a/docs/decisions/2026-08-20-startup-configuration-source-parity.md b/docs/decisions/2026-08-20-startup-configuration-source-parity.md new file mode 100644 index 0000000000..d97b13b6e5 --- /dev/null +++ b/docs/decisions/2026-08-20-startup-configuration-source-parity.md @@ -0,0 +1,117 @@ +# Startup Configuration Uses One Typed Source Model + +- **Status:** accepted +- **Date:** 2026-08-20 +- **Area:** backend, agentctl, frontend, cli, security, operations +- **Spec:** [Startup configuration parity](../specs/platform/startup-configuration-parity.md) + +## Context + +Kandev reads startup configuration in several places. The common configuration +package uses typed YAML and environment bindings. The Go launcher reads a +smaller environment-only set. Backend and agentctl packages also read some +environment variables directly. + +This creates different behavior for one logical setting. It also means that a +YAML value can arrive too late for the launcher. Operators cannot use one +durable file for stable startup configuration. + +Kandev already uses a first-readable-file model. It checks the working +directory and then `/etc/kandev`. Kandev also has a stable home directory, but +it does not search that location for configuration. + +## Decision + +Kandev will maintain one typed catalog for stable, operator-facing startup +settings. Each catalog entry defines its YAML key, compatible environment +variables, default, sensitivity, and owning component. + +The launcher and backend will use the same catalog and source resolution rules. +The launcher will read only the bootstrap projection that it needs. The backend +will read the complete typed configuration. Consumers will receive resolved +values through constructors or explicit startup configuration. Managed +agentctl processes will receive their resolved subset through explicit child +configuration. + +Kandev will not copy YAML values into public process environment variables. +An internal launcher handoff can identify the already selected configuration +file so the child process reads the same file after a working-directory change. +That handoff is process wiring and is not a public operator setting. + +The precedence for startup settings will be launch flag, explicit environment, +selected YAML, then built-in or profile default. A database override, when one +exists, follows YAML and precedes the final default. + +Kandev will select one file in this order: + +1. Working-directory `config.yaml`. +2. `/config.yaml`, with `~/.kandev` as the default home. +3. `/etc/kandev/config.yaml`. + +The first existing candidate owns startup. An unreadable or invalid candidate +causes startup to fail. Kandev will not merge files or fall through after an +error. + +The explicit home flag or environment variable selects the home path before +file discovery. A selected home file cannot relocate itself through `homeDir`. +Working-directory and system files can set `homeDir` because their own path does +not depend on that value. + +Stable startup secrets will support YAML. Kandev will mark secret fields in the +catalog, avoid value logging, and warn about broad Unix read permissions on a +secret-bearing file. The warning will recommend owner-only mode `0600`. + +The public configuration reference will inventory every stable startup +environment variable. It will name the YAML equivalent or explain why the +variable is internal, generated, test-only, debug-only, deprecated, or managed +through another surface. + +## Consequences + +- Operators can keep stable startup configuration in + `~/.kandev/config.yaml` without repeating environment setup. +- Environment variables remain compatible and keep override priority. +- Launcher and backend behavior become consistent for ports, paths, logging, + proxy trust, and other startup settings. +- Package-level environment reads for stable operator settings must move behind + typed configuration boundaries. +- Child process launch paths need explicit tests for agentctl configuration. +- The message queue settings API needs a `configuration` source so the UI can + describe YAML locks accurately. +- Adding a stable startup environment variable now requires a catalog entry or + an explicit exclusion. +- File-based secrets are possible, but operators remain responsible for file + access controls. +- Configuration changes still require a restart. + +## Alternatives considered + +### Copy YAML values into environment variables + +This would reuse current direct environment reads. It would hide setting +ownership, make provenance unreliable, and put file-based secrets into more +process environments. We rejected it. + +### Add only `server.trustedProxies` + +This would fix the immediate proxy warning but preserve the split configuration +contract. Operators would encounter the same issue for other settings. We +rejected it. + +### Expose every observed environment variable as YAML + +Many variables are generated handoffs, test controls, debug tools, or platform +detection inputs. Making them public would freeze internal implementation +details. We rejected it in favor of a reviewed stable catalog and explicit +exclusions. + +### Add a public `--config` flag instead of home discovery + +A flag is useful for one launch command but does not give manual and service +launches a shared default. The home file provides that stable location. We can +add an explicit path later if a separate use case requires it. + +### Merge working-directory, home, and system files + +Merging increases provenance and secret-handling complexity. It would also +change the existing first-file behavior. We rejected it. diff --git a/docs/decisions/INDEX.md b/docs/decisions/INDEX.md index aa50eb7086..eea03d595f 100644 --- a/docs/decisions/INDEX.md +++ b/docs/decisions/INDEX.md @@ -182,4 +182,5 @@ Read individual ADRs for full context. Create new ones via `/record decision` or | 2026-08-18-plugin-task-row-metadata | [Keep Task Row Plugin Metadata Generic](2026-08-18-plugin-task-row-metadata.md) | accepted | frontend | 2026-08-18 | | 2026-08-20-settings-prompt-editor-provider-ownership | [Settings Prompt Editors Own Monaco Completion Providers](2026-08-20-settings-prompt-editor-provider-ownership.md) | accepted | frontend | 2026-08-20 | | 2026-08-14-kandev-native-agent-rich-output | [Keep Agent Rich Output Host Native](2026-08-14-kandev-native-agent-rich-output.md) | accepted | backend, frontend, protocol, security | 2026-08-14 | +| 2026-08-20-startup-configuration-source-parity | [Startup Configuration Uses One Typed Source Model](2026-08-20-startup-configuration-source-parity.md) | accepted | backend, agentctl, frontend, cli, security, operations | 2026-08-20 | | 2026-08-20-acp-client-non-underscore-extension-methods | [Route Non-Underscore Inbound Client Methods to the Extension Handler](2026-08-20-acp-client-non-underscore-extension-methods.md) | accepted | backend, protocol | 2026-08-20 | diff --git a/docs/plans/startup-configuration-parity/plan.md b/docs/plans/startup-configuration-parity/plan.md new file mode 100644 index 0000000000..c163b0cbb5 --- /dev/null +++ b/docs/plans/startup-configuration-parity/plan.md @@ -0,0 +1,133 @@ +--- +spec: docs/specs/platform/startup-configuration-parity.md +decision: docs/decisions/2026-08-20-startup-configuration-source-parity.md +created: 2026-08-20 +status: complete +--- + +# Implementation Plan: Startup Configuration Parity + +## Overview + +Create one typed startup configuration contract for stable operator settings. +Add automatic home configuration discovery. Make the Go launcher, backend, and +managed agentctl processes use the same resolved values. Keep environment +variables as compatible overrides. + +The work starts with the common catalog and source resolver because every other +task depends on those contracts. Launcher work follows so it can preserve the +selected file and its bootstrap values. Backend consumers then move from direct +environment reads to typed configuration. Agentctl settings use explicit child +configuration across all executor paths. The final product slice reports YAML +locks accurately in Message Queue settings and publishes the complete reference. + +Implementation is sequential by default. Tasks 03 and 04 use different consumer +packages after Task 01 defines their fields, but both depend on shared startup +wiring from Task 02. No delegated execution is authorized by this plan. + +## Configuration contract + +The common configuration package owns a catalog entry for every stable startup +setting. An entry records its YAML key, environment aliases, sensitivity, +default policy, and owner. A separate exclusion list records internal, +generated, test, debug-only, deprecated, and externally managed variables. +Completeness tests compare the catalog with the audited public startup inventory. + +File selection uses the working directory, resolved home, then `/etc/kandev`. +The first existing candidate wins. Errors do not fall through. A private child +handoff preserves the selected path when the launcher changes the backend +working directory. + +The implementation keeps YAML values in typed configuration. It does not create +public environment variables from those values. Consumers accept typed values +or explicit startup options. + +## Implementation waves + +### Wave 1: Shared contract + +- [x] [Task 01: Configuration catalog and source resolution](task-01-configuration-catalog-and-source-resolution.md) + +### Wave 2: Launcher bootstrap + +- [x] [Task 02: Launcher configuration consumption](task-02-launcher-configuration-consumption.md) + +### Wave 3: Backend consumers + +- [x] [Task 03: Backend security and lifecycle settings](task-03-backend-security-and-lifecycle-settings.md) +- [x] [Task 04: Backend capacity and service settings](task-04-backend-capacity-and-service-settings.md) + +### Wave 4: Managed processes and product source reporting + +- [x] [Task 05: Agentctl settings propagation](task-05-agentctl-settings-propagation.md) +- [x] [Task 06: Message queue configuration lock](task-06-message-queue-configuration-lock.md) + +### Wave 5: Public contract + +- [x] [Task 07: Public configuration reference](task-07-public-configuration-reference.md) + +## Cross-task acceptance + +- Every stable operator-facing startup environment variable has a canonical + YAML key or a reviewed exclusion. +- Launch flags override environment values. Environment values override YAML. + YAML overrides database, profile, or built-in defaults as applicable. +- The launcher and backend use one selected file and agree on bootstrap values. +- Working-directory configuration wins over home configuration. Home + configuration wins over system configuration. +- An invalid or unreadable first candidate fails startup. +- A selected home file cannot relocate itself through `homeDir`. +- Secret values never appear in logs. Broad Unix file permissions produce a + warning for secret-bearing files. +- Managed agentctl processes receive YAML-backed settings through Local, + Worktree, Docker, Sprite, and SSH launch paths. +- Message Queue settings report `configuration` for a YAML lock on desktop and + mobile. +- Existing environment-only deployments keep their behavior. + +## Verification strategy + +Each task follows TDD and records its RED and GREEN commands. The final combined +gate runs backend configuration, launcher, runtime flag, queue, and agentctl +tests. It also runs the focused frontend unit and responsive E2E tests, the +internationalization checks, the public documentation validator, and a backend +build. + +The implementation turn must inspect current package paths before running each +command. If a package moved after this plan, update the task file with the exact +replacement instead of weakening the check. + +## Risks + +- Home discovery depends on the home path. Bootstrap resolution must not load a + home file before it knows that path. +- A launcher working-directory change can make the backend select a different + file. The private selected-path handoff must pin the same file. +- Current package-level environment reads run during initialization. They must + move behind explicit startup configuration without introducing mutable global + state. +- Agentctl runs through several executors. A missing propagation path can make + behavior depend on executor choice. +- The runtime flag registry already owns feature and debug metadata. The new + catalog must integrate with it instead of adding a second feature flag map. +- Environment parsing has historical fallback rules. YAML validation must fail + clearly without silently changing those legacy environment rules. +- File mode checks differ by platform. Unix warnings need focused tests and a + safe no-op on platforms without compatible permission bits. + +## Out of scope + +- `.env` loading, public `--config`, file merging, and live reload. +- A startup configuration editor in Settings. +- New keys for internal child wiring, generated tokens, packaging, mocks, E2E, + debug-only controls, or UI-managed credentials. + +## Verification results + +- The common configuration, launcher, backend consumer, queue, agentctl, and + lifecycle package suite passed 5,691 tests across 16 packages. +- The focused queue API and frontend unit suites passed 65 backend tests and 27 + frontend tests. +- The five-locale i18n check and public documentation validator passed. +- The final browser E2E, backend build, full repository verification, commit, + and PR checks are recorded in the delivery session and task results. diff --git a/docs/plans/startup-configuration-parity/task-01-configuration-catalog-and-source-resolution.md b/docs/plans/startup-configuration-parity/task-01-configuration-catalog-and-source-resolution.md new file mode 100644 index 0000000000..8ba92fa08a --- /dev/null +++ b/docs/plans/startup-configuration-parity/task-01-configuration-catalog-and-source-resolution.md @@ -0,0 +1,134 @@ +--- +id: "01-configuration-catalog-and-source-resolution" +title: "Configuration catalog and source resolution" +status: done +wave: 1 +depends_on: [] +plan: "plan.md" +spec: "../../specs/platform/startup-configuration-parity.md" +--- + +# Task 01: Configuration catalog and source resolution + +Build the shared typed contract before changing consumers. Add stable YAML +fields, source metadata, file discovery, validation, and secret permission +diagnostics to the common configuration package. + +## Acceptance + +- A canonical catalog records YAML key, compatible environment variables, + sensitivity, owner, and default policy for every stable startup setting. +- A reviewed exclusion list covers internal, generated, test, debug-only, + deprecated, platform-detection, and externally managed variables. +- Completeness tests fail when a stable environment setting has neither a + catalog entry nor an exclusion. +- Typed configuration includes every new key listed in the spec. +- Source resolution checks working directory, resolved home, then + `/etc/kandev`. It loads one file and never merges candidates. +- The first existing unreadable or invalid file causes a clear startup error. +- A selected home configuration file rejects `homeDir`. Working-directory and + system files can set it. +- Selected-file metadata records the path and whether a YAML key supplied each + resolved value. +- Secret-bearing files with broad Unix read permissions produce a warning that + contains the path but no value. Secure files and files without secrets do not + warn. +- Existing environment bindings and profile defaults keep their precedence and + behavior. + +## Files likely touched + +- `apps/backend/internal/common/config/config.go` +- `apps/backend/internal/common/config/config_test.go` +- `apps/backend/internal/common/config/catalog.go` (new) +- `apps/backend/internal/common/config/catalog_test.go` (new) +- `apps/backend/internal/common/config/source.go` (new) +- `apps/backend/internal/common/config/source_test.go` (new) + +## Dependencies + +None. + +## TDD sequence + +1. Add table tests for catalog completeness, aliases, sensitivity, source + precedence, home bootstrap, invalid first candidates, relocation rejection, + source provenance, and Unix permission warnings. Run them RED. +2. Add typed sections and catalog metadata. Implement file selection without + changing the current environment and profile precedence. +3. Add secret-aware permission diagnostics that never format secret values. +4. Run the focused tests GREEN. Run the complete common configuration and + runtime flag suites to prove registry compatibility. + +## Verification + +```bash +cd apps/backend && go test ./internal/common/config -run '^Test(ConfigurationCatalog|ConfigSource|ConfigPrecedence|HomeConfig|SecretConfigPermissions)' -count=1 +cd apps/backend && go test ./internal/common/config ./internal/runtimeflags -count=1 +``` + +## Risks + +- A broad environment scan can accidentally define internal variables as public + contract. The exclusion reason must be explicit and testable. +- Logging a decoded configuration object can expose secrets. Diagnostics must + use catalog metadata and paths only. +- Viper aliases and mapstructure names can drift. Catalog completeness tests + must compare the actual typed bindings. + +## Output contract + +Record RED and GREEN results, the final catalog shape, every exclusion class, +permission warning evidence, files changed, and remaining risks in `## Results`. + +## Results + +RED: + +- `go test ./internal/common/config -run 'TestLoadReadsStableYAMLFields|TestLoadDiscoversHomeConfiguration|TestHomeConfigurationCannotRelocateItself|TestConfigSourceMetadataReportsSelectedFileAndYAMLValues' -count=1` + failed on the missing typed fields, home discovery, relocation guard, and + source metadata as expected. +- `TestInvalidYAMLRangeNamesSettingAndFile` also failed before range validation + was added. + +GREEN: + +- `go test ./internal/common/config -run '^Test(ConfigurationCatalog|ConfigSource|ConfigPrecedence|HomeConfig|SecretConfigPermissions)' -count=1` + passed with 11 tests. +- `go test ./internal/common/config ./internal/runtimeflags -count=1` + passed with 108 tests. + +The shared contract now exposes typed task, credential, capacity, queue, +agentctl, planning, observability, and launcher sections. `ConfigurationCatalog` +records canonical keys, environment aliases, owners, defaults, and sensitivity. +`ConfigurationExclusions` records internal wiring, generated values, packaging, +workspace injection, profile, test, and debug-only variables. `ConfigSource` +records the selected file, per-key source, and path-only permission warnings. + +Discovery uses working directory, resolved home, then `/etc/kandev`; it selects +one existing file and does not merge or fall through after read or validation +errors. The selected home file rejects `homeDir`. YAML values are decoded +without copying them into the process environment, while explicit environment +aliases retain override priority and legacy invalid-value fallback behavior. +Secret-bearing files with broad Unix read permissions emit a path-only warning +recommending mode `0600`. + +Files changed: + +- `apps/backend/internal/common/config/config.go` +- `apps/backend/internal/common/config/catalog.go` +- `apps/backend/internal/common/config/catalog_test.go` +- `apps/backend/internal/common/config/source.go` +- `apps/backend/internal/common/config/source_test.go` +- `apps/backend/internal/common/config/validation.go` +- `apps/backend/internal/profiles/profiles.go` + +Review remediation: + +- The completeness test now compares catalog entries and exclusions with an + independent audited startup-environment inventory. +- A synthetic uncataloged variable regression proves that the audit rejects + omissions instead of relying on a minimum count or selected keys. +- GREEN: `go test ./internal/common/config -count=1` passed with 81 tests; + `go test ./internal/common/config ./internal/runtimeflags -count=1` passed + with 115 tests. diff --git a/docs/plans/startup-configuration-parity/task-02-launcher-configuration-consumption.md b/docs/plans/startup-configuration-parity/task-02-launcher-configuration-consumption.md new file mode 100644 index 0000000000..8f5cb0af5b --- /dev/null +++ b/docs/plans/startup-configuration-parity/task-02-launcher-configuration-consumption.md @@ -0,0 +1,113 @@ +--- +id: "02-launcher-configuration-consumption" +title: "Launcher configuration consumption" +status: done +wave: 2 +depends_on: ["01-configuration-catalog-and-source-resolution"] +plan: "plan.md" +spec: "../../specs/platform/startup-configuration-parity.md" +--- + +# Task 02: Launcher configuration consumption + +Make every Go launcher mode resolve its bootstrap settings from the shared +configuration contract before it starts the backend. + +## Acceptance + +- `dev`, `start`, `run`, and `service` use the same source resolver. +- Launch flags override environment values. Environment values override YAML. +- The launcher honors YAML for `homeDir`, `server.host`, `server.port`, + `database.path`, `logging.level`, `launcher.webPort`, + `launcher.healthTimeoutMs`, and `launcher.noBrowser`. +- Compatibility environment variables keep their current priority, including + `KANDEV_BACKEND_PORT` over `KANDEV_PORT`. +- The launcher does not replace a YAML `database.path` with its default path. +- The child backend reads the exact file selected by the launcher even when its + working directory differs. +- Internal path handoff remains private and does not appear in the public + operator catalog. +- Launches without a configuration file keep current defaults. +- Invalid bootstrap configuration stops before child startup and reports the + selected file. + +## Files likely touched + +- `apps/backend/internal/launcher/constants.go` +- `apps/backend/internal/launcher/ports.go` +- `apps/backend/internal/launcher/health.go` +- `apps/backend/internal/launcher/env.go` +- `apps/backend/internal/launcher/start.go` +- `apps/backend/internal/launcher/dev.go` +- `apps/backend/internal/launcher/service.go` +- `apps/backend/internal/launcher/*_test.go` +- `apps/backend/internal/common/config/source.go` + +## Dependencies + +Task 01 defines the catalog, source resolver, and typed launcher section. + +## TDD sequence + +1. Add launcher tests for each precedence layer, YAML database path, home file + discovery, selected-path handoff, no-browser, health timeout, and port + compatibility. Run them RED. +2. Add a bootstrap configuration projection and replace direct stable + environment reads in the launcher. +3. Preserve only required internal child wiring in the child environment. +4. Run focused tests GREEN. Run the complete launcher suite and build the Go + launcher binary. + +## Verification + +```bash +cd apps/backend && go test ./internal/launcher -run '^Test.*(Config|Port|Health|Browser|DatabasePath|Home)' -count=1 +cd apps/backend && go test ./internal/launcher -count=1 +cd apps/backend && go build ./cmd/kandev +``` + +## Risks + +- Service installation and direct launch can have different working + directories. Tests must cover the selected-path handoff. +- The internal backend port handoff is not operator configuration. Removing it + would break child startup even after public port resolution is typed. +- `homeDir` controls data paths and configuration discovery. Bootstrap code + must resolve it only once. + +## Output contract + +Record RED and GREEN results, precedence evidence for every launcher field, +child handoff behavior, files changed, and remaining risks in `## Results`. + +## Results + +- RED: `go test ./internal/launcher -run "^Test(Launcher|DevBootstrap)" -count=1` + failed to compile before the bootstrap projection and private handoff existed. +- GREEN: `go test ./internal/launcher -run '^Test.*(Config|Port|Health|Browser|DatabasePath|Home)' -count=1` + passed, covering YAML values, environment-over-YAML, flag-over-environment, + dev web-port selection, health timeout, browser suppression, database path, + invalid-file reporting, and selected-file handoff. +- GREEN: `go test ./internal/launcher -count=1` passed. +- GREEN: `go build ./cmd/kandev` passed. +- The launcher now loads the shared config before port, data-directory, and + child startup decisions. Start, run, dev, and service installation preserve + the selected file through private `KANDEV_INTERNAL_CONFIG_FILE` wiring. +- YAML database and logging values remain typed in the child process; the + launcher does not copy those YAML values into public environment overrides. +- Service units preserve YAML home/logging values and emit the private file + handoff. Existing no-file defaults and `KANDEV_BACKEND_PORT` over + `KANDEV_PORT` behavior remain covered by the complete launcher suite. + +Review remediation: + +- Service bootstrap discovery now resolves flag, explicit environment, and + service-mode default homes before selecting `config.yaml`. Systemd and + launchd tests cover flag-over-YAML and environment-over-YAML service metadata. +- The generated service unit emits `KANDEV_HOME_DIR` unless YAML is the actual + winning source, so a stronger flag or environment value cannot be shadowed + by the pinned file. +- Dev backend and Vite readiness waits now use the same typed YAML health + timeout. The regression captures both timeout arguments. +- GREEN: `go test ./internal/launcher -count=1` passed with 230 tests; + `go build ./cmd/kandev` passed; changed-file `golangci-lint` passed. diff --git a/docs/plans/startup-configuration-parity/task-03-backend-security-and-lifecycle-settings.md b/docs/plans/startup-configuration-parity/task-03-backend-security-and-lifecycle-settings.md new file mode 100644 index 0000000000..648de20b21 --- /dev/null +++ b/docs/plans/startup-configuration-parity/task-03-backend-security-and-lifecycle-settings.md @@ -0,0 +1,108 @@ +--- +id: "03-backend-security-and-lifecycle-settings" +title: "Backend security and lifecycle settings" +status: done +wave: 3 +depends_on: ["02-launcher-configuration-consumption"] +plan: "plan.md" +spec: "../../specs/platform/startup-configuration-parity.md" +--- + +# Task 03: Backend security and lifecycle settings + +Move stable security and lifecycle consumers from direct environment access to +the resolved typed configuration. + +## Acceptance + +- `server.trustedProxies` configures Gin proxy trust with the existing + fail-closed parsing and warning behavior. +- `tasks.preparationTimeout` controls setup and derived launch budgets without a + package-initialization environment read. +- `credentials.file` controls the operator credential file path through typed + startup configuration. +- Environment variables keep their existing behavior and override YAML. +- YAML type or value errors fail startup. Legacy invalid environment values keep + their documented fallback behavior. +- Constructors or startup options receive resolved values. The affected + packages do not gain mutable global configuration. +- Logs identify invalid setting names and sources without exposing secret data. + +## Files likely touched + +- `apps/backend/internal/backendapp/trustedproxies.go` +- `apps/backend/internal/backendapp/trustedproxies_test.go` +- `apps/backend/internal/backendapp/agents.go` +- `apps/backend/internal/backendapp/main.go` +- `apps/backend/internal/common/constants/timeouts.go` +- `apps/backend/internal/common/constants/timeouts_test.go` +- Agent runtime constructors and tests that consume preparation timeouts + +## Dependencies + +Task 02 makes the launcher and backend agree on the selected configuration. + +## TDD sequence + +1. Add tests that set only YAML for trusted proxies, preparation timeout, and + credentials file. Confirm current consumers ignore those values. +2. Add environment-over-YAML cases and invalid YAML cases. +3. Pass typed values into consumers and remove in-scope direct environment + reads. +4. Run focused tests GREEN. Run backend application, constants, auth, and + runtime launch regressions. + +## Verification + +```bash +cd apps/backend && go test ./internal/backendapp -run '^Test.*(TrustedProxies|CredentialsFile|StartupConfig)' -count=1 +cd apps/backend && go test ./internal/common/constants ./internal/agent/runtime/lifecycle -run '^Test.*(Preparation|LaunchTimeout|Config)' -count=1 +cd apps/backend && go test ./internal/backendapp/... ./internal/auth/... -count=1 +``` + +## Risks + +- Trusted proxy configuration affects client IP, login rate limiting, and stored + session IP data. The secure no-trust default must remain explicit. +- Preparation timeouts currently exist as package-level values. Initialization + order can preserve stale environment behavior unless the value becomes an + explicit dependency. +- The credentials file can contain secrets even though the path itself is not a + secret. Error logs must not dump file contents. + +## Output contract + +Record RED and GREEN results, environment compatibility evidence, constructor +changes, direct-read removal, files changed, and remaining risks in +`## Results`. + +## Results + +RED: + +- The new typed trusted-proxy, credential-file, and preparation-timeout tests + initially failed until the backend consumers accepted resolved startup + values. The old package-initialization timeout read also had to be removed + before the typed startup value could take effect. + +GREEN: + +- `go test ./internal/backendapp -run '^Test(ConfigureTrustedProxiesUsesTyped|CredentialFilePath)' -count=1` passed. +- `go test ./internal/common/constants -run '^TestApplyPreparation' -count=1` passed. +- The affected package suite passed as part of `go test ./internal/common/config ./internal/common/constants ./internal/common/subproc ./internal/launcher ./internal/backendapp ./internal/system/queuesettings ./internal/system ./internal/gateway/websocket ./internal/task/service ./internal/runs/scheduler ./internal/office/service ./internal/agentctl/server/config ./internal/agentctl/server/adapter/transport/acp ./internal/agentctl/tracing ./internal/agent/runtime/agentctl/launcher ./internal/agent/runtime/lifecycle`, with 5,691 tests across 16 packages. + +Trusted proxy parsing, credential-file selection, and preparation timeout +application now consume typed configuration. The legacy environment aliases +retain their fallback behavior, and invalid YAML values are rejected during +configuration loading. Preparation timeout state is applied explicitly at +backend startup instead of being captured by package initialization. + +Files changed: + +- `apps/backend/internal/backendapp/trustedproxies.go` +- `apps/backend/internal/backendapp/trustedproxies_test.go` +- `apps/backend/internal/backendapp/agents.go` +- `apps/backend/internal/backendapp/agents_startup_config_test.go` +- `apps/backend/internal/backendapp/main.go` +- `apps/backend/internal/common/constants/timeouts.go` +- `apps/backend/internal/common/constants/timeouts_test.go` diff --git a/docs/plans/startup-configuration-parity/task-04-backend-capacity-and-service-settings.md b/docs/plans/startup-configuration-parity/task-04-backend-capacity-and-service-settings.md new file mode 100644 index 0000000000..44c8a2f367 --- /dev/null +++ b/docs/plans/startup-configuration-parity/task-04-backend-capacity-and-service-settings.md @@ -0,0 +1,114 @@ +--- +id: "04-backend-capacity-and-service-settings" +title: "Backend capacity and service settings" +status: done +wave: 3 +depends_on: ["03-backend-security-and-lifecycle-settings"] +plan: "plan.md" +spec: "../../specs/platform/startup-configuration-parity.md" +--- + +# Task 04: Backend capacity and service settings + +Move stable capacity and service timing settings behind explicit typed startup +configuration. + +## Acceptance + +- `limits.ghMaxConcurrent` and `limits.gitMaxConcurrent` configure subprocess + admission without package-initialization environment reads. +- `limits.lspMaxConnections` configures LSP gateway capacity. +- `planning.coalesceWindowMs` configures plan update coalescing. +- `office.schedulerTickMs` configures the scheduler through one shared value and + removes duplicate parsing. +- Environment variables override YAML. YAML overrides built-in defaults. +- Invalid YAML values fail startup with the key and selected file. Invalid + legacy environment values keep current fallback behavior. +- Values are immutable for one backend process and require restart. +- Focused concurrency tests prove configured limits without timing-sensitive + sleeps. + +## Files likely touched + +- `apps/backend/internal/common/subproc/shared.go` +- `apps/backend/internal/common/subproc/*_test.go` +- `apps/backend/internal/gateway/websocket/lsp_capacity.go` +- `apps/backend/internal/gateway/websocket/*_test.go` +- `apps/backend/internal/task/service/plan_service.go` +- `apps/backend/internal/task/service/*_test.go` +- `apps/backend/internal/runs/scheduler/scheduler.go` +- `apps/backend/internal/runs/scheduler/*_test.go` +- Office scheduler construction and tests +- Backend startup constructors that assemble these services + +## Dependencies + +Task 03 establishes the backend pattern for replacing direct stable environment +reads with explicit configuration. + +## TDD sequence + +1. Add focused YAML-only and environment-over-YAML tests for every setting. Run + them RED. +2. Add constructor options or immutable startup setters at each owning package. +3. Remove duplicate environment parsing and package-initialization reads. +4. Run focused tests GREEN. Run package regressions and race-sensitive tests + where those packages already support them. + +## Verification + +```bash +cd apps/backend && go test ./internal/common/subproc ./internal/gateway/websocket -run '^Test.*(Config|Capacity|Concurrent)' -count=1 +cd apps/backend && go test ./internal/task/service ./internal/runs/scheduler -run '^Test.*(Config|Coalesce|SchedulerTick)' -count=1 +cd apps/backend && go test ./internal/common/subproc ./internal/gateway/websocket ./internal/task/service ./internal/runs/scheduler -count=1 +``` + +## Risks + +- Shared subprocess gates are process-wide singletons. Tests must prove that + startup constructs them once with the resolved limit. +- Two scheduler packages currently parse the same variable. Leaving either read + in place would preserve executor-dependent behavior. +- Millisecond settings can create busy loops. Typed validation needs a positive + lower bound that matches current safe behavior. + +## Output contract + +Record RED and GREEN results, the immutable ownership boundary for each value, +removed duplicate parsing, files changed, and remaining risks in `## Results`. + +## Results + +RED: + +- Capacity and scheduler tests initially failed until typed startup options + replaced package-initialization environment reads and the two scheduler + packages shared one resolved tick interval. + +GREEN: + +- `go test ./internal/common/config ./internal/common/constants ./internal/common/subproc ./internal/gateway/websocket ./internal/task/service ./internal/runs/scheduler ./internal/office/service ./internal/backendapp -run '^Test.*(Config|Capacity|Concurrent|TrustedProxies|CredentialsFile|Preparation|LaunchTimeout|Coalesce|SchedulerTick|Startup)' -count=1` passed. +- The complete affected package suite passed as part of the 5,691-test, + 16-package backend run recorded in Task 03. + +The backend now applies typed Git and GitHub admission caps, LSP capacity, +planning coalescing, and Office scheduler timing at startup. The shared +subprocess singleton is configured once, scheduler parsing is centralized, +and invalid legacy environment values retain their previous default fallback. +The configured values are immutable for the backend process and require a +restart. + +Files changed: + +- `apps/backend/internal/common/subproc/admission.go` +- `apps/backend/internal/common/subproc/shared.go` +- `apps/backend/internal/common/subproc/shared_test.go` +- `apps/backend/internal/common/subproc/throttle.go` +- `apps/backend/internal/gateway/websocket/lsp_handler.go` +- `apps/backend/internal/gateway/websocket/setup.go` +- `apps/backend/internal/gateway/websocket/lsp_capacity_test.go` +- `apps/backend/internal/task/service/plan_service.go` +- `apps/backend/internal/task/service/plan_service_test.go` +- `apps/backend/internal/runs/scheduler/scheduler.go` +- `apps/backend/internal/office/service/scheduler_integration.go` +- `apps/backend/internal/backendapp/main.go` diff --git a/docs/plans/startup-configuration-parity/task-05-agentctl-settings-propagation.md b/docs/plans/startup-configuration-parity/task-05-agentctl-settings-propagation.md new file mode 100644 index 0000000000..1defdd17e0 --- /dev/null +++ b/docs/plans/startup-configuration-parity/task-05-agentctl-settings-propagation.md @@ -0,0 +1,107 @@ +--- +id: "05-agentctl-settings-propagation" +title: "Agentctl settings propagation" +status: done +wave: 4 +depends_on: ["04-backend-capacity-and-service-settings"] +plan: "plan.md" +spec: "../../specs/platform/startup-configuration-parity.md" +--- + +# Task 05: Agentctl settings propagation + +Resolve agentctl settings in the backend and pass them explicitly through every +managed agentctl launch path. + +## Acceptance + +- `agentctl.idleTimeout` configures idle session cleanup. +- `agentctl.idleReaperInterval` configures the reaper interval. +- `agentctl.notificationQueueCapacity` configures ACP notification buffering. +- `observability.otlpEndpoint` configures tracing for the backend and managed + agentctl processes. +- Environment values override YAML and keep compatibility. +- Local, Worktree, Docker, Sprite, and SSH launches receive the same resolved + values. +- Remote and container configuration uses an explicit child contract. It does + not depend on inheriting the launcher's host environment. +- Missing or invalid required child configuration fails that launch clearly. +- Debug-only agentctl logging and retention variables remain excluded. + +## Files likely touched + +- `apps/backend/internal/agentctl/server/config/config.go` +- `apps/backend/internal/agentctl/server/config/config_test.go` +- `apps/backend/internal/agentctl/server/adapter/transport/acp/adapter.go` +- `apps/backend/internal/agentctl/server/adapter/transport/acp/adapter_queue_test.go` +- `apps/backend/internal/agentctl/tracing/otel.go` +- `apps/backend/internal/agent/runtime/lifecycle/manager_startup.go` +- `apps/backend/internal/agent/runtime/lifecycle/executor_docker.go` +- `apps/backend/internal/agent/runtime/lifecycle/executor_standalone_test.go` +- `apps/backend/internal/agent/runtime/lifecycle/executor_docker_test.go` +- `apps/backend/internal/agent/runtime/lifecycle/executor_sprites*.go` +- `apps/backend/internal/agent/runtime/lifecycle/executor_ssh*.go` + +## Dependencies + +Task 04 completes the backend typed-consumer pattern. Task 01 provides the +agentctl and observability fields. + +## TDD sequence + +1. Add server and adapter tests for YAML-only resolved values. Run them RED. +2. Add launch contract tests that inspect Local, Docker, Sprite, and SSH child + configuration. Include Worktree through its local launch path. +3. Replace direct stable environment reads in agentctl with explicit options. +4. Thread the resolved child contract through every executor. +5. Run focused tests GREEN. Run the complete agentctl and lifecycle suites. + +## Verification + +```bash +cd apps/backend && go test ./internal/agentctl/server/config ./internal/agentctl/server/adapter/transport/acp ./internal/agentctl/tracing -run '^Test.*(Config|Idle|Queue|OTLP)' -count=1 +cd apps/backend && go test ./internal/agent/runtime/lifecycle -run '^Test.*(Agentctl|Standalone|Docker|Sprite|SSH).*Config' -count=1 +cd apps/backend && go test ./internal/agentctl/... ./internal/agent/runtime/lifecycle -count=1 +``` + +## Risks + +- Container and remote script quoting can corrupt duration or endpoint values. + Tests must assert the structured launch input before shell rendering and the + rendered result where applicable. +- Utility and passthrough agentctl launches can use separate code paths. The + launch audit must include them. +- Tracing already uses a standard OpenTelemetry environment name. YAML support + must not change its environment override semantics. + +## Output contract + +Record RED and GREEN results, executor coverage, the child configuration shape, +direct-read removal, files changed, and remaining risks in `## Results`. + +## Results + +RED: + +- Agentctl server and executor contract tests initially failed until the + managed child configuration was represented in `common/config` and threaded + through lifecycle requests. + +GREEN: + +- `go test ./internal/agent/runtime/agentctl/launcher ./internal/agent/runtime/lifecycle ./internal/agentctl/server/config ./internal/agentctl/server/adapter/transport/acp ./internal/common/config -run '^Test.*(Startup|EnvironmentWithOverrides|Queue|LoadWithStartup|ConfigurationCatalog|Agentctl)' -count=1` passed 148 tests across 5 packages. +- `go test ./internal/common/config ./internal/agentctl/server/config ./internal/agentctl/server/adapter/transport/acp ./internal/agentctl/tracing ./internal/agent/runtime/agentctl/launcher ./internal/agent/runtime/lifecycle ./internal/backendapp -run '^Test.*(Startup|Config|Queue|Agentctl|Explicit|Capacity|CredentialFilePath|TrustedProxies)' -count=1` passed 393 tests across 7 packages. +- The complete affected package suite passed in the 5,691-test backend run recorded in Task 03. + +The backend resolves one validated `AgentctlStartupConfig` and passes it +through Local, Worktree, Docker, Sprite, and SSH launch paths. The private +contract carries idle timeout, reaper interval, notification queue capacity, +and OTLP endpoint values. Managed agentctl processes load that contract before +legacy standalone compatibility paths; malformed contracts fail launch rather +than silently falling back. Host environment inheritance is removed where it +could conflict with the managed contract, while direct standalone adapters +retain compatibility reads for existing callers and tests. + +Files changed include the common contract, agentctl server/config/adapter and +tracing packages, lifecycle manager and executor launch paths, and the focused +launcher, adapter, configuration, and lifecycle tests. diff --git a/docs/plans/startup-configuration-parity/task-06-message-queue-configuration-lock.md b/docs/plans/startup-configuration-parity/task-06-message-queue-configuration-lock.md new file mode 100644 index 0000000000..f4d14d85b5 --- /dev/null +++ b/docs/plans/startup-configuration-parity/task-06-message-queue-configuration-lock.md @@ -0,0 +1,137 @@ +--- +id: "06-message-queue-configuration-lock" +title: "Message queue configuration lock" +status: done +wave: 4 +depends_on: ["05-agentctl-settings-propagation"] +plan: "plan.md" +spec: "../../specs/platform/startup-configuration-parity.md" +--- + +# Task 06: Message queue configuration lock + +Make the queue capacity resolver honor YAML above database overrides and report +that source accurately in Settings. + +## Acceptance + +- `messageQueue.maxPerSession` supplies the queue capacity when no environment + value exists. +- Effective precedence is environment, YAML, database override, then default. +- The API source union adds `configuration` without changing existing + `environment`, `setting`, and `default` meanings. +- A YAML value locks editing in the API and UI just like an environment value. +- The UI names configuration as the source. It does not show the environment + variable lock message for a YAML value. +- Desktop and mobile use the same source semantics and accessible labels. +- The shared responsive Settings composition remains unchanged. +- All new user-facing text exists in English, Portuguese, Simplified Chinese, + Hong Kong Traditional Chinese, and Taiwan Traditional Chinese. + +## Files likely touched + +- `apps/backend/internal/system/queuesettings/types.go` +- `apps/backend/internal/system/queuesettings/service.go` +- `apps/backend/internal/system/queuesettings/*_test.go` +- Backend settings handler or boot wiring that supplies queue configuration +- `apps/web/lib/types/system.ts` +- `apps/web/components/settings/system/message-queue-settings.tsx` +- `apps/web/components/settings/system/message-queue-settings.test.tsx` +- `apps/web/src/locales/en/system.json` +- `apps/web/src/locales/pt-pt/system.json` +- `apps/web/src/locales/zh-cn/system.json` +- `apps/web/src/locales/zh-hk/system.json` +- `apps/web/src/locales/zh-tw/system.json` +- `apps/web/e2e/tests/system/message-queue-settings.spec.ts` +- `apps/web/e2e/tests/system/mobile-message-queue-settings.spec.ts` + +## Dependencies + +Task 01 defines the typed queue field and provenance. Task 05 completes the +startup configuration propagation work before the product-facing source change. + +## TDD sequence + +1. Add backend service tests for YAML source, all precedence layers, and locked + writes. Run them RED. +2. Add frontend unit tests for configuration source copy and disabled controls. + Run them RED. +3. Implement the backend source union and frontend rendering. Add translations, + then generate the Traditional Chinese pair with the repository command. +4. Extend the existing desktop and mobile queue E2E tests. Confirm the mobile + controls remain reachable, readable, and at least 44 CSS pixels where the + existing test requires an interactive target. +5. Run focused tests GREEN, then internationalization and E2E checks. + +## Verification + +```bash +cd apps/backend && go test ./internal/system/queuesettings -run '^Test.*(Config|Source|Precedence|Locked)' -count=1 +cd apps && pnpm --filter @kandev/web test -- components/settings/system/message-queue-settings.test.tsx +cd apps/web && pnpm run i18n:zh-hant && pnpm run i18n:check +cd apps/web && pnpm e2e:run --project chromium tests/system/message-queue-settings.spec.ts +cd apps/web && pnpm e2e:run --project mobile-chrome tests/system/mobile-message-queue-settings.spec.ts +``` + +Run `pnpm install --frozen-lockfile` from `apps/` first when the worktree does +not have `apps/node_modules/`. + +## Mobile parity + +This task changes source copy and lock state in an existing shared settings +form. It does not introduce a new mobile composition. The existing mobile E2E +spec remains the contract for navigation, scrolling, control reachability, and +touch target size. Extend that spec with the configuration-source state. + +## Risks + +- Treating YAML as a database value would allow the UI to overwrite an operator + policy. The backend source and write guard must agree. +- Reusing environment lock copy would misreport provenance even if the control + is correctly disabled. +- Manual Traditional Chinese edits can drift. Use the repository conversion + command and run the complete locale check. + +## Output contract + +Record RED and GREEN results, API source payloads, desktop and mobile evidence, +locale commands, files changed, and remaining risks in `## Results`. + +## Results + +RED: + +- `go test ./internal/system/queuesettings -run '^Test.*(Configuration|EnvironmentLock)' -count=1` failed to compile with the missing configuration source, startup configuration type, and configuration lock error. +- The frontend unit suite failed the new configuration-source test by rendering `Default` instead of `Configuration` before the source union and rendering branch were implemented. + +GREEN: + +- `go test ./internal/system/queuesettings ./internal/backendapp -run '^Test.*(Configuration|Source|Precedence|Locked|Queue)' -count=1` passed 65 tests. +- `pnpm --filter @kandev/web test -- components/settings/system/message-queue-settings.test.tsx` passed 27 tests. +- `pnpm run i18n:zh-hant` generated the Traditional Chinese pair, and `pnpm run i18n:check` passed with all five locale catalogs complete and no em-dash or non-JSX-copy violations. +- `pnpm e2e:run --project chromium tests/system/message-queue-settings.spec.ts` passed 6 tests, and `pnpm e2e:run --project mobile-chrome tests/system/mobile-message-queue-settings.spec.ts` passed 2 tests for the configuration lock and mobile touch-target behavior. + +The queue resolver now uses environment, YAML configuration, persisted setting, +then default precedence. The API adds `configuration` as a source and returns +`locked: true` for a YAML capacity. PATCH requests reject capacity changes with +a conflict while still allowing the two merge settings to change. Desktop and +mobile share the existing responsive form, source label, configuration-specific +lock copy, and accessible input/touch-target semantics. + +Files changed: + +- `apps/backend/internal/system/queuesettings/types.go` +- `apps/backend/internal/system/queuesettings/resolver.go` +- `apps/backend/internal/system/queuesettings/service.go` +- `apps/backend/internal/system/queuesettings/handler.go` +- `apps/backend/internal/system/queuesettings/queuesettings_test.go` +- `apps/backend/internal/system/system.go` +- `apps/backend/internal/backendapp/orchestrator.go` +- `apps/backend/internal/backendapp/main.go` +- `apps/backend/internal/backendapp/message_queue_settings_test.go` +- `apps/web/lib/types/system.ts` +- `apps/web/components/settings/system/message-queue-settings.tsx` +- `apps/web/components/settings/system/message-queue-settings.test.tsx` +- `apps/web/e2e/tests/system/message-queue-settings.spec.ts` +- `apps/web/e2e/tests/system/mobile-message-queue-settings.spec.ts` +- the five locale catalogs and pseudo locale diff --git a/docs/plans/startup-configuration-parity/task-07-public-configuration-reference.md b/docs/plans/startup-configuration-parity/task-07-public-configuration-reference.md new file mode 100644 index 0000000000..87f4d7a311 --- /dev/null +++ b/docs/plans/startup-configuration-parity/task-07-public-configuration-reference.md @@ -0,0 +1,113 @@ +--- +id: "07-public-configuration-reference" +title: "Public configuration reference" +status: done +wave: 5 +depends_on: ["06-message-queue-configuration-lock"] +plan: "plan.md" +spec: "../../specs/platform/startup-configuration-parity.md" +--- + +# Task 07: Public configuration reference + +Publish the complete startup configuration contract and synchronize affected +specs and engineering guidance with the implemented behavior. + +## Acceptance + +- The public configuration guide documents home file discovery, exact file + order, first-file failure behavior, no merging, precedence, and restart + requirements. +- The guide lists every stable startup setting with YAML key, environment + variable, type, default, and security notes where relevant. +- Every published startup environment variable has a YAML equivalent or a + justified exclusion. +- Secret guidance states that Kandev supports YAML secrets, never logs their + values, warns on broad Unix read permissions, and recommends mode `0600`. +- CLI and service guides explain that manual and service launches automatically + use `/config.yaml`. +- The trusted proxy example shows `server.trustedProxies` for the reverse proxy + case and keeps the spoofing warning. +- The setup timeout and trusted proxy behavioral specs match the new YAML + contract. +- Root and backend engineering guidance describe the catalog and source owner + if the implementation creates new architecture boundaries. +- Public documentation validation and link checks pass. + +## Files likely touched + +- `docs/public/configuration.md` +- `docs/public/cli.md` +- `docs/public/run-as-a-service.md` +- `docs/specs/auth/trusted-proxies.md` +- `docs/specs/platform/setup-launch-timeout.md` +- `AGENTS.md` +- `apps/backend/AGENTS.md` +- Any generated public navigation metadata required by the docs system + +## Dependencies + +Task 06 completes behavior and final public names before the reference is +published. + +## Documentation structure + +1. Explain the configuration surfaces and source precedence. +2. Show discovery examples for manual, service, and working-directory launches. +3. Publish one canonical key table. Avoid separate partial tables that can + drift. +4. Group exclusions by internal wiring, generated secrets, test and debug, + packaging, platform detection, and UI-managed data. +5. Add secure secret and reverse proxy examples. +6. Link affected topic guides back to the canonical reference. + +## Verification + +```bash +node scripts/validate-public-docs.mjs +git diff --check +``` + +Also run the configuration catalog completeness test from Task 01 after the +documentation inventory is final. + +## Risks + +- A hand-maintained documentation table can drift from the catalog. Prefer a + checked inventory or a test that compares documented identifiers with the + catalog. +- Examples must not include real tokens, passwords, internal child variables, + or unsafe broad trusted-proxy ranges. +- Existing topic specs currently describe some settings as environment-only. + Leaving those statements unchanged would create two sources of truth. + +## Output contract + +Record validation results, the final inventory counts, synchronized specs and +guides, files changed, and remaining risks in `## Results`. + +## Results + +- `node scripts/validate-public-docs.mjs` passed and validated 41 published + documentation pages. +- `git diff --check` is included in the final repository gate. + +The public configuration reference now documents the working-directory, home, +and `/etc/kandev` candidate order, first-existing-file failure behavior, no +merging, restart requirements, stable YAML keys and environment aliases, +capacity and agentctl settings, message-queue source locks, YAML secret +handling, owner-only `0600` guidance, and the trusted-proxy example. The CLI +and service guides describe automatic home-file discovery and selected-file +handoff. Trusted-proxy and setup-timeout specs now describe the YAML keys and +environment precedence. Root and backend agent guidance identifies the typed +catalog/source owner and private child contract boundary. + +Files changed: + +- `docs/public/configuration.md` +- `docs/public/cli.md` +- `docs/public/run-as-a-service.md` +- `docs/specs/auth/trusted-proxies.md` +- `docs/specs/platform/setup-launch-timeout.md` +- `AGENTS.md` +- `apps/backend/AGENTS.md` diff --git a/docs/public/automation-and-mcp.md b/docs/public/automation-and-mcp.md index c2b703b7a0..3970a1a8c8 100644 --- a/docs/public/automation-and-mcp.md +++ b/docs/public/automation-and-mcp.md @@ -540,8 +540,9 @@ Office runs use a smaller MCP surface than regular task-mode sessions. The built - `create_task_plan_kandev`, `get_task_plan_kandev`, `update_task_plan_kandev`, and `delete_task_plan_kandev`; - `list_related_tasks_kandev`; - `list_task_documents_kandev`, `get_task_document_kandev`, and `write_task_document_kandev`. +- `record_step_decision_kandev` records an `approved` or `rejected` verdict for the current workflow step. It requires a non-empty reason, and a later verdict supersedes the earlier one. -These tools cover human questions, the current task plan, related-task discovery, and task documents. Office state changes use the injected `$KANDEV_CLI kandev ...` commands instead. An Office agent should not search for additional Kandev MCP tools: Kanban/configuration tools and `step_complete_kandev` are task-mode only and are not registered in Office mode. +These tools cover human questions, the current task plan, related-task discovery, task documents, and quorum decisions. Office state changes use the injected `$KANDEV_CLI kandev ...` commands instead. An Office agent should not search for additional Kandev MCP tools: Kanban/configuration tools and `step_complete_kandev` are task-mode only and are not registered in Office mode. ### Runtime credentials diff --git a/docs/public/cli.md b/docs/public/cli.md index 49e027d1d5..f21c77ceae 100644 --- a/docs/public/cli.md +++ b/docs/public/cli.md @@ -239,6 +239,14 @@ See [Configuration](./configuration.md) before putting Kandev behind a reverse p Flags take precedence over the equivalent port variables. `KANDEV_BACKEND_PORT` takes precedence over `KANDEV_PORT`. +Every CLI launch automatically uses the startup configuration discovery in +[Configuration](./configuration.md): `config.yaml` in the working directory, +then `/config.yaml` (or `~/.kandev/config.yaml`), then +`/etc/kandev/config.yaml`. The first existing file is authoritative; Kandev +does not merge candidates, and an unreadable or invalid first file stops the +launch. There is no public `--config` flag. Service launches use the same +discovery and carry the selected file into the managed backend process. + | Variable | Default | Behavior | |---|---|---| | `KANDEV_BACKEND_PORT` | unset | Backend port when `--port` is absent. | @@ -335,4 +343,8 @@ Open the printed URL manually. Linux requires a working `xdg-open`; WSL often ne ### Configuration appears ignored -The public CLI has no `--config` option. `config.yaml` must be in the launcher's backend working directory or `/etc/kandev/`, and environment values override it. For predictable installed/service deployments, prefer explicit environment variables and verify the active home and database paths printed at startup. See [Configuration](./configuration.md). +The public CLI has no `--config` option. Confirm the first existing candidate +in the working-directory, home, and `/etc/kandev/` order, and remember that +environment values override YAML. For predictable installed/service +deployments, verify the active home and database paths printed at startup. +See [Configuration](./configuration.md). diff --git a/docs/public/configuration.md b/docs/public/configuration.md index 94f6000e9e..2ef97f4c65 100644 --- a/docs/public/configuration.md +++ b/docs/public/configuration.md @@ -25,10 +25,21 @@ This page is the startup-configuration reference. Executor-specific fields are c At backend startup, later sources override earlier ones: 1. embedded production-profile values and built-in defaults; -2. the first readable `config.yaml`; and +2. the first existing `config.yaml`; and 3. environment variables. -The public launcher has no `--config` option. It searches for a file named exactly `config.yaml` in the backend working directory and then `/etc/kandev/`. A missing file is allowed. Malformed YAML, unmarshal errors, and validated invalid values stop startup. +The public launcher has no `--config` option. It searches for exactly these +files, in order: + +1. `config.yaml` in the process working directory; +2. `/config.yaml`, or `~/.kandev/config.yaml` when + `KANDEV_HOME_DIR` is unset; and +3. `/etc/kandev/config.yaml`. + +Kandev uses only the first existing candidate. It does not merge files. A +missing candidate is skipped, but an unreadable, malformed, or invalid first +candidate stops startup and names that file. A home configuration file cannot +set `homeDir`, because it cannot relocate the directory that selected it. Configuration is read at process start; there is no file watcher. Restart Kandev after changing YAML or environment variables. The CLI, desktop shell, service manager, Docker, or Kubernetes may set environment variables on the backend, so those values can override a file unexpectedly. Use the process/service/container environment as the final source of truth. @@ -54,11 +65,12 @@ Some common camelCase keys have explicit compatibility aliases. Use the document |---|---|---|---| | `homeDir` | `KANDEV_HOME_DIR` | `~/.kandev` | Root for data, tasks, worktrees, cloned repositories, sessions, and logs. A leading `~/` expands. | | `server.host` | `KANDEV_SERVER_HOST` | `0.0.0.0` | HTTP listen address. Use `127.0.0.1` for local-only access. | -| `server.port` | `KANDEV_SERVER_PORT` | `38429` | UI, HTTP API, WebSocket, and MCP port; must be `1`-`65535`. The launcher normally supplies its selected port. | +| `server.port` | `KANDEV_SERVER_PORT` (`KANDEV_BACKEND_PORT`, `KANDEV_PORT` aliases) | `38429` | UI, HTTP API, WebSocket, and MCP port; must be `1`-`65535`. The launcher normally supplies its selected port. | | `server.readTimeout` | `KANDEV_SERVER_READTIMEOUT` | `30` | HTTP read timeout in seconds. | | `server.writeTimeout` | `KANDEV_SERVER_WRITETIMEOUT` | `30` | HTTP write timeout in seconds. | | `server.webInternalUrl` | `KANDEV_WEB_INTERNAL_URL` | empty | Development reverse-proxy target for a separately running web app. Installed releases normally serve embedded assets. | | `server.webTitlePrefix` | `KANDEV_WEB_TITLE_PREFIX` | empty | Prefixes the browser tab title as ` Kandev` (for example `TEST` renders `TEST Kandev`), so several instances stay distinguishable in adjacent tabs. `make dev` defaults to `Dev`; `make start-debug` keeps production defaults, enables diagnostics, and defaults to `Debug`; PR previews use `Preview`. An explicit value overrides these defaults. Empty keeps the plain `Kandev` title. | +| `server.trustedProxies` | `KANDEV_TRUSTED_PROXIES` | empty list | IP addresses or CIDR ranges for proxies whose forwarded client headers Kandev accepts. See [Trusted proxies](#trusted-proxies-for-x-forwarded-for). | The default host exposes the server on every interface even though the CLI prints a `localhost` URL. The current local product path must not be treated as an authenticated multi-user perimeter. For remote access, bind to loopback and use a trusted authenticated tunnel/proxy, or isolate the network at the deployment layer. @@ -121,9 +133,13 @@ The launcher starts `agentctl`, performs a one-time nonce handshake, and supplie ### Setup and launch timing -`KANDEV_TASK_PREPARATION_TIMEOUT` controls how long Kandev allows repository -setup and executor-profile prepare scripts to run. The value uses Go duration -syntax, such as `90s`, `10m`, or `1h`. The default is `10m`. +| YAML key | Environment variable | Default | Current behavior | +|---|---|---|---| +| `tasks.preparationTimeout` | `KANDEV_TASK_PREPARATION_TIMEOUT` | `10m` | Positive Go duration for repository setup and executor-profile prepare scripts. | + +`tasks.preparationTimeout` controls how long Kandev allows repository setup and +executor-profile prepare scripts to run. The value uses Go duration syntax, +such as `90s`, `10m`, or `1h`. Only positive durations are accepted. An unset, invalid, zero, or negative value uses the `10m` default. Kandev reads this environment variable when the backend @@ -134,8 +150,32 @@ Runtime launch phases use the configured preparation timeout plus a fixed five-minute allowance for runtime creation and `agentctl` readiness. With the default, each launch-phase limit is `15m`. Preparation scripts use a separate context, so earlier work such as Sprite uploads does not reduce their full -`10m` preparation budget. This setting is environment-only; it is not read from -YAML, the database, or Settings. +`10m` preparation budget. The environment variable overrides YAML. This is a +startup setting, not a database or Settings value. + +### Capacity and managed-process startup settings + +| YAML key | Environment variable | Type and default | Current behavior | +|---|---|---|---| +| `credentials.file` | `KANDEV_CREDENTIALS_FILE` | path, empty | Optional fallback JSON credential file. The file is read lazily and must be restricted to the Kandev service account. | +| `limits.ghMaxConcurrent` | `KANDEV_GH_MAX_CONCURRENT` | positive integer, `8` | Process-wide `gh` subprocess admission cap. | +| `limits.gitMaxConcurrent` | `KANDEV_GIT_MAX_CONCURRENT` | positive integer, `12` | Process-wide `git` subprocess admission cap. | +| `limits.lspMaxConnections` | `KANDEV_LSP_MAX_CONNECTIONS` | positive integer, `8` | Active browser-to-task-host language-server connection cap. | +| `messageQueue.maxPerSession` | `KANDEV_QUEUE_MAX_PER_SESSION` | integer `>= 0`, `10` | Per-session pending-message cap. Zero means unlimited. A non-negative YAML or environment value locks capacity in Settings; a negative environment value means unlimited, and invalid environment input falls through to the lower-precedence source. | +| `agentctl.idleTimeout` | `KANDEV_ACP_IDLE_TIMEOUT` | Go duration, `1h` | Idle managed-agent reaping timeout. Zero disables reaping. | +| `agentctl.idleReaperInterval` | `KANDEV_ACP_IDLE_REAPER_INTERVAL` | Go duration, `1m` | Interval between idle-agent scans. | +| `agentctl.notificationQueueCapacity` | `KANDEV_ACP_NOTIF_QUEUE` | integer `1024`-`131072`, `131072` | ACP inbound notification queue capacity. An out-of-range YAML value fails startup; an invalid or out-of-range environment value uses the built-in default. | +| `planning.coalesceWindowMs` | `KANDEV_PLAN_COALESCE_WINDOW_MS` | integer `>= 0`, `300000` | Same-author plan revision coalescing window in milliseconds. | +| `observability.otlpEndpoint` | `OTEL_EXPORTER_OTLP_ENDPOINT` | URL, empty | OTLP tracing endpoint. Treat the value and emitted spans as sensitive. | +| `office.schedulerTickMs` | `KANDEV_OFFICE_SCHEDULER_TICK_MS` | positive integer, `5000` | Office queued/retry run safety-net interval in milliseconds. | +| `launcher.webPort` | `KANDEV_WEB_PORT` | automatic, `0` | Development web-server port. It is used with `dev` and ignored by embedded-asset launches. | +| `launcher.healthTimeoutMs` | `KANDEV_HEALTH_TIMEOUT_MS` | positive integer, `45000` | Launcher startup-health timeout in milliseconds. Development and E2E profiles use a longer default. | +| `launcher.noBrowser` | `KANDEV_NO_BROWSER` | `false` | Suppresses browser opening when true or `1`, depending on launch mode. | + +These settings are read at startup. Their environment aliases remain +compatible overrides, and a YAML value is not copied into a public process +environment variable. Managed agentctl children receive their resolved subset +through a private process contract. ### Authentication, Office, Plugins, and feature flags @@ -143,12 +183,11 @@ YAML, the database, or Settings. |---|---|---|---| | `auth.jwtSecret` | `KANDEV_AUTH_JWTSECRET` | generated value | Accepted and validated compatibility configuration; the current main HTTP product path does not use it as an authentication boundary. | | `auth.tokenDuration` | `KANDEV_AUTH_TOKENDURATION` | `3600` | Must be positive, but is not consumed by the current main HTTP product path. | +| `auth.sessionTTLHours` | `KANDEV_AUTH_SESSIONTTLHOURS` | `720` | Compatibility session lifetime setting. | | `auth.cookieName` | `KANDEV_AUTH_COOKIE_NAME` | empty | Session cookie base name. Empty (default) means the effective name is derived from the request host: `kandev_session` on a default-port host, `kandev_session_` on a ported host. This isolates multiple instances on one host (see [authentication](authentication.md#multiple-instances-on-one-host)). A non-empty value is used verbatim (never port-suffixed) and disables automatic isolation, so it must be unique per cookie host. Precedence: environment over config file over default. | | `office.jwtSigningKey` | `KANDEV_OFFICE_JWTSIGNINGKEY` | random per start | HMAC key for Office agent-runtime JWTs. Set a stable secret when Office tasks must survive restarts. | -| `features.office` | `KANDEV_FEATURES_OFFICE` | `false` in production | Experimental Office UI, routes, services, and automation. | -| `features.auth` | `KANDEV_FEATURES_AUTH` | `false` in production | Opt-in authentication and per-user workspaces. The first visitor after enabling completes setup and becomes the admin. | -| `features.claude_background_prompt_handoff` | `KANDEV_FEATURES_CLAUDE_BACKGROUND_PROMPT_HANDOFF` | `false` | High-risk experiment that lets Claude Code accept a new prompt after its foreground yields while adapter-attested background work remains active. Other providers keep the coarse busy gate. | -| `features.claude_mid_turn_steering` | `KANDEV_FEATURES_CLAUDE_MID_TURN_STEERING` | `false` | High-risk experiment that delivers a new prompt into a Claude turn that is still generating (mid-turn steering) instead of queuing it, for agents that advertise prompt queueing. Whether the agent folds the prompt into the running turn or runs it next is the agent's decision; other providers keep the coarse busy gate. | +| `githubCredentialBroker.publicBaseUrl` | `KANDEV_GITHUB_CREDENTIAL_BROKER_PUBLIC_BASE_URL` | empty | Public HTTPS base URL used by remote executors to resolve GitHub credentials. Loopback HTTP is allowed for development. | +Runtime feature flags are documented in [Runtime feature toggles](#runtime-feature-toggles); they are profile, environment, and database-backed controls, not canonical YAML settings. Do not infer security from `auth.jwtSecret`: setting it currently does not turn the local server into an authenticated public service. Office's JWT key has a narrower, active purpose. Store both active secrets and third-party API keys in your deployment secret manager; never commit them in `config.yaml`. @@ -158,8 +197,9 @@ in that plugin's own settings. Kandev ignores the old key and no longer serves ` ### Trusted proxies for X-Forwarded-For -`KANDEV_TRUSTED_PROXIES` lists the reverse proxies whose forwarded client IP -Kandev trusts. Format: a comma-separated list of IP addresses or CIDR ranges, +`server.trustedProxies` and its `KANDEV_TRUSTED_PROXIES` override list the +reverse proxies whose forwarded client IP Kandev trusts. YAML uses an array; +the environment form is a comma-separated list of IP addresses or CIDR ranges, for example `10.0.0.0/8,192.168.0.0/16`. IPv6 addresses and CIDRs are accepted. When the TCP peer of a request is in the list, the client IP is read from `X-Forwarded-For` (then `X-Real-IP`); otherwise those headers are @@ -167,6 +207,15 @@ ignored and the TCP peer address is used. The resolved IP feeds the login session record (Settings > Account > Security) and the login rate-limiter key. +For a reverse proxy, use a narrow list of proxy addresses: + +```yaml +server: + trustedProxies: + - 10.0.0.5 + - 192.168.0.0/16 +``` + Default: unset, meaning no trusted proxies. Forwarded headers are ignored entirely and the recorded client IP is always the TCP peer. This is the secure default: gin would otherwise trust every proxy by default, which lets @@ -192,9 +241,9 @@ forwarded host. That warning is logged once per distinct peer and host, not per request, so it names the misconfiguration without flooding the log. Add the peer it names to `KANDEV_TRUSTED_PROXIES` if it is your reverse proxy. -The variable is read once at startup and must reach the backend process: set -it in the environment of the process that launches kandev, for example a -systemd drop-in for `kandev.service` (`systemctl --user edit +The value is read once at startup and must reach the backend process. Set the +environment override in the environment of the process that launches kandev, +for example a systemd drop-in for `kandev.service` (`systemctl --user edit kandev.service`) or the container environment. The supervisor manifest at `~/.kandev/supervisor/launch.json` is generated by the launcher on every launch and is not an environment configuration source. @@ -284,6 +333,7 @@ server: port: 38429 readTimeout: 30 writeTimeout: 30 + trustedProxies: [] webInternalUrl: "" webTitlePrefix: "" @@ -320,6 +370,36 @@ agent: standaloneHost: "localhost" standalonePort: 39429 +tasks: + preparationTimeout: "10m" + +credentials: + file: "" + +limits: + ghMaxConcurrent: 8 + gitMaxConcurrent: 12 + lspMaxConnections: 8 + +messageQueue: + maxPerSession: 10 + +agentctl: + idleTimeout: "1h" + idleReaperInterval: "1m" + notificationQueueCapacity: 131072 + +planning: + coalesceWindowMs: 300000 + +observability: + otlpEndpoint: "" + +launcher: + webPort: 0 + healthTimeoutMs: 45000 + noBrowser: false + auth: jwtSecret: "" # compatibility-only for the main HTTP product path tokenDuration: 3600 # compatibility-only for the main HTTP product path @@ -348,11 +428,8 @@ debug: office: jwtSigningKey: "" + schedulerTickMs: 5000 -features: - office: false - auth: false - claude_background_prompt_handoff: false ``` Copying this entire file is unnecessary and can freeze old defaults in a deployment. Keep only deliberate overrides. On Windows, do not copy the Unix Docker host/path literals from this example. @@ -365,12 +442,13 @@ Copying this entire file is unnecessary and can freeze old defaults in a deploym | Key | Environment lock | Production default | Effect | |---|---|---|---| -| `features.office` | `KANDEV_FEATURES_OFFICE` | off | Experimental autonomous-agent Office surfaces and automation. | -| `features.auth` | `KANDEV_FEATURES_AUTH` | off | Authentication and per-user workspaces for the whole install. | -| `features.claudeBackgroundPromptHandoff` | `KANDEV_FEATURES_CLAUDE_BACKGROUND_PROMPT_HANDOFF` | off | High-risk Claude Code experiment that exposes recognized background-only activity and admits a successor prompt. | -| `features.claudeMidTurnSteering` | `KANDEV_FEATURES_CLAUDE_MID_TURN_STEERING` | off | High-risk Claude Code experiment that delivers a prompt into a still-generating turn (mid-turn steering) instead of queuing it, for agents advertising prompt queueing. | | `debug.devMode` | `KANDEV_DEBUG_DEV_MODE` | off | High-risk diagnostic endpoints and ACP frame logging. | +The `KANDEV_FEATURES_*` values have no canonical YAML keys. They are selected +from the embedded profile and can be overridden by the environment or the +database-backed toggle controls in this section. Use **Settings → System → +Feature Toggles** for persistent product changes. + UI changes are persisted in the database and require a restart. An explicitly set environment value wins and locks the UI control. Otherwise a database override wins over the embedded profile/default. Resetting a toggle removes its database override. For a risky release feature, keep the flag off in the shipped profiles, enable it @@ -402,25 +480,37 @@ The file is loaded lazily, expects a flat string-to-string object, and is cached Profile environment variables are eventually injected into agent subprocesses or remote executor environments. Anyone who can edit a profile, inspect a remote host/container, enable debug frame logs, or run commands as the Kandev account may be able to access them. Use least-privilege, task-scoped credentials and rotate them after exposure. +### YAML secrets and file permissions + +Stable startup secrets can be stored in `config.yaml`, including +`database.password`, `nats.url`, `auth.jwtSecret`, `office.jwtSigningKey`, and +`observability.otlpEndpoint`. Kandev keeps secret values in typed +configuration, never writes those values to logs, and warns when a selected +secret-bearing file is readable by group or other users on Unix. The warning +does not block startup. Use owner-only mode `0600` and prefer the deployment's +secret injection mechanism when one is available. Do not commit secret values +to a repository. + ## Advanced operator tuning -These variables are supported by specific runtime components but are not YAML fields. Most are startup-only; the message queue limit also has a live database-backed setting described below. Leave them at defaults unless diagnosing a measured problem. +These settings are startup-only unless stated otherwise. Their YAML keys are +the canonical file form; the environment aliases remain compatibility +overrides. -| Variable | Default | Parsing and effect | -|---|---:|---| -| `KANDEV_GH_MAX_CONCURRENT` | `8` | Positive integer process-wide cap for `gh` subprocesses; invalid/non-positive uses default. | -| `KANDEV_GIT_MAX_CONCURRENT` | `12` | Positive integer process-wide cap for `git` subprocesses; invalid/non-positive uses default. | -| `KANDEV_LSP_MAX_CONNECTIONS` | `8` | Positive integer process-wide cap for active browser-to-task-host language-server streams; invalid/non-positive uses default. | -| `KANDEV_QUEUE_MAX_PER_SESSION` | `10` | Pending messages per session. A valid value overrides and locks the saved UI setting; zero/negative means unlimited, while invalid falls through to the saved setting or default. | -| `KANDEV_ACP_IDLE_TIMEOUT` | `1h` | Go duration after which idle agentctl instances are reaped; `0` disables. Invalid uses default. | -| `KANDEV_ACP_IDLE_REAPER_INTERVAL` | `1m` | Go duration between idle scans. Intended mainly for testing; use the default in production. | -| `KANDEV_ACP_NOTIF_QUEUE` | `131072` | Per-connection ACP inbound notification capacity; positive values clamp to `1024`-`131072`, invalid uses default. | -| `KANDEV_PLAN_COALESCE_WINDOW_MS` | `300000` | Non-negative milliseconds for same-author plan revision coalescing; invalid/negative uses five minutes. | -| `KANDEV_OFFICE_SCHEDULER_TICK_MS` | `5000` | Positive integer safety-net interval for queued/retry run claiming. New-run signals are event-driven; invalid/non-positive uses five seconds. | -| `KANDEV_MCP_LOG_FILE` | unset | File path for per-agentctl MCP debug logs. Logs tool names, arguments, session IDs, results for tool errors, and timings; invalid paths warn and disable this sink. | -| `OTEL_EXPORTER_OTLP_ENDPOINT` | unset | Enables OTLP/HTTP tracing for backend and agentctl spans; unset uses a no-op tracer. See the transport warning below. | +| YAML key | Environment variable | Default | Parsing and effect | +|---|---|---:|---| +| `limits.ghMaxConcurrent` | `KANDEV_GH_MAX_CONCURRENT` | `8` | Positive integer process-wide cap for `gh` subprocesses; invalid/non-positive uses default. | +| `limits.gitMaxConcurrent` | `KANDEV_GIT_MAX_CONCURRENT` | `12` | Positive integer process-wide cap for `git` subprocesses; invalid/non-positive uses default. | +| `limits.lspMaxConnections` | `KANDEV_LSP_MAX_CONNECTIONS` | `8` | Positive integer cap for active browser-to-task-host language-server streams; invalid/non-positive uses default. | +| `messageQueue.maxPerSession` | `KANDEV_QUEUE_MAX_PER_SESSION` | `10` | Pending messages per session. A non-negative YAML value locks the saved UI capacity; a valid environment value overrides YAML and locks it; a negative environment value means unlimited; malformed environment input falls through to YAML, the saved setting, or default. | +| `agentctl.idleTimeout` | `KANDEV_ACP_IDLE_TIMEOUT` | `1h` | Go duration after which idle managed agentctl instances are reaped; `0` disables. Invalid uses default. | +| `agentctl.idleReaperInterval` | `KANDEV_ACP_IDLE_REAPER_INTERVAL` | `1m` | Go duration between idle scans. | +| `agentctl.notificationQueueCapacity` | `KANDEV_ACP_NOTIF_QUEUE` | `131072` | Per-connection ACP inbound notification capacity. YAML values outside `1024`-`131072` fail startup; invalid or out-of-range environment values use `131072`. | +| `planning.coalesceWindowMs` | `KANDEV_PLAN_COALESCE_WINDOW_MS` | `300000` | Non-negative milliseconds for same-author plan revision coalescing; invalid/negative uses five minutes. | +| `office.schedulerTickMs` | `KANDEV_OFFICE_SCHEDULER_TICK_MS` | `5000` | Positive integer safety-net interval for queued/retry run claiming. New-run signals are event-driven. | +| `observability.otlpEndpoint` | `OTEL_EXPORTER_OTLP_ENDPOINT` | unset | Enables OTLP/HTTP tracing for backend and agentctl spans; unset uses a no-op tracer. | -Changing concurrency values trades process pressure against throughput and requires a restart. The queue environment value is also read at startup. Under **Settings > Task Behavior > Message Queue**, an admin can save an install-wide capacity and independently control manual and automatic merging. All three values apply live and persist across restarts. `KANDEV_QUEUE_MAX_PER_SESSION` overrides and locks only capacity; neither merge switch has an environment override. `0` means unlimited. Lowering the live limit does not prune existing rows; new admissions remain blocked until the pending count drops below the limit, while retries of already accepted work remain eligible. The default-on automatic switch affects only later admissions and never bypasses capacity or sweeps existing rows. +Changing concurrency values trades process pressure against throughput and requires a restart. Under **Settings > Task Behavior > Message Queue**, an admin can save an install-wide capacity and independently control manual and automatic merging. The merge switches apply live and persist across restarts. `messageQueue.maxPerSession` resolves as environment, YAML, saved setting, then default; a non-negative YAML value or any valid environment value locks only capacity. A negative environment value means unlimited, while malformed environment input falls through to the lower-precedence source. `0` means unlimited. Lowering the live limit does not prune existing rows; new admissions remain blocked until the pending count drops below the limit, while retries of already accepted work remain eligible. The default-on automatic switch affects only later admissions and never bypasses capacity or sweeps existing rows. The current OTLP exporter strips an `http://` or `https://` prefix from the configured endpoint and always uses `WithInsecure()`. Treat this as implementation-bound cleartext transport: send it only to a trusted private collector over a protected network, not directly across an untrusted network. The service name is `kandev-agentctl`, and spans can include task/session/execution IDs plus raw agent-event JSON truncated to 8192 characters. That payload can contain prompts, files, and tool data. Use collector-side access controls and retention accordingly. @@ -437,6 +527,34 @@ These apply only when `KANDEV_DEBUG_AGENT_MESSAGES=true`: Retention values must be positive integers; invalid/non-positive values use defaults. Directories and files use owner-only `0700`/`0600` modes on Unix. Rotation, age pruning, and file-count pruning bound normal growth, but these files remain highly sensitive and can exist inside a Docker executor rather than on the host. +## Configuration exclusions + +The catalog records variables that are not operator startup settings. They have +no public YAML key: + +- Internal launcher and process wiring: `KANDEV_INTERNAL_CONFIG_FILE`, + `KANDEV_INTERNAL_CONFIG_HOME_FILE`, + `KANDEV_INTERNAL_AGENTCTL_STARTUP_CONFIG`, + `KANDEV_LAUNCHER_PARENT_PID`, `KANDEV_BACKEND_PID_FILE`, + `KANDEV_DESKTOP_NATIVE_NOTIFICATIONS`, `KANDEV_TASK_ID`, + `KANDEV_SESSION_ID`, and `KANDEV_WORKSPACE_ID`. +- Generated or packaging values: `KANDEV_DESKTOP_HEALTH_TOKEN`, + `KANDEV_BUNDLE_DIR`, and `KANDEV_WEB_DIST_DIR`. +- Test and profile selectors: `KANDEV_E2E_MOCK`, `KANDEV_MOCK_AGENT`, + `KANDEV_MOCK_GITHUB`, `KANDEV_MOCK_GITLAB`, `KANDEV_MOCK_JIRA`, + `KANDEV_MOCK_LINEAR`, and `AGENTCTL_AUTO_APPROVE_PERMISSIONS`. +- Runtime flags and diagnostics: `KANDEV_FEATURES_OFFICE`, + `KANDEV_FEATURES_AUTH`, + `KANDEV_FEATURES_CLAUDE_BACKGROUND_PROMPT_HANDOFF`, + `KANDEV_FEATURES_CLAUDE_MID_TURN_STEERING`, + `KANDEV_DEBUG_AGENT_MESSAGES`, `KANDEV_DEBUG_ACP_MAX_FILES`, + `KANDEV_DEBUG_ACP_RETENTION_HOURS`, `KANDEV_DEBUG_ACP_MAX_FILE_BYTES`, + `KANDEV_MCP_LOG_FILE`, and `KANDEV_DEBUG_LOG_DIR`. + +These values are generated, injected by a supervisor or workspace, selected by +the test/profile system, or intentionally limited to diagnostics. They are +not part of the stable YAML contract. + ## Validation and troubleshooting Startup validation currently enforces: @@ -454,10 +572,10 @@ If a value appears ignored: 1. confirm the exact environment spelling, especially camelCase keys; 2. inspect the launcher/service/container environment for an overriding value; -3. confirm `config.yaml` is in the backend working directory or `/etc/kandev/`; +3. confirm the selected `config.yaml` candidate is the first existing file in the working-directory, home, system order; 4. restart the backend; and 5. check whether the field is marked compatibility-only above. Use `kandev --verbose` to surface startup errors. Do not use `--debug` merely to diagnose a YAML typo on an exposed machine; verbose logs are usually sufficient. -Variables used only to assemble/test the runtime: such as `KANDEV_WEB_DIST_DIR`, `KANDEV_DESKTOP_RUNTIME_DIR`, mock/E2E switches, supervisor socket/manifest values, and bootstrap nonces, are internal implementation contracts, not supported deployment configuration. `KANDEV_BUNDLE_DIR` is the narrow exception documented for installer/package integration in [CLI](./cli.md); end users should still let the installer set it. +Variables used only to assemble/test the runtime, such as `KANDEV_WEB_DIST_DIR`, `KANDEV_DESKTOP_RUNTIME_DIR`, mock/E2E switches, supervisor socket/manifest values, and bootstrap nonces, are internal implementation contracts, not supported deployment configuration. `KANDEV_MCP_LOG_FILE` remains a debug-only component variable without a YAML key. `KANDEV_BUNDLE_DIR` is the narrow exception documented for installer/package integration in [CLI](./cli.md); end users should still let the installer set it. diff --git a/docs/public/coverage.json b/docs/public/coverage.json index d06eab7ed2..80c550530c 100644 --- a/docs/public/coverage.json +++ b/docs/public/coverage.json @@ -80,6 +80,21 @@ "update_workflow_step_kandev" ] }, + { + "id": "office-and-quorum", + "audiences": ["user", "operator"], + "stability": "beta", + "docs": ["automation-and-mcp", "tasks-and-workflows"], + "sources": [ + "apps/backend/internal/mcp/server/agent_decision_tool.go", + "apps/backend/internal/office/dashboard/agent_decisions.go" + ], + "tests": [ + "apps/backend/internal/mcp/server/agent_decision_tool_test.go", + "apps/backend/internal/office/dashboard/agent_decisions_test.go" + ], + "mcpTools": ["record_step_decision_kandev"] + }, { "id": "task-lifecycle", "audiences": ["user"], diff --git a/docs/public/run-as-a-service.md b/docs/public/run-as-a-service.md index b5f4e203c7..78cf260b20 100644 --- a/docs/public/run-as-a-service.md +++ b/docs/public/run-as-a-service.md @@ -89,14 +89,28 @@ recursively chown the data tree or add a broad Git `safe.directory` exception. ## Bind safely -The backend config loader searches `config.yaml` in its working directory and then `/etc/kandev`. launchd sets the working directory to the Kandev home, but the generated systemd unit does not. For a shared conventional path, create `/etc/kandev/config.yaml` before first start, or stop and restart after changing it: +The backend config loader searches `config.yaml` in its working directory, +then `/config.yaml` (or `~/.kandev/config.yaml`), then +`/etc/kandev/config.yaml`. launchd sets the working directory to the Kandev +home; the generated systemd unit uses the selected home and carries the exact +selected file into the managed backend. Kandev uses only the first existing +candidate and does not merge files. For a shared conventional path, create +`/etc/kandev/config.yaml` before first start, or stop and restart after +changing it: ```yaml server: host: 127.0.0.1 ``` -The working-directory file wins when both exist, so on macOS merge or remove a conflicting `/config.yaml` if `/etc/kandev/config.yaml` should be authoritative. The service unit has an intentionally small fixed environment and does not inherit arbitrary exports from the installing shell. Put supported settings in the configuration file; see [Configuration](configuration.md). A system service needs permission to read the file, while secret-bearing configuration should not be world-readable. +The working-directory file wins when both exist, followed by the home file and +then `/etc/kandev/config.yaml`. If the first existing file is unreadable or +invalid, startup fails instead of falling through. A home configuration file +cannot set `homeDir`. The service unit has an intentionally small fixed +environment and does not inherit arbitrary exports from the installing shell. +Put supported settings in the configuration file; see +[Configuration](configuration.md). A system service needs permission to read +the file, while secret-bearing configuration should use owner-only mode `0600`. To access a loopback-only instance remotely, use SSH port forwarding: diff --git a/docs/specs/INDEX.md b/docs/specs/INDEX.md index 253dedec9a..93d2e44ffa 100644 --- a/docs/specs/INDEX.md +++ b/docs/specs/INDEX.md @@ -38,6 +38,7 @@ Product-wide capabilities that are not tied to a single feature area. | Spec | Status | |---|---| +| [startup-configuration-parity](platform/startup-configuration-parity.md) | shipped | | [agent-runtime-availability](platform/agent-runtime-availability.md) | draft | | [background-work-liveness](platform/background-work-liveness.md) | shipped | | [setup-launch-timeout](platform/setup-launch-timeout.md) | approved | diff --git a/docs/specs/auth/trusted-proxies.md b/docs/specs/auth/trusted-proxies.md index 957829ebca..1fc1279bed 100644 --- a/docs/specs/auth/trusted-proxies.md +++ b/docs/specs/auth/trusted-proxies.md @@ -16,16 +16,18 @@ client. ## What -- Kandev SHALL read a `KANDEV_TRUSTED_PROXIES` environment variable at - startup: a comma-separated list of IP addresses or CIDR ranges. +- Kandev SHALL read `server.trustedProxies` from startup YAML as a list of IP + addresses or CIDR ranges. The `KANDEV_TRUSTED_PROXIES` environment variable + remains a comma-separated override for the same setting. +- The environment value SHALL override the YAML value when both are present. - When the TCP peer of a request is in the trusted list, the client IP SHALL resolve from the first valid forwarded-IP header in gin's configured order (`X-Forwarded-For`, then `X-Real-IP`), falling back to the peer only when neither header yields a valid address. -- When the variable is unset or empty, or the peer is not in the list, the +- When the configured list is unset or empty, or the peer is not in the list, the client IP SHALL be the TCP peer; forwarded headers are ignored. - When any entry fails to parse as an IP or CIDR, startup SHALL log a warning - naming the bad value(s) and the whole variable SHALL be ignored (fail + naming the bad value(s) and the whole configured list SHALL be ignored (fail closed; no partial trust). Startup SHALL NOT fail. - The resolved client IP feeds the same consumers as today: the session IP recorded for login, setup, invite acceptance, and plugin-provided SSO @@ -33,13 +35,13 @@ client. ## Failure modes -- **Invalid entries.** A warning names the bad values and the variable is +- **Invalid entries.** A warning names the bad values and the configured list is ignored entirely; `X-Forwarded-For` is never trusted and the recorded IP stays the TCP peer. No crash. - **No valid forwarded-IP header** on a request from a trusted peer: gin falls back to the TCP peer (only when neither `X-Forwarded-For` nor `X-Real-IP` yields a valid address). -- **Spoofing.** An operator who sets the variable while the backend is +- **Spoofing.** An operator who configures the list while the backend is directly reachable lets any caller whose peer address falls inside a configured trusted IP/CIDR forge the client IP, including the login rate-limiter key. Callers outside every configured range still fall back to @@ -48,16 +50,17 @@ client. ## Scenarios -- **GIVEN** `KANDEV_TRUSTED_PROXIES` unset, **WHEN** a login request arrives +- **GIVEN** `server.trustedProxies` unset and `KANDEV_TRUSTED_PROXIES` unset, + **WHEN** a login request arrives from a proxy carrying `X-Forwarded-For: `, **THEN** the session IP is the proxy's address (the TCP peer). -- **GIVEN** `KANDEV_TRUSTED_PROXIES=10.0.0.0/8` and a request whose TCP peer +- **GIVEN** `server.trustedProxies: [10.0.0.0/8]` and a request whose TCP peer is `10.0.0.5`, **WHEN** the request carries `X-Forwarded-For: 203.0.113.7`, **THEN** the login session IP is `203.0.113.7`. -- **GIVEN** `KANDEV_TRUSTED_PROXIES=192.168.0.0/16` and a request whose TCP +- **GIVEN** `server.trustedProxies: [192.168.0.0/16]` and a request whose TCP peer is `10.0.0.5`, **WHEN** the request carries `X-Forwarded-For: 203.0.113.7`, **THEN** the session IP is `10.0.0.5` (header ignored). -- **GIVEN** `KANDEV_TRUSTED_PROXIES=10.0.0.0/8,not-a-cidr`, **WHEN** the +- **GIVEN** `server.trustedProxies: [10.0.0.0/8, not-a-cidr]`, **WHEN** the backend starts, **THEN** a startup warning names `not-a-cidr` and `X-Forwarded-For` is ignored entirely. - **GIVEN** `KANDEV_TRUSTED_PROXIES=10.0.0.0/8` and a backend reachable diff --git a/docs/specs/platform/setup-launch-timeout.md b/docs/specs/platform/setup-launch-timeout.md index 4ae4694d6a..6043248e80 100644 --- a/docs/specs/platform/setup-launch-timeout.md +++ b/docs/specs/platform/setup-launch-timeout.md @@ -15,11 +15,13 @@ hidden session-launch deadline expires first. ## What - Kandev allows setup and prepare scripts to run for 10 minutes by default. -- Operators can set `KANDEV_TASK_PREPARATION_TIMEOUT` before Kandev starts. The - value uses Go duration syntax, such as `90s`, `10m`, or `1h`. +- Operators can set `tasks.preparationTimeout` in startup YAML or use + `KANDEV_TASK_PREPARATION_TIMEOUT` as its environment override. The value uses + Go duration syntax, such as `90s`, `10m`, or `1h`. +- The environment value overrides YAML when both are present. - The configured value must be greater than zero. An unset, invalid, zero, or negative value uses the 10-minute default. -- Kandev reads the environment variable when the backend process starts. A +- Kandev reads the resolved startup value when the backend process starts. A change requires a restart. - The timeout applies to repository setup scripts and executor-profile prepare scripts on Local, Worktree, Docker, Sprite, and SSH launches. @@ -39,7 +41,7 @@ Decision: [ADR-2026-08-12-setup-timeout-owns-launch-budget](../../decisions/2026 | Condition | Behavior | |---|---| -| `KANDEV_TASK_PREPARATION_TIMEOUT` is absent | Kandev uses 10 minutes. | +| `tasks.preparationTimeout` and `KANDEV_TASK_PREPARATION_TIMEOUT` are absent | Kandev uses 10 minutes. | | The value is invalid, zero, or negative | Kandev uses 10 minutes. | | A setup script reaches the configured limit | Kandev stops the script and reports the existing runtime-specific setup failure. | | A runtime launch phase does not finish within its derived launch limit | The launch fails with a deadline error and releases its activity lease. | @@ -50,7 +52,7 @@ Decision: [ADR-2026-08-12-setup-timeout-owns-launch-budget](../../decisions/2026 - **GIVEN** no timeout override and a repository setup script that takes 90 seconds, **WHEN** a user launches a session, **THEN** setup completes without a one-minute session-launch deadline error. -- **GIVEN** `KANDEV_TASK_PREPARATION_TIMEOUT=15m` at process start and a prepare +- **GIVEN** `tasks.preparationTimeout: 15m` at process start and a prepare script that takes more than 10 minutes but less than 15 minutes, **WHEN** a user launches a session, **THEN** Kandev allows the script to complete. - **GIVEN** an invalid, zero, or negative timeout value, **WHEN** Kandev starts, @@ -70,5 +72,5 @@ Decision: [ADR-2026-08-12-setup-timeout-owns-launch-budget](../../decisions/2026 - Separate timeout settings for each executor or hook. - A configurable cleanup-script timeout. -- YAML, database, API, or Settings UI configuration. +- Database, API, or Settings UI configuration. - Changing whether a setup-script failure is fatal for a runtime. diff --git a/docs/specs/platform/startup-configuration-parity.md b/docs/specs/platform/startup-configuration-parity.md new file mode 100644 index 0000000000..46c3a0b68f --- /dev/null +++ b/docs/specs/platform/startup-configuration-parity.md @@ -0,0 +1,188 @@ +--- +status: shipped +created: 2026-08-20 +owner: kandev +--- + +# Startup Configuration Parity + +## Why + +Kandev has two startup configuration paths. Some settings use typed YAML and +environment variables. Other settings read only environment variables at their +consumer. Operators must then maintain service-specific environment setup even +when they already use `config.yaml`. + +This split also causes inconsistent launcher behavior. For example, the +launcher can replace a database path that the backend would otherwise read from +YAML. A stable configuration contract must apply before the launcher starts the +backend and continue through every managed process. + +## What + +Kandev gives every stable, operator-facing startup setting a canonical YAML key. +Each setting keeps its current environment variable as a compatible override. + +The effective startup precedence is: + +1. A command-line launch flag, when that setting has one. +2. An explicit environment variable. +3. The selected `config.yaml` value. +4. The existing built-in or profile default. + +For settings that also have a database override, the environment variable and +YAML value both take precedence over the database value. The full order is +environment variable, YAML, database override, then profile or built-in default. + +Kandev selects the first existing configuration file in this order: + +1. `config.yaml` in the process working directory. +2. `/config.yaml`, or `~/.kandev/config.yaml` when the home + directory has no explicit override. +3. `/etc/kandev/config.yaml`. + +Kandev does not merge these files. If the first existing candidate is invalid or +unreadable, startup fails and reports that file. Kandev does not continue to a +lower-priority candidate. + +The home directory is bootstrap configuration. A launch flag or the explicit +`KANDEV_HOME_DIR` environment variable selects it before Kandev searches for the +home configuration file. A configuration file in the working directory or in +`/etc/kandev` can set `homeDir`. The selected home configuration file cannot use +`homeDir` to relocate itself. + +Configuration changes require a restart. Kandev does not watch configuration +files. + +## Canonical additions + +The existing typed configuration keys remain canonical. The following stable +environment-only settings gain YAML keys: + +| YAML key | Type and valid YAML value | Default | Compatible environment variable | +|---|---|---|---| +| `server.trustedProxies` | List of valid IP or CIDR strings | Empty list | `KANDEV_TRUSTED_PROXIES` | +| `tasks.preparationTimeout` | Positive Go duration string | `10m` | `KANDEV_TASK_PREPARATION_TIMEOUT` | +| `credentials.file` | File path string. Empty disables the provider. | Empty | `KANDEV_CREDENTIALS_FILE` | +| `limits.ghMaxConcurrent` | Positive integer | `8` | `KANDEV_GH_MAX_CONCURRENT` | +| `limits.gitMaxConcurrent` | Positive integer | `12` | `KANDEV_GIT_MAX_CONCURRENT` | +| `limits.lspMaxConnections` | Positive integer | `8` | `KANDEV_LSP_MAX_CONNECTIONS` | +| `messageQueue.maxPerSession` | Integer of zero or greater. Zero disables the cap. | `10` | `KANDEV_QUEUE_MAX_PER_SESSION` | +| `agentctl.idleTimeout` | Nonnegative Go duration string. Zero disables reaping. | `1h` | `KANDEV_ACP_IDLE_TIMEOUT` | +| `agentctl.idleReaperInterval` | Positive Go duration string | `1m` | `KANDEV_ACP_IDLE_REAPER_INTERVAL` | +| `agentctl.notificationQueueCapacity` | Integer from `1024` through `131072` | `131072` | `KANDEV_ACP_NOTIF_QUEUE` | +| `planning.coalesceWindowMs` | Integer of zero or greater | `300000` | `KANDEV_PLAN_COALESCE_WINDOW_MS` | +| `office.schedulerTickMs` | Positive integer | `5000` | `KANDEV_OFFICE_SCHEDULER_TICK_MS` | +| `observability.otlpEndpoint` | Endpoint string. Empty disables tracing. | Empty | `OTEL_EXPORTER_OTLP_ENDPOINT` | +| `launcher.webPort` | Integer from `1` through `65535`, or absent for automatic selection | Automatic | `KANDEV_WEB_PORT` | +| `launcher.healthTimeoutMs` | Positive integer | `45000` in release, `600000` in dev | `KANDEV_HEALTH_TIMEOUT_MS` | +| `launcher.noBrowser` | Boolean | `false` | `KANDEV_NO_BROWSER` | + +YAML uses native lists, integers, and booleans as shown above. Duration values +use strings so their units remain visible. The trusted proxy environment value +keeps its comma-separated syntax. Numeric and boolean environment values keep +their current string syntax and compatibility parsing. + +The launcher also honors the existing YAML keys `homeDir`, `server.host`, +`server.port`, `database.path`, and `logging.level`. `server.port` is the YAML +equivalent for `KANDEV_BACKEND_PORT`, compatibility variable `KANDEV_PORT`, and +the internal backend port handoff. + +The published configuration reference lists every supported stable startup +environment variable. Each row names its YAML equivalent or a justified +exclusion. + +## Stable secrets + +Stable startup secrets get the same YAML parity as other stable startup +settings. This includes existing typed database, authentication, and office +secrets. It does not include credentials that users manage in the product UI or +database. + +Kandev never logs secret values. On Unix systems, Kandev warns when the selected +configuration file contains a secret and group or other users can read it. The +warning does not block startup. Public guidance recommends owner-only mode +`0600` and an environment-based secret manager where available. + +## Configuration ownership + +One typed configuration catalog maps stable operator settings to YAML keys, +environment variables, defaults, sensitivity, and owning runtime component. +Completeness tests prevent a stable setting from bypassing this catalog. + +Kandev does not copy YAML values into public process environment variables. +The launcher reads the bootstrap subset from the same catalog. The backend +loads the full typed configuration. Managed agentctl processes receive the +resolved settings that they own through explicit startup configuration. + +## Message queue source + +When `messageQueue.maxPerSession` comes from YAML, the Settings UI treats it as +an operator lock. The API reports `configuration` as the source. The UI names +the configuration file as the source and does not claim that an environment +variable set the value. + +The existing responsive Settings layout remains unchanged. Desktop and mobile +users receive the same source and lock behavior. + +## Failure modes + +| Condition | Behavior | +|---|---| +| No configuration candidate exists | Kandev starts with environment, profile, and built-in values. | +| The first existing candidate is unreadable | Startup fails and names the selected file. | +| The first existing candidate has invalid YAML or an invalid typed value | Startup fails and names the setting and file. | +| A legacy environment value is invalid | Kandev keeps that setting's current compatibility behavior. | +| A home configuration file contains `homeDir` | Startup fails with a relocation error. | +| A configuration file contains a secret and has broad Unix read permissions | Kandev warns without revealing the value, then continues. | +| A managed agentctl process cannot receive valid resolved configuration | Its launch fails instead of silently using a different value. | + +## Scenarios + +- **GIVEN** `~/.kandev/config.yaml` contains + `server.trustedProxies`, **WHEN** Kandev starts behind the listed proxy, + **THEN** it trusts forwarded headers from that proxy without an environment + variable. +- **GIVEN** YAML and an environment variable set the same option, **WHEN** + Kandev starts, **THEN** the environment value wins. +- **GIVEN** a launch flag, environment variable, and YAML set the backend port, + **WHEN** Kandev starts, **THEN** the launch flag wins. +- **GIVEN** working-directory, home, and system configuration files exist, + **WHEN** Kandev starts, **THEN** it reads only the working-directory file. +- **GIVEN** the working-directory file is absent and the home file exists, + **WHEN** Kandev starts, **THEN** it reads only the home file. +- **GIVEN** the first existing file is invalid, **WHEN** Kandev starts, **THEN** + startup fails without reading a lower-priority file. +- **GIVEN** `database.path` is set in the selected file, **WHEN** the launcher + starts the backend, **THEN** the backend uses that path. +- **GIVEN** a stable startup secret is set in YAML, **WHEN** Kandev starts, + **THEN** the owning component receives it and logs do not expose it. +- **GIVEN** a secret-bearing file is readable by other users on Unix, **WHEN** + Kandev loads it, **THEN** the logs contain a permission warning without the + secret value. +- **GIVEN** `messageQueue.maxPerSession` is set in YAML, **WHEN** a desktop or + mobile user opens Message Queue settings, **THEN** the field is locked and + identifies configuration as its source. +- **GIVEN** agentctl timeout or tracing settings are set in YAML, **WHEN** + Kandev launches agentctl through a supported executor, **THEN** that process + uses the resolved values. +- **GIVEN** an internal, generated, test, or debug-only environment variable, + **WHEN** the configuration completeness test runs, **THEN** its recorded + exclusion satisfies the catalog without creating a public YAML key. + +## Out of scope + +- A `.env` file loader. +- A public `--config` flag. +- Merging multiple configuration files. +- Live reload or a file watcher. +- A Settings UI editor for startup configuration. +- YAML keys for profile selectors, mocks, E2E controls, or debug-only controls. +- YAML keys for generated tokens, launcher-to-child wiring, packaging metadata, + bundle discovery, workspace or session injection, or deprecated no-op values. +- YAML keys for credentials managed in the product UI or database. +- Replacing standard platform detection when an existing typed setting already + controls the result, such as `DOCKER_HOST` with `docker.host`. + +Decision: +[Startup configuration uses one typed source model](../../decisions/2026-08-20-startup-configuration-source-parity.md).