From c650d74e806924c78a13f80c2ca8d3f253e58102 Mon Sep 17 00:00:00 2001 From: benbenbang Date: Fri, 9 Oct 2026 15:30:33 +0800 Subject: [PATCH] feat(config): auto-load current-directory jevwise.toml configuration Select `jevwise.toml` from the current directory before preserved XDG or HOME global configuration, while explicit paths always take precedence. Treat the selected file as a replacement rather than merging it with global settings, and preserve existing flag and environment precedence. Limit reads to 1 MiB and fail closed for invalid, nonregular, linked, or unsafe local files without ancestor-directory search or global fallback. Document the behavior and add path-resolution coverage. --- README.md | 3 +- docs/internal/config-commands.md | 10 ++++ internal/config/config.go | 31 +++++------- internal/config/config_test.go | 1 + internal/config/paths.go | 78 +++++++++++++++++++++++++++++ internal/config/paths_test.go | 84 ++++++++++++++++++++++++++++++++ 6 files changed, 187 insertions(+), 20 deletions(-) create mode 100644 docs/internal/config-commands.md create mode 100644 internal/config/paths.go create mode 100644 internal/config/paths_test.go diff --git a/README.md b/README.md index 9333500..cf3845a 100644 --- a/README.md +++ b/README.md @@ -117,7 +117,8 @@ Precedence: **changed flags > nonblank environment > TOML > defaults**. | Operation timeout | `--timeout` | `TS_JEV_TIMEOUT` | `10s` | - Explicit file: `--config /path/to/config.toml`. Missing or invalid explicit files fail. -- Otherwise: `$XDG_CONFIG_HOME/ts-jev/config.toml`, or `$HOME/.config/ts-jev/config.toml`. A missing discovered file is allowed. +- Otherwise: current-directory `jevwise.toml`, then `$XDG_CONFIG_HOME/ts-jev/config.toml` or `$HOME/.config/ts-jev/config.toml`. Only a missing global file is allowed. The selected file replaces rather than merges with global settings; no ancestor-directory search. +- Trust the current directory before running decision/MCP commands: local settings can change the upstream destination. Environment/changed flags still win. Invalid, oversized (over 1 MiB), linked or nonregular local files fail without global fallback; help/version do not load them. - Start with [config.example.toml](config.example.toml). Prefer environment credentials rather than command-line secrets. ## Skill setup diff --git a/docs/internal/config-commands.md b/docs/internal/config-commands.md new file mode 100644 index 0000000..0e04fb4 --- /dev/null +++ b/docs/internal/config-commands.md @@ -0,0 +1,10 @@ +# Config command plan + +- 27: centralize discovery: explicit `--config` > current-directory `jevwise.toml` > existing XDG/HOME `ts-jev/config.toml`; selected files replace, not merge. Preserve flags/environment precedence and credential-free help. Bound reads and reject nonregular local files. Never search ancestor directories. +- 28: add `config init` and redacted `config view`; init defaults global, `--local` creates `./jevwise.toml`, explicit paths supported, conflicting selectors rejected. Publish a private template without overwriting any existing file/link; view shows known file settings/defaults, not environment overrides or arbitrary extra keys/comments. +- 29: add `config edit`, default installed `vim` on PATH for macOS/Linux; configurable `editor` executable name/path, no shell or embedded editor. Forward terminal streams, test with injected/fake processes, safe errors and cancellation. No Windows editor support promise; preserve cross-builds. +- Worktrees under `.agent/worktrees`, linear `virtual-main` branches; soft 300/hard 450 aggregate lines each. Local commits via approved git ch/OpenAI/luna; no push/merge/tag/release without approval. +- Local auto-load requires trusting the current directory, especially its base URL. Do not copy environment API keys into templates or show them in view; add local file to project ignore rules before storing credentials. Existing provider/SDK/config namespace and Windows dependency deferral remain unchanged. +- Root and independent QA: fresh normal/race tests, tidy/vet/build, offline Python fixtures, formatting/diff checks, six platform builds and synthetic credential-free CLI smoke. Never launch a real editor/agent or use normal user config during tests. Private spec remains unstaged. + +- 27 local QA passed: root normal/race/tidy/vet/build, 11 offline Python fixtures, formatting/diff/docs checks and six platform builds; independent full tests and config race repeated 20 times. No network/model/editor calls. diff --git a/internal/config/config.go b/internal/config/config.go index 7b9de7e..17cb4be 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -2,11 +2,11 @@ package config import ( + "bytes" "errors" "fmt" "net/url" "os" - "path/filepath" "strings" "time" @@ -21,8 +21,8 @@ type Config struct { } // Load applies changed flags > nonblank environment > TOML > defaults. -// An empty path discovers XDG_CONFIG_HOME/ts-jev/config.toml or -// HOME/.config/ts-jev/config.toml. Only a missing discovered file is optional. +// An empty path selects ./jevwise.toml before the XDG/HOME global file. +// Only a missing default global file is optional; selected files never merge. // Neither global Viper state nor the supplied flags are mutated. func Load(path string, flags *pflag.FlagSet) (Config, error) { v := viper.New() @@ -30,23 +30,17 @@ func Load(path string, flags *pflag.FlagSet) (Config, error) { for key, value := range defaults { v.SetDefault(key, value) } - explicit := path != "" - if !explicit { - base := strings.TrimSpace(os.Getenv("XDG_CONFIG_HOME")) - if base == "" { - home, err := os.UserHomeDir() - if err != nil { - return Config{}, errors.New("cannot discover configuration: HOME is unset") - } - base = filepath.Join(home, ".config") - } - path = filepath.Join(base, "ts-jev", "config.toml") + path, required, err := ResolvePath(path) + if err != nil { + return Config{}, err + } + data, err := ReadFile(path) + if err != nil && (required || !errors.Is(err, os.ErrNotExist)) { + return Config{}, errors.New("cannot read configuration: file missing, unreadable or unsafe") } - v.SetConfigFile(path) v.SetConfigType("toml") - if err := v.ReadInConfig(); err != nil && (explicit || !errors.Is(err, os.ErrNotExist)) { - // Parser errors may contain file values, including credentials. - return Config{}, errors.New("cannot read configuration: file missing, unreadable or invalid TOML") + if err == nil && v.ReadConfig(bytes.NewReader(data)) != nil { + return Config{}, errors.New("cannot read configuration: invalid TOML") } upstream := map[string]string{"api_key": "TYPESAFE_API_KEY", "base_url": "TYPESAFE_BASE_URL", "model": "TYPESAFE_DEFAULT_MODEL"} env := make(map[string]any) @@ -78,7 +72,6 @@ func Load(path string, flags *pflag.FlagSet) (Config, error) { values[key] = strings.TrimSpace(value) } c := Config{APIKey: values["api_key"], BaseURL: values["base_url"], Model: values["model"], Provider: values["provider"]} - var err error c.Timeout, err = time.ParseDuration(values["timeout"]) if err != nil { return Config{}, errors.New("timeout must be a positive duration") diff --git a/internal/config/config_test.go b/internal/config/config_test.go index b796d2a..8b4cd0c 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -14,6 +14,7 @@ import ( func cleanEnv(t *testing.T) { t.Helper() + t.Chdir(t.TempDir()) for _, name := range []string{"TS_JEV_API_KEY", "TS_JEV_BASE_URL", "TS_JEV_MODEL", "TS_JEV_PROVIDER", "TS_JEV_TIMEOUT", "TYPESAFE_API_KEY", "TYPESAFE_BASE_URL", "TYPESAFE_DEFAULT_MODEL"} { t.Setenv(name, "") } diff --git a/internal/config/paths.go b/internal/config/paths.go new file mode 100644 index 0000000..60df8a7 --- /dev/null +++ b/internal/config/paths.go @@ -0,0 +1,78 @@ +package config + +import ( + "errors" + "io" + "os" + "path/filepath" + "strings" +) + +const LocalName = "jevwise.toml" +const MaxFileBytes = 1 << 20 + +// GlobalPath preserves the existing configuration namespace. +func GlobalPath() (string, error) { + base := strings.TrimSpace(os.Getenv("XDG_CONFIG_HOME")) + if base == "" { + home, err := os.UserHomeDir() + if err != nil { + return "", errors.New("cannot discover configuration: home is unset") + } + base = filepath.Join(home, ".config") + } + return filepath.Abs(filepath.Join(base, "ts-jev", "config.toml")) +} + +// ResolvePath returns the selected file and whether its presence is required. +// Only the current directory is searched; a local file replaces the global one. +func ResolvePath(explicit string) (string, bool, error) { + if explicit != "" { + path, err := filepath.Abs(explicit) + return path, true, err + } + cwd, err := os.Getwd() + if err != nil { + return "", true, errors.New("cannot discover current-directory configuration") + } + local := filepath.Join(cwd, LocalName) + info, err := os.Lstat(local) + if err == nil { + if !info.Mode().IsRegular() { + return "", true, errors.New("local configuration must be a regular file, not a link") + } + return local, true, nil + } + if !errors.Is(err, os.ErrNotExist) { + return "", true, errors.New("cannot inspect local configuration") + } + path, err := GlobalPath() + return path, false, err +} + +// ReadFile bounds TOML input and refuses special files before opening them. +// Explicit/global read-only symlinks retain their existing behavior. Checks +// assume cooperative filesystem storage, not hostile concurrent path changes. +func ReadFile(path string) ([]byte, error) { + info, err := os.Stat(path) + if err != nil { + return nil, err + } + if !info.Mode().IsRegular() || info.Size() > MaxFileBytes { + return nil, errors.New("configuration must be a regular file of at most 1 MiB") + } + file, err := os.Open(path) + if err != nil { + return nil, err + } + defer file.Close() + opened, err := file.Stat() + if err != nil || !opened.Mode().IsRegular() || !os.SameFile(info, opened) { + return nil, errors.New("configuration changed while opening") + } + data, err := io.ReadAll(io.LimitReader(file, MaxFileBytes+1)) + if err == nil && len(data) > MaxFileBytes { + err = errors.New("configuration exceeds 1 MiB") + } + return data, err +} diff --git a/internal/config/paths_test.go b/internal/config/paths_test.go new file mode 100644 index 0000000..bf02348 --- /dev/null +++ b/internal/config/paths_test.go @@ -0,0 +1,84 @@ +package config + +import ( + "os" + "path/filepath" + "runtime" + "strings" + "testing" + + "github.com/spf13/pflag" +) + +func TestLocalDiscoveryPrecedenceAndNoMerge(t *testing.T) { + cleanEnv(t) + global, _ := GlobalPath() + writeConfig(t, global, "model='global'\napi_key='global-secret'") + writeConfig(t, LocalName, "model='local'") + cfg, err := Load("", nil) + if err != nil || cfg.Model != "local" || cfg.APIKey != "" { + t.Fatal("local file did not replace global", err) + } + t.Setenv("TS_JEV_MODEL", "environment") + cfg, _ = Load("", nil) + if cfg.Model != "environment" { + t.Fatal("environment lost precedence") + } + flags := pflag.NewFlagSet("test", pflag.ContinueOnError) + flags.String("model", "", "") + _ = flags.Parse([]string{"--model=flag"}) + cfg, err = Load("", flags) + if err != nil || cfg.Model != "flag" { + t.Fatal("flag lost precedence", err) + } + t.Setenv("TS_JEV_MODEL", "") + cfg, err = Load(global, nil) + if err != nil || cfg.Model != "global" { + t.Fatal("explicit path lost precedence", err) + } + t.Setenv("HOME", "") + t.Setenv("USERPROFILE", "") + t.Setenv("XDG_CONFIG_HOME", "") + if _, err := Load("", nil); err != nil { + t.Fatal("local configuration requires home", err) + } +} + +func TestLocalDiscoveryFailsClosedAndDoesNotSearchAncestors(t *testing.T) { + for _, mode := range []string{"invalid", "oversize", "directory", "link"} { + t.Run(mode, func(t *testing.T) { + cleanEnv(t) + global, _ := GlobalPath() + writeConfig(t, global, "model='global'") + switch mode { + case "invalid": + writeConfig(t, LocalName, "api_key='private-secret'\n[") + case "oversize": + writeConfig(t, LocalName, strings.Repeat("x", MaxFileBytes+1)) + case "directory": + _ = os.Mkdir(LocalName, 0700) + case "link": + if runtime.GOOS == "windows" { + t.Skip("symlink creation may require privileges") + } + if err := os.Symlink(global, LocalName); err != nil { + t.Fatal(err) + } + } + if _, err := Load("", nil); err == nil || strings.Contains(err.Error(), "private-secret") { + t.Fatal("unsafe local configuration fell back or leaked", err) + } + }) + } + cleanEnv(t) + writeConfig(t, LocalName, "model='ancestor'") + // A nested directory of the current project must not inherit its file. + cwd, _ := os.Getwd() + child := filepath.Join(cwd, "child") + _ = os.Mkdir(child, 0700) + t.Chdir(child) + cfg, err := Load("", nil) + if err != nil || cfg.Model != "jev-latest" { + t.Fatal("ancestor configuration loaded", err) + } +}