diff --git a/apps/backend/internal/launcher/dev.go b/apps/backend/internal/launcher/dev.go index 798043ea55..7bc3e5808a 100644 --- a/apps/backend/internal/launcher/dev.go +++ b/apps/backend/internal/launcher/dev.go @@ -120,10 +120,6 @@ type devLaunchConfig struct { // 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, 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()) @@ -134,6 +130,10 @@ func devLaunchConfigFor(opts Options, configs ...*config.Config) (devLaunchConfi fmt.Fprintln(os.Stderr, "[kandev] "+err.Error()) return devLaunchConfig{}, 2 } + startupConfig, exitCode := devStartupConfig(repoRoot, configs...) + if exitCode != 0 { + return devLaunchConfig{}, exitCode + } backendPort, err := resolvePorts(opts, startupConfig) if err != nil { fmt.Fprintln(os.Stderr, "[kandev] "+err.Error()) @@ -155,25 +155,30 @@ func devLaunchConfigFor(opts Options, configs ...*config.Config) (devLaunchConfi return devLaunchConfig{}, 1 } ports.BackendURL = endpoints.accessURL - dbPath, extra := resolveDevBackendEnv(repoRoot, startupConfig) - homeDir := devKandevHome(repoRoot) - if !isInsideKandevTask(repoRoot) && configSourceIsExplicit(startupConfig, "homeDir") { - homeDir = startupConfig.ResolvedHomeDir() + database := resolveDevDatabaseTarget(repoRoot, startupConfig) + if err := validateDevDatabaseTarget(repoRoot, database); err != nil { + fmt.Fprintln(os.Stderr, "[kandev] "+err.Error()) + return devLaunchConfig{}, 1 + } + database, err = normalizeDevDatabaseTarget(database) + if err != nil { + fmt.Fprintln(os.Stderr, "[kandev] unable to normalize dev database target: "+err.Error()) + return devLaunchConfig{}, 1 } return devLaunchConfig{ repoRoot: repoRoot, ports: ports, - dbPath: dbPath, - extra: extra, + dbPath: database.path, + extra: database.extra, logLevel: resolveLogLevelForConfig(opts, startupConfig), debug: opts.Debug, - homeDir: homeDir, + homeDir: database.homeDir, startup: startupConfig, endpoints: endpoints, }, 0 } -func devStartupConfig(configs ...*config.Config) (*config.Config, int) { +func devStartupConfig(repoRoot string, configs ...*config.Config) (*config.Config, int) { if len(configs) > 0 { return configs[0], 0 } @@ -184,7 +189,7 @@ func devStartupConfig(configs ...*config.Config) (*config.Config, int) { return nil, 1 } } - startupConfig, err := loadDevBootstrapConfig() + startupConfig, err := loadDevBootstrapConfig(repoRoot) if err != nil { fmt.Fprintln(os.Stderr, "[kandev] "+err.Error()) return nil, 1 @@ -192,24 +197,45 @@ func devStartupConfig(configs ...*config.Config) (*config.Config, int) { return startupConfig, 0 } -func loadDevBootstrapConfig() (*config.Config, error) { +func loadDevBootstrapConfig(repoRoot string) (*config.Config, error) { + // Temporarily remove ambient KANDEV_HOME_DIR so a working-directory or + // system YAML homeDir is not silently overridden by the parent process's + // homeDir (which in dev mode is the parent backend's value, not an + // explicit instruction). This must happen before the fast-path check + // below because devStartupConfig sets KANDEV_DEBUG_DEV_MODE=true before + // calling us. + prevHome, homePresent := os.LookupEnv("KANDEV_HOME_DIR") + if homePresent { + if err := os.Unsetenv("KANDEV_HOME_DIR"); err != nil { + return nil, err + } + defer func() { + _ = os.Setenv("KANDEV_HOME_DIR", prevHome) // best effort restore + }() + } + if os.Getenv("KANDEV_E2E_MOCK") != "" || os.Getenv("KANDEV_DEBUG_DEV_MODE") != "" { - return loadBootstrapConfig() + return loadBootstrapConfigWithHome(devKandevHome(repoRoot)) } const selector = "KANDEV_DEBUG_DEV_MODE" - previous, present := os.LookupEnv(selector) + prevMode, modePresent := 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 { + cfg, err := loadBootstrapConfigWithHome(devKandevHome(repoRoot)) + if modePresent { + if restoreErr := os.Setenv(selector, prevMode); err == nil { err = restoreErr } } else if restoreErr := os.Unsetenv(selector); err == nil { err = restoreErr } + if homePresent { + if restoreErr := os.Setenv("KANDEV_HOME_DIR", prevHome); err == nil { + err = restoreErr + } + } return cfg, err } diff --git a/apps/backend/internal/launcher/dev_test.go b/apps/backend/internal/launcher/dev_test.go index c4eaf56336..76297f7be1 100644 --- a/apps/backend/internal/launcher/dev_test.go +++ b/apps/backend/internal/launcher/dev_test.go @@ -17,6 +17,7 @@ func itoa(n int) string { return strconv.Itoa(n) } var errTestHealth = errors.New("test health failure") func TestRunDevBuildVersionAndLaunchesBackendThenWebAndOpensBrowser(t *testing.T) { + clearLauncherConfigurationEnvironment(t) repo := makeRepoTree(t) t.Chdir(repo) t.Setenv("KANDEV_TASK_ID", "") @@ -130,7 +131,7 @@ func TestRunDevBuildVersionAndLaunchesBackendThenWebAndOpensBrowser(t *testing.T "KANDEV_WEB_INTERNAL_URL=http://localhost:" + itoa(backendCfg.Ports.WebPort), "KANDEV_DEBUG_DEV_MODE=true", "KANDEV_HOME_DIR=" + filepath.Join(repo, ".kandev-dev"), - "KANDEV_DATABASE_PATH=", + "KANDEV_DATABASE_PATH=" + filepath.Join(repo, ".kandev-dev", "data", "kandev.db"), "KANDEV_BACKEND_PID_FILE=" + filepath.Join(repo, ".kandev-dev", "supervisor", "backend.pid"), } { if !strings.Contains(backendEnv, expected) { @@ -228,6 +229,7 @@ func TestRunDevUsesConfiguredHealthTimeoutForBackendAndWeb(t *testing.T) { } func TestRunDevHeadlessSkipsBrowser(t *testing.T) { + clearLauncherConfigurationEnvironment(t) repo := makeRepoTree(t) t.Chdir(repo) t.Setenv("KANDEV_TASK_ID", "") @@ -282,9 +284,19 @@ func TestRunDevHeadlessSkipsBrowser(t *testing.T) { } func TestRunDevAbortsOnBackupFailureBeforeLaunch(t *testing.T) { + clearLauncherConfigurationEnvironment(t) repo := makeRepoTree(t) t.Chdir(repo) t.Setenv("KANDEV_TASK_ID", "") + ambientHome := t.TempDir() + sentinel := filepath.Join(ambientHome, "data", "kandev.db") + if err := os.MkdirAll(filepath.Dir(sentinel), 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(sentinel, []byte("must not be backed up"), 0o600); err != nil { + t.Fatal(err) + } + t.Setenv("KANDEV_HOME_DIR", ambientHome) // A path without a .kandev-dev segment is treated as production; making it // a directory forces the copy to fail so the abort path runs. t.Setenv("HOME", t.TempDir()) @@ -309,9 +321,13 @@ func TestRunDevAbortsOnBackupFailureBeforeLaunch(t *testing.T) { if launched { t.Fatal("backend launched despite a failed production-db backup") } + if got, err := os.ReadFile(sentinel); err != nil || string(got) != "must not be backed up" { + t.Fatalf("ambient-home sentinel changed or became unreadable: %q, %v", got, err) + } } func TestRunDevShutsDownTreeOnBackendHealthFailure(t *testing.T) { + clearLauncherConfigurationEnvironment(t) repo := makeRepoTree(t) t.Chdir(repo) t.Setenv("KANDEV_TASK_ID", "") @@ -434,6 +450,7 @@ func TestRunDevShutsDownTreeOnBackendReadinessFailure(t *testing.T) { } func TestRunDevShutsDownTreeWhenWebChildExits(t *testing.T) { + clearLauncherConfigurationEnvironment(t) repo := makeRepoTree(t) t.Chdir(repo) t.Setenv("KANDEV_TASK_ID", "") @@ -496,6 +513,7 @@ func TestRunDevShutsDownTreeWhenWebChildExits(t *testing.T) { } func TestRunDevFailsWhenRepoRootNotFound(t *testing.T) { + clearLauncherConfigurationEnvironment(t) t.Chdir(t.TempDir()) code := runDev(context.Background(), Options{Command: CommandDev}, BuildInfo{}) diff --git a/apps/backend/internal/launcher/devpaths.go b/apps/backend/internal/launcher/devpaths.go index 957c526f84..4a02202945 100644 --- a/apps/backend/internal/launcher/devpaths.go +++ b/apps/backend/internal/launcher/devpaths.go @@ -59,11 +59,32 @@ func isInsideKandevTask(repoRoot string) bool { // When invoked from inside a kandev task workspace, any KANDEV_DATABASE_PATH // is assumed to be leaked from the parent backend and is ignored. In a normal // shell, an explicit KANDEV_DATABASE_PATH is honored as an escape hatch. -// -// 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. +type devDatabaseSource string + +const ( + devDatabaseDefault devDatabaseSource = "repo-local default" + devDatabaseTask devDatabaseSource = "task workspace" + devDatabaseEnvironment devDatabaseSource = "environment" + devDatabaseConfiguration devDatabaseSource = "configuration" + devDatabaseConfigHome devDatabaseSource = "configuration home" +) + +// devDatabaseTarget is the one database decision shared by the launcher and +// its backend child. Keeping the provenance with the path prevents a backup +// from being performed against a different database than the child receives. +type devDatabaseTarget struct { + path string + homeDir string + source devDatabaseSource + extra []string +} + func resolveDevBackendEnv(repoRoot string, configs ...*config.Config) (dbPath string, extra []string) { + target := resolveDevDatabaseTarget(repoRoot, configs...) + return target.path, target.extra +} + +func resolveDevDatabaseTarget(repoRoot string, configs ...*config.Config) devDatabaseTarget { devHome := devKandevHome(repoRoot) devDBPath := filepath.Join(devHome, "data", "kandev.db") var startupConfig *config.Config @@ -76,32 +97,108 @@ func resolveDevBackendEnv(repoRoot string, configs ...*config.Config) (dbPath st // etc.) to its own env. The launcher must not restate them — profiles.yaml // at the repo root is the single source of truth. baseExtra := []string{"KANDEV_DEBUG_DEV_MODE=true"} + homeDir := devStateHome(repoRoot, startupConfig) if isInsideKandevTask(repoRoot) { fmt.Println("[kandev] task workspace detected → using local dev state") - return devDBPath, append(baseExtra, - "KANDEV_HOME_DIR="+devHome, - // Clear a parent-leaked DB path so the backend uses the - // HomeDir-derived default. - "KANDEV_DATABASE_PATH=", - ) + return newDevDatabaseTarget(devDBPath, devHome, devDatabaseTask, baseExtra) + } + if startupConfig == nil { + if override := strings.TrimSpace(os.Getenv("KANDEV_DATABASE_PATH")); override != "" { + return newDevDatabaseTarget(override, homeDir, devDatabaseEnvironment, baseExtra) + } } - if startupConfig != nil && startupConfig.SourceFor("database.path") == config.SourceConfiguration && - strings.TrimSpace(startupConfig.Database.Path) != "" { - return startupConfig.Database.Path, baseExtra + if startupConfig != nil && strings.TrimSpace(startupConfig.Database.Path) != "" { + switch startupConfig.SourceFor("database.path") { + case config.SourceEnvironment: + return newDevDatabaseTarget(startupConfig.Database.Path, homeDir, devDatabaseEnvironment, baseExtra) + case config.SourceConfiguration: + return newDevDatabaseTarget(startupConfig.Database.Path, homeDir, devDatabaseConfiguration, baseExtra) + } } - if startupConfig != nil && (startupConfig.SourceFor("homeDir") == config.SourceConfiguration || - startupConfig.SourceFor("homeDir") == config.SourceEnvironment) { - return filepath.Join(startupConfig.ResolvedDataDir(), "kandev.db"), baseExtra + if startupConfig != nil && startupConfig.SourceFor("homeDir") == config.SourceConfiguration { + path := filepath.Join(startupConfig.ResolvedDataDir(), "kandev.db") + return newDevDatabaseTarget(path, homeDir, devDatabaseConfigHome, baseExtra) } - if override := strings.TrimSpace(os.Getenv("KANDEV_DATABASE_PATH")); override != "" { - return override, append(baseExtra, "KANDEV_DATABASE_PATH="+override) - } - - return devDBPath, append(baseExtra, - "KANDEV_HOME_DIR="+devHome, - "KANDEV_DATABASE_PATH=", - ) + return newDevDatabaseTarget(devDBPath, homeDir, devDatabaseDefault, baseExtra) +} + +func devStateHome(repoRoot string, startupConfig *config.Config) string { + if startupConfig != nil && startupConfig.SourceFor("homeDir") == config.SourceConfiguration { + return startupConfig.ResolvedHomeDir() + } + return devKandevHome(repoRoot) +} + +func newDevDatabaseTarget(path, homeDir string, source devDatabaseSource, baseExtra []string) devDatabaseTarget { + extra := append([]string{}, baseExtra...) + extra = append(extra, "KANDEV_HOME_DIR="+homeDir, "KANDEV_DATABASE_PATH="+path) + return devDatabaseTarget{path: path, homeDir: homeDir, source: source, extra: extra} +} + +// validateDevDatabaseTarget checks whether the resolved database target is +// safe to use in dev mode. Repo-local targets must live under +// .kandev-dev/data/ and must not traverse symlinks. Repo-local-default or +// task targets that land outside the repo-local state are rejected outright. +// External targets (environment or YAML) are allowed without symlink +// checking; they may be the user's explicit production database. +func validateDevDatabaseTarget(repoRoot string, target devDatabaseTarget) error { + path, err := filepath.Abs(target.path) + if err != nil { + return fmt.Errorf("resolve dev database path %q: %w", target.path, err) + } + devHome, err := filepath.Abs(devKandevHome(repoRoot)) + if err != nil { + return fmt.Errorf("resolve dev home directory: %w", err) + } + dataDir, err := filepath.Abs(filepath.Join(devKandevHome(repoRoot), "data")) + if err != nil { + return fmt.Errorf("resolve dev data directory: %w", err) + } + insideDataDir := pathWithinDirectory(dataDir, path) + insideDevHome := pathWithinDirectory(devHome, path) + if (insideDevHome && !insideDataDir) || (!insideDataDir && (target.source == devDatabaseDefault || target.source == devDatabaseTask)) { + return fmt.Errorf("refusing dev database path outside repo-local data directory: %s", path) + } + if insideDataDir { + if err := rejectSymlinkComponents(path); err != nil { + return fmt.Errorf("refusing dev database path %s: %w", path, err) + } + } + return nil +} + +func pathWithinDirectory(root, path string) bool { + rel, err := filepath.Rel(root, path) + return err == nil && rel != ".." && !strings.HasPrefix(rel, ".."+string(os.PathSeparator)) +} + +// normalizeDevDatabaseTarget sets the target path and homeDir to their +// absolute forms and updates the corresponding entries in extra so all +// consumers (backup, output, child environment, supervisor paths) resolve +// them consistently regardless of the launcher's CWD. +func normalizeDevDatabaseTarget(target devDatabaseTarget) (devDatabaseTarget, error) { + absPath, err := filepath.Abs(target.path) + if err != nil { + return devDatabaseTarget{}, fmt.Errorf("resolve database path: %w", err) + } + target.path = absPath + + absHome, err := filepath.Abs(target.homeDir) + if err != nil { + return devDatabaseTarget{}, fmt.Errorf("resolve home directory: %w", err) + } + target.homeDir = absHome + + for i, item := range target.extra { + switch { + case strings.HasPrefix(item, "KANDEV_DATABASE_PATH="): + target.extra[i] = "KANDEV_DATABASE_PATH=" + absPath + case strings.HasPrefix(item, "KANDEV_HOME_DIR="): + target.extra[i] = "KANDEV_HOME_DIR=" + absHome + } + } + return target, nil } diff --git a/apps/backend/internal/launcher/devpaths_test.go b/apps/backend/internal/launcher/devpaths_test.go index 1defa62b77..81f8306d4e 100644 --- a/apps/backend/internal/launcher/devpaths_test.go +++ b/apps/backend/internal/launcher/devpaths_test.go @@ -124,8 +124,8 @@ func TestResolveDevBackendEnvDefaultUsesRepoLocalHome(t *testing.T) { if env["KANDEV_HOME_DIR"] != wantHome { t.Fatalf("KANDEV_HOME_DIR = %q, want %q", env["KANDEV_HOME_DIR"], wantHome) } - if env["KANDEV_DATABASE_PATH"] != "" { - t.Fatalf("KANDEV_DATABASE_PATH = %q, want empty (derive from home)", env["KANDEV_DATABASE_PATH"]) + if env["KANDEV_DATABASE_PATH"] != wantDB { + t.Fatalf("KANDEV_DATABASE_PATH = %q, want %q", env["KANDEV_DATABASE_PATH"], wantDB) } if env["KANDEV_DEBUG_DEV_MODE"] != "true" { t.Fatalf("KANDEV_DEBUG_DEV_MODE = %q, want true", env["KANDEV_DEBUG_DEV_MODE"]) @@ -146,8 +146,8 @@ func TestResolveDevBackendEnvHonorsExplicitDatabasePath(t *testing.T) { if env["KANDEV_DATABASE_PATH"] != "/custom/kandev.db" { t.Fatalf("KANDEV_DATABASE_PATH = %q", env["KANDEV_DATABASE_PATH"]) } - if _, ok := env["KANDEV_HOME_DIR"]; ok { - t.Fatal("explicit override must not set KANDEV_HOME_DIR") + if want := filepath.Join(repo, ".kandev-dev"); env["KANDEV_HOME_DIR"] != want { + t.Fatalf("KANDEV_HOME_DIR = %q, want repo-local dev home %q", env["KANDEV_HOME_DIR"], want) } if env["KANDEV_DEBUG_DEV_MODE"] != "true" { t.Fatalf("KANDEV_DEBUG_DEV_MODE = %q, want true", env["KANDEV_DEBUG_DEV_MODE"]) @@ -169,12 +169,12 @@ func TestResolveDevBackendEnvIgnoresWhitespaceOnlyOverride(t *testing.T) { if env["KANDEV_HOME_DIR"] != wantHome { t.Fatalf("KANDEV_HOME_DIR = %q, want %q", env["KANDEV_HOME_DIR"], wantHome) } - if env["KANDEV_DATABASE_PATH"] != "" { - t.Fatalf("KANDEV_DATABASE_PATH = %q, want empty", env["KANDEV_DATABASE_PATH"]) + if env["KANDEV_DATABASE_PATH"] != filepath.Join(wantHome, "data", "kandev.db") { + t.Fatalf("KANDEV_DATABASE_PATH = %q, want repo-local database", env["KANDEV_DATABASE_PATH"]) } } -func TestResolveDevBackendEnvHonorsEnvironmentConfiguredHome(t *testing.T) { +func TestResolveDevBackendEnvIgnoresAmbientConfiguredHome(t *testing.T) { repo := makeRepoTree(t) customHome := filepath.Join(t.TempDir(), "kandev") t.Setenv("KANDEV_HOME_DIR", customHome) @@ -188,11 +188,149 @@ func TestResolveDevBackendEnvHonorsEnvironmentConfiguredHome(t *testing.T) { }}, } dbPath, extra := resolveDevBackendEnv(repo, cfg) - if want := filepath.Join(customHome, "data", "kandev.db"); dbPath != want { + if want := filepath.Join(repo, ".kandev-dev", "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"]) + if env := devEnvToMap(extra); env["KANDEV_HOME_DIR"] != filepath.Join(repo, ".kandev-dev") { + t.Fatalf("KANDEV_HOME_DIR = %q, want repo-local dev home", env["KANDEV_HOME_DIR"]) + } +} + +func TestResolveDevBackendEnvHonorsConfiguredHomeDataDirectory(t *testing.T) { + repo := makeRepoTree(t) + t.Setenv("KANDEV_TASK_ID", "") + configuredHome := filepath.Join(t.TempDir(), "configured-kandev") + cfg := &commonconfig.Config{ + HomeDir: configuredHome, + Source: commonconfig.ConfigSource{Values: map[string]commonconfig.SettingSource{ + "homeDir": commonconfig.SourceConfiguration, + }}, + } + + dbPath, extra := resolveDevBackendEnv(repo, cfg) + want := filepath.Join(configuredHome, "data", "kandev.db") + if dbPath != want { + t.Fatalf("dbPath = %q, want configured home data path %q", dbPath, want) + } + if got := devEnvToMap(extra)["KANDEV_DATABASE_PATH"]; got != want { + t.Fatalf("KANDEV_DATABASE_PATH = %q, want %q", got, want) + } +} + +func TestDevLaunchConfigIgnoresAmbientHomeForSupervisorState(t *testing.T) { + repo := makeRepoTree(t) + t.Chdir(repo) + t.Setenv("KANDEV_TASK_ID", "") + ambientHome := filepath.Join(t.TempDir(), "kandev") + cfg := &commonconfig.Config{ + HomeDir: ambientHome, + Source: commonconfig.ConfigSource{Values: map[string]commonconfig.SettingSource{ + "homeDir": commonconfig.SourceEnvironment, + }}, + } + + launch, code := devLaunchConfigFor(Options{Command: CommandDev}, cfg) + if code != 0 { + t.Fatalf("devLaunchConfigFor() = %d, want 0", code) + } + if want := filepath.Join(repo, ".kandev-dev"); launch.homeDir != want { + t.Fatalf("supervisor home = %q, want repo-local %q", launch.homeDir, want) + } +} + +func TestResolveDevDatabaseTargetPreservesYamlHomeOverAmbientEnv(t *testing.T) { + // GIVEN a working-directory YAML with an explicit homeDir AND an ambient + // KANDEV_HOME_DIR that differs, WHEN the config is loaded through + // devStartupConfig AND resolved through resolveDevDatabaseTarget, + // THEN the YAML homeDir is preserved rather than silently discarded. + repo := makeRepoTree(t) + clearLauncherConfigurationEnvironment(t) + t.Setenv("KANDEV_TASK_ID", "") + configuredHome := filepath.Join(t.TempDir(), "configured-kandev") + ambientHome := filepath.Join(t.TempDir(), "ambient-home") + + // Place a config.yaml in the working directory with an explicit homeDir. + t.Chdir(repo) + configPath := filepath.Join(repo, "config.yaml") + writeLauncherConfig(t, configPath, "homeDir: "+configuredHome+"\n") + + // Set ambient KANDEV_HOME_DIR to a different value. Without the fix, + // loadDevBootstrapConfig would pick up this env and the homeDir source + // would become SourceEnvironment, causing devStateHome to fall back. + t.Setenv("KANDEV_HOME_DIR", ambientHome) + + cfg, exitCode := devStartupConfig(repo) + if exitCode != 0 { + t.Fatalf("devStartupConfig() = %d, want 0", exitCode) + } + if cfg.SourceFor("homeDir") != commonconfig.SourceConfiguration { + t.Fatalf("homeDir source = %q, want SourceConfiguration (YAML); ambient KANDEV_HOME_DIR must not override YAML during dev bootstrap", cfg.SourceFor("homeDir")) + } + if got := cfg.ResolvedHomeDir(); got != configuredHome { + t.Fatalf("resolved homeDir = %q, want YAML value %q", got, configuredHome) + } + + // Now resolve the database target with this config. + target := resolveDevDatabaseTarget(repo, cfg) + if target.homeDir != configuredHome { + t.Fatalf("target homeDir = %q, want YAML-configured home %q", target.homeDir, configuredHome) + } + wantDB := filepath.Join(configuredHome, "data", "kandev.db") + if target.path != wantDB { + t.Fatalf("target database path = %q, want configured home data path %q", target.path, wantDB) + } + if target.source != devDatabaseConfigHome { + t.Fatalf("target source = %q, want devDatabaseConfigHome", target.source) + } +} + +func TestResolveDevBackendEnvHonorsEnvironmentDatabaseBeforeAmbientHome(t *testing.T) { + repo := makeRepoTree(t) + t.Setenv("KANDEV_TASK_ID", "") + ambientHome := filepath.Join(t.TempDir(), "ambient-home") + explicitDB := filepath.Join(t.TempDir(), "explicit.db") + cfg := &commonconfig.Config{ + HomeDir: ambientHome, + Database: commonconfig.DatabaseConfig{Path: explicitDB}, + Source: commonconfig.ConfigSource{Values: map[string]commonconfig.SettingSource{ + "homeDir": commonconfig.SourceEnvironment, + "database.path": commonconfig.SourceEnvironment, + }}, + } + + dbPath, extra := resolveDevBackendEnv(repo, cfg) + if dbPath != explicitDB { + t.Fatalf("dbPath = %q, want explicit environment database %q", dbPath, explicitDB) + } + if env := devEnvToMap(extra); env["KANDEV_DATABASE_PATH"] != explicitDB { + t.Fatalf("KANDEV_DATABASE_PATH = %q, want %q", env["KANDEV_DATABASE_PATH"], explicitDB) + } else if want := filepath.Join(repo, ".kandev-dev"); env["KANDEV_HOME_DIR"] != want { + t.Fatalf("KANDEV_HOME_DIR = %q, want repo-local dev home %q", env["KANDEV_HOME_DIR"], want) + } +} + +func TestResolveDevBackendEnvPinsConfiguredHomeForConfiguredDatabase(t *testing.T) { + repo := makeRepoTree(t) + t.Setenv("KANDEV_TASK_ID", "") + t.Setenv("KANDEV_HOME_DIR", filepath.Join(t.TempDir(), "ambient-home")) + configuredHome := filepath.Join(t.TempDir(), "configured-home") + explicitDB := filepath.Join(t.TempDir(), "configured.db") + cfg := &commonconfig.Config{ + HomeDir: configuredHome, + Database: commonconfig.DatabaseConfig{Path: explicitDB}, + Source: commonconfig.ConfigSource{Values: map[string]commonconfig.SettingSource{ + "homeDir": commonconfig.SourceConfiguration, + "database.path": commonconfig.SourceConfiguration, + }}, + } + + dbPath, extra := resolveDevBackendEnv(repo, cfg) + if dbPath != explicitDB { + t.Fatalf("dbPath = %q, want %q", dbPath, explicitDB) + } + env := devEnvToMap(extra) + if env["KANDEV_HOME_DIR"] != configuredHome { + t.Fatalf("KANDEV_HOME_DIR = %q, want configured dev home %q", env["KANDEV_HOME_DIR"], configuredHome) } } @@ -213,7 +351,286 @@ func TestResolveDevBackendEnvClearsLeakedPathInTaskWorkspace(t *testing.T) { if env["KANDEV_HOME_DIR"] != wantHome { t.Fatalf("KANDEV_HOME_DIR = %q, want %q", env["KANDEV_HOME_DIR"], wantHome) } - if env["KANDEV_DATABASE_PATH"] != "" { - t.Fatalf("KANDEV_DATABASE_PATH = %q, want empty (parent-leaked path cleared)", env["KANDEV_DATABASE_PATH"]) + if env["KANDEV_DATABASE_PATH"] != filepath.Join(wantHome, "data", "kandev.db") { + t.Fatalf("KANDEV_DATABASE_PATH = %q, want repo-local database", env["KANDEV_DATABASE_PATH"]) + } +} + +func TestResolveDevBackendEnvTaskWorkspaceIgnoresSelectedDatabase(t *testing.T) { + root := makeRepoTree(t) + t.Setenv("KANDEV_TASK_ID", "task-123") + cfg := &commonconfig.Config{ + Database: commonconfig.DatabaseConfig{Path: filepath.Join(t.TempDir(), "parent.db")}, + Source: commonconfig.ConfigSource{Values: map[string]commonconfig.SettingSource{ + "database.path": commonconfig.SourceConfiguration, + }}, + } + + dbPath, extra := resolveDevBackendEnv(root, cfg) + want := filepath.Join(root, ".kandev-dev", "data", "kandev.db") + if dbPath != want { + t.Fatalf("dbPath = %q, want task-local %q", dbPath, want) + } + if got := devEnvToMap(extra)["KANDEV_DATABASE_PATH"]; got != want { + t.Fatalf("KANDEV_DATABASE_PATH = %q, want task-local database", got) + } +} + +func TestValidateDevDatabaseTargetRejectsRepoLocalSymlinkEscape(t *testing.T) { + repo := makeRepoTree(t) + devHome := devKandevHome(repo) + if err := os.Symlink(t.TempDir(), devHome); err != nil { + t.Fatal(err) + } + target := devDatabaseTarget{path: filepath.Join(devHome, "data", "kandev.db"), source: devDatabaseDefault} + if err := validateDevDatabaseTarget(repo, target); err == nil { + t.Fatal("validateDevDatabaseTarget accepted a symlinked repo-local database path") + } +} + +func TestValidateDevDatabaseTargetRejectsExplicitRepoLocalSymlinkEscape(t *testing.T) { + repo := makeRepoTree(t) + devHome := devKandevHome(repo) + dataDir := filepath.Join(devHome, "data") + if err := os.MkdirAll(devHome, 0o700); err != nil { + t.Fatal(err) + } + if err := os.Symlink(t.TempDir(), dataDir); err != nil { + t.Fatal(err) + } + target := devDatabaseTarget{path: filepath.Join(dataDir, "kandev.db"), source: devDatabaseEnvironment} + if err := validateDevDatabaseTarget(repo, target); err == nil { + t.Fatal("validateDevDatabaseTarget accepted an explicit database path through a repo-local symlink") + } +} + +func TestValidateDevDatabaseTargetRejectsPathOutsideDataDir(t *testing.T) { + repo := makeRepoTree(t) + devHome := devKandevHome(repo) + dataDir := filepath.Join(devHome, "data") + if err := os.MkdirAll(dataDir, 0o700); err != nil { + t.Fatal(err) + } + + tests := []struct { + name string + path string + source devDatabaseSource + }{ + { + name: "repo-local default sibling of data", + path: filepath.Join(devHome, "kandev.db"), + source: devDatabaseDefault, + }, + { + name: "repo-local default under supervisor", + path: filepath.Join(devHome, "supervisor", "kandev.db"), + source: devDatabaseDefault, + }, + { + name: "task source sibling of data", + path: filepath.Join(devHome, "kandev.db"), + source: devDatabaseTask, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + target := devDatabaseTarget{path: tt.path, source: tt.source} + if err := validateDevDatabaseTarget(repo, target); err == nil { + t.Fatal("validateDevDatabaseTarget accepted a database path outside .kandev-dev/data") + } + }) + } +} + +func TestValidateDevDatabaseTargetRejectsExplicitRepoLocalPathOutsideDataDir(t *testing.T) { + repo := makeRepoTree(t) + devHome := devKandevHome(repo) + + for _, source := range []devDatabaseSource{devDatabaseEnvironment, devDatabaseConfiguration} { + t.Run(string(source), func(t *testing.T) { + target := devDatabaseTarget{ + path: filepath.Join(devHome, "supervisor", "kandev.db"), + source: source, + } + if err := validateDevDatabaseTarget(repo, target); err == nil { + t.Fatal("validateDevDatabaseTarget accepted an explicit repo-local path outside .kandev-dev/data") + } + }) + } +} + +func TestValidateDevDatabaseTargetAcceptsPathInsideDataDir(t *testing.T) { + repo := makeRepoTree(t) + devHome := devKandevHome(repo) + dataDir := filepath.Join(devHome, "data") + if err := os.MkdirAll(dataDir, 0o700); err != nil { + t.Fatal(err) + } + + tests := []struct { + name string + path string + source devDatabaseSource + }{ + { + name: "repo-local default inside data", + path: filepath.Join(dataDir, "kandev.db"), + source: devDatabaseDefault, + }, + { + name: "task source inside data", + path: filepath.Join(dataDir, "kandev.db"), + source: devDatabaseTask, + }, + { + name: "environment source inside data", + path: filepath.Join(dataDir, "kandev.db"), + source: devDatabaseEnvironment, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + target := devDatabaseTarget{path: tt.path, source: tt.source} + if err := validateDevDatabaseTarget(repo, target); err != nil { + t.Fatalf("validateDevDatabaseTarget rejected valid database path: %v", err) + } + }) + } +} + +func TestNormalizeDevDatabaseTargetMakesPathAbsolute(t *testing.T) { + relPath := filepath.Join(".kandev-dev", "data", "kandev.db") + relHome := ".kandev-dev" + target := devDatabaseTarget{ + path: relPath, + homeDir: relHome, + source: devDatabaseDefault, + extra: []string{"KANDEV_DEBUG_DEV_MODE=true", "KANDEV_HOME_DIR=" + relHome, "KANDEV_DATABASE_PATH=" + relPath}, + } + + normalized, err := normalizeDevDatabaseTarget(target) + if err != nil { + t.Fatal(err) + } + wantPath, err := filepath.Abs(relPath) + if err != nil { + t.Fatal(err) + } + if normalized.path != wantPath { + t.Fatalf("normalized path = %q, want absolute %q", normalized.path, wantPath) + } + wantHome, err := filepath.Abs(relHome) + if err != nil { + t.Fatal(err) + } + if normalized.homeDir != wantHome { + t.Fatalf("normalized homeDir = %q, want absolute %q", normalized.homeDir, wantHome) + } + + foundPath := false + foundHome := false + for _, item := range normalized.extra { + switch { + case strings.HasPrefix(item, "KANDEV_DATABASE_PATH="): + val := item[len("KANDEV_DATABASE_PATH="):] + if val != wantPath { + t.Fatalf("KANDEV_DATABASE_PATH in extra = %q, want %q", val, wantPath) + } + foundPath = true + case strings.HasPrefix(item, "KANDEV_HOME_DIR="): + val := item[len("KANDEV_HOME_DIR="):] + if val != wantHome { + t.Fatalf("KANDEV_HOME_DIR in extra = %q, want %q", val, wantHome) + } + foundHome = true + } + } + if !foundPath { + t.Fatal("KANDEV_DATABASE_PATH not found in normalized extra") + } + if !foundHome { + t.Fatal("KANDEV_HOME_DIR not found in normalized extra") + } +} + +func TestNormalizeDevDatabaseTargetNestedCwdRelativeExplicit(t *testing.T) { + // Simulate invoking dev from a nested directory like /apps with a + // relative explicit database path and relative YAML homeDir. The + // launcher's CWD is /apps, but the backend's CWD will be . + // filepath.Abs resolves the path relative to CWD; normalization must + // produce the CWD-absolute form so the child does not resolve it + // differently. + repo := makeRepoTree(t) + nestedCWD := filepath.Join(repo, "apps") + if err := os.MkdirAll(nestedCWD, 0o700); err != nil { + t.Fatal(err) + } + relativePath := "../.kandev-dev/data/kandev.db" + relativeHome := "../.kandev-dev" + + // Change to the nested CWD so filepath.Abs resolves relative to it. + t.Chdir(nestedCWD) + + wantPath, err := filepath.Abs(relativePath) + if err != nil { + t.Fatal(err) + } + if wantPath != filepath.Join(repo, ".kandev-dev", "data", "kandev.db") { + t.Fatalf("abs path = %q, want %q", wantPath, filepath.Join(repo, ".kandev-dev", "data", "kandev.db")) + } + wantHome, err := filepath.Abs(relativeHome) + if err != nil { + t.Fatal(err) + } + if wantHome != filepath.Join(repo, ".kandev-dev") { + t.Fatalf("abs home = %q, want %q", wantHome, filepath.Join(repo, ".kandev-dev")) + } + + target := devDatabaseTarget{ + path: relativePath, + homeDir: relativeHome, + source: devDatabaseEnvironment, + extra: []string{"KANDEV_DEBUG_DEV_MODE=true", "KANDEV_HOME_DIR=" + relativeHome, "KANDEV_DATABASE_PATH=" + relativePath}, + } + + normalized, err := normalizeDevDatabaseTarget(target) + if err != nil { + t.Fatal(err) + } + if normalized.path != wantPath { + t.Fatalf("normalized path = %q, want %q", normalized.path, wantPath) + } + if normalized.homeDir != wantHome { + t.Fatalf("normalized homeDir = %q, want %q", normalized.homeDir, wantHome) + } +} + +func TestNormalizeDevDatabaseTargetRejectsUnavailableWorkingDirectory(t *testing.T) { + workingDir := t.TempDir() + t.Chdir(workingDir) + absolutePath := filepath.Join(workingDir, "absolute.db") + if err := os.Remove(workingDir); err != nil { + t.Skipf("executor does not allow removing the current directory: %v", err) + } + + tests := []struct { + name string + path string + }{ + {name: "relative database path", path: "relative.db"}, + {name: "absolute database path", path: absolutePath}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + normalized, err := normalizeDevDatabaseTarget(devDatabaseTarget{ + path: tt.path, + homeDir: "relative-home", + extra: []string{"KANDEV_DATABASE_PATH=" + tt.path, "KANDEV_HOME_DIR=relative-home"}, + }) + if err == nil { + t.Fatalf("normalizeDevDatabaseTarget returned %+v after absolute-path resolution failed", normalized) + } + }) } } diff --git a/apps/backend/internal/launcher/startup_failure_test.go b/apps/backend/internal/launcher/startup_failure_test.go index f3ce451b3c..9d002342dd 100644 --- a/apps/backend/internal/launcher/startup_failure_test.go +++ b/apps/backend/internal/launcher/startup_failure_test.go @@ -103,15 +103,14 @@ func TestBackendLogPathForDevConfigUsesEffectiveBackendHome(t *testing.T) { t.Fatalf("default dev backend log = %q, want %q", got, want) } - home := t.TempDir() - t.Setenv("HOME", home) + t.Setenv("HOME", t.TempDir()) databasePath := filepath.Join(t.TempDir(), "kandev.db") t.Setenv("KANDEV_DATABASE_PATH", databasePath) overrideExtra := devExtraForTest(t, repo) got = backendLogPathForDevConfig(devLaunchConfig{startup: nil, extra: overrideExtra}) - want = filepath.Join(home, ".kandev", "logs", "backend-logs.log") + want = filepath.Join(repo, ".kandev-dev", "logs", "backend-logs.log") if got != want { - t.Fatalf("explicit database dev backend log = %q, want %q", got, want) + t.Fatalf("explicit database dev backend log = %q, want repo-local %q", got, want) } } diff --git a/docs/specs/platform/requirements/go-dev-launcher.md b/docs/specs/platform/requirements/go-dev-launcher.md index 36bfc88738..79fa679bda 100644 --- a/docs/specs/platform/requirements/go-dev-launcher.md +++ b/docs/specs/platform/requirements/go-dev-launcher.md @@ -19,7 +19,7 @@ owners: #### Acceptance criteria -- **AC-PLATFORM-GO-DEV-LAUNCHER-001.1:** Inside a Kandev task workspace — signalled by `KANDEV_TASK_ID` being set, or by the repo root living under `~/.kandev/tasks/` — any inherited `KANDEV_DATABASE_PATH` is assumed to be leaked from the parent backend and is cleared, so the backend derives its DB from `KANDEV_HOME_DIR`. The launcher prints that it detected a task workspace. +- **AC-PLATFORM-GO-DEV-LAUNCHER-001.1:** Inside a Kandev task workspace — signalled by `KANDEV_TASK_ID` being set, or by the repo root living under `~/.kandev/tasks/` — the launcher replaces any inherited `KANDEV_DATABASE_PATH` with the task-local path and passes it with `KANDEV_HOME_DIR`. The launcher prints that it detected a task workspace. - **AC-PLATFORM-GO-DEV-LAUNCHER-001.2:** Otherwise an explicit `KANDEV_DATABASE_PATH` is honored as an escape hatch. - **AC-PLATFORM-GO-DEV-LAUNCHER-001.3:** Otherwise the DB is `/.kandev-dev/data/kandev.db`. - **AC-PLATFORM-GO-DEV-LAUNCHER-001.4:** **GIVEN** a host with non-loopback LAN and Tailscale IPv4 addresses, **WHEN** the `dev`, `start`, or `run` launcher prints its startup banner, **THEN** it prints one `network:` URL for each address using the backend port. @@ -89,15 +89,32 @@ Dev mode roots Kandev at `/.kandev-dev` so `make dev` never mutates th for a parent containing both `apps/backend` and `apps/web`; failing to find one is a usage error, not a silent fallback. +Dev resolves one database target, with provenance, before any backup, output, +supervisor setup, or backend launch. That exact target is passed explicitly to +the backend child so these consumers cannot resolve different databases. The +launcher also pins the child's `KANDEV_HOME_DIR` to the selected dev-state home +(repo-local by default, or explicit YAML `homeDir`) so ambient parent state +cannot diverge from supervisor state when an explicit database is selected. + `KANDEV_DATABASE_PATH` resolution keeps its current three-way rule: - Inside a Kandev task workspace — signalled by `KANDEV_TASK_ID` being set, or by the - repo root living under `~/.kandev/tasks/` — any inherited `KANDEV_DATABASE_PATH` is - assumed to be leaked from the parent backend and is cleared, so the backend derives - its DB from `KANDEV_HOME_DIR`. The launcher prints that it detected a task workspace. -- Otherwise an explicit `KANDEV_DATABASE_PATH` is honored as an escape hatch. + repo root living under `~/.kandev/tasks/` — the launcher replaces any inherited + `KANDEV_DATABASE_PATH` with the task-local path and passes it with `KANDEV_HOME_DIR`. + The launcher prints that it detected a task workspace. +- Otherwise an explicit `KANDEV_DATABASE_PATH`, whether selected through the + environment or YAML, is honored as an escape hatch. It takes precedence over + an ambient `KANDEV_HOME_DIR`. - Otherwise the DB is `/.kandev-dev/data/kandev.db`. +An ambient `KANDEV_HOME_DIR` alone is parent-process context, not an +authorization to redirect dev state: plain dev keeps its database, bootstrap +home, and supervisor state repo-local. A working-directory or system YAML +`homeDir` remains explicit configuration. Repo-local targets are rejected if +they leave `.kandev-dev/data` or traverse a symlink; external targets require +explicit database or YAML-home provenance and fail closed before the launcher +opens or copies a database. + Dev mode always sets `KANDEV_DEBUG_DEV_MODE=true`, which is the profile selector; the concrete dev defaults stay in `profiles.yaml`. The launcher must not restate them. @@ -228,6 +245,7 @@ them: building and serving the web app (Vite), the published npm shim, and repo | Repo root not found from CWD | Exit 2 with a message telling the user to run from the repo. | | Explicit backend or web port occupied | Exit 1 naming the port and its source (flag vs. env). No browser is opened. | | Production DB backup fails | Exit 1 before the backend starts; the message names the DB path. | +| Repo-local DB path escapes `.kandev-dev/data` or a symlink | Exit 1 before any database access or backend start; the message names the path. | | Backend fails health within 600s | Print the captured backend output, shut down the tree, exit 1. | | Vite never becomes ready | Shut down the tree, exit 1. | | Either child exits | Shut down the tree; exit with that child's code, or 0 if it was signalled. | @@ -238,7 +256,7 @@ them: building and serving the web app (Vite), the published npm shim, and repo ## Persistence guarantees - `.kandev-dev/` is the only state `make dev` writes, unless the user explicitly sets - `KANDEV_DATABASE_PATH` outside a task workspace. + `KANDEV_DATABASE_PATH` or a YAML `homeDir` outside a task workspace. - `dev-prod-db-*.db` snapshots are retained five deep in `~/.kandev/data/backups/` and never prune other backup families. - The supervisor manifest and control socket live under