fix(launcher): isolate dev database resolution from ambient environment (#2910)

Co-authored-by: ayattara <alassane.yattara@savoirfairelinux.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Carlos Florencio <carlosmflorencio@gmail.com>
This commit is contained in:
Alassane
2026-08-26 22:02:34 +00:00
committed by GitHub
parent 743ea849d3
commit 3febedd6ba
6 changed files with 641 additions and 66 deletions
+45 -19
View File
@@ -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
}
+19 -1
View File
@@ -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{})
+121 -24
View File
@@ -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
}
+429 -12
View File
@@ -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 <repo>/apps with a
// relative explicit database path and relative YAML homeDir. The
// launcher's CWD is <repo>/apps, but the backend's CWD will be <repo>.
// 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)
}
})
}
}
@@ -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)
}
}
@@ -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 `<repoRoot>/.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 `<repoRoot>/.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 `<repoRoot>/.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