-
Notifications
You must be signed in to change notification settings - Fork 1
feat(config): multi-layer config overlay (project .jcode + env vars + MCP trust gate) #173
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
adde47f
d96fd4d
7d1936c
3f561b7
13f4f84
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,66 @@ | ||
| package config | ||
|
|
||
| import ( | ||
| "os" | ||
| "strconv" | ||
| ) | ||
|
|
||
| // Environment variable names for configuration overrides. These provide the | ||
| // highest-precedence config layer — above both global (~/.jcode/config.json) | ||
| // and project-level (.jcode/config.json) settings. | ||
| const ( | ||
| // EnvModel overrides the active model ("provider/model" format). | ||
| EnvModel = "JCODE_MODEL" | ||
| // EnvSmallModel overrides the small/fast model ("provider/model" format). | ||
| EnvSmallModel = "JCODE_SMALL_MODEL" | ||
| // EnvMaxIterations overrides the agent iteration cap. | ||
| EnvMaxIterations = "JCODE_MAX_ITERATIONS" | ||
| // EnvTheme overrides the color theme name. | ||
| EnvTheme = "JCODE_THEME" | ||
| // EnvLanguage overrides the UI locale. | ||
| EnvLanguage = "JCODE_LANGUAGE" | ||
| // EnvDefaultMode overrides the session mode ("approval", "plan", "full_access"). | ||
| EnvDefaultMode = "JCODE_DEFAULT_MODE" | ||
| // EnvConfigFile overrides the config file path (default ~/.jcode/config.json). | ||
| EnvConfigFile = "JCODE_CONFIG" | ||
| ) | ||
|
|
||
| // ApplyEnvOverlay applies environment variable overrides to cfg. Environment | ||
| // variables have the highest precedence — they override both global and | ||
| // project-level config values. This is useful for CI/CD pipelines, containerized | ||
| // deployments, and quick one-off overrides without editing config files. | ||
| // | ||
| // Unlike project config, env vars MAY override DefaultMode because they are set | ||
| // by the user (or their CI system) directly, not by an untrusted repository. | ||
| func ApplyEnvOverlay(cfg *Config) { | ||
| if cfg == nil { | ||
| return | ||
| } | ||
| if v := os.Getenv(EnvModel); v != "" { | ||
| cfg.Model = v | ||
| } | ||
| if v := os.Getenv(EnvSmallModel); v != "" { | ||
| cfg.SmallModel = v | ||
| } | ||
| if v := os.Getenv(EnvMaxIterations); v != "" { | ||
| if n, err := strconv.Atoi(v); err == nil && n > 0 { | ||
| cfg.MaxIterations = n | ||
| } else { | ||
| Logger().Printf("[config] ignoring invalid %s=%q (must be a positive integer)", EnvMaxIterations, v) | ||
| } | ||
| } | ||
| if v := os.Getenv(EnvTheme); v != "" { | ||
| cfg.Theme = v | ||
| } | ||
| if v := os.Getenv(EnvLanguage); v != "" { | ||
| cfg.Language = v | ||
| } | ||
|
Comment on lines
+39
to
+57
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win Validate all environment overrides before applying them.
📍 Affects 2 files
🤖 Prompt for AI Agents |
||
| if v := os.Getenv(EnvDefaultMode); v != "" { | ||
| switch v { | ||
| case "approval", "plan", "auto", "full_access": | ||
| cfg.DefaultMode = v | ||
| default: | ||
| Logger().Printf("[config] ignoring invalid %s=%q (must be one of: approval, plan, auto, full_access)", EnvDefaultMode, v) | ||
| } | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,197 @@ | ||
| package config | ||
|
|
||
| import ( | ||
| "os" | ||
| "path/filepath" | ||
| "testing" | ||
| ) | ||
|
|
||
| func TestApplyEnvOverlay(t *testing.T) { | ||
| // Isolate from the real HOME so config.ConfigDir() resolves to a temp dir. | ||
| t.Setenv("HOME", t.TempDir()) | ||
|
|
||
| t.Run("nil config is a no-op", func(t *testing.T) { | ||
| ApplyEnvOverlay(nil) // must not panic | ||
| }) | ||
|
|
||
| t.Run("no env vars leaves config unchanged", func(t *testing.T) { | ||
| // Clear all JCODE_* env vars for this test. | ||
| for _, key := range []string{EnvModel, EnvSmallModel, EnvMaxIterations, EnvTheme, EnvLanguage, EnvDefaultMode} { | ||
| t.Setenv(key, "") | ||
| } | ||
| cfg := &Config{Model: "openai/gpt-4o", SmallModel: "openai/gpt-4o-mini", MaxIterations: 500, Theme: "nord-dark", Language: "zh", DefaultMode: "approval"} | ||
| ApplyEnvOverlay(cfg) | ||
| if cfg.Model != "openai/gpt-4o" { | ||
| t.Errorf("Model = %q, want %q", cfg.Model, "openai/gpt-4o") | ||
| } | ||
| if cfg.SmallModel != "openai/gpt-4o-mini" { | ||
| t.Errorf("SmallModel = %q, want %q", cfg.SmallModel, "openai/gpt-4o-mini") | ||
| } | ||
| if cfg.MaxIterations != 500 { | ||
| t.Errorf("MaxIterations = %d, want 500", cfg.MaxIterations) | ||
| } | ||
| if cfg.Theme != "nord-dark" { | ||
| t.Errorf("Theme = %q, want %q", cfg.Theme, "nord-dark") | ||
| } | ||
| if cfg.Language != "zh" { | ||
| t.Errorf("Language = %q, want %q", cfg.Language, "zh") | ||
| } | ||
| if cfg.DefaultMode != "approval" { | ||
| t.Errorf("DefaultMode = %q, want %q", cfg.DefaultMode, "approval") | ||
| } | ||
| }) | ||
|
|
||
| t.Run("overrides all supported fields", func(t *testing.T) { | ||
| t.Setenv(EnvModel, "anthropic/claude-sonnet-4-20250514") | ||
| t.Setenv(EnvSmallModel, "anthropic/claude-haiku") | ||
| t.Setenv(EnvMaxIterations, "42") | ||
| t.Setenv(EnvTheme, "github-light") | ||
| t.Setenv(EnvLanguage, "en") | ||
| t.Setenv(EnvDefaultMode, "full_access") | ||
|
|
||
| cfg := &Config{Model: "openai/gpt-4o", MaxIterations: 1000} | ||
| ApplyEnvOverlay(cfg) | ||
|
|
||
| if cfg.Model != "anthropic/claude-sonnet-4-20250514" { | ||
| t.Errorf("Model = %q, want anthropic/claude-sonnet-4-20250514", cfg.Model) | ||
| } | ||
| if cfg.SmallModel != "anthropic/claude-haiku" { | ||
| t.Errorf("SmallModel = %q, want anthropic/claude-haiku", cfg.SmallModel) | ||
| } | ||
| if cfg.MaxIterations != 42 { | ||
| t.Errorf("MaxIterations = %d, want 42", cfg.MaxIterations) | ||
| } | ||
| if cfg.Theme != "github-light" { | ||
| t.Errorf("Theme = %q, want github-light", cfg.Theme) | ||
| } | ||
| if cfg.Language != "en" { | ||
| t.Errorf("Language = %q, want en", cfg.Language) | ||
| } | ||
| if cfg.DefaultMode != "full_access" { | ||
| t.Errorf("DefaultMode = %q, want full_access", cfg.DefaultMode) | ||
| } | ||
| }) | ||
|
|
||
| t.Run("invalid max iterations is ignored", func(t *testing.T) { | ||
| t.Setenv(EnvMaxIterations, "not-a-number") | ||
| cfg := &Config{MaxIterations: 1000} | ||
| ApplyEnvOverlay(cfg) | ||
| if cfg.MaxIterations != 1000 { | ||
| t.Errorf("MaxIterations = %d, want 1000 (unchanged)", cfg.MaxIterations) | ||
| } | ||
| }) | ||
|
|
||
| t.Run("negative max iterations is ignored", func(t *testing.T) { | ||
| t.Setenv(EnvMaxIterations, "-5") | ||
| cfg := &Config{MaxIterations: 1000} | ||
| ApplyEnvOverlay(cfg) | ||
| if cfg.MaxIterations != 1000 { | ||
| t.Errorf("MaxIterations = %d, want 1000 (unchanged)", cfg.MaxIterations) | ||
| } | ||
| }) | ||
|
|
||
| t.Run("zero max iterations is ignored", func(t *testing.T) { | ||
| t.Setenv(EnvMaxIterations, "0") | ||
| cfg := &Config{MaxIterations: 1000} | ||
| ApplyEnvOverlay(cfg) | ||
| if cfg.MaxIterations != 1000 { | ||
| t.Errorf("MaxIterations = %d, want 1000 (unchanged)", cfg.MaxIterations) | ||
| } | ||
| }) | ||
|
|
||
| t.Run("partial override preserves other fields", func(t *testing.T) { | ||
| // Clear all then set only model. | ||
| for _, key := range []string{EnvSmallModel, EnvMaxIterations, EnvTheme, EnvLanguage, EnvDefaultMode} { | ||
| t.Setenv(key, "") | ||
| } | ||
| t.Setenv(EnvModel, "openai/o3") | ||
|
|
||
| cfg := &Config{Model: "openai/gpt-4o", Theme: "nord-dark", Language: "zh", MaxIterations: 200} | ||
| ApplyEnvOverlay(cfg) | ||
|
|
||
| if cfg.Model != "openai/o3" { | ||
| t.Errorf("Model = %q, want openai/o3", cfg.Model) | ||
| } | ||
| if cfg.Theme != "nord-dark" { | ||
| t.Errorf("Theme = %q, want nord-dark (unchanged)", cfg.Theme) | ||
| } | ||
| if cfg.Language != "zh" { | ||
| t.Errorf("Language = %q, want zh (unchanged)", cfg.Language) | ||
| } | ||
| if cfg.MaxIterations != 200 { | ||
| t.Errorf("MaxIterations = %d, want 200 (unchanged)", cfg.MaxIterations) | ||
| } | ||
| }) | ||
| t.Run("invalid default mode is ignored", func(t *testing.T) { | ||
| t.Setenv(EnvDefaultMode, "garbage") | ||
| cfg := &Config{DefaultMode: "approval"} | ||
| ApplyEnvOverlay(cfg) | ||
| if cfg.DefaultMode != "approval" { | ||
| t.Errorf("DefaultMode = %q, want approval (unchanged for invalid input)", cfg.DefaultMode) | ||
| } | ||
| }) | ||
|
|
||
| t.Run("valid default mode is accepted", func(t *testing.T) { | ||
| for _, mode := range []string{"approval", "plan", "auto", "full_access"} { | ||
| t.Setenv(EnvDefaultMode, mode) | ||
| cfg := &Config{DefaultMode: "approval"} | ||
| ApplyEnvOverlay(cfg) | ||
| if cfg.DefaultMode != mode { | ||
| t.Errorf("DefaultMode = %q, want %q", cfg.DefaultMode, mode) | ||
| } | ||
| } | ||
| }) | ||
| } | ||
|
|
||
| func TestConfigFilePathEnvOverride(t *testing.T) { | ||
| t.Setenv("HOME", t.TempDir()) | ||
|
|
||
| t.Run("JCODE_CONFIG overrides path", func(t *testing.T) { | ||
| custom := filepath.Join(t.TempDir(), "custom-config.json") | ||
| t.Setenv(EnvConfigFile, custom) | ||
|
|
||
| p, err := configFilePath() | ||
| if err != nil { | ||
| t.Fatalf("configFilePath() error: %v", err) | ||
| } | ||
| if p != custom { | ||
| t.Errorf("configFilePath() = %q, want %q", p, custom) | ||
| } | ||
| }) | ||
|
|
||
| t.Run("empty JCODE_CONFIG falls back to default", func(t *testing.T) { | ||
| t.Setenv(EnvConfigFile, "") | ||
|
|
||
| p, err := configFilePath() | ||
| if err != nil { | ||
| t.Fatalf("configFilePath() error: %v", err) | ||
| } | ||
| home, _ := os.UserHomeDir() | ||
| want := filepath.Join(home, ".jcode", "config.json") | ||
| if p != want { | ||
| t.Errorf("configFilePath() = %q, want %q", p, want) | ||
| } | ||
| }) | ||
| } | ||
|
|
||
| func TestEnvOverlayPrecedenceOverProject(t *testing.T) { | ||
| t.Setenv("HOME", t.TempDir()) | ||
|
|
||
| // Simulate: global config has model A, project sets model B, env sets model C. | ||
| // Env must win. | ||
| cfg := &Config{Model: "openai/gpt-4o", MaxIterations: 1000} | ||
|
|
||
| // Simulate project overlay (model B). | ||
| projectOverlay := &Config{Model: "anthropic/claude-sonnet-4-20250514"} | ||
| MergeProjectConfig(cfg, projectOverlay) | ||
| if cfg.Model != "anthropic/claude-sonnet-4-20250514" { | ||
| t.Fatalf("project overlay failed: Model = %q", cfg.Model) | ||
| } | ||
|
|
||
| // Now env overlay (model C) must win. | ||
| t.Setenv(EnvModel, "openai/o3") | ||
| ApplyEnvOverlay(cfg) | ||
| if cfg.Model != "openai/o3" { | ||
| t.Errorf("Model = %q, want openai/o3 (env must override project)", cfg.Model) | ||
| } | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift
Project MCP files never reach Web task agents.
At Line 413, the overlay populates
taskCfg.MCPServers, but agent construction reads only the process-widemcpToolsPtr, which is initialized from the unoverlaid startup config. A project that defines onlymcp.jsongets no MCP tools in Web, and tasks from different projects cannot retain distinct MCP configurations. Load/cache MCP tools per task/project fromtaskCfg.MCPServers, including the bootstrap task.🤖 Prompt for AI Agents