diff --git a/internal/config/config.go b/internal/config/config.go index 6b179abebbffe425ab624217ce40cb55ccbf7877..db6125017282f6e401c76002894c191e23e14d7c 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -259,13 +259,17 @@ func (Attribution) JSONSchemaExtend(schema *jsonschema.Schema) { } type Options struct { - ContextPaths []string `json:"context_paths,omitempty" jsonschema:"description=Paths to files containing context information for the AI,example=.cursorrules,example=CRUSH.md"` - SkillsPaths []string `json:"skills_paths,omitempty" jsonschema:"description=Paths to directories containing Agent Skills (folders with SKILL.md files),example=~/.config/crush/skills,example=./skills"` - TUI *TUIOptions `json:"tui,omitempty" jsonschema:"description=Terminal user interface options"` - Debug bool `json:"debug,omitempty" jsonschema:"description=Enable debug logging,default=false"` - DebugLSP bool `json:"debug_lsp,omitempty" jsonschema:"description=Enable debug logging for LSP servers,default=false"` - DisableAutoSummarize bool `json:"disable_auto_summarize,omitempty" jsonschema:"description=Disable automatic conversation summarization,default=false"` - DataDirectory string `json:"data_directory,omitempty" jsonschema:"description=Directory for storing application data (relative to working directory),default=.crush,example=.crush"` // Relative to the cwd + ContextPaths []string `json:"context_paths,omitempty" jsonschema:"description=Paths to files containing context information for the AI,example=.cursorrules,example=CRUSH.md"` + SkillsPaths []string `json:"skills_paths,omitempty" jsonschema:"description=Paths to directories containing Agent Skills (folders with SKILL.md files),example=~/.config/crush/skills,example=./skills"` + TUI *TUIOptions `json:"tui,omitempty" jsonschema:"description=Terminal user interface options"` + Debug bool `json:"debug,omitempty" jsonschema:"description=Enable debug logging,default=false"` + DebugLSP bool `json:"debug_lsp,omitempty" jsonschema:"description=Enable debug logging for LSP servers,default=false"` + DisableAutoSummarize bool `json:"disable_auto_summarize,omitempty" jsonschema:"description=Disable automatic conversation summarization,default=false"` + // DataDirectory is where Crush keeps per-project state such as + // the SQLite database and workspace overrides. Relative paths are + // resolved against the working directory; absolute paths are used + // verbatim. After defaulting the stored value is always absolute. + DataDirectory string `json:"data_directory,omitempty" jsonschema:"description=Directory for storing application data. Relative paths are resolved against the working directory; absolute paths are used as-is.,default=.crush,example=.crush"` DisabledTools []string `json:"disabled_tools,omitempty" jsonschema:"description=List of built-in tools to disable and hide from the agent,example=bash,example=sourcegraph"` DisableProviderAutoUpdate bool `json:"disable_provider_auto_update,omitempty" jsonschema:"description=Disable providers auto-update,default=false"` DisableDefaultProviders bool `json:"disable_default_providers,omitempty" jsonschema:"description=Ignore all default/embedded providers. When enabled\\, providers must be fully specified in the config file with base_url\\, models\\, and api_key - no merging with defaults occurs,default=false"` diff --git a/internal/config/load.go b/internal/config/load.go index bc75422a082d0a20d081a700ae3c126bd727b132..b705c0f4060ca0b17975949ecd3a8709bc0d9cc0 100644 --- a/internal/config/load.go +++ b/internal/config/load.go @@ -424,7 +424,7 @@ func (c *Config) setDefaults(workingDir, dataDir string) { if dataDir != "" { c.Options.DataDirectory = dataDir } else if c.Options.DataDirectory == "" { - if path, ok := fsext.LookupClosest(workingDir, defaultDataDirectory); ok { + if path, ok := fsext.LookupClosestBounded(workingDir, projectBoundary(workingDir), defaultDataDirectory); ok { c.Options.DataDirectory = path } else { c.Options.DataDirectory = filepath.Join(workingDir, defaultDataDirectory) @@ -702,7 +702,12 @@ func configureSelectedModels(store *ConfigStore, knownProviders []catwalk.Provid return nil } -// lookupConfigs searches config files recursively from CWD up to FS root +// lookupConfigs searches config files starting at cwd and walking up +// through the current project. The upward walk stops at the git +// working tree root when one can be detected, otherwise at cwd itself, +// so an unrelated crush.json placed above the project is never picked +// up. Global user-level config locations are always included +// regardless of the boundary. func lookupConfigs(cwd string) []string { // prepend default config paths configPaths := []string{ @@ -712,7 +717,7 @@ func lookupConfigs(cwd string) []string { configNames := []string{appName + ".json", "." + appName + ".json"} - foundConfigs, err := fsext.Lookup(cwd, configNames...) + foundConfigs, err := fsext.LookupBounded(cwd, projectBoundary(cwd), configNames...) if err != nil { // returns at least default configs return configPaths @@ -873,6 +878,48 @@ func isInsideWorktree() bool { return err == nil && strings.TrimSpace(string(bts)) == "true" } +// worktreeRoot returns the absolute path of the git working tree root for +// dir, or the empty string if dir is not inside a working tree (bare +// repositories, missing git binary, plain directories, or any other +// failure mode). Linked worktrees and submodules each report their own +// top-level, which is what callers want when bounding lookups. +func worktreeRoot(dir string) string { + cmd := exec.CommandContext( + context.Background(), + "git", "rev-parse", "--show-toplevel", + ) + cmd.Dir = dir + out, err := cmd.Output() + if err != nil { + return "" + } + root := strings.TrimSpace(string(out)) + if root == "" { + return "" + } + abs, err := filepath.Abs(root) + if err != nil { + return "" + } + return abs +} + +// projectBoundary returns the directory at which an upward configuration +// search rooted at dir should stop. It is the git working tree root when +// one can be detected, otherwise dir itself. Returning dir as a +// fallback keeps Crush from silently adopting state files placed above +// the current project. +func projectBoundary(dir string) string { + if root := worktreeRoot(dir); root != "" { + return root + } + abs, err := filepath.Abs(dir) + if err != nil { + return dir + } + return abs +} + // GlobalSkillsDirs returns the default directories for Agent Skills. // Skills in these directories are auto-discovered and their files can be read // without permission prompts. diff --git a/internal/config/load_test.go b/internal/config/load_test.go index 0a50cce5ea38c8f73bfa60111d410dc15a60fd4e..cb69976f7f8130e6023fde36629790a86d5138f1 100644 --- a/internal/config/load_test.go +++ b/internal/config/load_test.go @@ -4,6 +4,7 @@ import ( "io" "log/slog" "os" + "os/exec" "path/filepath" "testing" @@ -36,6 +37,98 @@ func TestConfig_LoadFromBytes(t *testing.T) { require.Equal(t, "https://api.openai.com/v2", pc.BaseURL) } +func TestLookupConfigs_BoundedByProject(t *testing.T) { + // Force GlobalConfig and GlobalConfigData to point at locations we + // control so they can be present in the result without polluting + // the developer's real config. + globalDir := t.TempDir() + t.Setenv("CRUSH_GLOBAL_CONFIG", globalDir) + t.Setenv("CRUSH_GLOBAL_DATA", globalDir) + + t.Run("does not pick up crush.json above non-git project", func(t *testing.T) { + parent := t.TempDir() + + // crush.json above the project must not be adopted. + require.NoError(t, os.WriteFile( + filepath.Join(parent, "crush.json"), + []byte(`{}`), + 0o644, + )) + + project := filepath.Join(parent, "project") + require.NoError(t, os.Mkdir(project, 0o755)) + + got := lookupConfigs(project) + for _, p := range got { + require.NotEqual(t, filepath.Join(parent, "crush.json"), p) + } + }) + + t.Run("does not climb out of git worktree to find crush.json", func(t *testing.T) { + if _, err := exec.LookPath("git"); err != nil { + t.Skip("git not available") + } + + parent := t.TempDir() + + require.NoError(t, os.WriteFile( + filepath.Join(parent, "crush.json"), + []byte(`{}`), + 0o644, + )) + + worktree := filepath.Join(parent, "worktree") + require.NoError(t, os.Mkdir(worktree, 0o755)) + gitInit := exec.CommandContext(t.Context(), "git", "init", "-q") + gitInit.Dir = worktree + require.NoError(t, gitInit.Run()) + + got := lookupConfigs(worktree) + strayEval, err := filepath.EvalSymlinks(filepath.Join(parent, "crush.json")) + require.NoError(t, err) + for _, p := range got { + pEval, err := filepath.EvalSymlinks(p) + if err != nil { + continue + } + require.NotEqual(t, strayEval, pEval, "must not adopt parent crush.json") + } + }) + + t.Run("picks up crush.json inside the project", func(t *testing.T) { + project := t.TempDir() + local := filepath.Join(project, "crush.json") + require.NoError(t, os.WriteFile(local, []byte(`{}`), 0o644)) + + got := lookupConfigs(project) + + localEval, err := filepath.EvalSymlinks(local) + require.NoError(t, err) + var foundLocal bool + for _, p := range got { + pEval, err := filepath.EvalSymlinks(p) + if err != nil { + continue + } + if pEval == localEval { + foundLocal = true + break + } + } + require.True(t, foundLocal, "expected project crush.json to be in lookup result: %v", got) + }) + + t.Run("global config is always included regardless of boundary", func(t *testing.T) { + project := t.TempDir() + + got := lookupConfigs(project) + // Global config and global data path are always prepended, + // even when no project file exists. + require.Contains(t, got, GlobalConfig()) + require.Contains(t, got, GlobalConfigData()) + }) +} + func TestLoadFromConfigPaths_InvalidJSON(t *testing.T) { t.Parallel() @@ -111,6 +204,98 @@ func TestConfig_setDefaults(t *testing.T) { require.Equal(t, filepath.Join(workingDir, "state"), cfg.Options.DataDirectory) }) + + t.Run("preserves absolute configured data directory", func(t *testing.T) { + // Use a platform-appropriate absolute path so the test runs + // the same way on POSIX and Windows. + absDir := filepath.Join(t.TempDir(), "data") + cfg := &Config{Options: &Options{DataDirectory: absDir}} + + cfg.setDefaults(filepath.Join(t.TempDir(), "worktree"), "") + + require.Equal(t, absDir, cfg.Options.DataDirectory) + }) + + t.Run("workspace merge re-entry keeps an absolute data directory", func(t *testing.T) { + // Simulate the load and reload paths: defaults are applied + // twice with the data directory potentially carried through + // from an earlier merge as a relative string. + workingDir := filepath.Join(t.TempDir(), "worktree") + cfg := &Config{} + cfg.setDefaults(workingDir, "") + + // Workspace JSON sets data_directory to a relative value; the + // merge replaces the struct, then setDefaults runs again. + cfg.Options.DataDirectory = "./state" + cfg.setDefaults(workingDir, "") + + require.True(t, filepath.IsAbs(cfg.Options.DataDirectory), + "data directory must remain absolute after re-merge, got %q", + cfg.Options.DataDirectory) + require.Equal(t, filepath.Join(workingDir, "state"), cfg.Options.DataDirectory) + }) + + t.Run("does not adopt .crush from a parent project", func(t *testing.T) { + parent := t.TempDir() + + // .crush in the parent: it should not be reused by the child + // because there is no git context joining them. + require.NoError(t, os.Mkdir(filepath.Join(parent, defaultDataDirectory), 0o755)) + + child := filepath.Join(parent, "child") + require.NoError(t, os.Mkdir(child, 0o755)) + + cfg := &Config{} + cfg.setDefaults(child, "") + + require.Equal(t, + filepath.Clean(filepath.Join(child, defaultDataDirectory)), + filepath.Clean(cfg.Options.DataDirectory), + ) + }) + + t.Run("does not climb out of git worktree to find .crush", func(t *testing.T) { + if _, err := exec.LookPath("git"); err != nil { + t.Skip("git not available") + } + + parent := t.TempDir() + + // Stray .crush above the worktree root. + require.NoError(t, os.Mkdir(filepath.Join(parent, defaultDataDirectory), 0o755)) + + worktree := filepath.Join(parent, "worktree") + require.NoError(t, os.Mkdir(worktree, 0o755)) + + sub := filepath.Join(worktree, "pkg") + require.NoError(t, os.Mkdir(sub, 0o755)) + + // Make worktree a real git repo so the boundary detection + // resolves to it, mirroring what happens with linked worktrees + // in real usage. + gitInit := exec.CommandContext(t.Context(), "git", "init", "-q") + gitInit.Dir = worktree + require.NoError(t, gitInit.Run()) + + cfg := &Config{} + cfg.setDefaults(sub, "") + + // Resolve symlinks because TempDir on macOS sits under /var + // which is a symlink to /private/var. The data directory has + // not been created yet, so resolve its parent and join. + gotDir, gotName := filepath.Split(cfg.Options.DataDirectory) + gotEvalDir, err := filepath.EvalSymlinks(filepath.Clean(gotDir)) + require.NoError(t, err) + gotEval := filepath.Join(gotEvalDir, gotName) + + strayEval, err := filepath.EvalSymlinks(filepath.Join(parent, defaultDataDirectory)) + require.NoError(t, err) + require.NotEqual(t, strayEval, gotEval, "must not adopt parent .crush") + + subEval, err := filepath.EvalSymlinks(sub) + require.NoError(t, err) + require.Equal(t, filepath.Join(subEval, defaultDataDirectory), gotEval) + }) } func TestConfig_configureProviders(t *testing.T) { diff --git a/internal/fsext/lookup.go b/internal/fsext/lookup.go index 098426571c69521a5978a2c2e0a4178b51b0aae6..696219ac89209b718ed0e106a4bc6a879105961c 100644 --- a/internal/fsext/lookup.go +++ b/internal/fsext/lookup.go @@ -82,6 +82,81 @@ func LookupClosest(dir, target string) (string, bool) { return found, err == nil && found != "" } +// LookupClosestBounded behaves like LookupClosest but constrains the +// upward search to stopDir. The walk inspects dir, then each ancestor up +// to and including stopDir, then terminates regardless of whether the +// target was found. Use this when the caller wants to avoid adopting +// matches from outside a project boundary (for example a sibling +// worktree or a parent project). +// +// If stopDir is empty, only dir itself is searched. If stopDir is not an +// ancestor of dir, the walk still terminates at the filesystem root. +// The $HOME and ownership safeguards from LookupClosest are preserved +// as outer bounds. +func LookupClosestBounded(dir, stopDir, target string) (string, bool) { + var found string + + err := traverseUpBounded(dir, stopDir, func(cwd string, owner int) error { + fpath := filepath.Join(cwd, target) + + err := probeEnt(fpath, owner) + if errors.Is(err, os.ErrNotExist) { + return nil + } + + if err != nil { + return fmt.Errorf("error probing file %s: %w", fpath, err) + } + + if cwd == home.Dir() { + return filepath.SkipAll + } + + found = fpath + return filepath.SkipAll + }) + + return found, err == nil && found != "" +} + +// LookupBounded behaves like Lookup but constrains the upward search to +// stopDir. The walk inspects dir, then each ancestor up to and including +// stopDir, then terminates. If stopDir is empty, only dir itself is +// searched. +func LookupBounded(dir, stopDir string, targets ...string) ([]string, error) { + if len(targets) == 0 { + return nil, nil + } + + var found []string + + err := traverseUpBounded(dir, stopDir, func(cwd string, owner int) error { + for _, target := range targets { + fpath := filepath.Join(cwd, target) + err := probeEnt(fpath, owner) + + // skip to the next file on permission denied + if errors.Is(err, os.ErrNotExist) || + errors.Is(err, os.ErrPermission) { + continue + } + + if err != nil { + return fmt.Errorf("error probing file %s: %w", fpath, err) + } + + found = append(found, fpath) + } + + return nil + }) + if err != nil { + return nil, err + } + + return found, nil +} + // traverseUp walks up from given directory up until filesystem root reached. // It passes absolute path of current directory and staring directory owner ID // to callback function. It is up to user to check ownership. @@ -116,6 +191,71 @@ func traverseUp(dir string, walkFn func(dir string, owner int) error) error { } } +// traverseUpBounded walks up from dir, visiting each ancestor up to and +// including stopDir, then terminates. If stopDir is empty, only dir +// itself is visited; callers that want an unbounded walk should use +// traverseUp instead. If stopDir is set but is not an ancestor of dir +// the walk still stops at the filesystem root, so callers cannot +// accidentally produce an infinite walk by passing a sibling path. +// +// Boundary comparison is performed against symlink-resolved paths so +// that callers passing logically equivalent paths (a symlinked /var vs +// the underlying /private/var, for example) still terminate at the +// expected directory. +func traverseUpBounded(dir, stopDir string, walkFn func(dir string, owner int) error) error { + cwd, err := filepath.Abs(dir) + if err != nil { + return fmt.Errorf("cannot convert CWD to absolute path: %w", err) + } + + stop := cwd + if stopDir != "" { + stop, err = filepath.Abs(stopDir) + if err != nil { + return fmt.Errorf("cannot convert stop dir to absolute path: %w", err) + } + } + canonStop := canonicalize(stop) + + owner, err := Owner(dir) + if err != nil { + return fmt.Errorf("cannot get ownership: %w", err) + } + + for { + err := walkFn(cwd, owner) + if err == nil || errors.Is(err, filepath.SkipDir) { + if canonicalize(cwd) == canonStop { + return nil + } + + parent := filepath.Dir(cwd) + if parent == cwd { + return nil + } + + cwd = parent + continue + } + + if errors.Is(err, filepath.SkipAll) { + return nil + } + + return err + } +} + +// canonicalize resolves any symbolic links in path. If resolution fails +// (typically because path does not exist yet) the original path is +// returned cleaned, so callers can still perform stable equality checks. +func canonicalize(path string) string { + if resolved, err := filepath.EvalSymlinks(path); err == nil { + return resolved + } + return filepath.Clean(path) +} + // probeEnt checks if entity at given path exists and belongs to given owner func probeEnt(fspath string, owner int) error { _, err := os.Stat(fspath) diff --git a/internal/fsext/lookup_test.go b/internal/fsext/lookup_test.go index 97c167f37d8ebcf4d19124367955874e7f816b67..f4e445c179459dc0deed85708014a6690d4a008d 100644 --- a/internal/fsext/lookup_test.go +++ b/internal/fsext/lookup_test.go @@ -353,6 +353,131 @@ func TestLookup(t *testing.T) { }) } +func TestLookupClosestBounded(t *testing.T) { + t.Run("found in starting directory", func(t *testing.T) { + testDir := t.TempDir() + + targetFile := filepath.Join(testDir, "target.txt") + require.NoError(t, os.WriteFile(targetFile, []byte("test"), 0o644)) + + foundPath, found := LookupClosestBounded(testDir, testDir, "target.txt") + require.True(t, found) + require.Equal(t, targetFile, foundPath) + }) + + t.Run("found at boundary directory", func(t *testing.T) { + boundary := t.TempDir() + + subDir := filepath.Join(boundary, "subdir") + require.NoError(t, os.Mkdir(subDir, 0o755)) + + targetFile := filepath.Join(boundary, "target.txt") + require.NoError(t, os.WriteFile(targetFile, []byte("test"), 0o644)) + + foundPath, found := LookupClosestBounded(subDir, boundary, "target.txt") + require.True(t, found) + require.Equal(t, targetFile, foundPath) + }) + + t.Run("does not climb past boundary", func(t *testing.T) { + parent := t.TempDir() + + // Target lives above the boundary. + require.NoError(t, os.WriteFile(filepath.Join(parent, "target.txt"), []byte("test"), 0o644)) + + boundary := filepath.Join(parent, "project") + require.NoError(t, os.Mkdir(boundary, 0o755)) + + subDir := filepath.Join(boundary, "subdir") + require.NoError(t, os.Mkdir(subDir, 0o755)) + + foundPath, found := LookupClosestBounded(subDir, boundary, "target.txt") + require.False(t, found) + require.Empty(t, foundPath) + }) + + t.Run("empty boundary searches only starting directory", func(t *testing.T) { + parent := t.TempDir() + + require.NoError(t, os.WriteFile(filepath.Join(parent, "target.txt"), []byte("test"), 0o644)) + + subDir := filepath.Join(parent, "subdir") + require.NoError(t, os.Mkdir(subDir, 0o755)) + + foundPath, found := LookupClosestBounded(subDir, "", "target.txt") + require.False(t, found) + require.Empty(t, foundPath) + }) + + t.Run("empty boundary still finds in starting directory", func(t *testing.T) { + testDir := t.TempDir() + + targetFile := filepath.Join(testDir, "target.txt") + require.NoError(t, os.WriteFile(targetFile, []byte("test"), 0o644)) + + foundPath, found := LookupClosestBounded(testDir, "", "target.txt") + require.True(t, found) + require.Equal(t, targetFile, foundPath) + }) +} + +func TestLookupBounded(t *testing.T) { + t.Run("returns matches at and below boundary", func(t *testing.T) { + boundary := t.TempDir() + + subDir := filepath.Join(boundary, "subdir") + require.NoError(t, os.Mkdir(subDir, 0o755)) + + atBoundary := filepath.Join(boundary, "target.txt") + atSub := filepath.Join(subDir, "target.txt") + require.NoError(t, os.WriteFile(atBoundary, []byte("a"), 0o644)) + require.NoError(t, os.WriteFile(atSub, []byte("b"), 0o644)) + + found, err := LookupBounded(subDir, boundary, "target.txt") + require.NoError(t, err) + require.Len(t, found, 2) + require.Contains(t, found, atBoundary) + require.Contains(t, found, atSub) + }) + + t.Run("ignores matches above boundary", func(t *testing.T) { + parent := t.TempDir() + + require.NoError(t, os.WriteFile(filepath.Join(parent, "target.txt"), []byte("nope"), 0o644)) + + boundary := filepath.Join(parent, "project") + require.NoError(t, os.Mkdir(boundary, 0o755)) + + subDir := filepath.Join(boundary, "subdir") + require.NoError(t, os.Mkdir(subDir, 0o755)) + + // Target lives only above the boundary. + found, err := LookupBounded(subDir, boundary, "target.txt") + require.NoError(t, err) + require.Empty(t, found) + }) + + t.Run("empty boundary searches only starting directory", func(t *testing.T) { + parent := t.TempDir() + + require.NoError(t, os.WriteFile(filepath.Join(parent, "target.txt"), []byte("nope"), 0o644)) + + subDir := filepath.Join(parent, "subdir") + require.NoError(t, os.Mkdir(subDir, 0o755)) + + found, err := LookupBounded(subDir, "", "target.txt") + require.NoError(t, err) + require.Empty(t, found) + }) + + t.Run("no targets returns nil", func(t *testing.T) { + dir := t.TempDir() + found, err := LookupBounded(dir, dir) + require.NoError(t, err) + require.Empty(t, found) + }) +} + func TestProbeEnt(t *testing.T) { t.Run("existing file with correct owner", func(t *testing.T) { tempDir := t.TempDir() diff --git a/internal/swagger/docs.go b/internal/swagger/docs.go index e36cae3f015d0cfc474bffd9f697e6170cc5a73a..ec106954f54e055d1ebe9ff64db9c63454a644e2 100644 --- a/internal/swagger/docs.go +++ b/internal/swagger/docs.go @@ -2939,7 +2939,7 @@ const docTemplate = `{ } }, "data_directory": { - "description": "Relative to the cwd", + "description": "DataDirectory is where Crush keeps per-project state such as the SQLite database and workspace overrides. Relative paths are resolved against the working directory; absolute paths are used verbatim. After defaulting the stored value is always absolute.", "type": "string" }, "debug": { diff --git a/internal/swagger/swagger.json b/internal/swagger/swagger.json index 3785d226e8d08af1be38786f83cea1f400c3eb68..b3ccbe22b783b78f508fa1a6b05f38d2f45f8612 100644 --- a/internal/swagger/swagger.json +++ b/internal/swagger/swagger.json @@ -2932,7 +2932,7 @@ } }, "data_directory": { - "description": "Relative to the cwd", + "description": "DataDirectory is where Crush keeps per-project state such as the SQLite database and workspace overrides. Relative paths are resolved against the working directory; absolute paths are used verbatim. After defaulting the stored value is always absolute.", "type": "string" }, "debug": { diff --git a/internal/swagger/swagger.yaml b/internal/swagger/swagger.yaml index f0de0979b842425fa5b5287377aa1d1f18b9a4e5..d1265256c785236bac581401c7c6e44838ff5dae 100644 --- a/internal/swagger/swagger.yaml +++ b/internal/swagger/swagger.yaml @@ -287,7 +287,11 @@ definitions: type: string type: array data_directory: - description: Relative to the cwd + description: |- + DataDirectory is where Crush keeps per-project state such as the SQLite + database and workspace overrides. Relative paths are resolved against + the working directory; absolute paths are used verbatim. After + defaulting the stored value is always absolute. type: string debug: type: boolean diff --git a/schema.json b/schema.json index 8b98269414175422347ae208f34997081cdc6249..751a5f529f8cb2773286d1dbd98b99b548c6503b 100644 --- a/schema.json +++ b/schema.json @@ -423,7 +423,7 @@ }, "data_directory": { "type": "string", - "description": "Directory for storing application data (relative to working directory)", + "description": "Directory for storing application data. Relative paths are resolved against the working directory; absolute paths are used as-is.", "default": ".crush", "examples": [ ".crush"