Skip to content

Commit 35c8bcf

Browse files
authored
fix: m2 wave-3 bug sweep — JSON-decoded narrate paths, expandEnv tail preservation, strict numeric flags (#192)
Three RED-first fixes, each pinned by failing tests on main: 1. narrate (LOW): extractPath scanned for the first quote pair instead of decoding JSON — an escaped quote inside a path truncated it ("we\"ird.go" → "we\"). extractShell was fixed for exactly this failure; extractPath now decodes JSON first with the same best-effort scan fallback. 2. config (MED): an unterminated ${VAR reference consumed the ENTIRE remaining config value — "a${b" expanded to "a$" — silent config-value corruption (bare $ references preserved their tails, ${ did not). Unterminated and empty (${) braces now emit verbatim. 3. cmd/odek (LOW-MED): --max-iter, --thinking-budget, and --temperature used unchecked fmt.Sscanf — "abc" was silently ignored (--temperature even overwrote the value with 0) and "1800junk" was accepted as 1800, hiding typos from the operator. All three now use strconv and reject garbage, matching every sibling numeric flag (--max-runtime, --max-tool-calls, ...). The pinned TestParseRunFlags_MaxIterNonNumeric documented the Sscanf quirk as intended; its contract is updated to the strict behavior with a comment explaining the change.
1 parent fb157a6 commit 35c8bcf

7 files changed

Lines changed: 125 additions & 16 deletions

File tree

‎cmd/odek/main.go‎

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import (
99
"os/signal"
1010
"path/filepath"
1111
"runtime/debug"
12+
"strconv"
1213
"strings"
1314
"sync/atomic"
1415
"time"
@@ -469,8 +470,10 @@ func parseRunFlags(args []string) (runFlags, error) {
469470
if i+1 >= len(args) {
470471
return f, fmt.Errorf("--max-iter requires a value")
471472
}
472-
var n int
473-
fmt.Sscanf(args[i+1], "%d", &n)
473+
n, err := strconv.Atoi(args[i+1])
474+
if err != nil {
475+
return f, fmt.Errorf("--max-iter requires an integer, got %q", args[i+1])
476+
}
474477
if n > 0 {
475478
f.MaxIter = n
476479
}
@@ -491,14 +494,20 @@ func parseRunFlags(args []string) (runFlags, error) {
491494
if i+1 >= len(args) {
492495
return f, fmt.Errorf("--thinking-budget requires a value")
493496
}
494-
fmt.Sscanf(args[i+1], "%d", &f.ThinkingBudget)
497+
n, err := strconv.Atoi(args[i+1])
498+
if err != nil {
499+
return f, fmt.Errorf("--thinking-budget requires an integer, got %q", args[i+1])
500+
}
501+
f.ThinkingBudget = n
495502
i += 2
496503
case "--temperature":
497504
if i+1 >= len(args) {
498505
return f, fmt.Errorf("--temperature requires a value")
499506
}
500-
var t float64
501-
fmt.Sscanf(args[i+1], "%f", &t)
507+
t, err := strconv.ParseFloat(args[i+1], 64)
508+
if err != nil {
509+
return f, fmt.Errorf("--temperature requires a number, got %q", args[i+1])
510+
}
502511
f.Temp = t
503512
i += 2
504513
case "--sandbox":

‎cmd/odek/main_test.go‎

Lines changed: 7 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -574,16 +574,13 @@ func captureStdout(fn func()) string {
574574

575575
// Test parseRunFlags with a non-numeric --max-iter value.
576576
func TestParseRunFlags_MaxIterNonNumeric(t *testing.T) {
577-
f, err := parseRunFlags([]string{"--max-iter", "abc", "task"})
578-
if err != nil {
579-
t.Fatalf("parseRunFlags error: %v", err)
580-
}
581-
// fmt.Sscanf with non-numeric leaves the zero value (not set)
582-
if f.MaxIter != 0 {
583-
t.Errorf("MaxIter = %d, want 0 (non-numeric should leave zero)", f.MaxIter)
584-
}
585-
if f.Task != "task" {
586-
t.Errorf("Task = %q, want %q", f.Task, "task")
577+
// Contract changed (m2 wave-3): a non-numeric --max-iter is an error,
578+
// matching every sibling numeric flag (--max-runtime, --max-tool-calls,
579+
// …). The old behavior — fmt.Sscanf silently ignoring "abc" and the
580+
// run proceeding with the default cap — hid typos from the operator.
581+
_, err := parseRunFlags([]string{"--max-iter", "abc", "task"})
582+
if err == nil {
583+
t.Fatal("--max-iter abc must be rejected, not silently ignored")
587584
}
588585
}
589586

‎cmd/odek/run_flags_numeric_test.go‎

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
package main
2+
3+
import "testing"
4+
5+
// Numeric flags used unchecked fmt.Sscanf: "abc" parsed as 0 silently
6+
// (--temperature OVERWROTE the value with 0; --max-iter was skipped), and
7+
// "1800junk" was accepted as 1800. Sibling budget flags check errors;
8+
// these must too.
9+
func TestParseRunFlags_NumericFlagsRejectGarbage(t *testing.T) {
10+
if _, err := parseRunFlags([]string{"--temperature", "abc", "do-work"}); err == nil {
11+
t.Fatal("--temperature abc accepted silently (value would be zeroed)")
12+
}
13+
if _, err := parseRunFlags([]string{"--max-iter", "abc", "do-work"}); err == nil {
14+
t.Fatal("--max-iter abc accepted silently")
15+
}
16+
if _, err := parseRunFlags([]string{"--max-iter", "1800junk", "do-work"}); err == nil {
17+
t.Fatal("--max-iter 1800junk accepted (trailing garbage ignored)")
18+
}
19+
if _, err := parseRunFlags([]string{"--thinking-budget", "x", "do-work"}); err == nil {
20+
t.Fatal("--thinking-budget x accepted silently")
21+
}
22+
23+
// Valid values still parse.
24+
f, err := parseRunFlags([]string{"--max-iter", "42", "--temperature", "0.7", "--thinking-budget", "1024", "do-work"})
25+
if err != nil {
26+
t.Fatalf("valid flags rejected: %v", err)
27+
}
28+
if f.MaxIter != 42 || f.Temp != 0.7 || f.ThinkingBudget != 1024 {
29+
t.Fatalf("valid values: %+v", f)
30+
}
31+
}
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
package config
2+
3+
import "testing"
4+
5+
// An unterminated ${VAR consumed the ENTIRE remaining value: "a${b"
6+
// expanded to "a$" — silent config-value corruption. The '$' plus the
7+
// rest must survive verbatim (an unterminated brace is not a variable
8+
// reference).
9+
func TestExpandEnv_UnterminatedBracePreservesTail(t *testing.T) {
10+
cases := map[string]string{
11+
"a${b": "a${b",
12+
"pre ${tail": "pre ${tail",
13+
"${only": "${only",
14+
"$": "$",
15+
"${}": "${}",
16+
}
17+
for in, want := range cases {
18+
if got := expandEnv(in); got != want {
19+
t.Errorf("expandEnv(%q) = %q, want %q", in, got, want)
20+
}
21+
}
22+
}

‎internal/config/loader.go‎

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -875,10 +875,18 @@ func parseVarName(s string) (string, int) {
875875
// ${VAR}
876876
for k := 1; k < len(s); k++ {
877877
if s[k] == '}' {
878+
if k == 1 {
879+
// ${} — empty name is not a variable reference; emit
880+
// verbatim like an unterminated brace.
881+
return "", 0
882+
}
878883
return s[1:k], k + 1
879884
}
880885
}
881-
return "", len(s) // unterminated — consume everything
886+
// Unterminated ${ — not a variable reference: consume only the '$'
887+
// (width 0) so the '{' and the rest emit verbatim; consuming the
888+
// whole tail silently corrupted config values ("a${b" → "a$").
889+
return "", 0
882890
}
883891
// $VAR or $VAR_NAME123
884892
if !isVarStart(s[0]) {
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
package narrate
2+
3+
import "testing"
4+
5+
// extractPath scanned for the first quote pair instead of decoding JSON —
6+
// an escaped quote inside the path truncated it (extractShell was fixed
7+
// for exactly this failure; extractPath wasn't).
8+
func TestExtractPath_EscapedQuoteInPath(t *testing.T) {
9+
got := extractPath(`{"path":"we\"ird.go","line":1}`)
10+
if got != `we"ird.go` {
11+
t.Fatalf("extractPath = %q, want %q (escaped quotes must decode)", got, `we"ird.go`)
12+
}
13+
}
14+
15+
func TestExtractPath_PlainPathStillWorks(t *testing.T) {
16+
if got := extractPath(`{"path":"/a/b/main.go"}`); got != "main.go" {
17+
t.Fatalf("plain path: %q, want main.go", got)
18+
}
19+
if got := extractPath(`{"file":"/tmp/x.txt"}`); got != "x.txt" {
20+
t.Fatalf("file key: %q, want x.txt", got)
21+
}
22+
}

‎internal/narrate/narrate.go‎

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,26 @@ func truncate(s string, n int) string {
100100
}
101101

102102
func extractPath(args string) string {
103+
// Decode the JSON properly: paths routinely contain escaped quotes
104+
// ("we\"ird.go"), and a raw first-quote scan truncates at the escape —
105+
// the same failure extractShell was fixed for. Falls back to a
106+
// best-effort scan only for non-JSON input.
107+
var parsed struct {
108+
Path string `json:"path"`
109+
File string `json:"file"`
110+
}
111+
if err := json.Unmarshal([]byte(args), &parsed); err == nil {
112+
path := parsed.Path
113+
if path == "" {
114+
path = parsed.File
115+
}
116+
if path != "" {
117+
if lastSlash := strings.LastIndex(path, "/"); lastSlash >= 0 {
118+
path = path[lastSlash+1:]
119+
}
120+
return path
121+
}
122+
}
103123
for _, key := range []string{`"path"`, `"file"`} {
104124
if idx := strings.Index(args, key); idx >= 0 {
105125
rest := args[idx+len(key):]

0 commit comments

Comments
 (0)