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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
99 changes: 99 additions & 0 deletions cmd/odek/init_template_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
61 changes: 53 additions & 8 deletions cmd/odek/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -1294,7 +1294,6 @@ const globalConfigTemplate = `{
"no_color": false,
"no_agents": false,
"system": "",
"sandbox": false,
"sandbox_image": "",
"sandbox_network": "none",
"sandbox_readonly": false,
Expand All @@ -1305,7 +1304,7 @@ const globalConfigTemplate = `{
"sandbox_volumes": [],
"dangerous": {
"action": "prompt",
"non_interactive": "deny",
"non_interactive": "read_only",
"classes": {
"destructive": "deny",
"network_egress": "prompt",
Expand All @@ -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,
Expand All @@ -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": {
Expand All @@ -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,
Expand All @@ -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": [],
Expand Down Expand Up @@ -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.")
Expand Down
2 changes: 1 addition & 1 deletion docs/CONFIG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).

Expand Down
Loading