Skip to content

Commit fa5f325

Browse files
authored
fix: wave-9 bug sweep — FIFO-safe @-resource loads, MCP server-to-client request routing, envelope-with-notes rendering (#189)
Three RED-first fixes, each pinned by failing tests on main: 1. resource (FileResolver.Load, HIGH): opening a FIFO (or other special file) in the workspace with no writer blocked the @-resource load in the syscall forever — ctx is not observable there — and the FIFO's zero size passed the cap, leaving the subsequent ReadAll unbounded. The open now uses O_NONBLOCK (a no-op for regular files) and non-regular files are rejected outright. 2. mcpclient (readLoop routing, HIGH): any parseable line was routed by id — a spec-legal server-to-client REQUEST (JSON-RPC 2.0: carries "method") whose id collided with an in-flight call delivered {result:null} to the waiter and the real response was dropped when it arrived. Lines carrying "method" are now skipped: they are never responses to our calls. 3. mcpclient (CallTool envelope + notes, MED): a multi-item result [envelope, trailing note] was joined with "\n" before the envelope probe — the trailing data failed the JSON parse and the RAW envelope JSON, including artifact refs that must never reach the model unvalidated, was delivered as plain text, bypassing artifact-ref validation entirely. Each text item is now parsed for the envelope; non-envelope items are preserved after the rendered form (through the per-server result cap). Dropped after honest verification: the claimed base64url blind spot in the unread-script decode pass — for realistic payloads the leading pre-boundary run still decodes the injection phrase, and no payload could be constructed where every run stays under the 24-char candidate threshold. No RED, no fix.
1 parent 6ac6bb5 commit fa5f325

5 files changed

Lines changed: 235 additions & 1 deletion

File tree

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
package mcpclient
2+
3+
import (
4+
"bufio"
5+
"context"
6+
"fmt"
7+
"io"
8+
"strings"
9+
"testing"
10+
"time"
11+
)
12+
13+
// A server returning [envelope item, trailing note] produced a JOINED text
14+
// "envelope\nnote": the envelope probe fails on the trailing data, the
15+
// envelope is treated as plain text, and the RAW envelope JSON — including
16+
// artifact refs that must never reach the model unvalidated — is delivered
17+
// verbatim, bypassing artifact-ref validation entirely.
18+
func TestClient_CallTool_EnvelopeWithTrailingContent(t *testing.T) {
19+
clientRead, serverWrite := io.Pipe()
20+
serverRead, clientWrite := io.Pipe()
21+
go io.Copy(io.Discard, serverRead)
22+
23+
c := &Client{
24+
name: "multiitem",
25+
stdin: clientWrite,
26+
stdout: bufio.NewReader(clientRead),
27+
lineCh: make(chan lineResult, 10),
28+
done: make(chan struct{}),
29+
writeCh: make(chan []byte, 2),
30+
writeDone: make(chan struct{}),
31+
closed: make(chan struct{}),
32+
pending: make(map[int]chan callResponse),
33+
timeout: 5 * time.Second,
34+
}
35+
go c.readLoop()
36+
go c.writeLoop()
37+
defer func() {
38+
c.closeOnce.Do(func() { close(c.closed) })
39+
clientWrite.Close()
40+
clientRead.Close()
41+
}()
42+
43+
go func() {
44+
// nextID starts at 0: the first call is id 0.
45+
fmt.Fprint(serverWrite, `{"jsonrpc":"2.0","id":0,"result":{"content":[`+
46+
`{"type":"text","text":"{\"schema\":\"odek.tool-result/v1\",\"text\":\"report ready\"}"},`+
47+
`{"type":"text","text":"(generated 2 artifacts)"}`+
48+
`]}}`+"\n")
49+
}()
50+
51+
out, err := c.CallTool(context.Background(), "build_report", `{}`)
52+
if err != nil {
53+
t.Fatalf("CallTool: %v", err)
54+
}
55+
if strings.Contains(out, `"schema":"odek.tool-result/v1"`) {
56+
t.Fatalf("raw envelope JSON delivered to the model (artifact-ref validation bypassed): %q", out)
57+
}
58+
if !strings.Contains(out, "report ready") {
59+
t.Fatalf("rendered envelope text missing from result: %q", out)
60+
}
61+
if !strings.Contains(out, "(generated 2 artifacts)") {
62+
t.Fatalf("trailing content note lost: %q", out)
63+
}
64+
}

‎internal/mcpclient/client.go‎

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,7 @@ type request struct {
9595
type response struct {
9696
JSONRPC string `json:"jsonrpc"`
9797
ID int `json:"id"`
98+
Method string `json:"method,omitempty"`
9899
Result json.RawMessage `json:"result,omitempty"`
99100
Error *rpcError `json:"error,omitempty"`
100101
}
@@ -645,6 +646,53 @@ func (c *Client) CallTool(ctx context.Context, name string, argsJSON string) (st
645646
if err != nil {
646647
return "", fmt.Errorf("mcpclient %s: tool %s: %w", c.name, name, err)
647648
}
649+
if env == nil && len(parts) > 1 {
650+
// A multi-item result carrying an envelope plus trailing notes: the
651+
// joined text no longer parses as one JSON document, so the probe
652+
// above treats it as plain text — delivering the RAW envelope JSON to
653+
// the model and bypassing artifact-ref validation. Parse each item
654+
// for the envelope instead; a schema-matched but malformed item still
655+
// fails closed.
656+
var envErr error
657+
for _, p := range parts {
658+
e, eerr := artifact.ParseEnvelope(p)
659+
if eerr != nil {
660+
envErr = eerr
661+
continue
662+
}
663+
if e != nil {
664+
env = e
665+
break
666+
}
667+
}
668+
if env == nil && envErr != nil {
669+
return "", fmt.Errorf("mcpclient %s: tool %s: %w", c.name, name, envErr)
670+
}
671+
// Preserve the non-envelope items after the rendered form: the
672+
// trailing notes are ordinary text the server meant the model to
673+
// see alongside the artifact metadata.
674+
if env != nil {
675+
var extras []string
676+
for _, p := range parts {
677+
if e, eerr := artifact.ParseEnvelope(p); eerr == nil && e != nil {
678+
continue // the envelope item(s) render above
679+
}
680+
if strings.TrimSpace(p) != "" {
681+
extras = append(extras, p)
682+
}
683+
}
684+
suffix := ""
685+
if len(extras) > 0 {
686+
suffix = "\n" + strings.Join(extras, "\n")
687+
}
688+
for i := range env.Artifacts {
689+
if _, err := artifact.Validate(env.Artifacts[i], c.artifactRoots); err != nil {
690+
return "", fmt.Errorf("mcpclient %s: tool %s: artifact ref rejected: %w", c.name, name, err)
691+
}
692+
}
693+
return c.applyResultLimit(name, c.renderCappedEnvelope(name, env)+suffix), nil
694+
}
695+
}
648696
if env != nil {
649697
for i := range env.Artifacts {
650698
// The resolved path is intentionally discarded here: it is
@@ -877,6 +925,13 @@ func (c *Client) readLoop() {
877925
if err := json.Unmarshal([]byte(line), &resp); err != nil {
878926
continue // skip malformed lines
879927
}
928+
// A line carrying a "method" field is a server→client request or
929+
// notification (JSON-RPC 2.0), never a response to one of our calls.
930+
// Routing it by id would deliver {result:null} to a waiting caller
931+
// whose id collides — and drop the real response when it arrives.
932+
if resp.Method != "" {
933+
continue
934+
}
880935

881936
// Route to the waiting caller, if any.
882937
c.mu.Lock()
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
package mcpclient
2+
3+
import (
4+
"bufio"
5+
"context"
6+
"encoding/json"
7+
"fmt"
8+
"io"
9+
"strings"
10+
"testing"
11+
"time"
12+
)
13+
14+
// A spec-legal server→client REQUEST (JSON-RPC 2.0: carries "method") whose
15+
// id collides with an in-flight call was routed to that call's waiter as
16+
// {result: null} — the real response was then dropped and the call failed
17+
// with a parse error. Responses to OUR calls never carry "method".
18+
func TestClient_Call_IgnoresServerToClientRequests(t *testing.T) {
19+
clientRead, serverWrite := io.Pipe()
20+
serverRead, clientWrite := io.Pipe()
21+
go io.Copy(io.Discard, serverRead) // drain client→server writes
22+
23+
c := &Client{
24+
name: "confused",
25+
stdin: clientWrite,
26+
stdout: bufio.NewReader(clientRead),
27+
lineCh: make(chan lineResult, 10),
28+
done: make(chan struct{}),
29+
writeCh: make(chan []byte, 2),
30+
writeDone: make(chan struct{}),
31+
closed: make(chan struct{}),
32+
pending: make(map[int]chan callResponse),
33+
timeout: 5 * time.Second,
34+
}
35+
go c.readLoop()
36+
go c.writeLoop()
37+
cleanup := func() {
38+
c.closeOnce.Do(func() { close(c.closed) })
39+
clientWrite.Close()
40+
clientRead.Close()
41+
}
42+
defer cleanup()
43+
44+
go func() {
45+
// 1) Spec-legal server→client request whose id collides with the call
46+
// (nextID starts at 0, so the first call is id 0).
47+
fmt.Fprint(serverWrite, `{"jsonrpc":"2.0","id":0,"method":"ping","params":{}}`+"\n")
48+
time.Sleep(100 * time.Millisecond) // deterministic ordering
49+
// 2) The real response to the client's call.
50+
fmt.Fprint(serverWrite, `{"jsonrpc":"2.0","id":0,"result":{"ok":true}}`+"\n")
51+
}()
52+
53+
res, err := c.call(context.Background(), "tools/call", json.RawMessage(`{}`))
54+
if err != nil {
55+
t.Fatalf("call failed after colliding server→client request: %v", err)
56+
}
57+
if res == nil || !strings.Contains(string(res), `"ok":true`) {
58+
t.Fatalf("result = %s, want the real response payload", res)
59+
}
60+
}

‎internal/resource/resource.go‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -333,7 +333,11 @@ func (f *FileResolver) Load(ctx context.Context, id string) (string, error) {
333333
// Open with O_NOFOLLOW to atomically prevent the final component from
334334
// being a symlink. If the path is a symlink, the open fails with ELOOP —
335335
// closing the TOCTOU window between a separate Lstat check and the read.
336-
fd, err := os.OpenFile(resolvedTarget, os.O_RDONLY|syscall.O_NOFOLLOW, 0)
336+
// O_NONBLOCK guards against special files: opening a FIFO with no
337+
// writer blocks in the syscall forever (ctx is not observable there),
338+
// and the FIFO's zero size would pass the cap below leaving ReadAll
339+
// unbounded. For regular files O_NONBLOCK is a no-op.
340+
fd, err := os.OpenFile(resolvedTarget, os.O_RDONLY|syscall.O_NOFOLLOW|syscall.O_NONBLOCK, 0)
337341
if err != nil {
338342
return "", err
339343
}
@@ -343,6 +347,9 @@ func (f *FileResolver) Load(ctx context.Context, id string) (string, error) {
343347
if err != nil {
344348
return "", err
345349
}
350+
if !info.Mode().IsRegular() {
351+
return "", fmt.Errorf("resource: %q is not a regular file", id)
352+
}
346353
if info.Size() > maxResourceFileBytes {
347354
return "", fmt.Errorf("resource: file too large (%d bytes, max %d)", info.Size(), maxResourceFileBytes)
348355
}
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
package resource
2+
3+
import (
4+
"context"
5+
"path/filepath"
6+
"sync"
7+
"syscall"
8+
"testing"
9+
"time"
10+
)
11+
12+
func mkfifo(path string) error {
13+
return syscall.Mkfifo(path, 0600)
14+
}
15+
16+
// A FIFO (or any non-regular file) in the workspace must not hang or
17+
// over-read @-resource loads: the open was blocking (no writer → forever,
18+
// ctx ignored), and a FIFO's zero size passed the size gate leaving the
19+
// subsequent ReadAll unbounded.
20+
func TestFileResolver_Load_FIFODoesNotHang(t *testing.T) {
21+
dir := t.TempDir()
22+
fifo := filepath.Join(dir, "pipe")
23+
if err := mkfifo(fifo); err != nil {
24+
t.Skipf("mkfifo unavailable: %v", err)
25+
}
26+
27+
r := NewFileResolver(dir)
28+
type res struct {
29+
content string
30+
err error
31+
}
32+
done := make(chan res, 1)
33+
var once sync.Once
34+
go func() {
35+
content, err := r.Load(context.Background(), "pipe")
36+
once.Do(func() { done <- res{content, err} })
37+
}()
38+
39+
select {
40+
case got := <-done:
41+
if got.err == nil {
42+
t.Fatalf("FIFO load unexpectedly succeeded: %d bytes", len(got.content))
43+
}
44+
case <-time.After(3 * time.Second):
45+
once.Do(func() {})
46+
t.Fatal("Load on a writerless FIFO hung — non-regular files must be rejected without blocking")
47+
}
48+
}

0 commit comments

Comments
 (0)