Detailed changes
@@ -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"`
@@ -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.
@@ -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) {
@@ -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)
@@ -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()
@@ -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": {
@@ -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": {
@@ -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
@@ -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"