feat(config): add YAML startup configuration parity (#2863)

This commit is contained in:
Carlos Florêncio
2026-08-21 19:28:21 +01:00
committed by GitHub
parent 64bc820c45
commit 025492a690
111 changed files with 5415 additions and 385 deletions
+9
View File
@@ -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`.
---
+1 -2
View File
@@ -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
+35 -6
View File
@@ -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 <cmd>", 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) {
@@ -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) {
@@ -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) {
@@ -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
}
@@ -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
}
@@ -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)
@@ -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
@@ -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
}
@@ -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()
})
}
@@ -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()
@@ -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
}
@@ -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 {
@@ -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
}
@@ -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.
@@ -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,
},
@@ -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,
@@ -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,
}
}
@@ -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"))
@@ -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)
}
}
@@ -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.
@@ -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)
@@ -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"},
@@ -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.
@@ -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()
+26 -4
View File
@@ -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
}
@@ -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 {
+13 -1
View File
@@ -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"))
}
@@ -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)
}
}
+2 -1
View File
@@ -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)
+8 -1
View File
@@ -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)
+21 -4
View File
@@ -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,
@@ -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
@@ -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
@@ -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 = "<empty>"
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
@@ -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 {
@@ -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
}
@@ -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: "<home>/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: "<home>/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
}
@@ -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")
}
}
+106 -42
View File
@@ -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)
}
@@ -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(","),
)))
}
@@ -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)
}
}
@@ -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)
}
}
@@ -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
}
@@ -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
@@ -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)
}
}
@@ -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()
+15 -2
View File
@@ -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.
@@ -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
@@ -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.
@@ -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)
}
}
@@ -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")),
}
}
@@ -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.
@@ -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
}
@@ -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)
}
}
+65 -11
View File
@@ -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 {
@@ -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)
+16 -1
View File
@@ -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)
}
@@ -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)
+38 -4
View File
@@ -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 {
+15
View File
@@ -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()
+54 -2
View File
@@ -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
+9 -3
View File
@@ -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,
})
}
+140 -53
View File
@@ -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) {
@@ -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 "<key>KANDEV_HOME_DIR</key>\n <string>" + homeDir + "</string>"
}
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)
}
}
@@ -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",
+51 -11
View File
@@ -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
@@ -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.
+38 -21
View File
@@ -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
@@ -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)
@@ -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
@@ -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"})
}
@@ -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.
@@ -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
}
@@ -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
}
@@ -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
+2
View File
@@ -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 {
@@ -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.
@@ -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
}
@@ -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
@@ -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(<MessageQueueSettings />);
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", () => {
@@ -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 && (
<Alert>
<IconLock className="size-4" />
<AlertTitle>{t("system:messageQueueEnvironmentLockTitle")}</AlertTitle>
<AlertDescription>
{t("system:messageQueueEnvironmentLocked", { variable: ENVIRONMENT_VARIABLE })}
</AlertDescription>
<AlertTitle>
{t(
isConfigurationLocked
? "system:messageQueueConfigurationLockTitle"
: "system:messageQueueEnvironmentLockTitle",
)}
</AlertTitle>
<AlertDescription>{queueCapacityLockMessage(t, state.lockSource)}</AlertDescription>
</Alert>
)}
{!state.isAdmin && (
@@ -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__));
@@ -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);
});
+1 -1
View File
@@ -195,7 +195,7 @@ export interface MessageQueueSettingsValue {
/** Partial PATCH payload: omitted fields are left unchanged server-side. */
export type MessageQueueSettingsPatch = Partial<MessageQueueSettingsValue>;
export type MessageQueueSettingsSource = "default" | "setting" | "environment";
export type MessageQueueSettingsSource = "default" | "setting" | "configuration" | "environment";
export interface MessageQueueEffectiveSettings extends MessageQueueSettingsValue {
source: MessageQueueSettingsSource;
+3
View File
@@ -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",
+3
View File
@@ -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ũēũē",
+3
View File
@@ -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",
+3
View File
@@ -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": "消息队列",
+3
View File
@@ -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": "訊息佇列",
+3
View File
@@ -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": "訊息佇列",
@@ -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. `<KANDEV_HOME_DIR>/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.
+1
View File
@@ -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 |
@@ -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.
@@ -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.
@@ -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.
@@ -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`
@@ -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`
@@ -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.

Some files were not shown because too many files have changed in this diff Show More