Skip to content

Commit 98885f5

Browse files
authored
feat: generic extension MVP (odek-extension/v1) (#128)
* feat: generic extension MVP (odek-extension/v1) Add five generic runtime capabilities for external MCP-based products, all additive and backward compatible (contract: docs/EXTENSIONS.md): - MCP per-server limits: timeout_seconds (30s/3600s cap), max_response_bytes (10MiB/64MiB ceiling), max_result_chars (200k/1M), artifact_roots; approval keys hash the new fields. - Artifact references: odek.tool-result/v1 envelope with odek.artifact-ref/v1 file:// refs, validated fail-closed (root containment after symlink resolution, sha256/size checks); model sees compact metadata only, never paths or content. - Structured runtime events: odek.event/v1 via Config.EventHandler (non-blocking, panic-isolated) and `run --events-jsonl` (0600, no symlinks, flush per event, SHA-256 args hashes, redacted). - External session refs: Session.ExternalRefs (validated, deduped, opaque — never dereferenced) + --external-ref on run/continue. - Hard execution budgets: limits section (runtime/tool-calls/ tokens/cost) enforced in the loop, typed budget.Error, CLI exit code 4, session persisted before return; project config may only lower limits, never raise/disable. Per-model pricing via limits.model_prices with fallback to the flat price pair; project-level prices rejected outright. Includes mock extension MCP server (internal/mcpclient/testdata), unit/race/fuzz coverage (6 new fuzz targets), and docs sweep (EXTENSIONS/CLI/CONFIG/API/MCP/SESSIONS/SECURITY + AGENTS.md). * style: satisfy staticcheck ST1005/QF1001 in external-ref validation * feat(cli): scaffold limits section in odek init --global template All values default to 0 (enforcement off, same as unset), so a fresh config behaves identically while making execution budgets — including model_prices — discoverable. The local template intentionally omits limits: project configs may only lower global limits, so the section is meaningless without a global budget to tighten. * feat(serve): GET /api/limits for client-side session cost rendering Returns the server's model, the resolved budget.Limits (including model_prices), and effective_prices resolved for the server model via Limits.ResolvePrices (0/0 when unconfigured = costs unavailable). Sits behind the same middleware stack as /api/models: per-instance serve token, loopback Host check, local origin. GET-only; 405 otherwise.
1 parent ff6a541 commit 98885f5

50 files changed

Lines changed: 7425 additions & 80 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎AGENTS.md‎

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ It provides context about the project's architecture, conventions, and how to up
1818
```
1919
odek.go Public API (Config, New, Run, Close, ModelProfile, KnownProfiles, Tool interface)
2020
cmd/odek/
21-
main.go CLI entry point, flag parsing, commands, sandbox setup, system prompt
21+
main.go CLI entry point, flag parsing, commands, sandbox setup, system prompt, --events-jsonl/--external-ref/budget flag wiring
2222
dispatch.go CLI subcommand dispatch
2323
shell.go Built-in shell tool (local or docker exec; danger-gated; optional timeout_seconds)
2424
serve.go Web UI server (HTTP + WebSocket; @-resource completion)
@@ -30,6 +30,7 @@ cmd/odek/
3030
subagent_key.go FD-based API key handoff (parent → sub-agent, never via env)
3131
browser_tool.go Built-in browser tool (HTTP fetch + headless navigation)
3232
file_tool.go Built-in file tools (read_file, write_file, search_files, patch, batch_read, glob, file_info)
33+
external_ref.go --external-ref flag parsing (run + continue) → session.ExternalRef
3334
perf_tools.go Performance/parallelism tools (batch_patch, parallel_shell, http_batch, math_eval, diff, count_lines, multi_grep, json_query, tree, checksum, sort, head_tail, base64, tr, word_count)
3435
mcp.go MCP server implementation (stdio + SSE transport)
3536
mcp_approval.go Per-tool MCP server approval UI and persistence
@@ -55,29 +56,32 @@ cmd/odek/
5556
*_test.go 250+ unit + E2E tests covering all tools
5657
internal/
5758
llm/ OpenAI-compatible HTTP client with reasoning_content support
58-
loop/ ReAct engine: observe → think → parallel-act → repeat. signal.go — SignalEvent observability (context_trimmed, tool_recovery, tool_running heartbeat).
59+
loop/ ReAct engine: observe → think → parallel-act → repeat. signal.go — SignalEvent observability (context_trimmed, tool_recovery, tool_running heartbeat). Execution-budget enforcement (budget.Checker) + odek.event/v1 emission.
5960
tool/ Thread-safe tool registry, clarify.go, send_message.go
6061
danger/ Command/URL classification + bypass-resistant tokenizer. TTYApprover with friction mode.
6162
auth/ Interactive approval system
6263
memory/ MemoryManager (facts, buffer, episodes, merge, scan). EpisodeProvenance — tainted episodes never auto-replayed.
63-
session/ Session store (CRUD, trim, cleanup, compact JSON). AuditStore + divergence heuristic.
64+
session/ Session store (CRUD, trim, cleanup, compact JSON). AuditStore + divergence heuristic. ExternalRef — opaque operator-supplied refs, never dereferenced.
65+
artifact/ odek.artifact-ref/v1 + odek.tool-result/v1: fail-closed ref validation (roots/symlinks/sha256/size) and model-safe rendering (metadata only, no paths/content).
66+
events/ odek.event/v1 runtime event stream: Event, non-blocking panic-isolated Emitter (args hashed, redact applied), JSONLSink (0600, no symlinks, flush per event).
67+
budget/ Hard execution budgets: Limits, typed Error, per-run Checker (runtime/tokens/cost/tool-calls).
6468
maintenance/ Storage-maintenance janitor (session/audit/plan retention, log rotation, media sweep, skill skip-list GC). Config: `maintenance` section (operator-only).
6569
skills/ Skill system (types, loader, triggers, self-improve, curator, import, cache). SkillProvenance gate.
66-
config/ Config file loading, env vars, secrets.env, priority merge
70+
config/ Config file loading, env vars, secrets.env, priority merge, limits clamp (project may only lower budgets)
6771
telegram/ Telegram bot: bot.go, poller.go, handler.go, commands.go, session.go, health.go, plan.go, media_path.go
6872
render/ Terminal output and narrator support
6973
narrate/ LLM-powered emoji-rich progress messages
7074
redact/ Secret redaction (20+ patterns)
7175
mcp/ MCP server handler (tools/list, tools/call, SSE streaming)
72-
mcpclient/ MCP client (connect to external MCP servers)
76+
mcpclient/ MCP client (connect to external MCP servers); per-server limits (timeout/response bytes/result chars/artifact roots) + odek-extension/v1 contract (contract.go), artifact-ref enforcement in CallTool
7377
sandbox/ Docker sandbox lifecycle
7478
flock/ Advisory file-locking helpers
7579
fsatomic/ Atomic file-write helpers
7680
pathutil/ Path helpers
7781
resource/ @-resource resolver (files, sessions) with size/symlink hardening
7882
transport/ Shared HTTP transport with connection pooling
7983
ws/ RFC 6455 WebSocket framing
80-
docs/ Documentation (CLI, API, CONFIG, MCP, MEMORY, TELEGRAM, SECURITY, etc.)
84+
docs/ Documentation (CLI, API, CONFIG, MCP, EXTENSIONS, MEMORY, TELEGRAM, SECURITY, etc.)
8185
```
8286

8387
## How It Works
@@ -200,6 +204,11 @@ Layered prompt-injection / approval-fatigue defenses. Full reference: [docs/SECU
200204
- **MCP inputSchema hardening** (`cmd/odek/mcp_approval.go`) — every string in an MCP tool's `inputSchema` is recursively guard-scanned for injection patterns; schemas larger than 256 KiB are rejected; the interactive approval prompt shows a SHA-256 hash and byte size of the schema so operators can detect changes.
201205
- **MCP tool batch classification** (`internal/loop/loop.go`) — MCP tools (`<server>__<tool>`) are classified as `unknown` by `classifyToolCall`, so the batch approval gate shows them and untrusted sub-agents force them to `deny`.
202206
- **MCP client robustness** (`internal/mcpclient/client.go`, `cmd/odek/mcp_approval.go`) — MCP calls use a default timeout when the caller supplies no deadline; server names and tool names are validated (tool names are rejected if they contain `__` to prevent collisions with odek's `<server>__<tool>` namespace); the interactive approval prompt sanitises tool descriptions to strip ANSI escape sequences and terminal control characters.
207+
- **MCP per-server limits + approval-key coverage** (`internal/mcpclient/client.go`, `cmd/odek/mcp_approval.go`) — extension servers are bounded per server: `timeout_seconds` (default 30s, cap 3600s), `max_response_bytes` (default 10 MiB, hard ceiling 64 MiB — oversized lines fail closed), `max_result_chars` (default 200000, cap 1000000 — structured truncation notice, never a silent cut), and `artifact_roots` (empty ⇒ all artifact refs rejected). Persisted MCP approval keys hash all four fields, so a project server editing them (e.g. widening `artifact_roots`) re-prompts instead of reusing an old approval; pre-extension approvals re-prompt once after upgrade.
208+
- **MCP artifact-ref validation** (`internal/artifact/`, `internal/mcpclient/client.go`) — `odek.artifact-ref/v1` refs from `odek.tool-result/v1` envelopes are validated fail-closed before anything reaches the model: exact schema match, `file://` only, absolute clean path, containment inside a configured root after `EvalSymlinks` on both sides, regular-file check, sha256/size verification when present. Artifact content is never auto-read into context; the model sees metadata lines only (id, media type, size, short hash, summary) — never absolute paths.
209+
- **Execution-budget clamp merge** (`internal/budget/`, `internal/config/loader.go::clampProjectLimits`, `internal/loop/loop.go`) — the `limits` section uses a clamp, not an overlay: global config may set any budget; project `./odek.json` may only *lower* one (raises clamped with a warning, zero-outs re-inherit the global value); project-set per-million prices (flat pair and `model_prices` alike) are rejected outright (a lower price would weaken cost enforcement); `model_prices` maps exact model IDs to per-model prices, resolved once per run with per-field fallback to the flat pair; CLI flags set limits explicitly. On exhaustion the loop emits `budget_exceeded`, persists the latest safe session state, and returns a typed `budget.Error` (CLI exit code 4). Currently enforced by `odek run` only; no `ODEK_*` env layer.
210+
- **Runtime event stream redaction** (`internal/events/`) — the `odek.event/v1` stream never carries raw tool arguments (SHA-256 `args_sha256` + sizes only), collapses errors into low-cardinality `error_class` strings, and runs `internal/redact` over the tool name and string `data` values before dispatch. The JSONL sink (`--events-jsonl`) is 0600, refuses symlink targets, requires an existing parent dir, and fsyncs per event; dispatch is non-blocking (drop-on-full) and panic-isolated.
211+
- **External session refs are opaque** (`internal/session/session.go`, `cmd/odek/external_ref.go`) — `Session.ExternalRefs` (kind/uri/created_by/read_only/created_at) are validated (charset/length), deduped, and persisted verbatim; odek has no code path that resolves or dereferences their URIs, so a ref can never become a fetch/exfiltration vector.
203212
- **Sub-agent trust defaults + delegate_tasks gate** (`cmd/odek/subagent.go`, `internal/loop/loop.go`) — a missing `trust_level` in `delegate_tasks` defaults to `untrusted`; `delegate_tasks` itself is classified as `system_write` so it requires explicit approval before spawning child processes.
204213
- **Memory add/replace pipe-to-shell filter** (`internal/memory/memory.go`) — `AddFact` and `ReplaceFact` now run `FactLooksUnsafe` in addition to the general guard scan, blocking agent-driven planting of download-and-execute facts.
205214
- **Skill learn-loop provenance propagation** (`internal/skills/learnloop.go`) — conversation-extracted suggestions and LLM-enhanced suggestions both retain the session's `SkillProvenance`, so tainted sessions cannot produce clean-looking auto-saved skills.

‎budget_test.go‎

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
package odek
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"net/http"
7+
"net/http/httptest"
8+
"sync"
9+
"testing"
10+
11+
"github.com/BackendStack21/odek/internal/budget"
12+
"github.com/BackendStack21/odek/internal/events"
13+
)
14+
15+
// TestAgent_Budget_LimitsWiredAndEventOrder pins the public-API wiring:
16+
// Config.Limits reaches the loop engine, the run returns a typed
17+
// budget.Error, and the event stream carries budget_exceeded BEFORE
18+
// run_failed (schema odek.event/v1, docs/EXTENSIONS.md).
19+
func TestAgent_Budget_LimitsWiredAndEventOrder(t *testing.T) {
20+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
21+
if r.URL.Path != "/chat/completions" {
22+
// /models discovery etc. — no context window discovered.
23+
w.WriteHeader(http.StatusNotFound)
24+
return
25+
}
26+
// One tool call with usage that blows the input-token budget.
27+
fmt.Fprint(w, `{
28+
"choices":[{"message":{"content":"working","tool_calls":[{
29+
"id":"call_1","type":"function",
30+
"function":{"name":"noop","arguments":"{}"}
31+
}]}}],
32+
"usage":{"prompt_tokens":1000,"completion_tokens":10}
33+
}`)
34+
}))
35+
defer server.Close()
36+
37+
var mu sync.Mutex
38+
var evs []events.Event
39+
agent, err := New(Config{
40+
APIKey: "sk-test",
41+
BaseURL: server.URL,
42+
Model: "test-model",
43+
NoProjectFile: true,
44+
MemoryDir: t.TempDir(),
45+
Limits: budget.Limits{MaxInputTokens: 100},
46+
EventHandler: func(ev events.Event) {
47+
mu.Lock()
48+
evs = append(evs, ev)
49+
mu.Unlock()
50+
},
51+
})
52+
if err != nil {
53+
t.Fatalf("New: %v", err)
54+
}
55+
defer agent.Close()
56+
57+
_, runErr := agent.Run(context.Background(), "do work")
58+
berr, ok := budget.As(runErr)
59+
if !ok {
60+
t.Fatalf("expected typed budget.Error from Run, got %v", runErr)
61+
}
62+
if berr.Limit != budget.LimitInputTokens {
63+
t.Errorf("limit = %q, want %q", berr.Limit, budget.LimitInputTokens)
64+
}
65+
66+
// Drain the async emitter before asserting on the stream.
67+
agent.Close()
68+
69+
mu.Lock()
70+
defer mu.Unlock()
71+
budgetIdx, failedIdx := -1, -1
72+
for i, ev := range evs {
73+
switch ev.Type {
74+
case events.TypeBudgetExceeded:
75+
if budgetIdx < 0 {
76+
budgetIdx = i
77+
if ev.Data["limit_name"] != budget.LimitInputTokens {
78+
t.Errorf("budget_exceeded limit_name = %v", ev.Data["limit_name"])
79+
}
80+
}
81+
case events.TypeRunFailed:
82+
if failedIdx < 0 {
83+
failedIdx = i
84+
}
85+
}
86+
}
87+
if budgetIdx < 0 {
88+
t.Fatal("budget_exceeded missing from event stream")
89+
}
90+
if failedIdx < 0 {
91+
t.Fatal("run_failed missing from event stream")
92+
}
93+
if budgetIdx > failedIdx {
94+
t.Errorf("budget_exceeded (idx %d) must precede run_failed (idx %d)", budgetIdx, failedIdx)
95+
}
96+
}

‎cmd/odek/budget_test.go‎

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
package main
2+
3+
import (
4+
"fmt"
5+
"testing"
6+
7+
"github.com/BackendStack21/odek/internal/budget"
8+
)
9+
10+
// TestParseRunFlags_BudgetFlags covers the WP6 execution-budget flags.
11+
func TestParseRunFlags_BudgetFlags(t *testing.T) {
12+
f, err := parseRunFlags([]string{
13+
"--max-runtime", "300",
14+
"--max-tool-calls", "50",
15+
"--max-input-tokens", "100000",
16+
"--max-output-tokens", "20000",
17+
"--max-cost-usd", "1.5",
18+
"do the task",
19+
})
20+
if err != nil {
21+
t.Fatalf("parseRunFlags: %v", err)
22+
}
23+
if f.MaxRuntime != 300 || f.MaxToolCalls != 50 || f.MaxInputTokens != 100000 ||
24+
f.MaxOutputTokens != 20000 || f.MaxCostUSD != 1.5 {
25+
t.Errorf("budget flags not parsed: %+v", f)
26+
}
27+
if f.Task != "do the task" {
28+
t.Errorf("Task = %q, want %q", f.Task, "do the task")
29+
}
30+
}
31+
32+
// TestParseRunFlags_BudgetFlagsInvalid: non-positive/garbage values are a
33+
// clear startup error, not a silently ignored budget.
34+
func TestParseRunFlags_BudgetFlagsInvalid(t *testing.T) {
35+
for _, args := range [][]string{
36+
{"--max-runtime", "0"},
37+
{"--max-runtime", "-5"},
38+
{"--max-runtime", "abc"},
39+
{"--max-tool-calls", "-1"},
40+
{"--max-input-tokens", "0"},
41+
{"--max-output-tokens", "x"},
42+
{"--max-cost-usd", "-0.5"},
43+
{"--max-runtime"}, // missing value
44+
} {
45+
if _, err := parseRunFlags(args); err == nil {
46+
t.Errorf("parseRunFlags(%v) should fail", args)
47+
}
48+
}
49+
}
50+
51+
// TestRunExit_BudgetExitCode pins the dispatch mapping: a typed budget.Error
52+
// exits 4 (odek-extension/v1), other errors keep exit 1, success exits 0.
53+
func TestRunExit_BudgetExitCode(t *testing.T) {
54+
if code := runExit(nil); code != 0 {
55+
t.Errorf("runExit(nil) = %d, want 0", code)
56+
}
57+
berr := &budget.Error{Limit: budget.LimitToolCalls, Observed: 6, Maximum: 5}
58+
if code := runExit(berr); code != 4 {
59+
t.Errorf("runExit(budget.Error) = %d, want 4", code)
60+
}
61+
// Wrapped budget errors still map to 4.
62+
if code := runExit(fmt.Errorf("iteration 2: %w", berr)); code != 4 {
63+
t.Errorf("runExit(wrapped budget.Error) = %d, want 4", code)
64+
}
65+
if code := runExit(fmt.Errorf("model exploded")); code != 1 {
66+
t.Errorf("runExit(other) = %d, want 1", code)
67+
}
68+
}

‎cmd/odek/dispatch.go‎

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ import (
55
"fmt"
66
"os"
77
"runtime"
8+
9+
"github.com/BackendStack21/odek/internal/budget"
810
)
911

1012
// dispatch routes a top-level CLI invocation to its handler. It takes the
@@ -27,7 +29,7 @@ func dispatch(args []string) int {
2729

2830
switch cmd {
2931
case "run":
30-
return cliExit(run(rest))
32+
return runExit(run(rest))
3133
case "version":
3234
printVersion()
3335
return 0
@@ -76,6 +78,21 @@ func cliExit(err error) int {
7678
return 1
7779
}
7880

81+
// runExit is the `odek run` error→exit translator: like cliExit, but maps a
82+
// typed budget.Error to exit code 4 (execution budget exhausted —
83+
// odek-extension/v1, see docs/EXTENSIONS.md) so orchestrators can tell a
84+
// budget stop apart from a task/model/tool failure (1).
85+
func runExit(err error) int {
86+
if err == nil {
87+
return 0
88+
}
89+
fmt.Fprintf(os.Stderr, "odek: %v\n", err)
90+
if _, ok := budget.As(err); ok {
91+
return 4
92+
}
93+
return 1
94+
}
95+
7996
// subagentExit honours the sub-agent JSON contract: stderr gets the
8097
// human-readable line, stdout gets a JSON envelope the parent can parse,
8198
// and the exit code is 3 (reserved for setup/contract errors so the

‎cmd/odek/events_jsonl_test.go‎

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
package main
2+
3+
import (
4+
"encoding/json"
5+
"os"
6+
"strings"
7+
"testing"
8+
9+
"github.com/BackendStack21/odek/internal/events"
10+
)
11+
12+
func TestParseRunFlags_EventsJSONL(t *testing.T) {
13+
f, err := parseRunFlags([]string{"--events-jsonl", "/tmp/odek-events.jsonl", "do the thing"})
14+
if err != nil {
15+
t.Fatalf("parseRunFlags error: %v", err)
16+
}
17+
if f.EventsJSONL != "/tmp/odek-events.jsonl" {
18+
t.Errorf("EventsJSONL = %q, want /tmp/odek-events.jsonl", f.EventsJSONL)
19+
}
20+
if f.Task != "do the thing" {
21+
t.Errorf("Task = %q, want %q", f.Task, "do the thing")
22+
}
23+
24+
// Missing value is a hard error.
25+
if _, err := parseRunFlags([]string{"--events-jsonl"}); err == nil {
26+
t.Error("expected error for --events-jsonl without a value")
27+
}
28+
29+
// Unset by default.
30+
f, err = parseRunFlags([]string{"do the thing"})
31+
if err != nil {
32+
t.Fatalf("parseRunFlags error: %v", err)
33+
}
34+
if f.EventsJSONL != "" {
35+
t.Errorf("EventsJSONL default = %q, want empty", f.EventsJSONL)
36+
}
37+
}
38+
39+
func TestEventsJSONL_SinkEndToEnd(t *testing.T) {
40+
// The exact handler shape wired into odek.Config.EventHandler by the run
41+
// command: emitter → handler → JSONL sink, drained on Close.
42+
dir := t.TempDir()
43+
path := dir + "/events.jsonl"
44+
45+
sink, err := events.OpenJSONLSink(path)
46+
if err != nil {
47+
t.Fatalf("OpenJSONLSink: %v", err)
48+
}
49+
em := events.NewEmitter(func(ev events.Event) {
50+
if err := sink.Write(ev); err != nil {
51+
t.Errorf("sink write: %v", err)
52+
}
53+
}, events.NewRunID())
54+
em.Emit(events.Event{Type: events.TypeRunStarted, Data: map[string]any{"model": "test-model"}})
55+
em.SetSessionID("sess-1")
56+
em.Emit(events.Event{Type: events.TypeSessionSaved, Data: map[string]any{"message_count": 5}})
57+
em.Emit(events.Event{Type: events.TypeRunCompleted, Data: map[string]any{"duration_ms": 3}})
58+
em.Close()
59+
if err := sink.Close(); err != nil {
60+
t.Fatalf("sink close: %v", err)
61+
}
62+
63+
data, err := os.ReadFile(path)
64+
if err != nil {
65+
t.Fatal(err)
66+
}
67+
lines := strings.Split(strings.TrimRight(string(data), "\n"), "\n")
68+
if len(lines) != 3 {
69+
t.Fatalf("got %d lines, want 3", len(lines))
70+
}
71+
for i, line := range lines {
72+
var ev events.Event
73+
if err := json.Unmarshal([]byte(line), &ev); err != nil {
74+
t.Fatalf("line %d not parseable: %v", i+1, err)
75+
}
76+
if ev.Schema != events.Schema {
77+
t.Errorf("line %d schema = %q", i+1, ev.Schema)
78+
}
79+
}
80+
}

0 commit comments

Comments
 (0)