diff --git a/cmd/odek/init_template_test.go b/cmd/odek/init_template_test.go new file mode 100644 index 0000000..a593c63 --- /dev/null +++ b/cmd/odek/init_template_test.go @@ -0,0 +1,99 @@ +package main + +import ( + "encoding/json" + "strings" + "testing" +) + +// TestGlobalConfigTemplate_CoversCurrentSections pins that the global +// (`odek init --global`) template carries every operator-facing section of +// the current config surface, so a freshly scaffolded config documents the +// whole schema instead of just the v1.2x-era subset. +func TestGlobalConfigTemplate_CoversCurrentSections(t *testing.T) { + var fc map[string]any + if err := json.Unmarshal([]byte(globalConfigTemplate), &fc); err != nil { + t.Fatalf("globalConfigTemplate is not valid JSON: %v", err) + } + for _, section := range []string{ + "guard", "limits", "planning", "profiles", "transcription", "vision", + "trusted_proxies", "dangerous", "tools", "skills", "memory", + "subagent", "mcp_servers", "web_search", "schedules", "maintenance", + "telegram", + } { + if _, ok := fc[section]; !ok { + t.Errorf("globalConfigTemplate missing section %q", section) + } + } +} + +// TestGlobalConfigTemplate_SandboxNotExplicitlyDisabled guards the default-on +// sandbox posture: an explicit `"sandbox": false` in a fresh global config +// silently opts the operator out (the CLI treats *unset* as sandbox-wanted). +func TestGlobalConfigTemplate_SandboxNotExplicitlyDisabled(t *testing.T) { + if strings.Contains(globalConfigTemplate, "\"sandbox\": false") { + t.Error(`globalConfigTemplate pins "sandbox": false — remove the key so unset inherits the default-on sandbox posture`) + } +} + +// TestGlobalConfigTemplate_NonInteractiveMatchesDocumentedDefault pins the +// documented built-in `non_interactive` default (read_only) so the template +// cannot drift back to the pre-read_only `deny`. +func TestGlobalConfigTemplate_NonInteractiveMatchesDocumentedDefault(t *testing.T) { + var fc struct { + Dangerous struct { + NonInteractive string `json:"non_interactive"` + } `json:"dangerous"` + } + if err := json.Unmarshal([]byte(globalConfigTemplate), &fc); err != nil { + t.Fatalf("globalConfigTemplate is not valid JSON: %v", err) + } + if fc.Dangerous.NonInteractive != "read_only" { + t.Errorf("dangerous.non_interactive = %q, want %q (documented built-in default)", fc.Dangerous.NonInteractive, "read_only") + } +} + +// TestGlobalConfigTemplate_NoDeadOrMissingKeys catches template drift: keys +// that no longer exist in the config struct must not linger, and keys whose +// defaults are worth making explicit must be present. +func TestGlobalConfigTemplate_NoDeadOrMissingKeys(t *testing.T) { + for _, dead := range []string{"skills_skip_max_age_days"} { + if strings.Contains(globalConfigTemplate, dead) { + t.Errorf("globalConfigTemplate contains key %q which no longer exists in the config struct", dead) + } + } + for _, want := range []string{ + `"artifacts_max_age_hours"`, + `"extract_facts"`, + `"auto_approve_episodes"`, + `"min_turns_for_extraction"`, + `"consolidate_on_end"`, + `"max_depth"`, + `"announce_budget"`, + `"budget_inherit"`, + `"default_chat_id"`, + `"max_download_size"`, + `"media_quota_per_chat"`, + `"trusted_proxies"`, + `"tool_outputs"`, + } { + if !strings.Contains(globalConfigTemplate, want) { + t.Errorf("globalConfigTemplate missing key %s", want) + } + } +} + +// TestLocalConfigTemplate_RemainsProjectSafe re-pins the local template's +// contract so global-template work cannot leak operator-only fields into it. +func TestLocalConfigTemplate_RemainsProjectSafe(t *testing.T) { + for _, op := range []string{ + `"api_key"`, `"base_url"`, `"system"`, `"dangerous"`, `"memory"`, + `"guard"`, `"maintenance"`, `"telegram"`, `"web_search"`, + `"embedding"`, `"sessions"`, `"trusted_proxies"`, `"profiles"`, + `"sandbox"`, `"compaction"`, `"limits"`, + } { + if strings.Contains(localConfigTemplate, op) { + t.Errorf("localConfigTemplate contains operator-only or default-pinning key %s", op) + } + } +} diff --git a/cmd/odek/main.go b/cmd/odek/main.go index de0ae33..3400fcd 100644 --- a/cmd/odek/main.go +++ b/cmd/odek/main.go @@ -1294,7 +1294,6 @@ const globalConfigTemplate = `{ "no_color": false, "no_agents": false, "system": "", - "sandbox": false, "sandbox_image": "", "sandbox_network": "none", "sandbox_readonly": false, @@ -1305,7 +1304,7 @@ const globalConfigTemplate = `{ "sandbox_volumes": [], "dangerous": { "action": "prompt", - "non_interactive": "deny", + "non_interactive": "read_only", "classes": { "destructive": "deny", "network_egress": "prompt", @@ -1316,10 +1315,30 @@ const globalConfigTemplate = `{ "allowlist": [], "denylist": [] }, + "guard": { + "provider": "local", + "url": "", + "batch_url": "", + "long_url": "", + "socket_path": "", + "threshold": 0.9, + "timeout_seconds": 5, + "fallback_to_local": true, + "max_text_length": 0, + "scan": { + "memory": true, + "system_prompt": true, + "mcp_descriptions": true, + "skills": true, + "tool_outputs": false, + "telegram": false + } + }, "tools": { "enabled": [], "disabled": [] }, + "profiles": {}, "skills": { "max_auto_load": 3, "max_lazy_slots": 5, @@ -1334,12 +1353,20 @@ const globalConfigTemplate = `{ "memory": { "enabled": true, "buffer_enabled": true, - "extract_on_end": true + "extract_on_end": true, + "min_turns_for_extraction": 3, + "extract_facts": false, + "auto_approve_episodes": false, + "merge_on_write": true, + "consolidate_on_end": true }, "subagent": { "max_concurrency": 3, "timeout_seconds": 1800, "max_iterations": 15, + "max_depth": 2, + "announce_budget": true, + "budget_inherit": "operator", "default_profile": "default" }, "limits": { @@ -1360,6 +1387,20 @@ const globalConfigTemplate = `{ "max_results": 10, "timeout_seconds": 15 }, + "transcription": { + "model": "tiny", + "language": "", + "auto_transcribe": true, + "models_dir": "", + "binary_path": "" + }, + "vision": { + "models_dir": "", + "binary_path": "", + "video_frames": 8, + "auto_describe": true + }, + "trusted_proxies": [], "schedules": { "enabled": true, "max_concurrent": 2, @@ -1373,16 +1414,19 @@ const globalConfigTemplate = `{ "audit_max_age_days": 14, "log_max_mb": 50, "plans_max_age_days": 30, - "skills_skip_max_age_days": 90 + "artifacts_max_age_hours": 24 }, "telegram": { "bot_token": "", "allowed_chats": [], "allowed_users": [], + "default_chat_id": 0, "bot_username": "", "poll_interval": 1, "poll_timeout": 30, "max_msg_length": 4096, + "max_download_size": 5242880, + "media_quota_per_chat": 0, "daily_token_budget": 0, "session_ttl_hours": 24, "fallback_urls": [], @@ -1517,10 +1561,11 @@ func initConfig(args []string) error { 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, limits,") - 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(" Sections: dangerous, guard, tools, profiles, skills, memory,") + fmt.Println(" subagent, limits, planning, mcp_servers, web_search, transcription,") + fmt.Println(" vision, trusted_proxies, schedules, maintenance, telegram, plus") + fmt.Println(" sandbox_image/network/readonly/memory/cpus/user/env/volumes.") + fmt.Println(" Full schema (also embedding and memory.extended):") fmt.Println(" see docs/CONFIG.md and docs/SANDBOXING.md.") } else { fmt.Println(" Project config — only project-safe fields are honored here.") diff --git a/docs/CONFIG.md b/docs/CONFIG.md index 5e0358c..e5a876b 100644 --- a/docs/CONFIG.md +++ b/docs/CONFIG.md @@ -1140,7 +1140,7 @@ odek init --global 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`, `memory`, `subagent`, `mcp_servers`, `web_search`, `schedules`, `maintenance`, and `telegram`. +The **global template** covers the full schema: connection (`model`, `base_url`, `api_key`), execution (`max_iterations`, `max_tool_parallel`, `prompt_caching`, `compaction`, `interaction_mode`), sandbox resource knobs (the `sandbox` key itself is deliberately absent — unset inherits the default-on posture), `dangerous` (with `non_interactive` pinned to the documented `read_only` default), `guard`, `tools`, `profiles`, `skills`, `memory` (including the `extract_facts` / `auto_approve_episodes` opt-outs), `subagent` (including `max_depth`, `announce_budget`, `budget_inherit`, `default_profile`), `limits`, `planning`, `mcp_servers`, `web_search`, `transcription`, `vision`, `trusted_proxies`, `schedules`, `maintenance`, and `telegram`. Blocks whose mere presence changes behavior (`embedding`, `memory.embedding`, `sessions.embedding`, `skills.embedding`) are intentionally omitted — add them only when you actually run an embedder. The **local template** contains only fields a project may legitimately set (`model`, `thinking`, iteration/parallelism limits, `prompt_caching`, `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. `compaction` is likewise omitted from the local template: it defaults to on, and pinning `"compaction": false` in a fresh project config would silently disable it (add the key explicitly if you want it off).