Skip to content
Open
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
62 changes: 62 additions & 0 deletions internal/todo/context_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
package todo

import (
"bytes"
"os"
"path/filepath"
"strings"
"syscall"
"testing"
"time"
)

func writeOpenTask(t *testing.T, dir, id string) {
t.Helper()
body := "---\nid: " + id + "\nstatus: open\n---\n\n# " + id + "\n"
if err := os.WriteFile(filepath.Join(dir, id+".md"), []byte(body), 0o644); err != nil {
t.Fatal(err)
}
}

// The body may be passed as trailing arguments instead of on stdin — the way an
// agent invokes it inline — and must be stored, not silently dropped.
func TestContextBodyFromArgs(t *testing.T) {
dir := t.TempDir()
writeOpenTask(t, dir, "0001-task")
var out bytes.Buffer
c := &cli{dir: dir, project: "p", stdout: &out, stderr: &out, stdin: strings.NewReader("")}
if code := c.context([]string{"0001-task", "first finding;", "second finding"}); code != 0 {
t.Fatalf("context with body args = %d, want 0: %s", code, out.String())
}
b, _ := os.ReadFile(filepath.Join(dir, "0001-task.md"))
if !strings.Contains(string(b), "## Context (synthesized ") || !strings.Contains(string(b), "first finding; second finding") {
t.Errorf("body args were not stored:\n%s", b)
}
}

// A socket on stdin — a tool harness that never closes it — must be refused the
// way a tty is, not read until the end of time.
func TestContextRefusesSocketStdin(t *testing.T) {
dir := t.TempDir()
writeOpenTask(t, dir, "0002-task")
fds, err := syscall.Socketpair(syscall.AF_UNIX, syscall.SOCK_STREAM, 0)
if err != nil {
t.Skip("socketpair unavailable:", err)
}
rd := os.NewFile(uintptr(fds[0]), "stdin-socket")
peer := os.NewFile(uintptr(fds[1]), "stdin-socket-peer")
defer rd.Close()
defer peer.Close() // stays open for the whole test: nothing ever ends stdin
var out bytes.Buffer
c := &cli{dir: dir, project: "p", stdout: &out, stderr: &out, stdin: rd}
done := make(chan int, 1)
go func() { done <- c.context([]string{"0002-task"}) }()
select {
case code := <-done:
if code != 1 || !strings.Contains(out.String(), "stdin") {
t.Errorf("socket stdin: code=%d stderr=%q, want 1 and a stdin hint", code, out.String())
}
case <-time.After(2 * time.Second):
t.Fatal("context blocked on an unclosed socket stdin instead of refusing it")
}
}
21 changes: 14 additions & 7 deletions internal/todo/todo.go
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ Tasks are created by /distill and worked by /continue — this CLI is the substr
todo self-heal [status...] close open/taken tasks whose recorded PR has merged (zombie sweep)
todo release <id> taken/review/held -> open (un-claim / un-hold)
todo reopen <id> [reason] FORCE done -> open (work verified absent; regenerate)
todo context <id> attach a synthesized "## Context" body section (reads stdin)
todo context <id> attach a synthesized "## Context" body section (body on stdin or as trailing args)
`

// cli holds one invocation's state: identity, streams, and the once-per-process
Expand Down Expand Up @@ -974,15 +974,22 @@ func (c *cli) context(args []string) int {
if !ok {
return c.die("no such todo: " + id)
}
if f, isFile := c.stdin.(*os.File); isFile {
if fi, err := f.Stat(); err == nil && fi.Mode()&os.ModeCharDevice != 0 {
return c.die("context reads the body on stdin (pipe or heredoc it)") // don't hang on a tty
// The body is either the trailing arguments (an agent calling inline) or,
// when there are none, stdin — which must be something that ends. A tty, or
// a socket a tool harness keeps open for the life of the process, would
// block here forever, so both are refused up front.
ctx := strings.TrimSpace(strings.Join(args[1:], " "))
if ctx == "" {
if f, isFile := c.stdin.(*os.File); isFile {
if fi, err := f.Stat(); err == nil && fi.Mode()&(os.ModeCharDevice|os.ModeSocket) != 0 {
return c.die("context reads the body on stdin (pipe or heredoc it) or as trailing arguments")
}
}
data, _ := io.ReadAll(c.stdin)
ctx = strings.TrimRight(string(data), "\n") // $(cat)
}
data, _ := io.ReadAll(c.stdin)
ctx := strings.TrimRight(string(data), "\n") // $(cat)
if ctx == "" {
return c.die("context needs the body on stdin")
return c.die("context needs the body on stdin or as trailing arguments")
}
// task minus any prior block; trailing blank lines stripped like $(awk …)
var kept []string
Expand Down
Loading