From 313fa01ab6d4c34092f9fab4f9bf64eed4880294 Mon Sep 17 00:00:00 2001 From: Rolando Santamaria Maso Date: Tue, 28 Jul 2026 20:55:05 +0200 Subject: [PATCH] fix(cli): scope-aware odek init templates for global vs project configs A single template served both scopes, so a fresh local ./odek.json came pre-filled with operator-only fields (api_key, base_url, system, dangerous, telegram, memory, maintenance, web_search, ...) that the loader ignores with warnings on every run, plus sandbox=false / sandbox_readonly=false pins that project configs are not allowed to weaken. Split defaultConfigTemplate into: - globalConfigTemplate (~/.odek/config.json): full operator schema, now also covering max_tool_parallel, prompt_caching, compaction, interaction_mode, tools, skills.auto_save/curation, memory, mcp_servers, web_search, schedules, and maintenance. - localConfigTemplate (./odek.json): only fields a project may legitimately set, so a fresh 'odek init' loads warning-free. Omits sandbox/sandbox_readonly entirely (projects may only enable, never disable) and operator-only sections rejected by the loader. Also adds an explicit --local/-l flag (equivalent to the default), scope-appropriate post-create summaries, and round-trip tests asserting both templates unmarshal into config.FileConfig with the expected operator-only fields absent/present. --- cmd/odek/main.go | 191 +++++++++++++++++++++++++++++++++++------- cmd/odek/main_test.go | 116 +++++++++++++++++++++++-- docs/CLI.md | 2 +- docs/CONFIG.md | 11 ++- 4 files changed, 278 insertions(+), 42 deletions(-) diff --git a/cmd/odek/main.go b/cmd/odek/main.go index 70cbde7..94a5123 100644 --- a/cmd/odek/main.go +++ b/cmd/odek/main.go @@ -841,7 +841,7 @@ func printUsage() { odek repl [flags] odek serve [--addr :8080] [--open] odek subagent --goal [--context ] [flags] - odek init [--global | -g] [--force | -f] + odek init [--global | -g | --local | -l] [--force | -f] odek skill odek mcp [--sandbox] odek telegram @@ -992,16 +992,28 @@ Environment variables: // ── Init ────────────────────────────────────────────────────────────── -const defaultConfigTemplate = `{ +// globalConfigTemplate is written to ~/.odek/config.json by `odek init --global`. +// The global config is operator-controlled, so every field is honored here, +// including the sensitive ones (api_key, base_url, system, dangerous, …) +// that project-level ./odek.json files are not allowed to set. +// +// Sections that are rarely tuned by hand (mcp_servers, transcription, +// vision, embedding, trusted_proxies) are intentionally omitted from the +// template to keep it maintainable — see docs/CONFIG.md for the full schema. +const globalConfigTemplate = `{ "model": "deepseek-v4-flash", "base_url": "https://api.deepseek.com/v1", "api_key": "${ODEK_API_KEY}", "thinking": "", "max_iterations": 90, - "sandbox": false, + "max_tool_parallel": 4, + "prompt_caching": false, + "compaction": false, + "interaction_mode": "engaging", "no_color": false, "no_agents": false, "system": "", + "sandbox": false, "sandbox_image": "", "sandbox_network": "none", "sandbox_readonly": false, @@ -1023,13 +1035,24 @@ const defaultConfigTemplate = `{ "allowlist": [], "denylist": [] }, + "tools": { + "enabled": [], + "disabled": [] + }, "skills": { "max_auto_load": 3, "max_lazy_slots": 5, "learn": true, "llm_learn": true, "llm_curate": true, + "verbose": false, "dirs": [], + "auto_save": { + "enabled": true, + "require_llm": true, + "max_per_run": 3, + "min_occurrences": 2 + }, "import": { "max_size_bytes": 1048576, "timeout_seconds": 5, @@ -1037,13 +1060,45 @@ const defaultConfigTemplate = `{ }, "curation": { "staleness_days": 90, - "auto_prune": false + "auto_prune": false, + "auto_curate": true, + "skip_threshold": 3, + "skip_reset_days": 30 } }, + "memory": { + "enabled": true, + "buffer_enabled": true, + "extract_on_end": true + }, "subagent": { "max_concurrency": 3, "timeout_seconds": 120, - "max_iterations": 15 + "max_iterations": 15, + "system_prompt": "" + }, + "mcp_servers": {}, + "web_search": { + "base_url": "", + "categories": "general", + "language": "en", + "max_results": 10, + "timeout_seconds": 15 + }, + "schedules": { + "enabled": true, + "max_concurrent": 2, + "timezone": "UTC", + "catchup": false + }, + "maintenance": { + "enabled": true, + "interval_minutes": 60, + "sessions_max_age_days": 30, + "audit_max_age_days": 14, + "log_max_mb": 50, + "plans_max_age_days": 30, + "skills_skip_max_age_days": 90 }, "telegram": { "bot_token": "", @@ -1061,18 +1116,72 @@ const defaultConfigTemplate = `{ } }` -// initConfig creates a new config file (local ./odek.json or global ~/.odek/config.json). +// localConfigTemplate is written to ./odek.json by `odek init`. +// Project configs are untrusted: the loader ignores sensitive fields +// (api_key, base_url, system, dangerous, memory, sessions, embedding, +// guard, maintenance, telegram, web_search, trusted_proxies, +// tools.enabled, skills.dirs) with a warning, and rejects sandbox=false / +// sandbox_readonly=false outright. The template therefore contains only +// fields a project may legitimately set, so a fresh `odek init` produces +// a config that loads warning-free. Empty/zero values inherit from the +// global config and the layers below. +const localConfigTemplate = `{ + "model": "", + "thinking": "", + "max_iterations": 0, + "max_tool_parallel": 0, + "prompt_caching": false, + "compaction": false, + "interaction_mode": "", + "no_color": false, + "no_agents": false, + "sandbox_image": "", + "sandbox_network": "none", + "sandbox_memory": "", + "sandbox_cpus": "", + "sandbox_user": "", + "sandbox_env": {}, + "sandbox_volumes": [], + "tools": { + "disabled": [] + }, + "skills": { + "max_auto_load": 3, + "max_lazy_slots": 5, + "learn": true, + "llm_learn": true, + "llm_curate": true, + "verbose": false + }, + "subagent": { + "max_concurrency": 3, + "timeout_seconds": 120, + "max_iterations": 15 + }, + "mcp_servers": {}, + "schedules": { + "enabled": true, + "max_concurrent": 2, + "timezone": "UTC", + "catchup": false + } +}` + +// initConfig creates a new config file — local ./odek.json by default, or +// global ~/.odek/config.json with --global / -g. // -// The file is populated with the defaultConfigTemplate showing every -// available field with sensible defaults. ${VAR} substitution works -// for api_key so users can reference environment variables. +// The two scopes use different templates (globalConfigTemplate vs +// localConfigTemplate) because the loader treats project configs as +// untrusted: sensitive fields set there are ignored with warnings, so the +// local template only includes project-safe fields. ${VAR} substitution +// works for api_key so users can reference environment variables. // // The function is safe by default: it refuses to overwrite an existing // file unless --force / -f is passed. Parent directories are created // automatically (os.MkdirAll handles "." as a no-op for local configs). // -// After creation, a summary is printed showing all available fields and -// a reminder of the config priority chain. +// After creation, a scope-appropriate summary is printed showing the +// available fields and the config priority chain. func initConfig(args []string) error { global := false force := false @@ -1081,6 +1190,8 @@ func initConfig(args []string) error { switch args[i] { case "--global", "-g": global = true + case "--local", "-l": + global = false case "--force", "-f": force = true default: @@ -1090,12 +1201,15 @@ func initConfig(args []string) error { var configPath string var scope string + var template string if global { configPath = config.GlobalConfigPath() scope = "global" + template = globalConfigTemplate } else { configPath = config.ProjectConfigPath() scope = "local" + template = localConfigTemplate } // Check if file already exists @@ -1111,33 +1225,46 @@ func initConfig(args []string) error { return fmt.Errorf("create directory %s: %w", dir, err) } - if err := os.WriteFile(configPath, []byte(defaultConfigTemplate+"\n"), 0600); err != nil { + if err := os.WriteFile(configPath, []byte(template+"\n"), 0600); err != nil { return fmt.Errorf("write config: %w", err) } fmt.Printf("✓ Created %s config: %s\n", scope, configPath) fmt.Println() - fmt.Println(" Edit this file to set your preferences. Available fields:") - fmt.Println(" model LLM model name") - fmt.Println(" base_url API endpoint URL") - fmt.Println(" api_key API key (supports ${VAR} substitution)") - fmt.Println(" thinking Reasoning depth (enabled/disabled/low/medium/high)") - fmt.Println(" max_iterations Max think->act cycles") - fmt.Println(" sandbox Run in Docker sandbox (true/false)") - fmt.Println(" no_color Disable colored output (true/false)") - fmt.Println(" no_agents Skip AGENTS.md (true/false)") - fmt.Println(" system System prompt override") - fmt.Println(" sandbox_image Docker image (alpine:latest if empty)") - fmt.Println(" sandbox_network Network mode (none | bridge | host)") - fmt.Println(" sandbox_readonly Mount working directory read-only") - fmt.Println(" sandbox_memory Memory limit (e.g. 512m, 2g)") - fmt.Println(" sandbox_cpus CPU limit (e.g. 0.5, 2)") - fmt.Println(" sandbox_user Container user (uid:gid)") - fmt.Println(" sandbox_env Extra env vars (object)") - fmt.Println(" sandbox_volumes Extra volume mounts (array)") + if global { + fmt.Println(" Edit this file to set your preferences. Common fields:") + fmt.Println(" model LLM model name (default: deepseek-v4-flash)") + fmt.Println(" base_url API endpoint URL") + fmt.Println(" api_key API key (supports ${VAR} substitution)") + fmt.Println(" thinking Reasoning depth (enabled/disabled/low/medium/high)") + fmt.Println(" max_iterations Max think→act cycles (default: 90)") + fmt.Println(" prompt_caching Provider prompt caching (true/false)") + fmt.Println(" compaction Rolling LLM context compaction (true/false)") + fmt.Println(" interaction_mode engaging | enhance | verbose | off") + fmt.Println(" sandbox Run in Docker sandbox (true/false)") + fmt.Println(" system System prompt override") + fmt.Println() + fmt.Println(" Sections: dangerous, tools, skills, memory, subagent,") + fmt.Println(" mcp_servers, web_search, schedules, maintenance, telegram,") + fmt.Println(" plus sandbox_image/network/readonly/memory/cpus/user/env/volumes.") + fmt.Println(" Full schema (also mcp_servers, transcription, vision, embedding):") + fmt.Println(" see docs/CONFIG.md and docs/SANDBOXING.md.") + } else { + fmt.Println(" Project config — only project-safe fields are honored here.") + fmt.Println(" Empty values inherit from your global config.") + fmt.Println() + fmt.Println(" The loader ignores operator-only fields in ./odek.json:") + fmt.Println(" api_key, base_url, system, dangerous, memory, sessions,") + fmt.Println(" embedding, guard, maintenance, telegram, web_search,") + fmt.Println(" trusted_proxies, tools.enabled, skills.dirs") + fmt.Println(" Set those in ~/.odek/config.json instead: odek init --global") + fmt.Println() + fmt.Println(" Note: project configs may only enable the sandbox") + fmt.Println(" (\"sandbox\": true) — sandbox=false is rejected.") + fmt.Println(" Full schema: see docs/CONFIG.md.") + } fmt.Println() - fmt.Println(" See docs/SANDBOXING.md for full sandbox documentation.") - fmt.Println(" Priority: config file < ODEK_* env < CLI flags") + fmt.Println(" Priority: ~/.odek/secrets.env → ~/.odek/config.json → ./odek.json → ODEK_* env → CLI flags") return nil } diff --git a/cmd/odek/main_test.go b/cmd/odek/main_test.go index be73a69..4b1faed 100644 --- a/cmd/odek/main_test.go +++ b/cmd/odek/main_test.go @@ -793,11 +793,31 @@ func TestInitConfig_Local(t *testing.T) { t.Fatalf("odek.json not created: %v", err) } content := string(data) - if !strings.Contains(content, "deepseek-v4-flash") { - t.Errorf("config should contain deepseek-v4-flash, got: %s", content) + // Local template uses the project-safe field set. + if !strings.Contains(content, "max_tool_parallel") { + t.Errorf("local config should contain max_tool_parallel, got: %s", content) + } + if !strings.Contains(content, "interaction_mode") { + t.Errorf("local config should contain interaction_mode, got: %s", content) + } + // Operator-only fields must NOT appear in a local (project) config — + // the loader would ignore them with warnings on every run. + for _, field := range []string{"api_key", "base_url", "\"system\"", "\"dangerous\"", "\"telegram\"", "\"memory\"", "\"maintenance\"", "\"web_search\""} { + if strings.Contains(content, field) { + t.Errorf("local config must not contain operator-only field %q, got: %s", field, content) + } + } + // "sandbox": false / "sandbox_readonly": false are rejected from + // project configs, so neither key may be present at all. + for _, field := range []string{"\"sandbox\"", "sandbox_readonly"} { + if strings.Contains(content, field) { + t.Errorf("local config must not contain %q (project configs may only enable it), got: %s", field, content) + } } - if !strings.Contains(content, "api_key") { - t.Errorf("config should contain api_key field, got: %s", content) + // Must be valid JSON. + var parsed map[string]any + if err := json.Unmarshal(data, &parsed); err != nil { + t.Errorf("local config is not valid JSON: %v", err) } } @@ -820,6 +840,18 @@ func TestInitConfig_Global(t *testing.T) { if !strings.Contains(content, "deepseek-v4-flash") { t.Errorf("config should contain deepseek-v4-flash, got: %s", content) } + // Global (operator) config includes the sensitive sections that are + // rejected from project-level ./odek.json files. + for _, field := range []string{"api_key", "base_url", "dangerous", "telegram", "memory", "maintenance", "web_search", "prompt_caching", "compaction", "interaction_mode", "max_tool_parallel"} { + if !strings.Contains(content, field) { + t.Errorf("global config should contain %q, got: %s", field, content) + } + } + // Must be valid JSON. + var parsed map[string]any + if err := json.Unmarshal(data, &parsed); err != nil { + t.Errorf("global config is not valid JSON: %v", err) + } } func TestInitConfig_LocalExists(t *testing.T) { @@ -870,8 +902,8 @@ func TestInitConfig_LocalForce(t *testing.T) { if strings.Contains(string(data), "old") { t.Error("config should have been overwritten") } - if !strings.Contains(string(data), "deepseek-v4-flash") { - t.Errorf("config should contain template, got: %s", string(data)) + if !strings.Contains(string(data), "max_tool_parallel") { + t.Errorf("config should contain local template, got: %s", string(data)) } } @@ -902,6 +934,78 @@ func TestInitConfig_ShortFlags(t *testing.T) { } } +func TestInitConfig_LocalFlag(t *testing.T) { + // --local explicitly selects the project config (same as no flag). + dir := t.TempDir() + cwd, _ := os.Getwd() + os.Chdir(dir) + defer os.Chdir(cwd) + + origHome := os.Getenv("HOME") + os.Setenv("HOME", t.TempDir()) + defer os.Setenv("HOME", origHome) + + if err := initConfig([]string{"--local"}); err != nil { + t.Fatalf("initConfig() --local error: %v", err) + } + + if _, err := os.Stat("odek.json"); err != nil { + t.Errorf("local odek.json should exist after --local: %v", err) + } + // --local must not touch the global config. + if _, err := os.Stat(os.Getenv("HOME") + "/.odek/config.json"); err == nil { + t.Error("--local must not create a global config") + } +} + +func TestInitConfig_LocalTemplateLoadsClean(t *testing.T) { + // The local template must round-trip through the real config loader + // as a project config: valid FileConfig JSON with no operator-only + // fields that the loader would reject. + var fc config.FileConfig + if err := json.Unmarshal([]byte(localConfigTemplate), &fc); err != nil { + t.Fatalf("localConfigTemplate does not unmarshal into FileConfig: %v", err) + } + if fc.APIKey != "" || fc.BaseURL != "" || fc.System != "" { + t.Error("localConfigTemplate contains operator-only connection fields") + } + if fc.Dangerous != nil || fc.Memory != nil || fc.Telegram != nil || + fc.WebSearch != nil || fc.Maintenance != nil || fc.Embedding != nil || + fc.Guard != nil || fc.Sessions != nil { + t.Error("localConfigTemplate contains operator-only sections") + } + if fc.Sandbox != nil || fc.SandboxReadonly != nil { + t.Error("localConfigTemplate must not pin sandbox/sandbox_readonly (project configs may only enable, never disable)") + } + if fc.Skills != nil && len(fc.Skills.Dirs) > 0 { + t.Error("localConfigTemplate must not set skills.dirs (rejected from project configs)") + } + if fc.Tools != nil && len(fc.Tools.Enabled) > 0 { + t.Error("localConfigTemplate must not set tools.enabled (project configs may only disable tools)") + } +} + +func TestInitConfig_GlobalTemplateLoadsClean(t *testing.T) { + // The global template must unmarshal into FileConfig and carry the + // documented current defaults. + var fc config.FileConfig + if err := json.Unmarshal([]byte(globalConfigTemplate), &fc); err != nil { + t.Fatalf("globalConfigTemplate does not unmarshal into FileConfig: %v", err) + } + if fc.Model != "deepseek-v4-flash" { + t.Errorf("global template model = %q, want deepseek-v4-flash", fc.Model) + } + if fc.MaxIter != 90 { + t.Errorf("global template max_iterations = %d, want 90", fc.MaxIter) + } + if fc.Dangerous == nil { + t.Error("global template should include the dangerous section") + } + if fc.Skills == nil || fc.Skills.AutoSave == nil { + t.Error("global template should include skills.auto_save") + } +} + // TestInitConfig_RestrictivePermissions verifies that config files // containing API keys are created with 0600 (owner read/write only), // not 0644 (world-readable). diff --git a/docs/CLI.md b/docs/CLI.md index fe20ede..d37fbbd 100644 --- a/docs/CLI.md +++ b/docs/CLI.md @@ -28,7 +28,7 @@ | `odek audit --list` | List sessions with non-zero ingest counts and divergence flags | || `odek serve [--addr :8080] [--open] [--no-sandbox] [--trusted-proxies ]` | Web UI server. Sandbox is on by default; pass `--no-sandbox` to disable. Accepts `--tool` and `--no-tool` flags. Binding to a non-loopback address prints a loud warning because anyone with the token can drive the agent. `--trusted-proxies` honours `X-Forwarded-For`/`X-Real-Ip` only from those addresses. | || `odek subagent --goal [flags]` | Run a focused sub-task; outputs JSON on stdout. Spawned by `delegate_tasks` tool. Flags: `--goal`, `--task `, `--context`, `--timeout` (≤3600s), `--max-iter` (≤100), `--quiet`, `--stream`. | -| `odek init [--global] [--force]` | Create a config file template | +| `odek init [--global|--local] [--force]` | Create a config file template (scope-aware: full schema globally, project-safe fields locally) | | `odek mcp [--sandbox]` | Start MCP server (expose tools to Claude Code) or connect to external MCP servers (via `mcp_servers` config) | | `odek telegram` | Start the Telegram bot (long-polling). Hosts the embedded scheduler unless `schedules.enabled=false` | | `odek schedule ` | Manage native in-process scheduled tasks (cron): `list`, `add`, `rm`, `enable`, `disable`, `run`, `next`, `daemon`. See [Schedules](SCHEDULES.md) | diff --git a/docs/CONFIG.md b/docs/CONFIG.md index 80db941..254f0cb 100644 --- a/docs/CONFIG.md +++ b/docs/CONFIG.md @@ -884,19 +884,24 @@ Key behaviors: ## odek init -Create a config file template: +Create a config file template. The two scopes use **different templates** because project configs are untrusted — the loader ignores sensitive fields in `./odek.json` (see [Project overrides](#project-overrides-odekjson) above), so the local template only contains project-safe fields and loads warning-free. ```bash -# Local project config (./odek.json) +# Local project config (./odek.json) — project-safe fields only odek init +odek init --local # explicit equivalent -# Global config (~/.odek/config.json) +# Global config (~/.odek/config.json) — full operator schema odek init --global # Overwrite existing file odek init --force ``` +The **global template** covers the full schema: connection (`model`, `base_url`, `api_key`), execution (`max_iterations`, `max_tool_parallel`, `prompt_caching`, `compaction`, `interaction_mode`), sandboxing, `dangerous`, `tools`, `skills` (incl. `auto_save` and `curation`), `memory`, `subagent`, `mcp_servers`, `web_search`, `schedules`, `maintenance`, and `telegram`. + +The **local template** contains only fields a project may legitimately set (`model`, `thinking`, iteration/parallelism limits, `prompt_caching`, `compaction`, `interaction_mode`, sandbox resource knobs, `tools.disabled`, `skills` without `dirs`, `subagent`, `mcp_servers`, `schedules`). Operator-only fields (`api_key`, `base_url`, `system`, `dangerous`, `memory`, `sessions`, `embedding`, `guard`, `maintenance`, `telegram`, `web_search`, `trusted_proxies`, `tools.enabled`, `skills.dirs`) belong in `~/.odek/config.json`. Note that project configs may only *enable* the sandbox — `"sandbox": false` is rejected, so neither template pins it locally. + ## Quick examples ```bash