@pi-in-go/pigpen-ahp 0.1.0 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -95,7 +95,10 @@ func (d *ChatDriver) dispatchAll(actions []ahptypes.StateAction) {
95
95
  }
96
96
  }
97
97
 
98
- // track runs fn as an in-flight operation that Quiesce waits for.
98
+ // track runs fn as an in-flight operation that Quiesce waits for. These goroutines carry client
99
+ // input into the backend (prompt and steering text and images, decoded there), outside the
100
+ // host's request recover, so a panic is logged here instead of ending the host process; the
101
+ // prompt and steering paths turn it into their usual error first (callBackend).
99
102
  func (d *ChatDriver) track(fn func()) {
100
103
  d.mu.Lock()
101
104
  d.inflight++
@@ -107,10 +110,26 @@ func (d *ChatDriver) track(fn func()) {
107
110
  d.idle.Broadcast()
108
111
  d.mu.Unlock()
109
112
  }()
113
+ defer func() {
114
+ if p := recover(); p != nil {
115
+ d.logf("chat operation failed: %v", p)
116
+ }
117
+ }()
110
118
  fn()
111
119
  }()
112
120
  }
113
121
 
122
+ // callBackend runs one backend call and returns a panic in it as an error, so a client message
123
+ // that breaks the backend (a hostile image in its decoders) fails like a rejected prompt.
124
+ func callBackend(fn func() error) (err error) {
125
+ defer func() {
126
+ if p := recover(); p != nil {
127
+ err = fmt.Errorf("internal error: %v", p)
128
+ }
129
+ }()
130
+ return fn()
131
+ }
132
+
114
133
  func (d *ChatDriver) drainInFlight() {
115
134
  d.mu.Lock()
116
135
  for d.inflight > 0 {
@@ -222,10 +241,13 @@ func (d *ChatDriver) steer(ctx context.Context, message ahptypes.Message) {
222
241
  if ctx.Err() != nil {
223
242
  return
224
243
  }
225
- input, err := messageInput(message)
226
- if err == nil {
227
- err = d.opts.Backend.Steer(ctx, input.Text, input.Images)
228
- }
244
+ err := callBackend(func() error {
245
+ input, err := messageInput(message)
246
+ if err != nil {
247
+ return err
248
+ }
249
+ return d.opts.Backend.Steer(ctx, input.Text, input.Images)
250
+ })
229
251
  if err != nil {
230
252
  d.logf("steer failed: %v", err)
231
253
  d.clearPendingSteering()
@@ -277,10 +299,13 @@ func (d *ChatDriver) runPrompt(m *mapper.TurnMapper, message ahptypes.Message, l
277
299
  if !d.stillCurrent(m, ctx) {
278
300
  return
279
301
  }
280
- input, err := messageInput(message)
281
- if err == nil {
282
- err = d.opts.Backend.Prompt(ctx, input.Text, input.Images)
283
- }
302
+ err := callBackend(func() error {
303
+ input, err := messageInput(message)
304
+ if err != nil {
305
+ return err
306
+ }
307
+ return d.opts.Backend.Prompt(ctx, input.Text, input.Images)
308
+ })
284
309
  if err != nil {
285
310
  d.mu.Lock()
286
311
  current := d.mapper == m
@@ -0,0 +1,89 @@
1
+ package pi
2
+
3
+ import (
4
+ "bytes"
5
+ "compress/zlib"
6
+ "encoding/base64"
7
+ "encoding/binary"
8
+ "hash/crc32"
9
+ "runtime"
10
+ "strings"
11
+ "testing"
12
+
13
+ "github.com/MichaelKinsy/pigpen/ahp/internal/mapper"
14
+ )
15
+
16
+ // bombPNG streams a w×h 8-bit grayscale PNG of zeros through zlib without ever holding the
17
+ // pixels, so the test itself stays small: about 400 KiB for 20000×20000.
18
+ func bombPNG(t *testing.T, w, h int) []byte {
19
+ t.Helper()
20
+ var out bytes.Buffer
21
+ out.WriteString("\x89PNG\r\n\x1a\n")
22
+ chunk := func(kind string, body []byte) {
23
+ var n [4]byte
24
+ binary.BigEndian.PutUint32(n[:], uint32(len(body)))
25
+ out.Write(n[:])
26
+ out.WriteString(kind)
27
+ out.Write(body)
28
+ crc := crc32.NewIEEE()
29
+ crc.Write([]byte(kind))
30
+ crc.Write(body)
31
+ binary.BigEndian.PutUint32(n[:], crc.Sum32())
32
+ out.Write(n[:])
33
+ }
34
+ ihdr := make([]byte, 13)
35
+ binary.BigEndian.PutUint32(ihdr[0:], uint32(w))
36
+ binary.BigEndian.PutUint32(ihdr[4:], uint32(h))
37
+ ihdr[8] = 8 // bit depth; colour type 0 is grayscale
38
+ chunk("IHDR", ihdr)
39
+ var idat bytes.Buffer
40
+ // BestSpeed: about the same size for all-zero rows, and several times faster (this runs under -race too).
41
+ zw, err := zlib.NewWriterLevel(&idat, zlib.BestSpeed)
42
+ if err != nil {
43
+ t.Fatal(err)
44
+ }
45
+ row := make([]byte, 1+w) // filter byte 0 plus zero pixels
46
+ for y := 0; y < h; y++ {
47
+ if _, err := zw.Write(row); err != nil {
48
+ t.Fatal(err)
49
+ }
50
+ }
51
+ if err := zw.Close(); err != nil {
52
+ t.Fatal(err)
53
+ }
54
+ chunk("IDAT", idat.Bytes())
55
+ chunk("IEND", nil)
56
+ return out.Bytes()
57
+ }
58
+
59
+ // A client-supplied 20000×20000 PNG (400 MP, about 400 KiB on the wire) must be refused from its
60
+ // header, before the decoder allocates the pixel buffer.
61
+ func TestPrepareImagesRefusesDecodeBomb(t *testing.T) {
62
+ raw := bombPNG(t, 20000, 20000)
63
+ if len(raw) > 2<<20 {
64
+ t.Fatalf("fixture is %d KiB, expected a small bomb", len(raw)>>10)
65
+ }
66
+ data := base64.StdEncoding.EncodeToString(raw)
67
+ for _, autoResize := range []bool{true, false} {
68
+ runtime.GC()
69
+ var before, after runtime.MemStats
70
+ runtime.ReadMemStats(&before)
71
+ _, err := PrepareImagesForPi([]mapper.Image{{Type: "image", Data: data, MimeType: "image/png"}}, autoResize)
72
+ runtime.ReadMemStats(&after)
73
+ if autoResize && (err == nil || !strings.Contains(err.Error(), "too large")) {
74
+ t.Fatalf("autoResize=%v: want a too-large refusal, got %v", autoResize, err)
75
+ }
76
+ if grown := after.TotalAlloc - before.TotalAlloc; grown > 32<<20 {
77
+ t.Fatalf("autoResize=%v: refusing allocated %d MiB (want under 32)", autoResize, grown>>20)
78
+ }
79
+ }
80
+ }
81
+
82
+ // Images at or under the cap still work.
83
+ func TestPrepareImagesAcceptsLargeButBoundedPNG(t *testing.T) {
84
+ data := base64.StdEncoding.EncodeToString(bombPNG(t, 3000, 3000))
85
+ out, err := PrepareImagesForPi([]mapper.Image{{Type: "image", Data: data, MimeType: "image/png"}}, true)
86
+ if err != nil || len(out) != 1 {
87
+ t.Fatalf("%v %v", out, err)
88
+ }
89
+ }
@@ -24,6 +24,11 @@ import (
24
24
  const (
25
25
  maxImageDimension = 2000
26
26
  maxImageBytes = 4_500_000
27
+ // maxDecodePixels bounds what a client image may declare before it is decoded: the decoders
28
+ // allocate the whole pixel buffer from the header, so 420 KiB of PNG can ask for 20000×20000
29
+ // pixels (hundreds of MiB, seconds of CPU). Same value and name as websearch's imageproc.go
30
+ // (a separate module; websearch's TestDecodePixelCapMatchesAHP keeps them equal).
31
+ maxDecodePixels = 1 << 26
27
32
  )
28
33
 
29
34
  var supportedInlineTypes = map[string]bool{"image/png": true, "image/jpeg": true, "image/gif": true, "image/webp": true}
@@ -68,7 +73,25 @@ func prepareImage(img mapper.Image, autoResize bool, index int) (mapper.Image, e
68
73
  return mapper.Image{Type: "image", Data: resized, MimeType: resizedType}, nil
69
74
  }
70
75
 
76
+ // checkDecodeSize reads only the header and refuses images declaring more than maxDecodePixels.
77
+ func checkDecodeSize(raw []byte) error {
78
+ cfg, _, err := image.DecodeConfig(bytes.NewReader(raw))
79
+ if err != nil {
80
+ return err
81
+ }
82
+ if cfg.Width <= 0 || cfg.Height <= 0 || int64(cfg.Width)*int64(cfg.Height) > maxDecodePixels {
83
+ return fmt.Errorf("image too large to process (%d×%d pixels; the limit is %d megapixels)", cfg.Width, cfg.Height, maxDecodePixels/(1<<20))
84
+ }
85
+ return nil
86
+ }
87
+
71
88
  func decodeAny(raw []byte, mimeType string) (image.Image, error) {
89
+ switch mimeType {
90
+ case "image/png", "image/jpeg", "image/gif":
91
+ if err := checkDecodeSize(raw); err != nil {
92
+ return nil, err
93
+ }
94
+ }
72
95
  switch mimeType {
73
96
  case "image/png":
74
97
  return png.Decode(bytes.NewReader(raw))
@@ -0,0 +1,44 @@
1
+ package pi_test
2
+
3
+ import (
4
+ "context"
5
+ "strings"
6
+ "testing"
7
+
8
+ "github.com/microsoft/agent-host-protocol/clients/go/ahptypes"
9
+
10
+ "github.com/MichaelKinsy/pigpen/ahp/internal/testkit"
11
+ )
12
+
13
+ // The chat driver runs a client's prompt and steering on goroutines of its own (ChatDriver.track),
14
+ // and that is where client images are decoded (live.Session → PrepareImagesForPi). A panic there
15
+ // was outside every recover in the host, so one hostile message ended the AHP host process. It
16
+ // now ends that turn (or drops that steering message) with an error, as a rejected prompt does.
17
+
18
+ func TestChatDriverSurvivesPanickingPrompt(t *testing.T) {
19
+ backend := funcBackend{prompt: func(context.Context, string) error { panic("decoder exploded") }}
20
+ f := startFixture(t, backend)
21
+ f.turnStarted("t1", userMessage("hi"))
22
+ testkit.Eventually(t, "the panicking prompt to close its turn", turnCount(f, 1))
23
+ turn := f.chat().Turns[0]
24
+ if turn.State != ahptypes.TurnStateError {
25
+ t.Fatalf("state = %s", turn.State)
26
+ }
27
+ last, ok := turn.ResponseParts[len(turn.ResponseParts)-1].Value.(*ahptypes.ErrorResponsePart)
28
+ if !ok || !strings.Contains(last.Error.Message, "decoder exploded") {
29
+ t.Fatalf("error part = %+v", turn.ResponseParts)
30
+ }
31
+ // The host still serves the next turn.
32
+ f.turnStarted("t2", userMessage("again"))
33
+ testkit.Eventually(t, "the next turn to close too", turnCount(f, 2))
34
+ }
35
+
36
+ func TestChatDriverSurvivesPanickingSteer(t *testing.T) {
37
+ backend := funcBackend{steer: func(context.Context, string) error { panic("decoder exploded") }}
38
+ f := startFixture(t, backend)
39
+ f.turnStarted("t1", userMessage("long running"))
40
+ testkit.Eventually(t, "the turn to be active", func() bool { return f.chat().ActiveTurn != nil })
41
+ f.pending("steering", "steer-1", userMessage("focus"))
42
+ testkit.Eventually(t, "the steering message to enter protocol state", func() bool { return f.chat().SteeringMessage != nil })
43
+ testkit.Eventually(t, "the failed steering message to leave protocol state", func() bool { return f.chat().SteeringMessage == nil })
44
+ }
@@ -6,6 +6,7 @@ import (
6
6
  "os"
7
7
  "reflect"
8
8
  "testing"
9
+ "time"
9
10
 
10
11
  "github.com/microsoft/agent-host-protocol/clients/go/ahp"
11
12
  "github.com/microsoft/agent-host-protocol/clients/go/ahptypes"
@@ -357,6 +358,19 @@ func projectsOutcome(t *testing.T, outcome string) {
357
358
  }
358
359
  }
359
360
 
361
+ // waitUntil polls condition until it holds. The bound only turns a lost projection into a failure
362
+ // instead of a hang; a passing run does not depend on it.
363
+ func waitUntil(t *testing.T, what string, condition func() bool) {
364
+ t.Helper()
365
+ deadline := time.Now().Add(30 * time.Second)
366
+ for !condition() {
367
+ if time.Now().After(deadline) {
368
+ t.Fatalf("timed out waiting for %s", what)
369
+ }
370
+ time.Sleep(time.Millisecond)
371
+ }
372
+ }
373
+
360
374
  func replaysProjection(t *testing.T, running bool) {
361
375
  f := newSummaryFixture(t, nil)
362
376
  baseline := f.h.host.ServerSeq()
@@ -372,6 +386,19 @@ func replaysProjection(t *testing.T, running bool) {
372
386
  h.DispatchServerAction(f.chat, ahptypes.StateAction{Value: &ahptypes.ChatActivityChangedAction{Type: ahptypes.ActionTypeChatActivityChanged}})
373
387
  h.DispatchServerAction(f.chat, ahptypes.StateAction{Value: &ahptypes.ChatTurnCompleteAction{Type: ahptypes.ActionTypeChatTurnComplete, TurnId: "turn", Duration: 1000}})
374
388
  }
389
+ // DispatchServerAction returns once its own listener events are queued: when another goroutine
390
+ // is already draining the host's listener queue (an earlier action's projection), the call
391
+ // returns before the chat-to-session projection of these actions has run. A reconnect taken
392
+ // at that instant replays a session channel that lacks the projection, so the mirror keeps the
393
+ // chat's creation time. Wait for the projection the way a client would see it.
394
+ wantModified := endAt
395
+ if running {
396
+ wantModified = startAt
397
+ }
398
+ waitUntil(t, "the session summary to project the chat's modification time", func() bool {
399
+ session := f.sessionState()
400
+ return session != nil && len(session.Chats) == 1 && session.Chats[0].ModifiedAt == wantModified
401
+ })
375
402
  resumed := testkit.Connect(t, h)
376
403
  t.Cleanup(resumed.Close)
377
404
  var replay struct {
@@ -402,10 +429,6 @@ func replaysProjection(t *testing.T, running bool) {
402
429
  if mirror.Status != ahptypes.SessionStatusIdle {
403
430
  t.Fatalf("mirror status = %d", mirror.Status)
404
431
  }
405
- wantModified := endAt
406
- if running {
407
- wantModified = startAt
408
- }
409
432
  if mirror.Chats[0].ModifiedAt != wantModified {
410
433
  t.Fatalf("chat modifiedAt = %s", mirror.Chats[0].ModifiedAt)
411
434
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-in-go/pigpen-ahp",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "Go port of pi-ahp: serves the running PiG session to remote Microsoft Agent Host Protocol clients over WebSocket, with reconnect and auth. No listener unless you ask; no remote filesystem or terminal unless you opt in",
5
5
  "keywords": [
6
6
  "pig-package",
package/proof/PORT.md CHANGED
@@ -96,7 +96,9 @@ session. The equivalence method is therefore three layers, not the scenario harn
96
96
  service refuses to start there. A signalled child reports `128+signal`.
97
97
  9. **Images.** Pi's `convertToPng`/`resizeImage` are not in the SDK: PNG, JPEG, GIF decode with the
98
98
  standard library, BMP with a small decoder, resizing is a box filter, WebP passes through unvalidated
99
- beyond its header.
99
+ beyond its header. PNG, JPEG and GIF declaring more than 64 megapixels are refused from their header,
100
+ before decoding (Go's decoders allocate the whole image from the header). A
101
+ panic while a prompt or steering message is handed to the agent fails that turn or message.
100
102
  10. **MIME table** is the subset of the `mime` package that matters for source trees.
101
103
  11. **Async side effects.** Registry and driver effects run on goroutines where upstream runs them in
102
104
  the same tick; a few twins wait for the backend call before the next step. `host.drain` defers