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
14 changes: 12 additions & 2 deletions cmd/odek/dispatch.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package main

import (
"encoding/json"
"errors"
"fmt"
"os"
"runtime"
Expand Down Expand Up @@ -95,12 +96,21 @@ func runExit(err error) int {

// subagentExit honours the sub-agent JSON contract: stderr gets the
// human-readable line, stdout gets a JSON envelope the parent can parse,
// and the exit code is 3 (reserved for setup/contract errors so the
// parent can distinguish them from task-level failures).
// and exit codes follow docs/EXTENSIONS.md: 0 success, 1 task error,
// 2 timeout, 3 setup error. Task errors and timeouts arrive as
// *subagentRunError with their envelope already printed, so they only map
// to an exit code here.
func subagentExit(err error) int {
if err == nil {
return 0
}
var runErr *subagentRunError
if errors.As(err, &runErr) {
if runErr.timeout {
return 2
}
return 1
}
fmt.Fprintf(os.Stderr, "odek: %v\n", err)
_ = json.NewEncoder(os.Stdout).Encode(subagentResult{
Status: "error",
Expand Down
1 change: 1 addition & 0 deletions cmd/odek/file_tool.go
Original file line number Diff line number Diff line change
Expand Up @@ -1222,6 +1222,7 @@ func isProtectedOdekPath(rel string) bool {
"schedules.lock",
"mcp_approvals.json",
"mcp_tool_approvals.json",
"project_sandbox_approvals.json",
"restart.json",
"telegram.lock",
"telegram.pid",
Expand Down
16 changes: 16 additions & 0 deletions cmd/odek/file_tool_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2438,3 +2438,19 @@ func TestFileInfo_EmptyPath(t *testing.T) {
t.Errorf("error should mention 'path is required', got: %s", r.Error)
}
}

// TestRED_ConfinesProjectSandboxApprovals pins the same gap on the write
// side: confineToCWD's ~/.odek carve-out must reject writes to the project
// sandbox approval store, not only the other trust anchors.
func TestRED_ConfinesProjectSandboxApprovals(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
if err := os.MkdirAll(filepath.Join(home, ".odek"), 0755); err != nil {
t.Fatal(err)
}
t.Chdir(home)

if _, err := confineToCWD(filepath.Join(home, ".odek", "project_sandbox_approvals.json")); err == nil {
t.Fatal("confineToCWD(~/.odek/project_sandbox_approvals.json) allowed; want protected-odek rejection")
}
}
225 changes: 225 additions & 0 deletions cmd/odek/redbugs2_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,225 @@
package main

import (
"fmt"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"time"

"github.com/BackendStack21/odek/internal/danger"
"github.com/BackendStack21/odek/internal/llm"
"github.com/BackendStack21/odek/internal/session"
)

// ────────────────────────────────────────────────────────────────────────
// RED #B1 (U2): The sandbox kill follow-up runs `docker exec` with no
// deadline, synchronously after the command's own timeout already fired.
// A hung Docker daemon wedges the tool call forever — voiding the shell
// tool's contract that a stuck command can never wedge the agent.
func TestRED_ShellSandboxKillFollowUpHasDeadline(t *testing.T) {
dir := t.TempDir()
binDir := filepath.Join(dir, "bin")
os.MkdirAll(binDir, 0o755)
// Fake docker: any `exec` subcommand hangs (simulates a dockerd that
// accepts connections but never responds). Everything else succeeds.
script := "#!/bin/sh\n" +
`if [ "$1" = "exec" ]; then sleep 299; fi` + "\n" +
"exit 0\n"
if err := os.WriteFile(filepath.Join(binDir, "docker"), []byte(script), 0o755); err != nil {
t.Fatal(err)
}
t.Setenv("PATH", binDir+string(os.PathListSeparator)+os.Getenv("PATH"))

allow := "allow"
tool := &shellTool{
containerName: "red-test-container",
dangerousConfig: danger.DangerousConfig{DefaultAction: &allow},
}

type callRes struct {
result string
err error
}
done := make(chan callRes, 1)
go func() {
res, err := tool.Call(`{"command":"sleep 30","timeout_seconds":1}`)
done <- callRes{res, err}
}()

select {
case r := <-done:
// The command itself must time out; the kill follow-up must not add
// an unbounded delay of its own.
if r.err == nil || !strings.Contains(r.err.Error()+" "+r.result, "timed out") {
t.Errorf("expected timeout error, got result=%q err=%v", r.result, r.err)
}
case <-time.After(20 * time.Second):
t.Fatal("shell tool call wedged past its own timeout — the docker kill follow-up has no deadline")
}
}

// ────────────────────────────────────────────────────────────────────────
// RED #B2 (V3): /api/sessions search filters against the pre-fetched
// recency window, so matches deeper in the store are silently dropped:
// a query whose only match is older than limit+offset returns count=0
// even though the session exists.
func TestRED_SessionSearchSearchesWholeStore(t *testing.T) {
dir := t.TempDir()
store, err := session.NewStoreWithDir(dir)
if err != nil {
t.Fatal(err)
}
// Save oldest-first so the matching session is LAST in recency order.
for i := 4; i >= 0; i-- {
task := fmt.Sprintf("filler session %d", i)
if i == 0 {
task = "the needle-in-the-haystack session"
}
s := &session.Session{
ID: session.GenerateID(),
Task: task,
Messages: llmMessage("hi"),
}
if err := store.Save(s); err != nil {
t.Fatal(err)
}
time.Sleep(2 * time.Millisecond) // distinct UpdatedAt ordering
}

handler := handleSessionListPaged(store)
req := httptest.NewRequest(http.MethodGet, "/api/sessions?q=needle&limit=2&offset=0", nil)
rec := httptest.NewRecorder()
handler(rec, req)

var out struct {
Sessions []struct {
ID string `json:"id"`
Task string `json:"task"`
} `json:"sessions"`
Count int `json:"count"`
}
mustUnmarshal(t, rec.Body.String(), &out)

if out.Count == 0 {
t.Fatalf("search returned %d results for 'needle'; the matching session is older than the fetch window but must still be found (got %+v)", out.Count, out.Sessions)
}
found := false
for _, s := range out.Sessions {
if strings.Contains(s.Task, "needle") {
found = true
}
}
if !found {
t.Errorf("search results missing the matching session: %+v", out.Sessions)
}
}

// ────────────────────────────────────────────────────────────────────────
// RED #B3 (V4): Concurrent prompts on one session overwrite each other's
// cancel registration and unregisterPromptCancel deletes unconditionally —
// when the FIRST prompt finishes it removes the SECOND prompt's cancel
// func, making /api/cancel a silent no-op while the newer prompt runs.
func TestRED_PromptCancelSurvivesEarlierPromptFinishing(t *testing.T) {
calledSecond := false
unregisterFirst := registerPromptCancel("red-b3-sess", func() {}) // prompt 1 starts
registerPromptCancel("red-b3-sess", func() { calledSecond = true }) // prompt 2 starts

unregisterFirst() // prompt 1 finishes first — must remove ONLY its own registration

if !cancelPrompt("red-b3-sess") {
t.Fatal("cancelPrompt found no registration after the earlier prompt finished — the second prompt can no longer be cancelled")
}
if !calledSecond {
t.Fatal("cancelPrompt did not invoke the second (live) prompt's cancel function")
}
unregisterPromptCancel("red-b3-sess")
if cancelPrompt("red-b3-sess") {
t.Error("cancelPrompt should report false once the live prompt unregisters")
}
}

// ────────────────────────────────────────────────────────────────────────
// RED #B6 (M6): The REPL editor advertises slash commands via
// tab-completion that handleREPLCommand doesn't implement — completing one
// and pressing enter yields "Unknown command". Every advertised command
// must be implemented.
func TestRED_REPLCompletionsAreImplemented(t *testing.T) {
advertised := replCommands
if len(advertised) == 0 {
t.Fatal("replCommands is empty")
}
sess := &session.Session{ID: "red-b6"}

r, w, err := os.Pipe()
if err != nil {
t.Fatal(err)
}
old := os.Stderr
os.Stderr = w
defer func() { os.Stderr = old }()

var sb strings.Builder
done := make(chan struct{})
go func() {
buf := make([]byte, 4096)
for {
n, err := r.Read(buf)
sb.Write(buf[:n])
if err != nil {
close(done)
return
}
}
}()

for _, cmd := range advertised {
handleREPLCommand(cmd, sess)
}
w.Close()
<-done

if strings.Contains(sb.String(), "Unknown command") {
t.Errorf("REPL advertises commands it does not implement: %s", sb.String())
}
}

// llmMessage builds a single user message for session fixtures.
func llmMessage(content string) []llm.Message {
return []llm.Message{{Role: "user", Content: content}}
}

// ────────────────────────────────────────────────────────────────────────

// Regression #M4: sub-agent exit codes. docs/EXTENSIONS.md pins
// 0=success, 1=task error, 2=timeout, 3=setup error. Task errors and
// timeouts previously returned nil from subagentCmd — every run exited 0.
func TestRED_SubagentExitCodeContract(t *testing.T) {
if got := subagentExit(nil); got != 0 {
t.Errorf("subagentExit(nil) = %d, want 0", got)
}
if got := subagentExit(&subagentRunError{timeout: true}); got != 2 {
t.Errorf("subagentExit(timeout) = %d, want 2", got)
}
if got := subagentExit(&subagentRunError{}); got != 1 {
t.Errorf("subagentExit(task error) = %d, want 1", got)
}
// Setup errors keep their envelope-printing behavior and exit 3.
oldOut, oldErr := os.Stdout, os.Stderr
r, w, _ := os.Pipe()
os.Stdout, os.Stderr = w, w
got := subagentExit(fmt.Errorf("bad flags"))
os.Stdout, os.Stderr = oldOut, oldErr
w.Close()
buf := make([]byte, 1024)
n, _ := r.Read(buf)
if got != 3 {
t.Errorf("subagentExit(setup error) = %d, want 3", got)
}
if !strings.Contains(string(buf[:n]), "bad flags") {
t.Errorf("setup error envelope missing on stdout/stderr: %q", string(buf[:n]))
}
}
16 changes: 11 additions & 5 deletions cmd/odek/repl.go
Original file line number Diff line number Diff line change
Expand Up @@ -228,13 +228,12 @@ func replCmd(args []string) error {
// first turn after resuming with `odek repl --id <session>`.
resumedSession := sessionID != ""

// Line editor with history and tab completion for slash commands
// Line editor with history and tab completion for slash commands.
// Keep this list in sync with handleREPLCommand — completing a command
// that isn't implemented yields "Unknown command".
editor := newReplEditor(
fmt.Sprintf("odek %d> ", turn+1),
[]string{
"/exit", "/quit", "/help", "/info",
"/sandbox", "/model", "/session",
},
replCommands,
)
editor.history.Load(filepath.Join(odekDir(), historyFilename))
for {
Expand Down Expand Up @@ -344,6 +343,13 @@ func replCmd(args []string) error {
return nil
}

// replCommands lists the slash commands the REPL implements (tab
// completion + docs source of truth). Only commands handleREPLCommand
// actually handles may appear here.
var replCommands = []string{
"/exit", "/quit", "/help", "/info",
}

// handleREPLCommand processes a REPL slash command.
// Returns true if the session should exit.
func handleREPLCommand(input string, sess *session.Session) bool {
Expand Down
Loading
Loading