@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.
- package/extensions/ahp/internal/pi/chatdriver.go +34 -9
- package/extensions/ahp/internal/pi/imagebomb_test.go +89 -0
- package/extensions/ahp/internal/pi/imageinput.go +23 -0
- package/extensions/ahp/internal/pi/panic_test.go +44 -0
- package/extensions/ahp/internal/pi/summary_test.go +27 -4
- package/package.json +1 -1
- package/proof/PORT.md +3 -1
|
@@ -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
|
-
|
|
226
|
-
|
|
227
|
-
err
|
|
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
|
-
|
|
281
|
-
|
|
282
|
-
err
|
|
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.
|
|
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
|