Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions internal/command/acp.go
Original file line number Diff line number Diff line change
Expand Up @@ -367,6 +367,9 @@ func (a *acpAgent) NewSession(ctx context.Context, params acp.NewSessionRequest)
pwd = util.GetWorkDir()
}

// Apply project-level config overlay (walk-up .jcode/config.json + mcp.json).
config.ApplyProjectOverlay(cfg, pwd)

providerName, modelName := cfg.GetProviderModel()
rec, _ := session.NewRecorder(pwd, providerName, modelName)
// LLM session titles ride the small model (checked at fire time).
Expand Down Expand Up @@ -1112,6 +1115,9 @@ func (a *acpAgent) LoadSession(ctx context.Context, params acp.LoadSessionReques
pwd = util.GetWorkDir()
}

// Apply project-level config overlay (walk-up .jcode/config.json + mcp.json).
config.ApplyProjectOverlay(cfg, pwd)

providerName, modelName := cfg.GetProviderModel()
rec, _ := session.NewRecorder(pwd, providerName, modelName)
if rec != nil {
Expand Down Expand Up @@ -1176,6 +1182,9 @@ func (a *acpAgent) ResumeSession(ctx context.Context, params acp.ResumeSessionRe
pwd = util.GetWorkDir()
}

// Apply project-level config overlay (walk-up .jcode/config.json + mcp.json).
config.ApplyProjectOverlay(cfg, pwd)

providerName, modelName := cfg.GetProviderModel()
rec, _ := session.NewRecorder(pwd, providerName, modelName)
// Reuse the original session UUID so transcript entries are written to
Expand Down
4 changes: 4 additions & 0 deletions internal/command/interactive.go
Original file line number Diff line number Diff line change
Expand Up @@ -464,6 +464,7 @@ func (s *interactiveState) reloadMCP() {
config.Logger().Printf("[mcp] reload: config load failed: %v", err)
return
}
config.ApplyProjectOverlay(latest, s.pwd)
s.cfg = latest
mcpTools, statuses := tools.LoadMCPTools(s.ctx, latest.MCPServers)
s.mcpTools = mcpTools
Expand Down Expand Up @@ -1142,6 +1143,9 @@ func RunInteractive(prompt, resumeUUID, agentName string, unsafe bool) error {
platform := util.GetSystemInfo()
envInfo := util.CollectEnvInfo(pwd)

// Apply project-level config overlay (walk-up .jcode/config.json + mcp.json).
config.ApplyProjectOverlay(cfg, pwd)

var resumeEntries []session.Entry
var resumeState *session.SessionState
if resumeUUID != "" {
Expand Down
12 changes: 11 additions & 1 deletion internal/command/web.go
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,10 @@ func runWebServer(parent context.Context, port int, host string, openBrowser boo
if err != nil {
return fmt.Errorf("config error: %w", err)
}
// Apply env overlay to the server-level config so JCODE_MODEL,
// JCODE_DEFAULT_MODE etc. take effect for provider resolution and
// startup mode. Project overlay is applied per-task in buildWebTask.
config.ApplyEnvOverlay(cfg)
} else {
// Create a minimal config for setup mode.
cfg = &config.Config{
Expand Down Expand Up @@ -409,7 +413,13 @@ func runWebServer(parent context.Context, port int, host string, openBrowser boo
if modeStr != "" {
startMode = mode.Parse(modeStr)
}

if exec == nil { // project config overlay (local tasks only)
config.ApplyProjectOverlay(taskCfg, taskPwd)
} else {
// Remote tasks skip project config (can't read .jcode/ remotely)
// but env vars are local process state and always apply.
config.ApplyEnvOverlay(taskCfg)
}
Comment on lines +416 to +422

Copy link
Copy Markdown

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-wide mcpToolsPtr, which is initialized from the unoverlaid startup config. A project that defines only mcp.json gets no MCP tools in Web, and tasks from different projects cannot retain distinct MCP configurations. Load/cache MCP tools per task/project from taskCfg.MCPServers, including the bootstrap task.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/command/web.go` around lines 412 - 414, Update the Web task agent
construction flow around the project overlay and process-wide mcpToolsPtr so MCP
tools are loaded and cached per task/project from taskCfg.MCPServers rather than
reused from the startup configuration. Apply the same per-task loading to the
bootstrap task, while preserving distinct MCP configurations across projects and
the existing local-task overlay behavior.

// Fresh execution environment for this task only.
tenv := tools.NewEnv(taskPwd, platform)
tenv.AutomationStore = autoStore
Expand Down
20 changes: 18 additions & 2 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,9 @@ type MCPServer struct {
// OAuth configures OAuth 2.0 authorization for HTTP/SSE transports (MCP
// authorization spec). When nil, only static Headers are used.
OAuth *MCPOAuthConfig `json:"oauth,omitempty"`
// Source tracks which config layer defined this server (global | project).
// Not persisted to disk — populated at load time for UI display.
Source string `json:"-"`
}

// MCPOAuthConfig holds OAuth 2.0 settings for an MCP server. Tokens are NOT
Expand Down Expand Up @@ -966,8 +969,13 @@ func ConfigDir() string {
return filepath.Join(home, configDir)
}

// configFilePath returns the full path to the config file
// configFilePath returns the full path to the config file. When JCODE_CONFIG is
// set it takes precedence, allowing CI/CD pipelines and containerized deployments
// to point at a mounted config without touching ~/.jcode/.
func configFilePath() (string, error) {
if v := os.Getenv(EnvConfigFile); v != "" {
return v, nil
}
home, err := os.UserHomeDir()
if err != nil {
return "", fmt.Errorf("failed to get home directory: %w", err)
Expand Down Expand Up @@ -1059,6 +1067,13 @@ func LoadConfig() (*Config, error) {
cfg.MaxIterations = 1000
}

// Tag MCP servers loaded from global config for UI provenance display.
for _, srv := range cfg.MCPServers {
if srv != nil {
srv.Source = "global"
}
}

return cfg, nil
}

Expand Down Expand Up @@ -1121,7 +1136,8 @@ func containsSlash(s string) bool {
return false
}

// SaveConfig writes the config to $HOME/.jcode/config.json.
// SaveConfig writes the config to the active config file path (default
// $HOME/.jcode/config.json; overridden by JCODE_CONFIG when set).
func SaveConfig(cfg *Config) error {
return saveConfig(cfg, os.Rename)
}
Expand Down
66 changes: 66 additions & 0 deletions internal/config/env.go
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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Validate all environment overrides before applying them.

JCODE_MODEL, JCODE_SMALL_MODEL, JCODE_THEME, and JCODE_LANGUAGE currently replace valid configuration with any non-empty string. This violates the PR requirement that invalid environment values be logged and ignored; malformed high-precedence values can leave startup with an unusable model or invalid UI settings.

  • internal/config/env.go#L39-L57: validate each value against its existing config domain before assignment, and log rejected values through Logger().
  • internal/config/env_test.go#L44-L143: add invalid-value cases for model, small model, theme, and language, asserting the prior config remains unchanged.
📍 Affects 2 files
  • internal/config/env.go#L39-L57 (this comment)
  • internal/config/env_test.go#L44-L143
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/config/env.go` around lines 39 - 57, Validate JCODE_MODEL,
JCODE_SMALL_MODEL, JCODE_THEME, and JCODE_LANGUAGE against their existing
configuration domains before assigning them in the environment override logic;
log each rejected non-empty value via Logger() and preserve the prior
configuration. In internal/config/env_test.go lines 44-143, add invalid-value
cases for all four variables and assert that each existing value remains
unchanged.

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)
}
}
}
197 changes: 197 additions & 0 deletions internal/config/env_test.go
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)
}
}
Loading
Loading