okami.mom/albedo

git clone https://git.nkp.pet/okami.mom/albedo

dawntui: probe the terminal for kitty clipboard support before pasting an image3704fc3

main
12.9 KiB343 linesraw
1// The daemon E2E cannot drive terminal clipboard replies. These tests catch
2// chunk corruption, stale replies attaching to a new draft, input-reader
3// routing that would swallow OSC 5522 or insert its bytes into the composer, and
4// a capability probe that sends a paste to the wrong clipboard.
5package tui
6
7import (
8	"bytes"
9	"context"
10	"encoding/base64"
11	"fmt"
12	"image"
13	"image/png"
14	"io"
15	"strings"
16	"testing"
17	"time"
18
19	"albedo/cli/internal/daemon"
20	tea "charm.land/bubbletea/v2"
21	uv "github.com/charmbracelet/ultraviolet"
22	"github.com/charmbracelet/x/ansi"
23)
24
25func clipboardPacket(id, status, mime, payload string) string {
26	metadata := "type=read:id=" + id + ":status=" + status
27	if mime != "" {
28		metadata += ":mime=" + base64.StdEncoding.EncodeToString([]byte(mime))
29	}
30	return "\x1b]5522;" + metadata + ";" + payload + "\x1b\\"
31}
32
33func clipboardPNG(t *testing.T) []byte {
34	t.Helper()
35	var out bytes.Buffer
36	if err := png.Encode(&out, image.NewRGBA(image.Rect(0, 0, 2, 3))); err != nil {
37		t.Fatal(err)
38	}
39	return out.Bytes()
40}
41
42func setClipboardEnv(t *testing.T, remote bool) {
43	t.Helper()
44	for _, name := range []string{"SSH_CONNECTION", "SSH_CLIENT", "MOSH_CONNECTION"} {
45		t.Setenv(name, "")
46	}
47	if remote {
48		t.Setenv("SSH_CONNECTION", "audit")
49	}
50}
51
52func clipboardApp(t *testing.T) *AppModel {
53	t.Helper()
54	setClipboardEnv(t, true)
55	t.Setenv("TERM", "xterm")
56	t.Setenv("TERM_PROGRAM", "")
57	t.Setenv("TMUX", "")
58	t.Setenv("KITTY_WINDOW_ID", "")
59	return &AppModel{
60		ActiveSession: &daemon.Session{ID: "s"},
61		Chat:          composer(t, 80, 25), State: AppStateChat,
62	}
63}
64
65func reportClipboardMode(app *AppModel, value ansi.ModeSetting) tea.Cmd {
66	_, cmd := app.Update(tea.ModeReportMsg{Mode: kittyClipboardMode, Value: value})
67	return cmd
68}
69
70// startClipboardRead presses ctrl+v on a terminal that supports the protocol
71// and returns the id of the read it starts.
72func startClipboardRead(app *AppModel) string {
73	app.Update(tea.KeyPressMsg{Code: 'v', Mod: tea.ModCtrl})
74	reportClipboardMode(app, ansi.ModeReset)
75	return app.Chat.terminalClipboard.id
76}
77
78func deliverClipboardPacket(t *testing.T, app *AppModel, packet string) {
79	t.Helper()
80	var decoder uv.EventDecoder
81	n, event := decoder.Decode([]byte(packet))
82	if n != len(packet) {
83		t.Fatalf("terminal decoder consumed %d of %d bytes", n, len(packet))
84	}
85	_, cmd := app.Update(event)
86	if cmd != nil {
87		msg := cmd()
88		if _, ok := msg.(ClipboardImagePastedMsg); !ok {
89			t.Fatalf("unexpected clipboard completion %T", msg)
90		}
91		app.Update(msg)
92	}
93}
94
95func TestClipboardProbeChoosesWhereToPasteFrom(t *testing.T) {
96	for _, tc := range []struct {
97		name   string
98		remote bool
99		answer func(*AppModel) tea.Cmd
100		read   bool
101	}{
102		{"terminal supports it", false, func(a *AppModel) tea.Cmd { return reportClipboardMode(a, ansi.ModeReset) }, true},
103		{"terminal supports it while set", false, func(a *AppModel) tea.Cmd { return reportClipboardMode(a, ansi.ModeSet) }, true},
104		{"local terminal does not know the mode", false, func(a *AppModel) tea.Cmd { return reportClipboardMode(a, ansi.ModeNotRecognized) }, false},
105		{"local terminal pins the mode off", false, func(a *AppModel) tea.Cmd { return reportClipboardMode(a, ansi.ModePermanentlyReset) }, false},
106		{"ssh terminal does not know the mode", true, func(a *AppModel) tea.Cmd { return reportClipboardMode(a, ansi.ModeNotRecognized) }, true},
107		{"local terminal stays silent", false, func(a *AppModel) tea.Cmd { return probeTimeout(a) }, false},
108		{"ssh terminal stays silent", true, func(a *AppModel) tea.Cmd { return probeTimeout(a) }, true},
109	} {
110		t.Run(tc.name, func(t *testing.T) {
111			app := clipboardApp(t)
112			setClipboardEnv(t, tc.remote)
113			app.Update(tea.KeyPressMsg{Code: 'v', Mod: tea.ModCtrl})
114			if !app.Chat.terminalClipboard.probing {
115				t.Fatal("ctrl+v did not probe the terminal")
116			}
117			if cmd := tc.answer(app); cmd == nil {
118				t.Fatal("deciding a paste returned no command")
119			}
120			if reading := app.Chat.terminalClipboard.id != ""; reading != tc.read || app.Chat.terminalClipboard.probing {
121				t.Fatalf("terminal read = %v, want %v", reading, tc.read)
122			}
123		})
124	}
125}
126
127func probeTimeout(app *AppModel) tea.Cmd {
128	_, cmd := app.Update(terminalClipboardProbeTimeoutMsg{app.Chat.SessionID, app.Chat.terminalClipboard.id, app.Chat.Generation})
129	return cmd
130}
131
132func TestClipboardProbeIgnoresLateAndUnrelatedReports(t *testing.T) {
133	app := clipboardApp(t)
134	setClipboardEnv(t, false)
135	app.Update(tea.KeyPressMsg{Code: 'v', Mod: tea.ModCtrl})
136	id := app.Chat.terminalClipboard.id
137	app.Update(tea.ModeReportMsg{Mode: ansi.ModeBracketedPaste, Value: ansi.ModeNotRecognized})
138	if !app.Chat.terminalClipboard.probing {
139		t.Fatal("a report for another mode ended the probe")
140	}
141	app.Update(terminalClipboardProbeTimeoutMsg{"other", id, app.Chat.Generation})
142	app.Update(terminalClipboardProbeTimeoutMsg{app.Chat.SessionID, "stale", app.Chat.Generation})
143	if !app.Chat.terminalClipboard.probing {
144		t.Fatal("a stale timeout ended the probe")
145	}
146	probeTimeout(app)
147	if app.Chat.terminalClipboard.id != "" {
148		t.Fatal("a silent local terminal did not fall back to local tools")
149	}
150	if cmd := reportClipboardMode(app, ansi.ModeReset); cmd != nil || app.Chat.terminalClipboard.id != "" {
151		t.Fatal("a report after the timeout started a read")
152	}
153}
154
155func TestSSHClipboardPasteAcrossModal(t *testing.T) {
156	app := clipboardApp(t)
157	app.Chat.TextArea.SetValue("describe ")
158	app.Update(tea.KeyPressMsg{Code: 'v', Mod: tea.ModCtrl})
159	if _, cmd := app.Update(tea.KeyPressMsg{Code: 'v', Mod: tea.ModCtrl}); cmd != nil {
160		t.Fatal("repeated ctrl+v started a second probe")
161	}
162	app.State = AppStateModelPicker
163	reportClipboardMode(app, ansi.ModeReset)
164	id := app.Chat.terminalClipboard.id
165	if id == "" || app.Chat.terminalClipboard.probing {
166		t.Fatal("the probe reply did not start a terminal clipboard read")
167	}
168	if _, cmd := app.Update(tea.KeyPressMsg{Code: 'v', Mod: tea.ModCtrl}); cmd != nil {
169		t.Fatal("repeated ctrl+v started a second read")
170	}
171	data := clipboardPNG(t)
172	deliverClipboardPacket(t, app, clipboardPacket(id, "OK", "", ""))
173	for _, chunk := range [][]byte{data[:13], data[13:]} {
174		deliverClipboardPacket(t, app, clipboardPacket(id, "DATA", "image/png", base64.StdEncoding.EncodeToString(chunk)))
175	}
176	deliverClipboardPacket(t, app, clipboardPacket(id, "DONE", "", ""))
177	images := app.Chat.Images.referenced(app.Chat.TextArea.Value())
178	if len(images) != 1 || app.Chat.Images.byNumber[images[0]].Data != base64.StdEncoding.EncodeToString(data) {
179		t.Fatalf("clipboard chunks did not attach intact: %v", images)
180	}
181	if !strings.HasPrefix(app.Chat.TextArea.Value(), "describe ") || app.Chat.terminalClipboard.id != "" {
182		t.Fatal("paste lost the draft or left the read pending")
183	}
184	app.Chat, _ = app.Chat.Update(tea.PasteMsg{Content: "plain text"})
185	if !strings.HasSuffix(app.Chat.TextArea.Value(), "plain text") {
186		t.Fatal("normal text paste was intercepted")
187	}
188}
189
190func TestSSHClipboardRejectsInvalidReplies(t *testing.T) {
191	for _, scenario := range []string{
192		"denied", "unsupported", "busy", "bad-base64", "chunk-limit", "image-limit",
193		"data-before-ok", "duplicate-ok", "empty", "invalid-image", "timeout",
194	} {
195		t.Run(scenario, func(t *testing.T) {
196			app := clipboardApp(t)
197			app.Chat.TextArea.SetValue("keep this draft")
198			id := startClipboardRead(app)
199			if scenario != "data-before-ok" {
200				deliverClipboardPacket(t, app, clipboardPacket(id, "OK", "", ""))
201			}
202			switch scenario {
203			case "denied", "unsupported", "busy":
204				status := map[string]string{"denied": "EPERM", "unsupported": "ENOSYS", "busy": "EBUSY"}[scenario]
205				deliverClipboardPacket(t, app, clipboardPacket(id, status, "", ""))
206			case "bad-base64":
207				deliverClipboardPacket(t, app, clipboardPacket(id, "DATA", "image/png", "YQ==\n"))
208			case "chunk-limit":
209				deliverClipboardPacket(t, app, clipboardPacket(id, "DATA", "image/png", base64.StdEncoding.EncodeToString(make([]byte, 4097))))
210			case "image-limit":
211				chunk := base64.StdEncoding.EncodeToString(make([]byte, 4096))
212				for range MaxClipboardImageBytes/4096 + 1 {
213					deliverClipboardPacket(t, app, clipboardPacket(id, "DATA", "image/png", chunk))
214				}
215			case "data-before-ok":
216				deliverClipboardPacket(t, app, clipboardPacket(id, "DATA", "image/png", "YQ=="))
217			case "duplicate-ok":
218				deliverClipboardPacket(t, app, clipboardPacket(id, "OK", "", ""))
219			case "invalid-image":
220				deliverClipboardPacket(t, app, clipboardPacket(id, "DATA", "image/png", "YQ=="))
221				deliverClipboardPacket(t, app, clipboardPacket(id, "DONE", "", ""))
222			case "empty":
223				deliverClipboardPacket(t, app, clipboardPacket(id, "DONE", "", ""))
224			case "timeout":
225				_, cmd := app.Update(terminalClipboardTimeoutMsg{app.Chat.SessionID, id, app.Chat.Generation})
226				app.Update(cmd())
227			}
228			if !app.Chat.Notices.HasError() || app.Chat.terminalClipboard.id != "" {
229				t.Fatal("failed paste did not report an error and release its buffer")
230			}
231			if app.Chat.TextArea.Value() != "keep this draft" || len(app.Chat.Images.referenced(app.Chat.TextArea.Value())) != 0 {
232				t.Fatal("failed paste changed the draft")
233			}
234			// A delayed DONE must not attach data after failure or after retry.
235			startClipboardRead(app)
236			deliverClipboardPacket(t, app, clipboardPacket(id, "DONE", "", ""))
237			if app.Chat.terminalClipboard.id == "" {
238				t.Fatal("a stale response completed the new paste")
239			}
240		})
241	}
242}
243
244func TestSSHClipboardIgnoresOtherSessionAndGeneration(t *testing.T) {
245	app := clipboardApp(t)
246	oldID := startClipboardRead(app)
247	oldGen := app.Chat.Generation
248	app.Chat.Close()
249	app.Chat = composer(t, 80, 25)
250	id := startClipboardRead(app)
251	deliverClipboardPacket(t, app, clipboardPacket(oldID, "OK", "", ""))
252	deliverClipboardPacket(t, app, clipboardPacket(oldID, "DATA", "image/png", strings.Repeat("A", 9000)))
253	app.Update(terminalClipboardTimeoutMsg{"other", id, app.Chat.Generation})
254	app.Update(terminalClipboardTimeoutMsg{"s", oldID, oldGen})
255	if app.Chat.terminalClipboard.id != id || app.Chat.terminalClipboard.started || app.Chat.Notices.HasError() {
256		t.Fatal("old session replies changed the new paste")
257	}
258}
259
260// This model runs the real Bubble Tea reader/writer without starting daemon I/O.
261type clipboardTerminalProbe struct{ app *AppModel }
262
263func (m clipboardTerminalProbe) Init() tea.Cmd {
264	return func() tea.Msg { return tea.KeyPressMsg{Code: 'v', Mod: tea.ModCtrl} }
265}
266func (m clipboardTerminalProbe) View() tea.View { return tea.NewView("") }
267func (m clipboardTerminalProbe) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
268	_, cmd := m.app.Update(msg)
269	if _, ok := msg.(ClipboardImagePastedMsg); ok {
270		return m, tea.Quit
271	}
272	return m, cmd
273}
274
275type clipboardTerminalWriter struct{ requests chan string }
276
277func (w clipboardTerminalWriter) Write(p []byte) (int, error) {
278	out := string(p)
279	if strings.Contains(out, ansi.RequestMode(kittyClipboardMode)) {
280		w.requests <- ansi.RequestMode(kittyClipboardMode)
281	}
282	if start := strings.Index(out, "\x1b]5522;type=read:"); start >= 0 {
283		packet, _, _ := strings.Cut(out[start:], "\x1b\\")
284		w.requests <- packet + "\x1b\\"
285	}
286	return len(p), nil
287}
288
289func TestSSHClipboardThroughBubbleTeaReader(t *testing.T) {
290	app := clipboardApp(t)
291	input, replies := io.Pipe()
292	defer input.Close()
293	defer replies.Close()
294	requests := make(chan string, 2)
295	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
296	defer cancel()
297	program := tea.NewProgram(clipboardTerminalProbe{app}, tea.WithInput(input),
298		tea.WithOutput(clipboardTerminalWriter{requests}), tea.WithContext(ctx), tea.WithoutSignalHandler())
299	done := make(chan error, 1)
300	go func() { _, err := program.Run(); done <- err }()
301	nextRequest := func() string {
302		select {
303		case request := <-requests:
304			return request
305		case <-ctx.Done():
306			t.Fatal("Bubble Tea never wrote the next clipboard request")
307			return ""
308		}
309	}
310	if probe := nextRequest(); probe != ansi.RequestMode(kittyClipboardMode) {
311		t.Fatalf("first request was %q, want the capability probe", probe)
312	}
313	if _, err := fmt.Fprint(replies, ansi.ReportMode(kittyClipboardMode, ansi.ModeReset)); err != nil {
314		t.Fatal(err)
315	}
316	request := nextRequest()
317	metadata, payload, _ := strings.Cut(strings.TrimSuffix(strings.TrimPrefix(request, "\x1b]5522;"), "\x1b\\"), ";")
318	id := strings.TrimPrefix(metadata, "type=read:id=")
319	if payload != base64.StdEncoding.EncodeToString([]byte("image/png image/jpeg")) {
320		t.Fatalf("unexpected clipboard request: %q", request)
321	}
322	data := clipboardPNG(t)
323	response := clipboardPacket(id, "OK", "", "") +
324		clipboardPacket(id, "DATA", "image/png", base64.StdEncoding.EncodeToString(data)) +
325		clipboardPacket(id, "DONE", "", "")
326	// Split even the escape sequence across writes, as an SSH stream can.
327	for _, part := range []string{response[:1], response[1:19], response[19:]} {
328		if _, err := fmt.Fprint(replies, part); err != nil {
329			t.Fatal(err)
330		}
331	}
332	select {
333	case err := <-done:
334		if err != nil {
335			t.Fatal(err)
336		}
337	case <-ctx.Done():
338		t.Fatal("Bubble Tea did not finish the image paste")
339	}
340	if len(app.Chat.Images.referenced(app.Chat.TextArea.Value())) != 1 || app.Chat.Notices.HasError() {
341		t.Fatal("terminal reader failed to deliver the local image")
342	}
343}