diff --git a/internal/command/acp.go b/internal/command/acp.go index 12a28fbb..62a7ddab 100644 --- a/internal/command/acp.go +++ b/internal/command/acp.go @@ -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). @@ -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 { @@ -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 diff --git a/internal/command/interactive.go b/internal/command/interactive.go index a8812f27..cd5d0a5a 100644 --- a/internal/command/interactive.go +++ b/internal/command/interactive.go @@ -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 @@ -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 != "" { diff --git a/internal/command/web.go b/internal/command/web.go index 5275987b..efa4664a 100644 --- a/internal/command/web.go +++ b/internal/command/web.go @@ -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{ @@ -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) + } // Fresh execution environment for this task only. tenv := tools.NewEnv(taskPwd, platform) tenv.AutomationStore = autoStore diff --git a/internal/config/config.go b/internal/config/config.go index 5b57d175..6eb08d96 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -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 @@ -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) @@ -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 } @@ -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) } diff --git a/internal/config/env.go b/internal/config/env.go new file mode 100644 index 00000000..de3efe36 --- /dev/null +++ b/internal/config/env.go @@ -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 + } + 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) + } + } +} diff --git a/internal/config/env_test.go b/internal/config/env_test.go new file mode 100644 index 00000000..0119f273 --- /dev/null +++ b/internal/config/env_test.go @@ -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) + } +} diff --git a/internal/config/mcp_file.go b/internal/config/mcp_file.go new file mode 100644 index 00000000..342f18db --- /dev/null +++ b/internal/config/mcp_file.go @@ -0,0 +1,133 @@ +package config + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" +) + +// mcpFileNames are the standalone MCP config filenames searched in each +// .jcode/ directory and the project root. The format is compatible with +// Claude Desktop / Cursor mcp.json: { "mcpServers": { "name": { ... } } }. +var mcpFileNames = []string{"mcp.json", ".mcp.json"} + +// mcpFileSchema is the on-disk format of a standalone mcp.json file. +type mcpFileSchema struct { + MCPServers map[string]*MCPServer `json:"mcpServers"` +} + +// LoadMCPFile reads a single standalone mcp.json file and returns its server +// map. Returns nil (without error) when the file does not exist. +func LoadMCPFile(path string) (map[string]*MCPServer, error) { + data, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + return nil, nil + } + return nil, fmt.Errorf("mcp file read %s: %w", path, err) + } + var schema mcpFileSchema + if err := json.Unmarshal(data, &schema); err != nil { + return nil, fmt.Errorf("mcp file parse %s: %w", path, err) + } + return schema.MCPServers, nil +} + +// LoadMCPFiles discovers and merges standalone mcp.json files from multiple +// locations (lowest → highest precedence): +// +// 1. ~/.jcode/mcp.json (global) +// 2. Walk-up: /.jcode/mcp.json → ... → /.jcode/mcp.json +// 3. /mcp.json and /.mcp.json (project root convenience) +// +// Servers are merged by name using the same rules as config.json MCP merge: +// later files can add new servers or override tuning fields of existing ones, +// but cannot change command/url of a server defined in an earlier layer. +// +// Returns nil when no mcp.json files exist anywhere in the chain. +func LoadMCPFiles(pwd string) (map[string]*MCPServer, error) { + var gitRoot string + if pwd != "" { + gitRoot = GitRoot(pwd) + } + return loadMCPFilesWithRoot(pwd, gitRoot) +} + +// loadMCPFilesWithRoot is the internal implementation that accepts a +// pre-resolved gitRoot to avoid redundant git subprocess calls. +func loadMCPFilesWithRoot(pwd, gitRoot string) (map[string]*MCPServer, error) { + var merged map[string]*MCPServer + + merge := func(servers map[string]*MCPServer, source string) { + if len(servers) == 0 { + return + } + if merged == nil { + merged = make(map[string]*MCPServer, len(servers)) + } + for name, srv := range servers { + if srv != nil && srv.Source == "" { + srv.Source = source + } + if existing := merged[name]; existing != nil { + mergeMCPServer(existing, srv) + } else { + merged[name] = srv + } + } + } + + // 1. Global ~/.jcode/mcp.json + for _, fname := range mcpFileNames { + servers, err := LoadMCPFile(filepath.Join(ConfigDir(), fname)) + if err != nil { + return nil, err + } + merge(servers, "global") + } + + // 2. Walk-up .jcode/mcp.json from git root to pwd + if pwd != "" { + dirs := ConfigWalkDirs(gitRoot, pwd) + for _, dir := range dirs { + for _, fname := range mcpFileNames { + servers, err := LoadMCPFile(filepath.Join(dir, configDir, fname)) + if err != nil { + return nil, err + } + merge(servers, "project") + } + } + + // 3. Project root convenience: /mcp.json, /.mcp.json + for _, fname := range mcpFileNames { + servers, err := LoadMCPFile(filepath.Join(pwd, fname)) + if err != nil { + return nil, err + } + merge(servers, "project") + } + } + + return merged, nil +} + +// MergeMCPServers merges standalone mcp.json servers into the config's +// MCPServers map. The same security rules apply: new servers are added freely, +// existing servers only get tuning-field overrides (not command/url). +func MergeMCPServers(cfg *Config, servers map[string]*MCPServer) { + if cfg == nil || len(servers) == 0 { + return + } + if cfg.MCPServers == nil { + cfg.MCPServers = make(map[string]*MCPServer, len(servers)) + } + for name, srv := range servers { + if existing := cfg.MCPServers[name]; existing != nil { + mergeMCPServer(existing, srv) + } else { + cfg.MCPServers[name] = srv + } + } +} diff --git a/internal/config/mcp_file_test.go b/internal/config/mcp_file_test.go new file mode 100644 index 00000000..20bc555d --- /dev/null +++ b/internal/config/mcp_file_test.go @@ -0,0 +1,169 @@ +package config + +import ( + "os" + "path/filepath" + "testing" +) + +func TestLoadMCPFile_NotExist(t *testing.T) { + servers, err := LoadMCPFile("/nonexistent/mcp.json") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if servers != nil { + t.Errorf("expected nil, got %v", servers) + } +} + +func TestLoadMCPFile_Valid(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "mcp.json") + content := `{ + "mcpServers": { + "filesystem": { + "command": "npx", + "args": ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"] + }, + "remote": { + "url": "http://localhost:3000/mcp", + "timeout_seconds": 30 + } + } + }` + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + t.Fatal(err) + } + + servers, err := LoadMCPFile(path) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(servers) != 2 { + t.Fatalf("expected 2 servers, got %d", len(servers)) + } + fs := servers["filesystem"] + if fs.Command != "npx" { + t.Errorf("filesystem.Command = %q, want npx", fs.Command) + } + if len(fs.Args) != 3 { + t.Errorf("filesystem.Args = %v, want 3 args", fs.Args) + } + remote := servers["remote"] + if remote.URL != "http://localhost:3000/mcp" { + t.Errorf("remote.URL = %q", remote.URL) + } + if remote.TimeoutSeconds != 30 { + t.Errorf("remote.TimeoutSeconds = %d, want 30", remote.TimeoutSeconds) + } +} + +func TestLoadMCPFile_InvalidJSON(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "mcp.json") + if err := os.WriteFile(path, []byte("{invalid"), 0o644); err != nil { + t.Fatal(err) + } + _, err := LoadMCPFile(path) + if err == nil { + t.Fatal("expected parse error") + } +} + +func TestLoadMCPFiles_ProjectRoot(t *testing.T) { + // Isolate HOME so global ~/.jcode/mcp.json doesn't interfere. + t.Setenv("HOME", t.TempDir()) + + dir := t.TempDir() + content := `{"mcpServers": {"local-tool": {"command": "/usr/bin/local"}}}` + if err := os.WriteFile(filepath.Join(dir, "mcp.json"), []byte(content), 0o644); err != nil { + t.Fatal(err) + } + + servers, err := LoadMCPFiles(dir) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if servers == nil { + t.Fatal("expected non-nil servers") + } + if servers["local-tool"] == nil { + t.Fatal("expected local-tool server") + } + if servers["local-tool"].Command != "/usr/bin/local" { + t.Errorf("Command = %q", servers["local-tool"].Command) + } +} + +func TestLoadMCPFiles_DotJcodeDir(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + + dir := t.TempDir() + jcodeDir := filepath.Join(dir, ".jcode") + if err := os.MkdirAll(jcodeDir, 0o755); err != nil { + t.Fatal(err) + } + content := `{"mcpServers": {"dot-tool": {"command": "/usr/bin/dot"}}}` + if err := os.WriteFile(filepath.Join(jcodeDir, "mcp.json"), []byte(content), 0o644); err != nil { + t.Fatal(err) + } + + servers, err := LoadMCPFiles(dir) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if servers["dot-tool"] == nil { + t.Fatal("expected dot-tool server from .jcode/mcp.json") + } +} + +func TestLoadMCPFiles_MergePrecedence(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + + dir := t.TempDir() + jcodeDir := filepath.Join(dir, ".jcode") + if err := os.MkdirAll(jcodeDir, 0o755); err != nil { + t.Fatal(err) + } + + // .jcode/mcp.json defines a server with args. + dotContent := `{"mcpServers": {"tool": {"command": "/usr/bin/tool", "args": ["--from-dot"]}}}` + if err := os.WriteFile(filepath.Join(jcodeDir, "mcp.json"), []byte(dotContent), 0o644); err != nil { + t.Fatal(err) + } + + // Root mcp.json overrides args (higher precedence). + rootContent := `{"mcpServers": {"tool": {"args": ["--from-root"]}}}` + if err := os.WriteFile(filepath.Join(dir, "mcp.json"), []byte(rootContent), 0o644); err != nil { + t.Fatal(err) + } + + servers, err := LoadMCPFiles(dir) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + tool := servers["tool"] + if tool == nil { + t.Fatal("expected tool server") + } + // Command from .jcode/mcp.json (first definition), args from root (override). + if tool.Command != "/usr/bin/tool" { + t.Errorf("Command = %q, want /usr/bin/tool", tool.Command) + } + if len(tool.Args) != 1 || tool.Args[0] != "--from-root" { + t.Errorf("Args = %v, want [--from-root]", tool.Args) + } +} + +func TestMergeMCPServers_NilConfig(t *testing.T) { + // Should not panic. + MergeMCPServers(nil, map[string]*MCPServer{"x": {Command: "y"}}) +} + +func TestMergeMCPServers_EmptyServers(t *testing.T) { + cfg := &Config{} + MergeMCPServers(cfg, nil) + if cfg.MCPServers != nil { + t.Error("expected nil MCPServers for empty input") + } +} diff --git a/internal/config/project.go b/internal/config/project.go new file mode 100644 index 00000000..1edf1c0b --- /dev/null +++ b/internal/config/project.go @@ -0,0 +1,416 @@ +package config + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "time" +) + +// projectConfigFile is the project-level config filename inside /.jcode/. +const projectConfigFile = "config.json" + +// GitRoot returns the top-level directory of the git repository containing dir, +// or "" if dir is not inside a git work tree. It shells out to git with a 2s +// timeout so a hung network mount cannot block startup indefinitely. The +// returned path has symlinks resolved (macOS /var → /private/var). +func GitRoot(dir string) string { + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + cmd := exec.CommandContext(ctx, "git", "-C", dir, "rev-parse", "--show-toplevel") + cmd.Env = scrubbedGitEnv() + var out bytes.Buffer + cmd.Stdout = &out + cmd.Stderr = nil + if err := cmd.Run(); err != nil { + return "" + } + root := strings.TrimSpace(out.String()) + // Resolve symlinks so the result is comparable to caller-provided paths + // (macOS: /var/folders/... → /private/var/folders/...). + if resolved, err := filepath.EvalSymlinks(root); err == nil { + return resolved + } + return root +} + +// scrubbedGitEnv returns the process environment with repo-targeting GIT_* +// variables removed so git subprocesses resolve against -C/cwd rather than an +// inherited GIT_DIR (e.g. from a parent repo's hook). +func scrubbedGitEnv() []string { + drop := map[string]bool{ + "GIT_DIR": true, "GIT_WORK_TREE": true, "GIT_INDEX_FILE": true, + "GIT_OBJECT_DIRECTORY": true, "GIT_COMMON_DIR": true, "GIT_PREFIX": true, + "GIT_ALTERNATE_OBJECT_DIRECTORIES": true, "GIT_NAMESPACE": true, + } + env := os.Environ() + out := make([]string, 0, len(env)) + for _, kv := range env { + name, _, _ := strings.Cut(kv, "=") + if !drop[name] { + out = append(out, kv) + } + } + return out +} + +// ConfigWalkDirs returns the list of directories from gitRoot down to pwd +// (inclusive), ordered root-first. When gitRoot is empty or pwd is not a +// descendant of gitRoot, it returns just [pwd]. Exported for use by the +// prompts package (walk-up AGENTS.md). +func ConfigWalkDirs(gitRoot, pwd string) []string { + if gitRoot == "" { + return []string{pwd} + } + // Resolve symlinks on pwd so it is comparable to the already-resolved + // gitRoot (macOS: /var → /private/var). + resolvedPwd := pwd + if r, err := filepath.EvalSymlinks(pwd); err == nil { + resolvedPwd = r + } + // Ensure pwd is under gitRoot. + rel, err := filepath.Rel(gitRoot, resolvedPwd) + if err != nil || strings.HasPrefix(rel, "..") { + return []string{pwd} + } + if rel == "." { + return []string{pwd} + } + + parts := strings.Split(rel, string(filepath.Separator)) + dirs := make([]string, 0, len(parts)+1) + dirs = append(dirs, gitRoot) + cur := gitRoot + for _, p := range parts { + cur = filepath.Join(cur, p) + dirs = append(dirs, cur) + } + return dirs +} + +// LoadProjectConfig discovers and merges project-level config files. It walks +// from the git repository root down to pwd, loading /.jcode/config.json at +// each level. Closer-to-pwd files have higher precedence (merged last). +// +// Returns nil (without error) when no project config exists anywhere in the +// chain — a missing project config is the common case and not an error. +func LoadProjectConfig(pwd string) (*Config, error) { + if pwd == "" { + return nil, nil + } + return loadProjectConfigWithRoot(pwd, GitRoot(pwd)) +} + +// loadProjectConfigWithRoot is the internal implementation that accepts a +// pre-resolved gitRoot to avoid redundant git subprocess calls. +func loadProjectConfigWithRoot(pwd, gitRoot string) (*Config, error) { + if pwd == "" { + return nil, nil + } + + dirs := ConfigWalkDirs(gitRoot, pwd) + + var merged *Config + for _, dir := range dirs { + path := filepath.Join(dir, configDir, projectConfigFile) + data, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + continue + } + return nil, fmt.Errorf("project config read %s: %w", path, err) + } + var pc Config + if err := json.Unmarshal(data, &pc); err != nil { + return nil, fmt.Errorf("project config parse %s: %w", path, err) + } + if merged == nil { + merged = &pc + } else { + mergeProjectFields(merged, &pc) + } + } + return merged, nil +} + +// MergeProjectConfig merges a project-level config overlay into the base +// (global) config. The merge is field-by-field: project values override global +// values when set. Security-sensitive fields (providers, telemetry, cloud, +// SSH/Docker aliases) are NEVER taken from project config — a malicious repo +// must not be able to redirect API keys or exfiltrate credentials. +// +// MCP servers are merged by name: the project can add new servers or override +// individual fields of an existing server (e.g. change args), but cannot +// change command/url of an existing server (security). +// +// The base config is mutated in place and returned for convenience. +func MergeProjectConfig(base, overlay *Config) *Config { + if base == nil || overlay == nil { + return base + } + mergeProjectFields(base, overlay) + return base +} + +// mergeProjectFields performs the actual field-by-field merge. It is used both +// for merging the project chain internally and for the final global→project +// merge. +func mergeProjectFields(base, overlay *Config) { + // --- Model selection --- + if overlay.Model != "" { + base.Model = overlay.Model + } + if overlay.SmallModel != "" { + base.SmallModel = overlay.SmallModel + } + + // --- Iteration cap --- + if overlay.MaxIterations > 0 { + base.MaxIterations = overlay.MaxIterations + } + + // --- Session mode --- + // DefaultMode is intentionally NOT merged: a project config must not + // escalate to "full_access" and bypass the user's approval policy. + + // --- Theme / Language --- + if overlay.Theme != "" { + base.Theme = overlay.Theme + } + if overlay.Language != "" { + base.Language = overlay.Language + } + + // --- Context limits --- + if overlay.DefaultContextLimit > 0 { + base.DefaultContextLimit = overlay.DefaultContextLimit + } + if len(overlay.ContextLimits) > 0 { + if base.ContextLimits == nil { + base.ContextLimits = make(map[string]int, len(overlay.ContextLimits)) + } + for k, v := range overlay.ContextLimits { + base.ContextLimits[k] = v + } + } + + // --- MCP servers: merge by name --- + // New servers from project config are gated behind JCODE_MCP_TRUST_PROJECT=1 + // (same pattern as project hooks). A hostile repo shipping a .jcode/config.json + // with a new stdio server would otherwise achieve arbitrary code execution the + // moment the user runs jcode in the clone. Tuning existing servers (args, env, + // timeout, disable) is always allowed — those cannot redirect the binary. + if len(overlay.MCPServers) > 0 { + trustProjectMCP := os.Getenv("JCODE_MCP_TRUST_PROJECT") == "1" + if base.MCPServers == nil { + base.MCPServers = make(map[string]*MCPServer, len(overlay.MCPServers)) + } + for name, srv := range overlay.MCPServers { + if srv != nil { + srv.Source = "project" + } + if existing := base.MCPServers[name]; existing != nil { + mergeMCPServer(existing, srv) + } else if trustProjectMCP { + base.MCPServers[name] = srv + } else { + Logger().Printf("[config] project MCP server %q skipped (set JCODE_MCP_TRUST_PROJECT=1 to allow new project servers)", name) + } + } + } + + // --- Disabled skills: union --- + if len(overlay.DisabledSkills) > 0 { + seen := make(map[string]bool, len(base.DisabledSkills)) + for _, s := range base.DisabledSkills { + seen[s] = true + } + for _, s := range overlay.DisabledSkills { + if !seen[s] { + base.DisabledSkills = append(base.DisabledSkills, s) + } + } + } + + // --- Disabled providers: union --- + if len(overlay.DisabledProviders) > 0 { + seen := make(map[string]bool, len(base.DisabledProviders)) + for _, p := range base.DisabledProviders { + seen[p] = true + } + for _, p := range overlay.DisabledProviders { + if !seen[p] { + base.DisabledProviders = append(base.DisabledProviders, p) + } + } + } + + // --- Pointer-block overrides (project replaces the whole block if set) --- + if overlay.Budget != nil { + base.Budget = overlay.Budget + } + if overlay.Compaction != nil { + base.Compaction = overlay.Compaction + } + if overlay.Prompt != nil { + base.Prompt = overlay.Prompt + } + if overlay.Subagent != nil { + base.Subagent = overlay.Subagent + } + if overlay.Team != nil { + base.Team = overlay.Team + } + if overlay.ToolSearch != nil { + base.ToolSearch = overlay.ToolSearch + } + if overlay.Channel != nil { + base.Channel = overlay.Channel + } + if overlay.ApprovalReview != nil { + base.ApprovalReview = overlay.ApprovalReview + } + + // --- SECURITY DENYLIST --- + // The following fields are intentionally NOT merged from project config: + // - Providers / Models (API keys, base URLs, headers) + // - Telemetry (Langfuse secrets) + // - Cloud (relay credentials, E2EE keys) + // - SSHAliases / DockerAliases (remote access credentials) + // - Memory (pipeline model/budget — could redirect to attacker endpoint) + // - Developer (debug/tracing toggles) + // - AutoApprove / DefaultMode (privilege escalation) + // - Browser / Computer (capability toggles + preapproved permission lists — + // a project enabling computer-use and auto-approving its own app would + // bypass the user's approval policy, same escalation class as AutoApprove) + // A project config that sets these fields has them silently ignored. +} + +// ApplyProjectOverlay loads and merges all project-level configuration sources +// into cfg: walk-up .jcode/config.json files and standalone mcp.json files. +// Environment variable overrides (JCODE_MODEL, JCODE_CONFIG, etc.) are applied +// last as the highest-precedence layer. This is the single entry point the +// command layer calls after LoadConfig(). Errors from individual files are +// logged but do not abort startup — a broken project config should not prevent +// the agent from running. +func ApplyProjectOverlay(cfg *Config, pwd string) { + if cfg == nil { + return + } + + if pwd != "" { + // Resolve git root once and share across both loaders to avoid spawning + // two git subprocesses per startup. + gitRoot := GitRoot(pwd) + + // Walk-up project config.json + if projCfg, err := loadProjectConfigWithRoot(pwd, gitRoot); err != nil { + Logger().Printf("[config] project config error (ignored): %v", err) + } else if projCfg != nil { + MergeProjectConfig(cfg, projCfg) + } + + // Standalone mcp.json files + if mcpServers, err := loadMCPFilesWithRoot(pwd, gitRoot); err != nil { + Logger().Printf("[config] mcp file error (ignored): %v", err) + } else { + MergeMCPServers(cfg, mcpServers) + } + } + + // Environment variables have the highest precedence — applied last so they + // override both global and project-level values. + ApplyEnvOverlay(cfg) +} + +// dangerousEnvPrefixes lists environment variable names that must never be +// injected via project-level MCP config. These are well-known code-execution +// vectors that would let a malicious repo achieve arbitrary code execution +// against a trusted (immutable) Command binary. +var dangerousEnvPrefixes = []string{ + "LD_PRELOAD", + "LD_LIBRARY_PATH", + "DYLD_INSERT_LIBRARIES", + "DYLD_LIBRARY_PATH", + "DYLD_FRAMEWORK_PATH", + "NODE_OPTIONS", + "NODE_PATH", + "PYTHONPATH", + "PYTHONSTARTUP", + "PERL5LIB", + "PERLLIB", + "RUBYLIB", + "RUBYOPT", + "GIT_SSH_COMMAND", + "GIT_EXEC_PATH", + "BASH_ENV", + "ENV", + "ZDOTDIR", +} + +// isDangerousEnv reports whether an env var name matches a known code-execution +// vector that project config must not inject. +func isDangerousEnv(name string) bool { + upper := strings.ToUpper(name) + for _, prefix := range dangerousEnvPrefixes { + if upper == prefix { + return true + } + } + return false +} + +// filterDangerousEnv removes dangerous env vars from a "KEY=VALUE" slice. +// Returns the filtered slice and logs any removed entries. +func filterDangerousEnv(env []string) []string { + filtered := make([]string, 0, len(env)) + for _, kv := range env { + name, _, _ := strings.Cut(kv, "=") + if isDangerousEnv(name) { + Logger().Printf("[config] project MCP env %q blocked (dangerous variable)", name) + continue + } + filtered = append(filtered, kv) + } + return filtered +} + +// mergeMCPServer merges overlay fields into an existing MCP server config. +// Only tuning fields (args, env, timeout, disabled) are merged — Command and +// URL are NOT overridable so a malicious project config cannot redirect a +// trusted global server to a different binary or endpoint. New servers (added +// via the map-merge in mergeProjectFields) get their full definition from the +// project config, gated behind JCODE_MCP_TRUST_PROJECT=1. +func mergeMCPServer(base, overlay *MCPServer) { + // Command and URL are intentionally NOT merged for existing servers. + if len(overlay.Args) > 0 { + base.Args = overlay.Args + } + if len(overlay.Env) > 0 { + // Filter out dangerous env vars (LD_PRELOAD, DYLD_INSERT_LIBRARIES, etc.) + // that would allow code execution against the immutable Command binary. + base.Env = filterDangerousEnv(overlay.Env) + } + if len(overlay.Headers) > 0 { + if base.Headers == nil { + base.Headers = make(map[string]string, len(overlay.Headers)) + } + for k, v := range overlay.Headers { + base.Headers[k] = v + } + } + if overlay.TimeoutSeconds > 0 { + base.TimeoutSeconds = overlay.TimeoutSeconds + } + // Disabled can be set to true by project config (to suppress a global + // server in this project) but not back to false (project cannot + // re-enable a server the user globally disabled). + if overlay.Disabled { + base.Disabled = true + } +} diff --git a/internal/config/project_test.go b/internal/config/project_test.go new file mode 100644 index 00000000..3ef1de56 --- /dev/null +++ b/internal/config/project_test.go @@ -0,0 +1,241 @@ +package config + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" +) + +func TestConfigWalkDirs_NoGitRoot(t *testing.T) { + dirs := ConfigWalkDirs("", "/home/user/project") + if len(dirs) != 1 || dirs[0] != "/home/user/project" { + t.Errorf("expected [/home/user/project], got %v", dirs) + } +} + +func TestConfigWalkDirs_SameDir(t *testing.T) { + dirs := ConfigWalkDirs("/repo", "/repo") + if len(dirs) != 1 || dirs[0] != "/repo" { + t.Errorf("expected [/repo], got %v", dirs) + } +} + +func TestConfigWalkDirs_Nested(t *testing.T) { + dirs := ConfigWalkDirs("/repo", "/repo/packages/foo") + expected := []string{"/repo", "/repo/packages", "/repo/packages/foo"} + if len(dirs) != len(expected) { + t.Fatalf("expected %d dirs, got %d: %v", len(expected), len(dirs), dirs) + } + for i, d := range expected { + if dirs[i] != d { + t.Errorf("dirs[%d] = %q, want %q", i, dirs[i], d) + } + } +} + +func TestConfigWalkDirs_PwdOutsideRoot(t *testing.T) { + dirs := ConfigWalkDirs("/repo", "/other/path") + if len(dirs) != 1 || dirs[0] != "/other/path" { + t.Errorf("expected [/other/path], got %v", dirs) + } +} + +func TestLoadProjectConfig_NoFile(t *testing.T) { + dir := t.TempDir() + cfg, err := LoadProjectConfig(dir) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if cfg != nil { + t.Errorf("expected nil config, got %+v", cfg) + } +} + +func TestLoadProjectConfig_SingleFile(t *testing.T) { + dir := t.TempDir() + jcodeDir := filepath.Join(dir, ".jcode") + if err := os.MkdirAll(jcodeDir, 0o755); err != nil { + t.Fatal(err) + } + pc := Config{Model: "openai/gpt-4o", MaxIterations: 50} + data, _ := json.Marshal(pc) + if err := os.WriteFile(filepath.Join(jcodeDir, "config.json"), data, 0o644); err != nil { + t.Fatal(err) + } + + cfg, err := LoadProjectConfig(dir) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if cfg == nil { + t.Fatal("expected non-nil config") + } + if cfg.Model != "openai/gpt-4o" { + t.Errorf("Model = %q, want %q", cfg.Model, "openai/gpt-4o") + } + if cfg.MaxIterations != 50 { + t.Errorf("MaxIterations = %d, want 50", cfg.MaxIterations) + } +} + +func TestMergeProjectConfig_ModelOverride(t *testing.T) { + base := &Config{Model: "openai/gpt-4o", MaxIterations: 1000} + overlay := &Config{Model: "anthropic/claude-sonnet-4-20250514"} + MergeProjectConfig(base, overlay) + if base.Model != "anthropic/claude-sonnet-4-20250514" { + t.Errorf("Model = %q, want anthropic/claude-sonnet-4-20250514", base.Model) + } + if base.MaxIterations != 1000 { + t.Errorf("MaxIterations = %d, want 1000 (unchanged)", base.MaxIterations) + } +} + +func TestMergeProjectConfig_SecurityDenylist(t *testing.T) { + base := &Config{ + Model: "openai/gpt-4o", + Providers: map[string]*ProviderConfig{ + "openai": {APIKey: "sk-real"}, + }, + AutoApprove: false, + DefaultMode: "approval", + } + overlay := &Config{ + Model: "evil/model", + Providers: map[string]*ProviderConfig{ + "evil": {APIKey: "sk-stolen", BaseURL: "https://evil.com"}, + }, + AutoApprove: true, + DefaultMode: "full_access", + Telemetry: &TelemetryConfig{Langfuse: &LangfuseConfig{SecretKey: "stolen"}}, + } + MergeProjectConfig(base, overlay) + + // Model should be overridden (allowed). + if base.Model != "evil/model" { + t.Errorf("Model = %q, want evil/model", base.Model) + } + // Providers must NOT be overridden. + if _, ok := base.Providers["evil"]; ok { + t.Error("project config must not add providers") + } + if base.Providers["openai"].APIKey != "sk-real" { + t.Error("project config must not modify existing providers") + } + // AutoApprove must NOT be overridden. + if base.AutoApprove { + t.Error("project config must not set AutoApprove") + } + // DefaultMode must NOT be overridden. + if base.DefaultMode != "approval" { + t.Errorf("DefaultMode = %q, want approval", base.DefaultMode) + } + // Telemetry must NOT be overridden. + if base.Telemetry != nil { + t.Error("project config must not set Telemetry") + } +} + +func TestMergeProjectConfig_MCPServers(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + + t.Run("existing server tuning", func(t *testing.T) { + base := &Config{ + MCPServers: map[string]*MCPServer{ + "existing": {Command: "/usr/bin/tool", Args: []string{"--old"}}, + }, + } + overlay := &Config{ + MCPServers: map[string]*MCPServer{ + "existing": {Command: "/evil/tool", Args: []string{"--new"}}, + }, + } + MergeProjectConfig(base, overlay) + + // Existing server: command must NOT change, args should update. + existing := base.MCPServers["existing"] + if existing.Command != "/usr/bin/tool" { + t.Errorf("existing.Command = %q, must not change", existing.Command) + } + if len(existing.Args) != 1 || existing.Args[0] != "--new" { + t.Errorf("existing.Args = %v, want [--new]", existing.Args) + } + }) + + t.Run("new server requires trust", func(t *testing.T) { + // Without JCODE_MCP_TRUST_PROJECT=1, new servers are skipped. + t.Setenv("JCODE_MCP_TRUST_PROJECT", "") + base := &Config{ + MCPServers: map[string]*MCPServer{ + "existing": {Command: "/usr/bin/tool"}, + }, + } + overlay := &Config{ + MCPServers: map[string]*MCPServer{ + "new-srv": {Command: "/usr/bin/new", URL: "http://localhost:8080"}, + }, + } + MergeProjectConfig(base, overlay) + if base.MCPServers["new-srv"] != nil { + t.Error("new-srv should be skipped without JCODE_MCP_TRUST_PROJECT=1") + } + }) + + t.Run("new server allowed with trust", func(t *testing.T) { + t.Setenv("JCODE_MCP_TRUST_PROJECT", "1") + base := &Config{ + MCPServers: map[string]*MCPServer{ + "existing": {Command: "/usr/bin/tool"}, + }, + } + overlay := &Config{ + MCPServers: map[string]*MCPServer{ + "new-srv": {Command: "/usr/bin/new", URL: "http://localhost:8080"}, + }, + } + MergeProjectConfig(base, overlay) + + newSrv := base.MCPServers["new-srv"] + if newSrv == nil { + t.Fatal("new-srv not added despite JCODE_MCP_TRUST_PROJECT=1") + } + if newSrv.Command != "/usr/bin/new" { + t.Errorf("new-srv.Command = %q, want /usr/bin/new", newSrv.Command) + } + }) +} + +func TestMergeProjectConfig_DisabledSkillsUnion(t *testing.T) { + base := &Config{DisabledSkills: []string{"a", "b"}} + overlay := &Config{DisabledSkills: []string{"b", "c"}} + MergeProjectConfig(base, overlay) + if len(base.DisabledSkills) != 3 { + t.Errorf("DisabledSkills = %v, want [a b c]", base.DisabledSkills) + } +} + +func TestMergeProjectConfig_PointerBlocks(t *testing.T) { + base := &Config{Budget: &BudgetConfig{MaxTokensPerTurn: 100}} + overlay := &Config{Budget: &BudgetConfig{MaxTokensPerTurn: 200}} + MergeProjectConfig(base, overlay) + if base.Budget.MaxTokensPerTurn != 200 { + t.Errorf("Budget.MaxTokensPerTurn = %d, want 200", base.Budget.MaxTokensPerTurn) + } +} + +func TestMergeMCPServer_DisabledOnlyOneWay(t *testing.T) { + base := &MCPServer{Command: "/bin/tool", Disabled: false} + overlay := &MCPServer{Disabled: true} + mergeMCPServer(base, overlay) + if !base.Disabled { + t.Error("project should be able to disable a server") + } + + // Cannot re-enable. + base2 := &MCPServer{Command: "/bin/tool", Disabled: true} + overlay2 := &MCPServer{Disabled: false} + mergeMCPServer(base2, overlay2) + if !base2.Disabled { + t.Error("project must not re-enable a globally disabled server") + } +} diff --git a/internal/prompts/memory.go b/internal/prompts/memory.go index fb424365..181bb83a 100644 --- a/internal/prompts/memory.go +++ b/internal/prompts/memory.go @@ -35,7 +35,9 @@ func NewMemoryLoader(cfg MemoryConfig) *MemoryLoader { // Load loads and merges multi-level AGENTS.md files: // 1. ~/.jcode/AGENTS.md (global) -// 2. {pwd}/AGENTS.md (project-level) +// 2. Walk-up from git root → pwd: each directory's AGENTS.md (root first, +// pwd last). This lets monorepo roots define shared instructions while +// sub-packages add their own. When not in a git repo, only pwd is checked. // 3. {pwd}/AGENTS.local.md (local, expected gitignored) // // Each file's @include directives are resolved recursively. @@ -49,10 +51,14 @@ func (m *MemoryLoader) Load(pwd string) (string, error) { sections = append(sections, "\n"+content) } - // 2. Project AGENTS.md (case-insensitive lookup) - if projectPath := HasAgentsMd(pwd); projectPath != "" { - if content, err := m.loadFile(projectPath); err == nil && content != "" { - sections = append(sections, "\n"+content) + // 2. Walk-up project AGENTS.md (git root → pwd, case-insensitive lookup) + gitRoot := config.GitRoot(pwd) + dirs := config.ConfigWalkDirs(gitRoot, pwd) + for _, dir := range dirs { + if projectPath := HasAgentsMd(dir); projectPath != "" { + if content, err := m.loadFile(projectPath); err == nil && content != "" { + sections = append(sections, "\n"+content) + } } } diff --git a/internal/prompts/memory_test.go b/internal/prompts/memory_test.go index f2fca111..c8f86019 100644 --- a/internal/prompts/memory_test.go +++ b/internal/prompts/memory_test.go @@ -2,6 +2,7 @@ package prompts import ( "os" + "os/exec" "path/filepath" "strings" "testing" @@ -209,3 +210,68 @@ func TestMemoryLoader_EmptyDir(t *testing.T) { t.Errorf("expected empty result for dir with no AGENTS.md, got: %s", result) } } + +func TestMemoryLoader_WalkUpGitRepo(t *testing.T) { + // Create a git repo with AGENTS.md at root and in a subdirectory. + root := t.TempDir() + t.Setenv("HOME", t.TempDir()) + + // Init a real git repo so GitRoot works. + initGitRepo(t, root) + + // Root AGENTS.md + if err := os.WriteFile(filepath.Join(root, "AGENTS.md"), []byte("root instructions"), 0o644); err != nil { + t.Fatal(err) + } + + // Subdirectory with its own AGENTS.md + sub := filepath.Join(root, "packages", "foo") + if err := os.MkdirAll(sub, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(sub, "AGENTS.md"), []byte("foo instructions"), 0o644); err != nil { + t.Fatal(err) + } + + loader := NewMemoryLoader(MemoryConfig{MaxTotalChars: 40000, MaxIncDepth: 5}) + result, err := loader.Load(sub) + if err != nil { + t.Fatalf("Load returned error: %v", err) + } + + // Both root and sub AGENTS.md should be present. + if !strings.Contains(result, "root instructions") { + t.Errorf("expected root instructions in walk-up result, got: %s", result) + } + if !strings.Contains(result, "foo instructions") { + t.Errorf("expected foo instructions in walk-up result, got: %s", result) + } + + // Root should come before sub (root-first ordering). + rootIdx := strings.Index(result, "root instructions") + fooIdx := strings.Index(result, "foo instructions") + if rootIdx > fooIdx { + t.Errorf("root AGENTS.md should appear before sub-directory AGENTS.md") + } +} + +func initGitRepo(t *testing.T, dir string) { + t.Helper() + cmd := exec.Command("git", "-C", dir, "init", "-q") + // Scrub GIT_* vars so the init targets dir, not an inherited repo. + env := os.Environ() + var clean []string + for _, kv := range env { + if !strings.HasPrefix(kv, "GIT_DIR=") && + !strings.HasPrefix(kv, "GIT_WORK_TREE=") && + !strings.HasPrefix(kv, "GIT_INDEX_FILE=") && + !strings.HasPrefix(kv, "GIT_COMMON_DIR=") && + !strings.HasPrefix(kv, "GIT_PREFIX=") { + clean = append(clean, kv) + } + } + cmd.Env = clean + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("git init failed: %v\n%s", err, out) + } +} diff --git a/internal/skills/skills.go b/internal/skills/skills.go index c253c3c4..d321a5f6 100644 --- a/internal/skills/skills.go +++ b/internal/skills/skills.go @@ -21,6 +21,7 @@ type Skill struct { Body string // full markdown content (Layer 2, on-demand) Builtin bool // true if embedded in binary Path string // filesystem path (empty for built-in) + Source string // provenance: builtin | agents | user | project } // Loader discovers and caches skills from built-in embeds and user directories. @@ -91,6 +92,7 @@ func (l *Loader) loadBuiltin() { continue } sk := parseSkill(skillName, string(data), true, "") + sk.Source = "builtin" l.mu.Lock() l.skills[sk.Name] = sk l.mu.Unlock() @@ -145,6 +147,7 @@ func (l *Loader) scanDir(dir, source string) { continue } sk := parseSkill(skillName, string(data), false, fullPath) + sk.Source = source l.mu.Lock() l.skills[sk.Name] = sk l.mu.Unlock() diff --git a/internal/web/mcp.go b/internal/web/mcp.go index 5dbfdc34..acf020db 100644 --- a/internal/web/mcp.go +++ b/internal/web/mcp.go @@ -36,6 +36,7 @@ type mcpServerView struct { HasAuth bool `json:"has_auth"` // a token is stored Status string `json:"status"` // connected | needs_auth | error | disabled | configured Error string `json:"error,omitempty"` + Scope string `json:"scope"` // global | project — which config layer defines this server } // mcpServerReq is the request body for creating/updating an MCP server. @@ -233,6 +234,10 @@ func (s *Server) handleListMCP(w http.ResponseWriter, r *http.Request) { continue } status, errMsg := s.mcpServerStatus(name, srv) + scope := srv.Source + if scope == "" { + scope = "global" + } servers[name] = mcpServerView{ Name: name, Type: srv.Type, @@ -247,6 +252,7 @@ func (s *Server) handleListMCP(w http.ResponseWriter, r *http.Request) { HasAuth: tools.HasMCPOAuthToken(name), Status: status, Error: errMsg, + Scope: scope, } } writeJSON(w, http.StatusOK, map[string]any{"servers": servers}) diff --git a/internal/web/skills.go b/internal/web/skills.go index a33f680d..299fd3fb 100644 --- a/internal/web/skills.go +++ b/internal/web/skills.go @@ -24,9 +24,13 @@ func (s *Server) handleListSkills(w http.ResponseWriter, r *http.Request) { var items []skillItem if s.skillLoader != nil { for _, sk := range s.skillLoader.All() { - source := "local" - if sk.Builtin { - source = "builtin" + source := sk.Source + if source == "" { + if sk.Builtin { + source = "builtin" + } else { + source = "user" + } } items = append(items, skillItem{ Name: sk.Name, diff --git a/site/docs/configuration.md b/site/docs/configuration.md index 9f52985a..8d404f4b 100644 --- a/site/docs/configuration.md +++ b/site/docs/configuration.md @@ -5,7 +5,7 @@ nav_order: 7 # Configuration -jcode stores all configuration in a single JSON file at `~/.jcode/config.json`. The setup wizard creates this file on first launch. +jcode stores global configuration in `~/.jcode/config.json` and supports project-level overlays via `.jcode/` directories in your repository. The setup wizard creates the global file on first launch. ## Config File Location @@ -330,9 +330,131 @@ Notification channel settings. See [Channels](overview/channels). In TUI mode, channels are always available via `/channel` — no config needed. +## Project-Level Configuration + +In addition to the global `~/.jcode/config.json`, jcode supports **project-level config overlays** via `.jcode/` directories. This lets you commit shared settings (model choice, MCP servers, feature toggles) into a repository so every contributor gets the same agent behavior. + +### Discovery Order (lowest → highest precedence) + +| Layer | Location | Scope | +|---|---|---| +| 1 | `~/.jcode/config.json` | Global (your machine) | +| 2 | `/.jcode/config.json` | Repo-wide | +| 3 | `/.jcode/config.json` (walk-up) | Monorepo sub-package | +| 4 | `/.jcode/config.json` | Current working directory | +| 5 (highest) | Environment variables (`JCODE_*`) | Process / CI override | + +When you work in a subdirectory of a monorepo, jcode walks from the git root down to your cwd, merging each `.jcode/config.json` it finds. Closer-to-cwd files win. Environment variables override everything — useful for CI/CD pipelines and one-off overrides. + +### Project Directory Layout + +```text +my-project/ +├── .jcode/ +│ ├── config.json # Project config overlay +│ ├── mcp.json # Project MCP servers (see MCP docs) +│ └── skills/ # Project-local skills +│ └── deploy/SKILL.md +├── AGENTS.md # Project instructions (walk-up supported) +└── src/ + └── .jcode/ + └── config.json # Sub-package override (monorepo) +``` + +### Mergeable Fields + +Project config supports these fields (same JSON schema as global config): + +| Field | Merge Behavior | +|---|---| +| `model` / `small_model` | Override | +| `max_iterations` | Override | +| `theme` / `language` | Override | +| `default_context_limit` / `context_limits` | Override / deep-merge | +| `mcp_servers` | Merge by name (see security rules below) | +| `disabled_skills` / `disabled_providers` | Union (additive) | +| `budget`, `compaction`, `prompt`, `subagent`, `team`, `browser`, `computer`, `tool_search`, `channel`, `approval_review` | Whole-block replace | + +### Security Denylist + +The following fields are **silently ignored** in project config to prevent a malicious repo from escalating privileges or exfiltrating credentials: + +- `providers` (API keys, base URLs) +- `telemetry` (Langfuse secrets) +- `cloud` (relay credentials, E2EE keys) +- `ssh_aliases` / `docker_aliases` +- `memory` (pipeline model/budget) +- `developer` +- `auto_approve` / `default_mode` + +### Walk-Up AGENTS.md + +Project instructions (`AGENTS.md`) also support walk-up: from the git root down to cwd, each directory's `AGENTS.md` is concatenated (root-first). This lets a monorepo root define shared coding standards while sub-packages add domain-specific rules. + +### Example: Monorepo Project Config + +```json +// /.jcode/config.json +{ + "model": "anthropic/claude-sonnet-4-20250514", + "max_iterations": 500, + "mcp_servers": { + "internal-docs": { + "command": "npx", + "args": ["-y", "@acme/docs-mcp"] + } + }, + "disabled_skills": ["pptx"] +} +``` + +```json +// /packages/frontend/.jcode/config.json +{ + "model": "openai/gpt-4o", + "budget": { "max_tokens_per_turn": 80000 } +} +``` + +Working in `packages/frontend/` uses `gpt-4o` (closer wins) with the repo-wide MCP server and disabled skills still applied. + ## Changing Configuration - **Setup wizard**: Run `jcode` and the wizard launches if config is missing - **TUI**: Type `/setting` to open the settings menu - **Manual**: Edit `~/.jcode/config.json` directly (changes are hot-reloaded) - **Model picker**: Press **Ctrl+L** to switch models mid-session + +## Environment Variable Overrides + +Environment variables provide the highest-precedence config layer — they override both global and project-level settings. This is useful for CI/CD pipelines, containerized deployments, and quick one-off overrides without editing config files. + +| Variable | Overrides | Example | +|---|---|---| +| `JCODE_MODEL` | Active model (`provider/model`) | `JCODE_MODEL=openai/o3` | +| `JCODE_SMALL_MODEL` | Small/fast model | `JCODE_SMALL_MODEL=openai/gpt-4o-mini` | +| `JCODE_MAX_ITERATIONS` | Agent iteration cap (positive integer) | `JCODE_MAX_ITERATIONS=50` | +| `JCODE_THEME` | Color theme name | `JCODE_THEME=nord-dark` | +| `JCODE_LANGUAGE` | UI locale | `JCODE_LANGUAGE=en` | +| `JCODE_DEFAULT_MODE` | Session mode (`approval` \| `plan` \| `auto` \| `full_access`) | `JCODE_DEFAULT_MODE=full_access` | +| `JCODE_CONFIG` | Config file path (default `~/.jcode/config.json`) | `JCODE_CONFIG=/app/config.json` | + +### Examples + +```bash +# CI: run with a specific model and auto-approve all tools +JCODE_MODEL=anthropic/claude-sonnet-4-20250514 JCODE_DEFAULT_MODE=full_access jcode -p "fix the build" + +# Docker: mount a config file at a custom path +docker run -v ./ci-config.json:/app/config.json -e JCODE_CONFIG=/app/config.json jcode ... + +# Quick one-off: try a different model without editing config +JCODE_MODEL=openai/o3 jcode +``` + +### Notes + +- Unlike project config, `JCODE_DEFAULT_MODE` **is** honored via env vars — they are set by you (or your CI system), not by an untrusted repository. +- `JCODE_CONFIG` redirects both reads and writes: `SaveConfig` (e.g. settings changes via the UI) writes to the same path. +- Invalid values for `JCODE_MAX_ITERATIONS` (non-numeric, ≤ 0) and `JCODE_DEFAULT_MODE` (unknown mode) are logged and ignored — the previous layer's value is preserved. +- Empty string values are treated as unset (they do not clear a field). diff --git a/site/docs/overview/mcp.md b/site/docs/overview/mcp.md index d2d27365..6a00f7f4 100644 --- a/site/docs/overview/mcp.md +++ b/site/docs/overview/mcp.md @@ -197,6 +197,57 @@ Once connected, MCP tools appear alongside built-in tools. The agent can use the ╰─────────────────────────────────────────────────────╯ ``` +## Project-Level MCP Servers + +MCP servers can be defined at the project level so every contributor gets the same tooling. jcode discovers standalone `mcp.json` files from multiple locations: + +### Discovery Order (lowest → highest precedence) + +| Layer | Location | +|---|---| +| 1 | `~/.jcode/mcp.json` (global) | +| 2 | `/.jcode/mcp.json` → ... → `/.jcode/mcp.json` (walk-up) | +| 3 (highest) | `/mcp.json` and `/.mcp.json` (project root convenience) | + +The format is compatible with Claude Desktop / Cursor: + +```json +{ + "mcpServers": { + "filesystem": { + "command": "npx", + "args": ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"] + }, + "internal-api": { + "url": "http://localhost:3001/mcp", + "timeout_seconds": 30 + } + } +} +``` + +### Merge & Security Rules + +- **New servers** defined in project config are added freely. +- **Existing servers** (defined globally): project config can override tuning fields (`args`, `env`, `headers`, `timeout_seconds`, `disabled`) but **cannot change `command` or `url`** — this prevents a malicious repo from redirecting a trusted server. +- **Disable is one-way**: a project can disable a global server for that repo, but cannot re-enable one the user globally disabled. + +### Example: Project MCP File + +```json +// .jcode/mcp.json (committed to the repo) +{ + "mcpServers": { + "project-db": { + "command": "npx", + "args": ["-y", "@acme/db-mcp", "--schema", "public"] + } + } +} +``` + +You can also define MCP servers inside `.jcode/config.json` under the `mcp_servers` key — the same security rules apply. + ## Security MCP tools may access and operate external systems. Be aware of security implications. diff --git a/web/src/components/SettingsView.tsx b/web/src/components/SettingsView.tsx index 71d8c32e..406ed7b3 100644 --- a/web/src/components/SettingsView.tsx +++ b/web/src/components/SettingsView.tsx @@ -2587,6 +2587,7 @@ function MCPTab() {
{name} {info.type || 'stdio'} + {info.scope === 'project' && {t('settings.scope.project')}} ● {mcpStatusLabel(info, t)} @@ -2724,6 +2725,8 @@ function SkillsTab() {
{s.name} {s.builtin && {t('settings.skills.builtin')}} + {!s.builtin && s.source === 'project' && {t('settings.scope.project')}} + {!s.builtin && s.source === 'agents' && {t('settings.scope.agents')}}
{s.description && (
{s.description}
diff --git a/web/src/i18n/locales/en.ts b/web/src/i18n/locales/en.ts index 1fda3f27..a59085c1 100644 --- a/web/src/i18n/locales/en.ts +++ b/web/src/i18n/locales/en.ts @@ -581,6 +581,10 @@ export default { builtin: 'Built-in', loadingHint: 'Loading…', }, + scope: { + project: 'Project', + agents: 'Agents', + }, memory: { title: 'Memory', subtitle: 'Memory configuration applies to every local project. Content stays on disk and this page only reads metadata.', diff --git a/web/src/i18n/locales/ja.ts b/web/src/i18n/locales/ja.ts index da2ba367..965a58b6 100644 --- a/web/src/i18n/locales/ja.ts +++ b/web/src/i18n/locales/ja.ts @@ -535,6 +535,10 @@ export default { builtin: '組み込み', loadingHint: '読み込み中…', }, + scope: { + project: 'プロジェクト', + agents: 'Agents', + }, memory: { title: 'メモリ', subtitle: 'メモリ設定はすべてのローカルプロジェクトに適用されます。内容はローカルに保持し、この画面ではメタデータのみを読み取ります。', diff --git a/web/src/i18n/locales/ko.ts b/web/src/i18n/locales/ko.ts index d3c82546..7508a99a 100644 --- a/web/src/i18n/locales/ko.ts +++ b/web/src/i18n/locales/ko.ts @@ -535,6 +535,10 @@ export default { builtin: '내장', loadingHint: '로딩 중…', }, + scope: { + project: '프로젝트', + agents: 'Agents', + }, memory: { title: '메모리', subtitle: '메모리 설정은 모든 로컬 프로젝트에 적용됩니다. 내용은 로컬에 보관되며 이 화면은 메타데이터만 읽습니다.', diff --git a/web/src/i18n/locales/zh-Hans.ts b/web/src/i18n/locales/zh-Hans.ts index b9322111..b1d271fa 100644 --- a/web/src/i18n/locales/zh-Hans.ts +++ b/web/src/i18n/locales/zh-Hans.ts @@ -565,6 +565,10 @@ export default { builtin: '内置', loadingHint: '加载中…', }, + scope: { + project: '项目', + agents: 'Agents', + }, memory: { title: '记忆', subtitle: '记忆配置对所有本地项目生效;正文保留在本地文件中,此页面仅读取元数据。', diff --git a/web/src/i18n/locales/zh-Hant.ts b/web/src/i18n/locales/zh-Hant.ts index 18427624..120b94b8 100644 --- a/web/src/i18n/locales/zh-Hant.ts +++ b/web/src/i18n/locales/zh-Hant.ts @@ -536,6 +536,10 @@ export default { builtin: '內建', loadingHint: '載入中…', }, + scope: { + project: '專案', + agents: 'Agents', + }, memory: { title: '記憶', subtitle: '記憶設定會套用至所有本機專案;內容保留在本機檔案,此頁面只讀取中繼資料。', diff --git a/web/src/lib/types.ts b/web/src/lib/types.ts index a070dd7b..11304ffe 100644 --- a/web/src/lib/types.ts +++ b/web/src/lib/types.ts @@ -238,6 +238,7 @@ export interface MCPServerInfo { has_auth: boolean status: string // connected | needs_auth | error | disabled | configured error?: string + scope?: string // global | project — which config layer defines this server } export interface MCPListResponse { @@ -342,7 +343,7 @@ export interface SkillInfo { description: string slash?: string builtin?: boolean - source?: string // builtin | local + source?: string // builtin | agents | user | project enabled?: boolean }