external: stream hook input too, closing the last EOF-wait path (#1398) · Entire
External: Stream Hook Input Too, Closing the Last EOF-Wait Path (#1398)
6d40e25→main·Karthik Rameshkumar·4d ago·3 files·+63 added/-12 removed
Trail review correctly flagged that external.ParseHookEvent still used io.ReadAll(io.LimitReader(stdin, …)) for the piped case, so an external/plugin agent that keeps the stdin pipe open after delivering its payload would still hang forever — the exact #1398 bug the rest of the PR fixes. The StdinLooksInteractive guard only covered the TTY/no-payload case.
The external "parse-hook" contract receives the host's hook payload, which is JSON (verified by the agent's own tests/fixtures), and forwards its raw bytes to the subprocess. So it can stream a single JSON value like every other agent. Add ReadHookInputRawLimited (ReadHookInputRaw with a byte ceiling) so external keeps its 10 MB bound without waiting for EOF, and forward the decoded raw bytes verbatim. No behavior change to the subprocess payload.
Adds regression tests: the bounded reader returns before stdin EOF, and rejects an over-limit payload instead of reading unbounded.
Co-Authored-By: Claude Opus 4.8 (1M context) noreply@anthropic.com
Sessions
01KXDWPCKTKDTWM9D7YAEP2XFF View transcript
Changes
3
- cmd/entire/cli/agent
- Mevent.go +13/-1
- Mevent_test.go +43
- external
- Mexternal.go +7/-11
// (e.g. key-name fallbacks, or forwarding the bytes to a subprocess) use this
// directly, while the common case uses ReadAndParseHookInput.
func ReadHookInputRaw(stdin io.Reader) (json.RawMessage, error) {
return ReadHookInputRawLimited(stdin, -1)
}
// ReadHookInputRawLimited is ReadHookInputRaw with a ceiling of limit bytes on
// the JSON value (limit < 0 means unlimited). It is used at the external/plugin
// boundary to bound an untrusted payload — without reintroducing the EOF-wait
// hang, since the streaming decoder still returns on the first complete value.
func ReadHookInputRawLimited(stdin io.Reader, limit int64) (json.RawMessage, error) {
// If stdin is an interactive terminal there is no payload coming at all: the
// command was run by hand, or the agent left the console attached instead of
// wiring up a pipe. Decoding would block waiting for input that never comes,
// ... remaining implementation ...
}
Test Cases
// TestReadHookInputRawLimited_ReturnsBeforeEOF tests that the size-bounded raw reader must also return on the first complete JSON value without waiting for stdin close (issue #1398).
func TestReadHookInputRawLimited_ReturnsBeforeEOF(t *testing.T) {
t.Parallel()
pr, pw := io.Pipe()
go func() {
if _, err := pw.Write([]byte(`{"session_file":"/t.jsonl"}`)); err != nil {
_ = pw.CloseWithError(err)
}
}
done := make(chan error, 1)
go func() {
_, err := ReadHookInputRawLimited(pr, 10*1024*1024)
done <- err
}
select {
case err := <-done:
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
case <-time.After(3 * time.Second):
t.Fatal("ReadHookInputRawLimited blocked waiting for EOF — regression of #1398")
}
}
// TestReadHookInputRawLimited_RejectsOversized proves the byte ceiling turns an over-limit payload into an error rather than an unbounded read.
func TestReadHookInputRawLimited_RejectsOversized(t *testing.T) {
t.Parallel()
big := `{"k":"` + strings.Repeat("x", 512) + `"}`
_, err := ReadHookInputRawLimited(strings.NewReader(big), 64)
if err == nil {
t.Fatal("expected error for payload exceeding the limit, got nil")
}
}
// TestReadAndParseHookInput_EmptyInputEOF ... remaining implementation ...
func (e *Agent) ParseHookEvent(ctx context.Context, hookName string, stdin io.Reader) (*agent.Event, error) {
const maxParseHookBytes = 10 * 1024 * 1024 // 10 MB
// Bail out promptly on an interactive terminal — no payload is coming, and
// io.ReadAll would otherwise block forever (issue #1398). We keep io.ReadAll
// for the piped case because the external "parse-hook" contract forwards the
// raw stdin bytes verbatim (which may be empty or non-JSON), so unlike the
// built-in agents we can't stream-decode a single JSON value here.
// ... remaining implementation ...
}