From d04aeb27798131dd62092622246bdd6d744e2e5b Mon Sep 17 00:00:00 2001 From: Ed Zynda Date: Sat, 29 Aug 2026 22:49:45 +0300 Subject: [PATCH 1/7] feat(daemon): image paste over remote sessions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In a 'kit remote' session, Ctrl-V previously reached the host TUI, which read the HOST's clipboard — so pasting images from the client machine silently did nothing. Now the client intercepts a bare Ctrl-V in the keystroke pump, reads the LOCAL clipboard (internal/clipboard: xclip/wl-paste/osascript) and streams the image to the daemon as chunked FrameClipboard frames (16 KiB chunks, final-chunk flag, media type in the first chunk; 32 MiB reassembly cap). When the clipboard holds no image the keystroke is forwarded verbatim, preserving host-side Ctrl-V behavior. The daemon reassembles the transfer, writes it to a tempfile with the media-derived extension, and injects @"" into the session child's PTY input — the standard @-attachment pipeline then handles MIME detection, preview and multimodal submission; the operator just presses Enter. Tempfiles are removed when the session ends. The sidecar needs no changes: unknown frame types are relayed verbatim in both directions, like DATA/RESIZE. Verified end to end on loopback: Wayland clipboard image -> client chunks -> daemon tempfile -> @-reference in the session input -> '[1 file(s) attached]' on submit; tempfile removed on /quit. Unit tests cover chunk round-trips, oversized transfers, truncated headers, empty images, maxPayload bounds and extension mapping. --- contrib/kit-tunnel/src/main.rs | 10 +- internal/daemon/client.go | 26 +++++ internal/daemon/clipboard.go | 161 ++++++++++++++++++++++++++++++ internal/daemon/clipboard_test.go | 135 +++++++++++++++++++++++++ internal/daemon/protocol.go | 6 ++ internal/daemon/server.go | 80 ++++++++++++++- 6 files changed, 413 insertions(+), 5 deletions(-) create mode 100644 internal/daemon/clipboard.go create mode 100644 internal/daemon/clipboard_test.go diff --git a/contrib/kit-tunnel/src/main.rs b/contrib/kit-tunnel/src/main.rs index d9e49e8f..213c7815 100644 --- a/contrib/kit-tunnel/src/main.rs +++ b/contrib/kit-tunnel/src/main.rs @@ -443,7 +443,10 @@ fn send_to_go(f: &Frame) -> bool { } async fn serve(flags: &Flags) { - let secret_bytes = parse_seed(&flags.get("secret-hex")); + let secret_bytes = parse_seed(&secret_material(flags, "KIT_TUNNEL_SECRET")); + if secret_bytes.len() != 32 { + fail("daemon identity seed must be 32 bytes"); + } let secret = secret_from_seed(&secret_bytes); let endpoint = Endpoint::builder(presets::N0) @@ -1052,7 +1055,10 @@ async fn relay_client_session(mut send: SendStream, mut recv: RecvStream, sessio /// id. The Go side enforces the window timeout; every wait here is also /// bounded so a stalled peer cannot pin the task. async fn serve_pair(flags: &Flags) { - let seed = parse_seed(&flags.get("pair-seed-hex")); + let seed = parse_seed(&secret_material(flags, "KIT_TUNNEL_PAIR_SEED")); + if seed.len() != 32 { + fail("pairing seed must be 32 bytes"); + } let key = Arc::new(auth_key(&seed)); let secret = secret_from_seed(&seed); diff --git a/internal/daemon/client.go b/internal/daemon/client.go index 44c2a7c1..cf1f5608 100644 --- a/internal/daemon/client.go +++ b/internal/daemon/client.go @@ -5,6 +5,8 @@ import ( "context" "fmt" "os" + + "github.com/mark3labs/kit/internal/clipboard" "strings" "sync" "sync/atomic" @@ -18,6 +20,13 @@ import ( // forwarding the keystroke. const detachKey = 0x1d +// pasteKey is Ctrl-V. In a remote session the client intercepts a bare +// Ctrl-V: it reads THIS machine's clipboard and streams any image to the +// daemon as FrameClipboard chunks (the host TUI would otherwise read the +// host's clipboard, which is the wrong one). When the clipboard holds no +// image the keystroke is forwarded verbatim. +const pasteKey = 0x16 + // terminalResetSeq restores terminal modes the remote TUI may have enabled // and we may not have seen disabled: alt screen off, cursor on, mouse and // bracketed paste off, kitty keyboard protocol popped. Emitted by the @@ -214,6 +223,23 @@ func RunHost(ctx context.Context, name string) error { detached.Store(true) return } + if n == 1 && buf[0] == pasteKey { + // Image paste: read the local clipboard and stream any + // image to the daemon. No image — forward the keystroke + // so the host keeps its normal Ctrl-V behavior. + if img, err := clipboard.ReadImage(); err == nil && len(img.Data) > 0 { + writeMu.Lock() + for _, payload := range EncodeClipboardChunks(img.MediaType, img.Data) { + if werr := WriteFrame(tun.Stdin(), FrameClipboard, 0, payload); werr != nil { + writeMu.Unlock() + return + } + } + writeMu.Unlock() + fmt.Fprintln(os.Stderr, "Image sent from local clipboard.") + continue + } + } writeMu.Lock() werr := WriteDataFrames(tun.Stdin(), 0, buf[:n]) writeMu.Unlock() diff --git a/internal/daemon/clipboard.go b/internal/daemon/clipboard.go new file mode 100644 index 00000000..9897d53f --- /dev/null +++ b/internal/daemon/clipboard.go @@ -0,0 +1,161 @@ +package daemon + +import ( + "errors" + "fmt" +) + +// Clipboard image transfer over the session wire. +// +// Terminals cannot paste binary image data through bracketed paste, and the +// host TUI reads the HOST's clipboard on ctrl+v — which is the wrong one +// when the session is driven via `kit remote`. Instead the CLIENT +// intercepts a bare ctrl+v, reads the client machine's clipboard +// (internal/clipboard), and streams the image to the daemon as chunked +// FrameClipboard frames. The daemon reassembles the bytes into a tempfile +// and injects @"" into the session child's input, where the +// normal @-attachment pipeline takes over (MIME detection, preview, +// multimodal submission). +// +// The sidecar relays FrameClipboard verbatim like DATA/RESIZE — no sidecar +// changes. Frames are tied to the session id like every other frame. +// +// FrameClipboard payload layout (client -> daemon): +// +// byte 0 flags: bit 0 (0x01) = final chunk +// first chunk only: +// byte 1 media type length (n) +// bytes 2..2+n media type, e.g. "image/png" +// bytes 2+n.. first image bytes +// continuation chunks: bytes 1.. are image bytes +// +// Chunk data uses the same 16 KiB budget as PTY DATA frames, keeping every +// frame under maxPayload. + +const ( + // FrameClipboardFlagFinal marks the last chunk of a clipboard transfer. + FrameClipboardFlagFinal byte = 0x01 + + // clipboardMaxImageSize caps reassembly so a hostile or buggy client + // cannot exhaust daemon memory. Far beyond any real screenshot. + clipboardMaxImageSize = 32 << 20 +) + +// clipboardChunkSize is the per-frame image byte budget (16 KiB). +const clipboardChunkSize = 16 * 1024 + +var ( + // ErrClipboardTooLarge is returned by the collector when a transfer + // exceeds clipboardMaxImageSize. + ErrClipboardTooLarge = errors.New("clipboard image too large") +) + +// EncodeClipboardChunks splits an image into FrameClipboard payloads. The +// first chunk carries the media type; each payload's low bit marks the +// final chunk. +func EncodeClipboardChunks(mediaType string, data []byte) [][]byte { + // First-chunk budget: flags(1) + mediaLen(1) + mediaType + data. + const maxMedia = 255 + media := mediaType + if len(media) > maxMedia { + media = media[:maxMedia] + } + first := 1 + 1 + len(media) + n := len(data) + // Data split across chunks: the first chunk carries (clipboardChunkSize - first). + var chunks [][]byte + if n == 0 { + // Empty image: single frame, no data. + p := []byte{FrameClipboardFlagFinal, byte(len(media))} + p = append(p, media...) + return [][]byte{p} + } + firstData := clipboardChunkSize - first + if firstData <= 0 { + firstData = 1 // pathological media type; still make progress + } + count := 1 + remaining := n - firstData + if remaining > 0 { + count += (remaining + clipboardChunkSize - 1) / clipboardChunkSize + } + for i := 0; i < count; i++ { + last := i == count-1 + var p []byte + flags := byte(0) + if last { + flags |= FrameClipboardFlagFinal + } + if i == 0 { + p = append(p, flags, byte(len(media))) + p = append(p, media...) + end := min(firstData, n) + p = append(p, data[:end]...) + } else { + p = append(p, flags) + start := firstData + (i-1)*clipboardChunkSize + end := min(start+clipboardChunkSize, n) + p = append(p, data[start:end]...) + } + chunks = append(chunks, p) + } + return chunks +} + +// ClipboardCollector reassembles a chunked clipboard transfer. +type ClipboardCollector struct { + media string + buf []byte + started bool +} + +// NewClipboardCollector returns a collector for one image transfer. +func NewClipboardCollector() *ClipboardCollector { + return &ClipboardCollector{} +} + +// Add consumes one FrameClipboard payload. On the final chunk it returns +// the complete image; the collector must then be discarded. +func (c *ClipboardCollector) Add(payload []byte) (done bool, mediaType string, data []byte, err error) { + if len(payload) < 1 { + return false, "", nil, fmt.Errorf("empty clipboard chunk") + } + final := payload[0]&FrameClipboardFlagFinal != 0 + body := payload[1:] + if !c.started { + if len(body) < 1 { + return false, "", nil, fmt.Errorf("clipboard chunk missing media type") + } + n := int(body[0]) + if len(body) < 1+n { + return false, "", nil, fmt.Errorf("truncated clipboard media type") + } + c.media = string(body[1 : 1+n]) + body = body[1+n:] + c.started = true + } + c.buf = append(c.buf, body...) + if len(c.buf) > clipboardMaxImageSize { + return false, "", nil, ErrClipboardTooLarge + } + if final { + return true, c.media, c.buf, nil + } + return false, "", nil, nil +} + +// mediaExtension maps an image media type to a tempfile extension. +func mediaExtension(mediaType string) string { + switch mediaType { + case "image/png": + return ".png" + case "image/jpeg": + return ".jpg" + case "image/gif": + return ".gif" + case "image/webp": + return ".webp" + default: + return ".bin" + } +} diff --git a/internal/daemon/clipboard_test.go b/internal/daemon/clipboard_test.go new file mode 100644 index 00000000..2d5d6c3b --- /dev/null +++ b/internal/daemon/clipboard_test.go @@ -0,0 +1,135 @@ +package daemon + +import ( + "bytes" + "encoding/hex" + "testing" +) + +func TestEncodeClipboardChunksSingleChunk(t *testing.T) { + media := "image/png" + data := bytes.Repeat([]byte{0xAB}, 100) + chunks := EncodeClipboardChunks(media, data) + if len(chunks) != 1 { + t.Fatalf("chunk count = %d, want 1", len(chunks)) + } + p := chunks[0] + if p[0]&FrameClipboardFlagFinal == 0 { + t.Fatal("single chunk must be marked final") + } + if p[1] != byte(len(media)) { + t.Fatalf("media len = %d, want %d", p[1], len(media)) + } + if got := string(p[2 : 2+len(media)]); got != media { + t.Fatalf("media = %q, want %q", got, media) + } + if !bytes.Equal(p[2+len(media):], data) { + t.Fatal("data mismatch") + } +} + +func TestEncodeClipboardChunksMultiChunkRoundTrip(t *testing.T) { + media := "image/jpeg" + data := bytes.Repeat([]byte{0x42}, clipboardChunkSize*3+17) // 4 chunks + chunks := EncodeClipboardChunks(media, data) + if len(chunks) != 4 { + t.Fatalf("chunk count = %d, want 4", len(chunks)) + } + coll := NewClipboardCollector() + var got []byte + var gotMedia string + for i, p := range chunks { + done, m, d, err := coll.Add(p) + if err != nil { + t.Fatalf("chunk %d: %v", i, err) + } + if i < len(chunks)-1 && done { + t.Fatalf("chunk %d reported done early", i) + } + if done { + got, gotMedia = d, m + } + } + if gotMedia != media { + t.Fatalf("media = %q, want %q", gotMedia, media) + } + if !bytes.Equal(got, data) { + t.Fatalf("round-trip mismatch: got %d bytes, want %d", len(got), len(data)) + } +} + +func TestClipboardCollectorRejectsOversize(t *testing.T) { + coll := NewClipboardCollector() + big := bytes.Repeat([]byte{0x01}, clipboardChunkSize) + // Simulate a stream that never ends and exceeds the cap. + for range clipboardMaxImageSize/clipboardChunkSize + 2 { + done, _, _, err := coll.Add(append([]byte{0}, big...)) + if err == ErrClipboardTooLarge { + return // expected once over the cap + } + if done { + t.Fatal("unexpected completion") + } + } + t.Fatal("oversize transfer was never rejected") +} + +func TestClipboardCollectorTruncatedMedia(t *testing.T) { + coll := NewClipboardCollector() + // First chunk declares 10 bytes of media type but carries none. + if _, _, _, err := coll.Add([]byte{0, 10}); err == nil { + t.Fatal("expected error for truncated media type") + } +} + +func TestClipboardCollectorEmptyImage(t *testing.T) { + chunks := EncodeClipboardChunks("image/png", nil) + done, media, data, err := NewClipboardCollector().Add(chunks[0]) + if err != nil || !done { + t.Fatalf("empty image should complete immediately: done=%v err=%v", done, err) + } + if media != "image/png" || len(data) != 0 { + t.Fatalf("unexpected empty image: media=%q len=%d", media, len(data)) + } +} + +func TestMediaExtension(t *testing.T) { + cases := map[string]string{ + "image/png": ".png", + "image/jpeg": ".jpg", + "image/gif": ".gif", + "image/webp": ".webp", + "weird/type": ".bin", + } + for media, want := range cases { + if got := mediaExtension(media); got != want { + t.Fatalf("mediaExtension(%q) = %q, want %q", media, got, want) + } + } +} + +func TestChunkPayloadsStayUnderMaxPayload(t *testing.T) { + data := bytes.Repeat([]byte{0x77}, clipboardChunkSize*5) + for i, p := range EncodeClipboardChunks("image/png", data) { + if len(p) > maxPayload { + t.Fatalf("chunk %d is %d bytes, exceeds maxPayload %d", i, len(p), maxPayload) + } + } +} + +// Sanity: the injection text uses the quoted form the @-tokenizer accepts. +func TestInjectionQuotingMatchesTokenizer(t *testing.T) { + path := "/tmp/kit-clip-12345.png" + injected := "@" + hexOrQuote(path) + " " + // The tokenizer pattern @"[^"]+"|@[^\s]+ must match the quoted form. + if injected != `@"/tmp/kit-clip-12345.png" ` { + t.Fatalf("unexpected injection: %q", injected) + } +} + +func hexOrQuote(path string) string { + return `"` + path + `"` +} + +// silence unused import in constrained builds +var _ = hex.EncodeToString diff --git a/internal/daemon/protocol.go b/internal/daemon/protocol.go index 53c791d7..ad7717ba 100644 --- a/internal/daemon/protocol.go +++ b/internal/daemon/protocol.go @@ -21,6 +21,12 @@ const ( FramePing FrameType = 0x04 FramePong FrameType = 0x05 + // Client -> daemon clipboard image transfer (chunked; see + // internal/daemon/clipboard.go for the payload layout). Relayed by the + // sidecar verbatim like DATA/RESIZE; consumed by the daemon, never + // written to the session PTY. + FrameClipboard FrameType = 0x06 + // Tunnel -> daemon session lifecycle (serve side only). FrameSessionOpen FrameType = 0x16 FrameSessionClosed FrameType = 0x17 diff --git a/internal/daemon/server.go b/internal/daemon/server.go index ecd35f7c..9b83e5de 100644 --- a/internal/daemon/server.go +++ b/internal/daemon/server.go @@ -117,8 +117,10 @@ type sessionTable struct { rt *daemonRuntime mu sync.Mutex sessions map[uint32]*remoteSession - pendingAuths map[[8]byte]authChallenge // confined to the frame loop - writeMu sync.Mutex // tunnel stdin is shared by all session pumps + pendingAuths map[[8]byte]authChallenge // confined to the frame loop + clipboards map[uint32]*ClipboardCollector // in-flight image transfers, frame loop only + sessionTemps map[uint32][]string // temp files injected into each child + writeMu sync.Mutex // tunnel stdin is shared by all session pumps } // writeTo sends one frame to the tunnel stdin. Errors are the caller's to @@ -135,6 +137,8 @@ func runSessions(ctx context.Context, tun *Tunnel, rt *daemonRuntime) error { rt: rt, sessions: make(map[uint32]*remoteSession), pendingAuths: make(map[[8]byte]authChallenge), + clipboards: make(map[uint32]*ClipboardCollector), + sessionTemps: make(map[uint32][]string), } defer table.teardownAll() @@ -151,6 +155,8 @@ func runSessions(ctx context.Context, tun *Tunnel, rt *daemonRuntime) error { table.handleAuthRequest(frame.Payload) case FrameAuthPayload: table.handleAuthPayload(frame.Payload) + case FrameClipboard: + table.handleClipboardChunk(frame.Session, frame.Payload) case FrameSessionOpen: table.openSession(frame.Session) case FrameSessionClosed, FrameBye: @@ -174,6 +180,64 @@ func runSessions(ctx context.Context, tun *Tunnel, rt *daemonRuntime) error { } } +// handleClipboardChunk reassembles a client clipboard image transfer. On +// the final chunk it writes the image to a tempfile and types +// @"" into the session child's input: the standard @-attachment +// pipeline then handles detection, preview and multimodal submission — +// the operator just presses Enter. +func (t *sessionTable) handleClipboardChunk(session uint32, payload []byte) { + coll := t.clipboards[session] + if coll == nil { + coll = NewClipboardCollector() + t.clipboards[session] = coll + } + done, mediaType, data, err := coll.Add(payload) + if err != nil { + log.Warn("clipboard transfer dropped", "session_id", session, "error", err) + delete(t.clipboards, session) + return + } + if !done { + return + } + delete(t.clipboards, session) + + s := t.get(session) + if s == nil { + return + } + tmp, err := os.CreateTemp("", "kit-clip-*"+mediaExtension(mediaType)) + if err != nil { + log.Error("daemon: clipboard tempfile failed", "session_id", session, "error", err) + return + } + path := tmp.Name() + if _, err := tmp.Write(data); err != nil { + _ = tmp.Close() + _ = os.Remove(path) + log.Error("daemon: clipboard tempfile write failed", "session_id", session, "error", err) + return + } + if err := tmp.Close(); err != nil { + _ = os.Remove(path) + log.Error("daemon: clipboard tempfile close failed", "session_id", session, "error", err) + return + } + + t.mu.Lock() + t.sessionTemps[session] = append(t.sessionTemps[session], path) + t.mu.Unlock() + + // Inject as a quoted @-reference (tokenizer supports @"path") followed + // by a space, so the operator can append text and hit Enter. Written as + // raw bytes into the child's PTY — printable characters only. + if _, err := fmt.Fprintf(s.ptmx, "@%q ", path); err != nil { + log.Error("daemon: clipboard inject failed", "session_id", session, "error", err) + return + } + log.Info("clipboard image attached", "session_id", session, "path", path, "bytes", len(data), "media_type", mediaType) +} + // handleAuthRequest stashes the handshake parameters so the signature can // be verified when the client's AUTH_PAYLOAD arrives. func (t *sessionTable) handleAuthRequest(payload []byte) { @@ -307,17 +371,27 @@ func (table *sessionTable) openSession(id uint32) { // by the pipe and applied right after registration. // closeSession tears down one session: tell the client we are done, stop -// the child, and free the table slot. Idempotent. +// the child, free the table slot, and remove any clipboard tempfiles that +// were injected but never consumed. Idempotent. func (t *sessionTable) closeSession(id uint32) { t.mu.Lock() s, ok := t.sessions[id] delete(t.sessions, id) + temps := t.sessionTemps[id] + delete(t.sessionTemps, id) + delete(t.clipboards, id) active := len(t.sessions) t.mu.Unlock() if !ok { + for _, p := range temps { + _ = os.Remove(p) + } return } t.rt.setSessions(active) + for _, p := range temps { + _ = os.Remove(p) + } _ = t.writeTo(Frame{Type: FrameBye, Session: id}) From 708a977e076b5ebfad2d280ba712c2f21c50afbd Mon Sep 17 00:00:00 2001 From: Ed Zynda Date: Sat, 29 Aug 2026 23:21:07 +0300 Subject: [PATCH 2/7] fix(daemon): address CodeRabbit review on remote image paste (#115) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - sidecar: secret_material takes the calling mode's flag name — serve can no longer silently substitute a pairing or client seed for its own identity when a stray sibling flag is present - daemon: handleClipboardChunk takes ctx and checks cancellation first (tempfile + PTY writes can block) - daemon: close the clipboards/session-lookup race — closeSession runs on child-exit goroutines while chunks arrive on the frame loop; all map access now happens under t.mu, the tempfile registers only after re-validating the session under the lock, and stragglers are removed instead of leaking --- contrib/kit-tunnel/src/main.rs | 36 ++++++++++++++---------- internal/daemon/server.go | 50 ++++++++++++++++++++++++++-------- 2 files changed, 61 insertions(+), 25 deletions(-) diff --git a/contrib/kit-tunnel/src/main.rs b/contrib/kit-tunnel/src/main.rs index 213c7815..fe114bea 100644 --- a/contrib/kit-tunnel/src/main.rs +++ b/contrib/kit-tunnel/src/main.rs @@ -219,16 +219,12 @@ fn parse_seed(hex_seed: &str) -> Vec { /// Key material never travels in argv (world-readable via ps); the Go side /// passes it in the child's environment and the mode flag selects the /// variable to read. -fn secret_material(flags: &Flags, env_var: &str) -> String { - if let Some(flag) = ["secret-hex", "pair-seed-hex", "client-seed-hex"] - .iter() - .find_map(|f| { - let v = flags.get(f); - (!v.is_empty()).then_some(v) - }) - { - // Direct hex flag (used by tests and manual runs). - return flag; +fn secret_material(flags: &Flags, flag_name: &str, env_var: &str) -> String { + // Only the calling mode's flag is honored: a stray sibling flag can + // never silently substitute the wrong key material. + let direct = flags.get(flag_name); + if !direct.is_empty() { + return direct; } std::env::var(env_var).unwrap_or_else(|_| fail(&format!("missing key material: set {env_var}"))) } @@ -443,7 +439,7 @@ fn send_to_go(f: &Frame) -> bool { } async fn serve(flags: &Flags) { - let secret_bytes = parse_seed(&secret_material(flags, "KIT_TUNNEL_SECRET")); + let secret_bytes = parse_seed(&secret_material(flags, "secret-hex", "KIT_TUNNEL_SECRET")); if secret_bytes.len() != 32 { fail("daemon identity seed must be 32 bytes"); } @@ -778,7 +774,11 @@ async fn handle_connection( // --------------------------------------------------------------------------- async fn dial_pair(flags: &Flags) { - let seed = parse_seed(&secret_material(flags, "KIT_TUNNEL_PAIR_SEED")); + let seed = parse_seed(&secret_material( + flags, + "pair-seed-hex", + "KIT_TUNNEL_PAIR_SEED", + )); if seed.len() != 32 { fail("pairing seed must be 32 bytes"); } @@ -876,7 +876,11 @@ async fn dial_host(flags: &Flags) { } let server_id = EndpointId::from_bytes(&server_bytes.try_into().expect("checked above")) .unwrap_or_else(|e| fail(&format!("bad endpoint id: {e}"))); - let signing_seed = parse_seed(&secret_material(flags, "KIT_TUNNEL_CLIENT_SEED")); + let signing_seed = parse_seed(&secret_material( + flags, + "client-seed-hex", + "KIT_TUNNEL_CLIENT_SEED", + )); if signing_seed.len() != 32 { fail("client seed must be 32 bytes"); } @@ -1055,7 +1059,11 @@ async fn relay_client_session(mut send: SendStream, mut recv: RecvStream, sessio /// id. The Go side enforces the window timeout; every wait here is also /// bounded so a stalled peer cannot pin the task. async fn serve_pair(flags: &Flags) { - let seed = parse_seed(&secret_material(flags, "KIT_TUNNEL_PAIR_SEED")); + let seed = parse_seed(&secret_material( + flags, + "pair-seed-hex", + "KIT_TUNNEL_PAIR_SEED", + )); if seed.len() != 32 { fail("pairing seed must be 32 bytes"); } diff --git a/internal/daemon/server.go b/internal/daemon/server.go index 9b83e5de..bdd58e17 100644 --- a/internal/daemon/server.go +++ b/internal/daemon/server.go @@ -156,7 +156,7 @@ func runSessions(ctx context.Context, tun *Tunnel, rt *daemonRuntime) error { case FrameAuthPayload: table.handleAuthPayload(frame.Payload) case FrameClipboard: - table.handleClipboardChunk(frame.Session, frame.Payload) + table.handleClipboardChunk(ctx, frame.Session, frame.Payload) case FrameSessionOpen: table.openSession(frame.Session) case FrameSessionClosed, FrameBye: @@ -185,27 +185,41 @@ func runSessions(ctx context.Context, tun *Tunnel, rt *daemonRuntime) error { // @"" into the session child's input: the standard @-attachment // pipeline then handles detection, preview and multimodal submission — // the operator just presses Enter. -func (t *sessionTable) handleClipboardChunk(session uint32, payload []byte) { - coll := t.clipboards[session] - if coll == nil { +// +// Locking: closeSession may run on any goroutine (child exit), so every +// clipboards/sessionTemps/sessions map access happens under t.mu. The +// collector object itself is only ever touched by the frame loop. +func (t *sessionTable) handleClipboardChunk(ctx context.Context, session uint32, payload []byte) { + if ctx.Err() != nil { + return + } + t.mu.Lock() + coll, ok := t.clipboards[session] + if !ok { coll = NewClipboardCollector() t.clipboards[session] = coll } + _, live := t.sessions[session] + t.mu.Unlock() + if !live { + return // session already torn down; ignore stragglers + } + done, mediaType, data, err := coll.Add(payload) if err != nil { - log.Warn("clipboard transfer dropped", "session_id", session, "error", err) + t.mu.Lock() delete(t.clipboards, session) + t.mu.Unlock() + log.Warn("clipboard transfer dropped", "session_id", session, "error", err) return } if !done { return } + t.mu.Lock() delete(t.clipboards, session) + t.mu.Unlock() - s := t.get(session) - if s == nil { - return - } tmp, err := os.CreateTemp("", "kit-clip-*"+mediaExtension(mediaType)) if err != nil { log.Error("daemon: clipboard tempfile failed", "session_id", session, "error", err) @@ -224,14 +238,28 @@ func (t *sessionTable) handleClipboardChunk(session uint32, payload []byte) { return } + // Register the tempfile and re-validate the session under the same + // lock: if teardown ran while the transfer completed, the file is + // removed again instead of leaking. t.mu.Lock() - t.sessionTemps[session] = append(t.sessionTemps[session], path) + s, live := t.sessions[session] + if live { + t.sessionTemps[session] = append(t.sessionTemps[session], path) + } + var ptmx *os.File + if live && s != nil { + ptmx = s.ptmx + } t.mu.Unlock() + if !live { + _ = os.Remove(path) + return + } // Inject as a quoted @-reference (tokenizer supports @"path") followed // by a space, so the operator can append text and hit Enter. Written as // raw bytes into the child's PTY — printable characters only. - if _, err := fmt.Fprintf(s.ptmx, "@%q ", path); err != nil { + if _, err := fmt.Fprintf(ptmx, "@%q ", path); err != nil { log.Error("daemon: clipboard inject failed", "session_id", session, "error", err) return } From ca9c88a00f2469b74cc36451da3f3b6b60866122 Mon Sep 17 00:00:00 2001 From: Ed Zynda Date: Sun, 30 Aug 2026 00:11:12 +0300 Subject: [PATCH 3/7] fix(daemon): intercept Ctrl-V under the kitty keyboard protocol MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Over 'kit remote', the host TUI enables the kitty keyboard protocol on the user's terminal through the PTY (Bubble Tea v2 always sets disambiguation, and kit adds report-event-types). In that mode a Ctrl-V keypress arrives as 'CSI 118 ; 5 u' (press) and 'CSI 118 ; 5 : 3 u' (release) instead of the legacy 0x16 byte, so the client's clipboard interception never fired and image paste silently did nothing — the host TUI then read the host's clipboard. Add keyScanner, an incremental CSI-u/legacy decoder on the client's stdin stream: Ctrl-V presses/repeats are detected in both encodings (and legacy 0x16), the matching release is swallowed after an intercepted press, and every other byte — mouse reports, bracketed paste, plain keys, other CSI sequences, sequences split across reads — is forwarded byte-identical. Verified on two hosts: capture harness confirmed the wire encoding from a real kitty window; a plain (non-tmux) kitty window running 'kit remote' against violet's daemon pasted the local Wayland clipboard image — tempfile written, @-reference injected, attach logged. --- internal/daemon/client.go | 47 +++++--- internal/daemon/keyscan.go | 183 ++++++++++++++++++++++++++++++++ internal/daemon/keyscan_test.go | 115 ++++++++++++++++++++ 3 files changed, 328 insertions(+), 17 deletions(-) create mode 100644 internal/daemon/keyscan.go create mode 100644 internal/daemon/keyscan_test.go diff --git a/internal/daemon/client.go b/internal/daemon/client.go index cf1f5608..05e893c0 100644 --- a/internal/daemon/client.go +++ b/internal/daemon/client.go @@ -212,6 +212,7 @@ func RunHost(ctx context.Context, name string) error { // Local keystrokes -> remote. A lone Ctrl-] detaches. go func() { defer finish() + scanner := &keyScanner{} buf := make([]byte, 256) for { n, err := os.Stdin.Read(buf) @@ -223,29 +224,41 @@ func RunHost(ctx context.Context, name string) error { detached.Store(true) return } - if n == 1 && buf[0] == pasteKey { - // Image paste: read the local clipboard and stream any - // image to the daemon. No image — forward the keystroke - // so the host keeps its normal Ctrl-V behavior. - if img, err := clipboard.ReadImage(); err == nil && len(img.Data) > 0 { - writeMu.Lock() - for _, payload := range EncodeClipboardChunks(img.MediaType, img.Data) { - if werr := WriteFrame(tun.Stdin(), FrameClipboard, 0, payload); werr != nil { - writeMu.Unlock() + for _, ev := range scanner.Feed(buf[:n]) { + if ev.Paste { + // Image paste: read the local clipboard and stream + // any image to the daemon. No image — forward the + // keystroke so the host keeps its normal Ctrl-V + // behavior. + img, imgErr := clipboard.ReadImage() + if imgErr == nil && len(img.Data) > 0 { + writeMu.Lock() + sent := true + for _, payload := range EncodeClipboardChunks(img.MediaType, img.Data) { + if werr := WriteFrame(tun.Stdin(), FrameClipboard, 0, payload); werr != nil { + sent = false + break + } + } + writeMu.Unlock() + if !sent { return } + fmt.Fprintln(os.Stderr, "Image sent from local clipboard.") + continue } + // No image: fall through and forward the original + // wire bytes below. + } + if len(ev.Data) > 0 { + writeMu.Lock() + werr := WriteDataFrames(tun.Stdin(), 0, ev.Data) writeMu.Unlock() - fmt.Fprintln(os.Stderr, "Image sent from local clipboard.") - continue + if werr != nil { + return + } } } - writeMu.Lock() - werr := WriteDataFrames(tun.Stdin(), 0, buf[:n]) - writeMu.Unlock() - if werr != nil { - return - } } if err != nil { return diff --git a/internal/daemon/keyscan.go b/internal/daemon/keyscan.go new file mode 100644 index 00000000..a1fd3a70 --- /dev/null +++ b/internal/daemon/keyscan.go @@ -0,0 +1,183 @@ +package daemon + +import ( + "strconv" + "strings" +) + +// Kitty keyboard protocol scanning for the remote client. +// +// The host TUI (Bubble Tea v2) enables the kitty keyboard protocol on the +// user's terminal through the PTY (`CSI = 3 ; 1 u`: disambiguate + report +// event types). Once active, a Ctrl-V keystroke no longer arrives as the +// legacy single byte 0x16 but as CSI sequences: +// +// press ESC [ 118 ; 5 u +// release ESC [ 118 ; 5 : 3 u +// repeat ESC [ 118 ; 5 : 2 u +// +// keyScanner is an incremental parser over the client's stdin stream that +// reports Ctrl-V presses in both encodings so the client can attach local +// clipboard images. Everything else — including split sequences — is +// forwarded byte-identical. + +const ( + // keyV is the kitty key code for 'v'. + keyV = 118 + // kittyModCtrl is the ctrl bit in the kitty modifier encoding (the + // modifier value is 1 + shift|alt|ctrl). + kittyModCtrl = 4 +) + +// kittyEventKind classifies a decoded CSI-u event for the 'v' key. +type kittyEventKind int + +const ( + kittyPress kittyEventKind = iota + kittyRepeat + kittyRelease +) + +type keyEvent struct { + // Paste is true when the event is a Ctrl-V press (or repeat) in any + // supported encoding. The original wire bytes are in Data. + Paste bool + // Release is true for a Ctrl-V release event. + Release bool + // Data is the original wire bytes for passthrough. + Data []byte +} + +// keyScanner is an incremental CSI-u/legacy key scanner. +type keyScanner struct { + buf []byte // pending partial escape sequence + inCSI bool // saw ESC [ — accumulating until a final byte + swallowRel bool // swallow the next ctrl+v release (press was consumed) +} + +// Feed consumes one stdin chunk and returns the decoded events. Events are +// in wire order; a paste press sets the internal flag so the matching +// release is swallowed instead of forwarded. +func (k *keyScanner) Feed(chunk []byte) []keyEvent { + var events []keyEvent + var other []byte // accumulated passthrough bytes + + i := 0 + var emitOther = func() { + if len(other) > 0 { + events = append(events, keyEvent{Data: other}) + other = nil + } + } + for i < len(chunk) { + b := chunk[i] + switch { + case len(k.buf) > 0 && !k.inCSI: + // After ESC: '[' introduces a CSI sequence; anything else is + // a two-byte legacy escape — pass both through. + if b == '[' { + k.buf = append(k.buf, b) + k.inCSI = true + } else { + other = append(other, k.buf...) + other = append(other, b) + k.buf = k.buf[:0] + } + case len(k.buf) > 0 && k.inCSI: + // Inside a CSI sequence: params/intermediates are 0x20-0x3f, + // the final byte is 0x40-0x7e. + k.buf = append(k.buf, b) + if b >= 0x40 && b <= 0x7e { + // Final byte: decode the sequence, then consume it. + seq := append([]byte(nil), k.buf...) + k.buf = k.buf[:0] + k.inCSI = false + i++ + paste, release, handled := k.decodeCSI(seq) + if handled { + emitOther() + if paste { + events = append(events, keyEvent{Paste: true}) + continue + } + if release { + if k.swallowRel { + k.swallowRel = false + continue + } + events = append(events, keyEvent{Release: true, Data: seq}) + continue + } + } + other = append(other, seq...) + } + case b == 0x1b: + // Start of a potential escape sequence. + k.buf = append(k.buf[:0], b) + case b == pasteKey: + // Legacy encoding of Ctrl-V. + emitOther() + k.swallowRel = false + events = append(events, keyEvent{Paste: true}) + default: + other = append(other, b) + } + i++ + } + emitOther() + return events +} + +// decodeCSI inspects a complete CSI sequence. It reports ctrl+v press, +// repeat and release events; everything else is passthrough. +func (k *keyScanner) decodeCSI(seq []byte) (paste, release, handled bool) { + // Shape: ESC [ params final; params are 0x30-0x3f, final 0x40-0x7e. + if len(seq) < 3 || seq[0] != 0x1b || seq[1] != '[' { + return false, false, false + } + final := seq[len(seq)-1] + if final != 'u' { + return false, false, false + } + params := strings.Split(string(seq[2:len(seq)-1]), ";") + if len(params) == 0 { + return false, false, false + } + // First parameter: key code (with optional :alternate). + key, _, ok := strings.Cut(params[0], ":") + if !ok { + key = params[0] + } + if key != strconv.Itoa(keyV) { + return false, false, false + } + mod := 1 + event := kittyPress + if len(params) > 1 { + modPart, evPart, hasEv := strings.Cut(params[1], ":") + if m, err := strconv.Atoi(modPart); err == nil { + mod = m + } + if hasEv { + if e, err := strconv.Atoi(evPart); err == nil { + switch e { + case 2: + event = kittyRepeat + case 3: + event = kittyRelease + } + } + } + } + if (mod-1)&kittyModCtrl == 0 { + return false, false, false // no ctrl held — plain 'v' + } + switch event { + case kittyPress, kittyRepeat: + k.swallowRel = true + return true, false, true + case kittyRelease: + return false, true, true + } + return false, false, false +} diff --git a/internal/daemon/keyscan_test.go b/internal/daemon/keyscan_test.go new file mode 100644 index 00000000..3be535b7 --- /dev/null +++ b/internal/daemon/keyscan_test.go @@ -0,0 +1,115 @@ +package daemon + +import ( + "bytes" + "testing" +) + +// The wire bytes captured from a real kitty window with the protocol +// enabled the way the host TUI does (CSI = 3 ; 1 u). +var ( + kittyCtrlVPress = []byte{0x1b, '[', '1', '1', '8', ';', '5', 'u'} + kittyCtrlVRelease = []byte{0x1b, '[', '1', '1', '8', ';', '5', ':', '3', 'u'} + kittyCtrlVRepeat = []byte{0x1b, '[', '1', '1', '8', ';', '5', ':', '2', 'u'} + kittyAPressRel = []byte{0x1b, '[', '9', '7', ';', '1', ':', '3', 'u'} +) + +func TestKeyScannerKittyCtrlVPress(t *testing.T) { + k := &keyScanner{} + evs := k.Feed(kittyCtrlVPress) + if len(evs) != 1 || !evs[0].Paste { + t.Fatalf("expected paste event, got %+v", evs) + } +} + +func TestKeyScannerKittyCtrlVReleaseSwallowedAfterPress(t *testing.T) { + k := &keyScanner{} + _ = k.Feed(kittyCtrlVPress) + evs := k.Feed(kittyCtrlVRelease) + if len(evs) != 0 { + t.Fatalf("release after press should be swallowed, got %+v", evs) + } +} + +func TestKeyScannerKittyCtrlVReleaseForwardedWithoutPress(t *testing.T) { + k := &keyScanner{} + evs := k.Feed(kittyCtrlVRelease) + if len(evs) != 1 || !evs[0].Release || !bytes.Equal(evs[0].Data, kittyCtrlVRelease) { + t.Fatalf("release without press should forward, got %+v", evs) + } +} + +func TestKeyScannerLegacyCtrlV(t *testing.T) { + k := &keyScanner{} + evs := k.Feed([]byte{pasteKey}) + if len(evs) != 1 || !evs[0].Paste { + t.Fatalf("legacy ctrl+v should be a paste event, got %+v", evs) + } +} + +func TestKeyScannerPlainKeyPassesThrough(t *testing.T) { + k := &keyScanner{} + evs := k.Feed([]byte{'a'}) + if len(evs) != 1 || evs[0].Paste || !bytes.Equal(evs[0].Data, []byte{'a'}) { + t.Fatalf("plain key should pass through, got %+v", evs) + } + k = &keyScanner{} + evs = k.Feed(kittyAPressRel) + if len(evs) != 1 || evs[0].Paste || !bytes.Equal(evs[0].Data, kittyAPressRel) { + t.Fatalf("kitty plain key should pass through, got %+v", evs) + } +} + +func TestKeyScannerSequenceSplitAcrossChunks(t *testing.T) { + k := &keyScanner{} + evs := k.Feed(kittyCtrlVPress[:3]) + if len(evs) != 0 { + t.Fatalf("partial sequence should emit nothing, got %+v", evs) + } + evs = k.Feed(kittyCtrlVPress[3:]) + if len(evs) != 1 || !evs[0].Paste { + t.Fatalf("expected paste event after split feed, got %+v", evs) + } +} + +func TestKeyScannerRepeatIsPaste(t *testing.T) { + k := &keyScanner{} + evs := k.Feed(kittyCtrlVRepeat) + if len(evs) != 1 || !evs[0].Paste { + t.Fatalf("repeat should be a paste event, got %+v", evs) + } +} + +func TestKeyScannerMouseAndNonVUSequencesPassThrough(t *testing.T) { + k := &keyScanner{} + seqs := [][]byte{ + {0x1b, '[', '<', '0', ';', '5', ';', '1', '0', 'M'}, + {0x1b, '[', '2', '0', '0', '~'}, + {0x1b, '[', '9', '7', ';', '5', 'u'}, + {0x1b, '[', '1', '1', '8', ';', '1', 'u'}, + } + for i, seq := range seqs { + evs := k.Feed(seq) + if len(evs) != 1 || evs[0].Paste || !bytes.Equal(evs[0].Data, seq) { + t.Fatalf("seq %d should pass through unchanged, got %+v", i, evs) + } + } +} + +func TestKeyScannerMixedBatch(t *testing.T) { + k := &keyScanner{} + chunk := append(append([]byte("abc"), kittyCtrlVPress...), 'x', 'y') + evs := k.Feed(chunk) + if len(evs) != 3 { + t.Fatalf("expected data+paste+data, got %+v", evs) + } + if !bytes.Equal(evs[0].Data, []byte("abc")) || evs[0].Paste { + t.Fatalf("first event mismatch: %+v", evs[0]) + } + if !evs[1].Paste { + t.Fatalf("second event should be paste: %+v", evs[1]) + } + if !bytes.Equal(evs[2].Data, []byte("xy")) { + t.Fatalf("third event mismatch: %+v", evs[2]) + } +} From a558b24dfa63bfce9746d863d36fe22d95555c86 Mon Sep 17 00:00:00 2001 From: Ed Zynda Date: Sun, 30 Aug 2026 00:25:43 +0300 Subject: [PATCH 4/7] fix(daemon): harden keyScanner escape-sequence handling (#115) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address the CodeRabbit re-review findings on the client key scanner: - a lone Escape is flushed as passthrough when the next read arrives — it previously stayed in the partial-sequence buffer forever, eating the user's Esc key in remote sessions - a CSI sequence exceeding maxCSILen without a final byte is flushed as malformed passthrough instead of buffering without bound - an unhandled CSI sequence no longer drops the byte following it (the final byte was consumed twice: once by the decoder, once by the loop) - paste and release events carry their original wire bytes and the scanner never decides suppression: the client marks the matching release for suppression only after an image interception actually succeeded, so a text-only clipboard still forwards the full Ctrl-V Regression tests: lone-Escape flush, oversize CSI flush with recovery, no byte loss after unhandled sequences, event byte fidelity. --- internal/daemon/client.go | 11 ++++-- internal/daemon/keyscan.go | 65 +++++++++++++++++++++++---------- internal/daemon/keyscan_test.go | 61 +++++++++++++++++++++++++++---- 3 files changed, 107 insertions(+), 30 deletions(-) diff --git a/internal/daemon/client.go b/internal/daemon/client.go index 05e893c0..4044f9d3 100644 --- a/internal/daemon/client.go +++ b/internal/daemon/client.go @@ -213,6 +213,7 @@ func RunHost(ctx context.Context, name string) error { go func() { defer finish() scanner := &keyScanner{} + suppressRel := false // swallow the ctrl+v release after a successful image interception buf := make([]byte, 256) for { n, err := os.Stdin.Read(buf) @@ -244,11 +245,15 @@ func RunHost(ctx context.Context, name string) error { if !sent { return } + suppressRel = true // the matching release is ours fmt.Fprintln(os.Stderr, "Image sent from local clipboard.") - continue + continue // swallow the press bytes } - // No image: fall through and forward the original - // wire bytes below. + suppressRel = false // forwarding the press; forward its release too + } + if ev.Release && suppressRel { + suppressRel = false + continue } if len(ev.Data) > 0 { writeMu.Lock() diff --git a/internal/daemon/keyscan.go b/internal/daemon/keyscan.go index a1fd3a70..7af2deb0 100644 --- a/internal/daemon/keyscan.go +++ b/internal/daemon/keyscan.go @@ -19,7 +19,18 @@ import ( // keyScanner is an incremental parser over the client's stdin stream that // reports Ctrl-V presses in both encodings so the client can attach local // clipboard images. Everything else — including split sequences — is -// forwarded byte-identical. +// forwarded byte-identical, with two safety rules: +// +// - A lone Escape (a complete key press on its own) is flushed as +// passthrough when the next read arrives; it would otherwise be stuck +// in the partial-sequence buffer forever, eating the user's Esc key. +// - A CSI sequence that exceeds maxCSILen without a final byte is +// malformed input (e.g. pasted binary): it is flushed as passthrough +// instead of buffering without bound. +// +// The scanner never decides suppression: paste and release events carry +// their original wire bytes, and the client marks a release for suppression +// only after an image interception actually succeeded. const ( // keyV is the kitty key code for 'v'. @@ -27,6 +38,9 @@ const ( // kittyModCtrl is the ctrl bit in the kitty modifier encoding (the // modifier value is 1 + shift|alt|ctrl). kittyModCtrl = 4 + // maxCSILen bounds a partial CSI sequence: real sequences are far + // shorter; anything longer is treated as malformed passthrough. + maxCSILen = 64 ) // kittyEventKind classifies a decoded CSI-u event for the 'v' key. @@ -40,9 +54,10 @@ const ( type keyEvent struct { // Paste is true when the event is a Ctrl-V press (or repeat) in any - // supported encoding. The original wire bytes are in Data. + // supported encoding. Data holds the original wire bytes. Paste bool - // Release is true for a Ctrl-V release event. + // Release is true for a Ctrl-V release event. Data holds the original + // wire bytes. Release bool // Data is the original wire bytes for passthrough. Data []byte @@ -50,25 +65,32 @@ type keyEvent struct { // keyScanner is an incremental CSI-u/legacy key scanner. type keyScanner struct { - buf []byte // pending partial escape sequence - inCSI bool // saw ESC [ — accumulating until a final byte - swallowRel bool // swallow the next ctrl+v release (press was consumed) + buf []byte // pending partial escape sequence + inCSI bool // saw ESC [ — accumulating until a final byte } -// Feed consumes one stdin chunk and returns the decoded events. Events are -// in wire order; a paste press sets the internal flag so the matching -// release is swallowed instead of forwarded. +// Feed consumes one stdin chunk and returns the decoded events in wire +// order. A Ctrl-V press/repeat event is reported for both the kitty and +// legacy encodings, carrying the original bytes; a lone Escape pending +// from a previous chunk is flushed as passthrough when new input arrives. func (k *keyScanner) Feed(chunk []byte) []keyEvent { var events []keyEvent - var other []byte // accumulated passthrough bytes - - i := 0 + var other []byte var emitOther = func() { if len(other) > 0 { events = append(events, keyEvent{Data: other}) other = nil } } + + // A lone Escape from the previous chunk is a complete Esc key press: + // flush it before processing this chunk. + if len(k.buf) == 1 && k.buf[0] == 0x1b && !k.inCSI { + events = append(events, keyEvent{Data: append([]byte(nil), k.buf...)}) + k.buf = k.buf[:0] + } + + i := 0 for i < len(chunk) { b := chunk[i] switch { @@ -86,6 +108,14 @@ func (k *keyScanner) Feed(chunk []byte) []keyEvent { case len(k.buf) > 0 && k.inCSI: // Inside a CSI sequence: params/intermediates are 0x20-0x3f, // the final byte is 0x40-0x7e. + if len(k.buf) > maxCSILen { + // Malformed oversized sequence — flush as passthrough and + // treat this byte in ground state. + other = append(other, k.buf...) + k.buf = k.buf[:0] + k.inCSI = false + continue + } k.buf = append(k.buf, b) if b >= 0x40 && b <= 0x7e { // Final byte: decode the sequence, then consume it. @@ -97,19 +127,16 @@ func (k *keyScanner) Feed(chunk []byte) []keyEvent { if handled { emitOther() if paste { - events = append(events, keyEvent{Paste: true}) + events = append(events, keyEvent{Paste: true, Data: seq}) continue } if release { - if k.swallowRel { - k.swallowRel = false - continue - } events = append(events, keyEvent{Release: true, Data: seq}) continue } } other = append(other, seq...) + continue } case b == 0x1b: // Start of a potential escape sequence. @@ -117,8 +144,7 @@ func (k *keyScanner) Feed(chunk []byte) []keyEvent { case b == pasteKey: // Legacy encoding of Ctrl-V. emitOther() - k.swallowRel = false - events = append(events, keyEvent{Paste: true}) + events = append(events, keyEvent{Paste: true, Data: []byte{pasteKey}}) default: other = append(other, b) } @@ -174,7 +200,6 @@ func (k *keyScanner) decodeCSI(seq []byte) (paste, release, handled bool) { } switch event { case kittyPress, kittyRepeat: - k.swallowRel = true return true, false, true case kittyRelease: return false, true, true diff --git a/internal/daemon/keyscan_test.go b/internal/daemon/keyscan_test.go index 3be535b7..301514d0 100644 --- a/internal/daemon/keyscan_test.go +++ b/internal/daemon/keyscan_test.go @@ -17,17 +17,19 @@ var ( func TestKeyScannerKittyCtrlVPress(t *testing.T) { k := &keyScanner{} evs := k.Feed(kittyCtrlVPress) - if len(evs) != 1 || !evs[0].Paste { - t.Fatalf("expected paste event, got %+v", evs) + if len(evs) != 1 || !evs[0].Paste || !bytes.Equal(evs[0].Data, kittyCtrlVPress) { + t.Fatalf("expected paste event with original bytes, got %+v", evs) } } -func TestKeyScannerKittyCtrlVReleaseSwallowedAfterPress(t *testing.T) { +func TestKeyScannerKittyCtrlVReleaseCarriesData(t *testing.T) { k := &keyScanner{} _ = k.Feed(kittyCtrlVPress) evs := k.Feed(kittyCtrlVRelease) - if len(evs) != 0 { - t.Fatalf("release after press should be swallowed, got %+v", evs) + // The scanner never decides suppression: the release is reported with + // its bytes and the CLIENT drops it after a successful interception. + if len(evs) != 1 || !evs[0].Release || !bytes.Equal(evs[0].Data, kittyCtrlVRelease) { + t.Fatalf("release should be reported with bytes, got %+v", evs) } } @@ -42,8 +44,8 @@ func TestKeyScannerKittyCtrlVReleaseForwardedWithoutPress(t *testing.T) { func TestKeyScannerLegacyCtrlV(t *testing.T) { k := &keyScanner{} evs := k.Feed([]byte{pasteKey}) - if len(evs) != 1 || !evs[0].Paste { - t.Fatalf("legacy ctrl+v should be a paste event, got %+v", evs) + if len(evs) != 1 || !evs[0].Paste || !bytes.Equal(evs[0].Data, []byte{pasteKey}) { + t.Fatalf("legacy ctrl+v should be a paste event with bytes, got %+v", evs) } } @@ -113,3 +115,48 @@ func TestKeyScannerMixedBatch(t *testing.T) { t.Fatalf("third event mismatch: %+v", evs[2]) } } + +func TestKeyScannerLoneEscapeFlushedOnNextFeed(t *testing.T) { + k := &keyScanner{} + if evs := k.Feed([]byte{0x1b}); len(evs) != 0 { + t.Fatalf("lone ESC should stay pending in the same chunk, got %+v", evs) + } + evs := k.Feed([]byte{'x'}) + joined := []byte{} + for _, ev := range evs { + joined = append(joined, ev.Data...) + } + if !bytes.Equal(joined, []byte{0x1b, 'x'}) { + t.Fatalf("lone ESC should flush with the following input, got %+v", evs) + } +} + +func TestKeyScannerOversizeCSIFlushed(t *testing.T) { + k := &keyScanner{} + // A CSI that exceeds maxCSILen without a final byte is malformed. + evs := k.Feed(append([]byte{0x1b, '['}, bytes.Repeat([]byte{0x31}, maxCSILen+10)...)) + if len(evs) != 1 { + t.Fatalf("oversize CSI should flush as one data event, got %+v", evs) + } + // The scanner must recover and keep working. + evs = k.Feed(kittyCtrlVPress) + if len(evs) != 1 || !evs[0].Paste { + t.Fatalf("scanner should work after flushing, got %+v", evs) + } +} + +func TestKeyScannerUnhandledCSIDoesNotDropFollowingBytes(t *testing.T) { + k := &keyScanner{} + chunk := append(append([]byte{}, []byte{0x1b, '[', 'A'}...), 'x') + evs := k.Feed(chunk) + joined := []byte{} + for _, ev := range evs { + joined = append(joined, ev.Data...) + if ev.Paste || ev.Release { + t.Fatalf("ctrl+a up-arrow must not be a paste/release: %+v", ev) + } + } + if !bytes.Equal(joined, chunk) { + t.Fatalf("bytes lost: sent %d, got %d", len(chunk), len(joined)) + } +} From db217a9c6896a87a9ecfcb8148a72ddd94b88305 Mon Sep 17 00:00:00 2001 From: Ed Zynda Date: Sun, 30 Aug 2026 00:38:38 +0300 Subject: [PATCH 5/7] fix(daemon): flush a lone Escape on idle time, not chunk arrival (#115) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address the CodeRabbit re-review: flushing the pending Escape at the next Feed broke a CSI sequence whose read boundary falls right after ESC (e.g. ESC read, then [118;5u) — the paste detection was lost. The pending lone Escape now flushes only after escIdleFlush (50ms) of silence, the standard ttimeoutlen approach: a genuine standalone Esc key press is followed by human-scale silence, while a sequence split across reads continues composing. Regression tests cover both the idle flush and the split-after-ESC detection. --- internal/daemon/keyscan.go | 24 +++++++++++++++++++----- internal/daemon/keyscan_test.go | 19 +++++++++++++++++-- 2 files changed, 36 insertions(+), 7 deletions(-) diff --git a/internal/daemon/keyscan.go b/internal/daemon/keyscan.go index 7af2deb0..0de338d5 100644 --- a/internal/daemon/keyscan.go +++ b/internal/daemon/keyscan.go @@ -3,6 +3,7 @@ package daemon import ( "strconv" "strings" + "time" ) // Kitty keyboard protocol scanning for the remote client. @@ -41,6 +42,11 @@ const ( // maxCSILen bounds a partial CSI sequence: real sequences are far // shorter; anything longer is treated as malformed passthrough. maxCSILen = 64 + // escIdleFlush is how long a pending lone Escape waits for its + // sequence continuation before it is flushed as an Esc key press. A + // CSI sequence split across reads arrives within microseconds; a + // standalone Esc key press is followed by human-scale silence. + escIdleFlush = 50 * time.Millisecond ) // kittyEventKind classifies a decoded CSI-u event for the 'v' key. @@ -65,8 +71,9 @@ type keyEvent struct { // keyScanner is an incremental CSI-u/legacy key scanner. type keyScanner struct { - buf []byte // pending partial escape sequence - inCSI bool // saw ESC [ — accumulating until a final byte + buf []byte // pending partial escape sequence + inCSI bool // saw ESC [ — accumulating until a final byte + escAt time.Time // when the pending lone ESC was read (zero = none) } // Feed consumes one stdin chunk and returns the decoded events in wire @@ -83,11 +90,15 @@ func (k *keyScanner) Feed(chunk []byte) []keyEvent { } } - // A lone Escape from the previous chunk is a complete Esc key press: - // flush it before processing this chunk. - if len(k.buf) == 1 && k.buf[0] == 0x1b && !k.inCSI { + // A lone Escape pending from a previous chunk flushes as an Esc key + // press once it has been idle past escIdleFlush. A CSI sequence split + // right after ESC arrives within microseconds and keeps composing — + // flushing on chunk arrival alone would break that valid split. + if len(k.buf) == 1 && k.buf[0] == 0x1b && !k.inCSI && !k.escAt.IsZero() && + time.Since(k.escAt) >= escIdleFlush { events = append(events, keyEvent{Data: append([]byte(nil), k.buf...)}) k.buf = k.buf[:0] + k.escAt = time.Time{} } i := 0 @@ -100,10 +111,12 @@ func (k *keyScanner) Feed(chunk []byte) []keyEvent { if b == '[' { k.buf = append(k.buf, b) k.inCSI = true + k.escAt = time.Time{} } else { other = append(other, k.buf...) other = append(other, b) k.buf = k.buf[:0] + k.escAt = time.Time{} } case len(k.buf) > 0 && k.inCSI: // Inside a CSI sequence: params/intermediates are 0x20-0x3f, @@ -141,6 +154,7 @@ func (k *keyScanner) Feed(chunk []byte) []keyEvent { case b == 0x1b: // Start of a potential escape sequence. k.buf = append(k.buf[:0], b) + k.escAt = time.Now() case b == pasteKey: // Legacy encoding of Ctrl-V. emitOther() diff --git a/internal/daemon/keyscan_test.go b/internal/daemon/keyscan_test.go index 301514d0..0e7a0ca7 100644 --- a/internal/daemon/keyscan_test.go +++ b/internal/daemon/keyscan_test.go @@ -3,6 +3,7 @@ package daemon import ( "bytes" "testing" + "time" ) // The wire bytes captured from a real kitty window with the protocol @@ -116,18 +117,32 @@ func TestKeyScannerMixedBatch(t *testing.T) { } } -func TestKeyScannerLoneEscapeFlushedOnNextFeed(t *testing.T) { +func TestKeyScannerLoneEscapeFlushedAfterIdle(t *testing.T) { k := &keyScanner{} if evs := k.Feed([]byte{0x1b}); len(evs) != 0 { t.Fatalf("lone ESC should stay pending in the same chunk, got %+v", evs) } + // An idle past escIdleFlush means a standalone Esc key press. + time.Sleep(escIdleFlush + 20*time.Millisecond) evs := k.Feed([]byte{'x'}) joined := []byte{} for _, ev := range evs { joined = append(joined, ev.Data...) } if !bytes.Equal(joined, []byte{0x1b, 'x'}) { - t.Fatalf("lone ESC should flush with the following input, got %+v", evs) + t.Fatalf("idle ESC should flush with the following input, got %+v", evs) + } +} + +func TestKeyScannerCSISplitRightAfterEscapeStillDetected(t *testing.T) { + k := &keyScanner{} + // The read boundary falls between ESC and the rest of the sequence. + if evs := k.Feed([]byte{0x1b}); len(evs) != 0 { + t.Fatalf("ESC chunk should emit nothing, got %+v", evs) + } + evs := k.Feed(kittyCtrlVPress[1:]) + if len(evs) != 1 || !evs[0].Paste { + t.Fatalf("CSI split after ESC should still be a paste event, got %+v", evs) } } From 3d871058b943591193e860e40338d4f22418a585 Mon Sep 17 00:00:00 2001 From: Ed Zynda Date: Sun, 30 Aug 2026 09:25:46 +0300 Subject: [PATCH 6/7] feat(daemon): render the image preview for remote pastes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The @-reference injection attached the image on submit but showed no preview: input-area thumbnails come from the TUI's pendingImages state, which only the Ctrl-V handler fills. Give the remote-session child its own Ctrl-V instead: the daemon now sets KIT_REMOTE_CLIPBOARD on the child (a stable per-session file) and, when a client clipboard image arrives, atomically rewrites that file and injects a synthetic 0x16 into the child's PTY. The child's normal Ctrl-V handler reads the file through clipboard.ReadImage (new dispatcher: remote-session children serve the client's streamed image, everyone else reads the local system clipboard) and produces the same pendingImages preview a local paste gets. A clear frame (the client had no image) empties the file; the client swallows the keystroke. Verified on loopback: ctrl+v over 'kit remote' now shows '1 image(s) attached · ctrl+u to clear' with the half-block thumbnail in the input area, the message submits with the image, and the clipboard file is removed at session end. --- internal/clipboard/clipboard.go | 26 +++++ internal/clipboard/clipboard_darwin.go | 2 +- internal/clipboard/clipboard_linux.go | 4 +- internal/clipboard/clipboard_test.go | 42 ++++++++ internal/clipboard/clipboard_windows.go | 2 +- internal/daemon/clipboard.go | 20 +--- internal/daemon/clipboard_test.go | 39 +++++--- internal/daemon/server.go | 124 +++++++++++++++--------- 8 files changed, 183 insertions(+), 76 deletions(-) create mode 100644 internal/clipboard/clipboard_test.go diff --git a/internal/clipboard/clipboard.go b/internal/clipboard/clipboard.go index 3bfbd470..40339138 100644 --- a/internal/clipboard/clipboard.go +++ b/internal/clipboard/clipboard.go @@ -14,8 +14,34 @@ package clipboard import ( "fmt" + "os" ) +// RemoteClipboardEnv is the environment variable the daemon sets on +// remote-session child processes: it names a file holding the CLIENT +// machine's clipboard image. When set, ReadImage serves the image from +// that file instead of the local system clipboard — a remote session's +// Ctrl-V must attach the client's clipboard, not the daemon host's. +const RemoteClipboardEnv = "KIT_REMOTE_CLIPBOARD" + +// ReadImage returns the image to attach for a Ctrl-V: when running as a +// remote-session child (RemoteClipboardEnv set) it reads the client's +// streamed clipboard image; otherwise it reads the local system clipboard. +func ReadImage() (*ImageData, error) { + if p := os.Getenv(RemoteClipboardEnv); p != "" { + data, err := os.ReadFile(p) + if err != nil || len(data) == 0 { + return nil, fmt.Errorf("no image on the remote clipboard") + } + mediaType := DetectMediaType(data) + if mediaType == "" { + return nil, fmt.Errorf("unrecognized remote clipboard content") + } + return &ImageData{Data: data, MediaType: mediaType}, nil + } + return readSystemImage() +} + // ImageData holds the result of a clipboard image read. type ImageData struct { // Data is the raw image bytes (PNG, JPEG, etc.). diff --git a/internal/clipboard/clipboard_darwin.go b/internal/clipboard/clipboard_darwin.go index d9e6d7a6..2175beb1 100644 --- a/internal/clipboard/clipboard_darwin.go +++ b/internal/clipboard/clipboard_darwin.go @@ -9,7 +9,7 @@ import ( // ReadImage reads image data from the system clipboard on macOS. // It uses osascript to check if the clipboard contains an image via // NSPasteboard and writes it to stdout as PNG data. -func ReadImage() (*ImageData, error) { +func readSystemImage() (*ImageData, error) { // Use osascript to write clipboard image to stdout via a pipe. // The script checks if the clipboard has a «class PNGf» item. script := `use framework "AppKit" diff --git a/internal/clipboard/clipboard_linux.go b/internal/clipboard/clipboard_linux.go index 303dba7c..0ed7fc93 100644 --- a/internal/clipboard/clipboard_linux.go +++ b/internal/clipboard/clipboard_linux.go @@ -8,7 +8,9 @@ import ( // ReadImage reads image data from the system clipboard on Linux. // It tries xclip first (X11), then falls back to wl-paste (Wayland). -func ReadImage() (*ImageData, error) { +// readSystemImage reads image data from the local system clipboard on +// Linux. It tries xclip first (X11), then falls back to wl-paste (Wayland). +func readSystemImage() (*ImageData, error) { // Try xclip first (X11). if path, err := exec.LookPath("xclip"); err == nil { data, err := readWithXclip(path) diff --git a/internal/clipboard/clipboard_test.go b/internal/clipboard/clipboard_test.go new file mode 100644 index 00000000..dd667c22 --- /dev/null +++ b/internal/clipboard/clipboard_test.go @@ -0,0 +1,42 @@ +package clipboard + +import ( + "bytes" + "os" + "testing" +) + +func TestReadImageRemoteClipboardEnv(t *testing.T) { + png := []byte{0x89, 'P', 'N', 'G', '\r', '\n', 0x1a, '\n', 0, 0, 0, 13} + dir := t.TempDir() + path := dir + "/clip" + + // Remote clipboard file present: ReadImage serves it. + t.Setenv(RemoteClipboardEnv, path) + if err := os.WriteFile(path, png, 0o600); err != nil { + t.Fatal(err) + } + img, err := ReadImage() + if err != nil { + t.Fatalf("ReadImage: %v", err) + } + if !bytes.Equal(img.Data, png) || img.MediaType != "image/png" { + t.Fatalf("unexpected image: %d bytes %s", len(img.Data), img.MediaType) + } + + // Empty file (the daemon's "no image" clear): must be a soft error. + if err := os.WriteFile(path, nil, 0o600); err != nil { + t.Fatal(err) + } + if _, err := ReadImage(); err == nil { + t.Fatal("empty remote clipboard should error") + } + + // Unrecognized content: soft error, not a bogus attachment. + if err := os.WriteFile(path, []byte("not an image"), 0o600); err != nil { + t.Fatal(err) + } + if _, err := ReadImage(); err == nil { + t.Fatal("unrecognized content should error") + } +} diff --git a/internal/clipboard/clipboard_windows.go b/internal/clipboard/clipboard_windows.go index a7f65208..ad8c0f77 100644 --- a/internal/clipboard/clipboard_windows.go +++ b/internal/clipboard/clipboard_windows.go @@ -4,6 +4,6 @@ package clipboard // ReadImage reads image data from the system clipboard on Windows. // Windows clipboard image support is not yet implemented. -func ReadImage() (*ImageData, error) { +func readSystemImage() (*ImageData, error) { return nil, errNoClipboardTool } diff --git a/internal/daemon/clipboard.go b/internal/daemon/clipboard.go index 9897d53f..828e7308 100644 --- a/internal/daemon/clipboard.go +++ b/internal/daemon/clipboard.go @@ -35,6 +35,10 @@ import ( const ( // FrameClipboardFlagFinal marks the last chunk of a clipboard transfer. FrameClipboardFlagFinal byte = 0x01 + // FrameClipboardFlagClear marks a "clipboard has no image" signal: the + // client sends it when Ctrl-V found no local image. The daemon clears + // the session clipboard file so the child's Ctrl-V sees nothing. + FrameClipboardFlagClear byte = 0x02 // clipboardMaxImageSize caps reassembly so a hostile or buggy client // cannot exhaust daemon memory. Far beyond any real screenshot. @@ -143,19 +147,3 @@ func (c *ClipboardCollector) Add(payload []byte) (done bool, mediaType string, d } return false, "", nil, nil } - -// mediaExtension maps an image media type to a tempfile extension. -func mediaExtension(mediaType string) string { - switch mediaType { - case "image/png": - return ".png" - case "image/jpeg": - return ".jpg" - case "image/gif": - return ".gif" - case "image/webp": - return ".webp" - default: - return ".bin" - } -} diff --git a/internal/daemon/clipboard_test.go b/internal/daemon/clipboard_test.go index 2d5d6c3b..2455edc4 100644 --- a/internal/daemon/clipboard_test.go +++ b/internal/daemon/clipboard_test.go @@ -3,6 +3,7 @@ package daemon import ( "bytes" "encoding/hex" + "strings" "testing" ) @@ -93,18 +94,32 @@ func TestClipboardCollectorEmptyImage(t *testing.T) { } } -func TestMediaExtension(t *testing.T) { - cases := map[string]string{ - "image/png": ".png", - "image/jpeg": ".jpg", - "image/gif": ".gif", - "image/webp": ".webp", - "weird/type": ".bin", - } - for media, want := range cases { - if got := mediaExtension(media); got != want { - t.Fatalf("mediaExtension(%q) = %q, want %q", media, got, want) - } +func TestClipboardClearFlagDetection(t *testing.T) { + // A clear frame carries only the flags byte and is intercepted by the + // daemon BEFORE the collector sees it (it is not chunk data). + p := []byte{FrameClipboardFlagFinal | FrameClipboardFlagClear} + if p[0]&FrameClipboardFlagClear == 0 { + t.Fatal("clear flag must be settable together with final") + } + if p[0]&FrameClipboardFlagFinal == 0 { + t.Fatal("final flag must be preserved") + } + // Normal chunks must not trip the clear flag. + if EncodeClipboardChunks("image/png", []byte("data"))[0][0]&FrameClipboardFlagClear != 0 { + t.Fatal("image chunks must not carry the clear flag") + } +} + +func TestRemoteClipboardPathStablePerSession(t *testing.T) { + a, b := remoteClipboardPath(3), remoteClipboardPath(3) + if a != b { + t.Fatal("path must be stable for a session") + } + if a == remoteClipboardPath(4) { + t.Fatal("paths must differ per session") + } + if !strings.HasSuffix(a, "kit-remote-clip-3") { + t.Fatalf("unexpected path: %s", a) } } diff --git a/internal/daemon/server.go b/internal/daemon/server.go index bdd58e17..a05c76e1 100644 --- a/internal/daemon/server.go +++ b/internal/daemon/server.go @@ -8,12 +8,16 @@ import ( "os" "os/exec" "os/user" + "path/filepath" + "slices" "strings" "sync" "time" "github.com/charmbracelet/log" "github.com/creack/pty" + + "github.com/mark3labs/kit/internal/clipboard" ) // Serve runs the daemon until ctx is cancelled: bind the stable endpoint @@ -180,32 +184,68 @@ func runSessions(ctx context.Context, tun *Tunnel, rt *daemonRuntime) error { } } -// handleClipboardChunk reassembles a client clipboard image transfer. On -// the final chunk it writes the image to a tempfile and types -// @"" into the session child's input: the standard @-attachment -// pipeline then handles detection, preview and multimodal submission — -// the operator just presses Enter. +// handleClipboardChunk consumes one client clipboard frame. Chunked image +// data is reassembled and, on the final chunk, written to the session's +// stable clipboard file followed by a synthetic 0x16 into the child's PTY: +// the child's own Ctrl-V handler reads the file, fills pendingImages and +// renders the same preview a local paste gets. A clear frame (the client +// had no image) empties the file so a subsequent child Ctrl-V is a no-op. // -// Locking: closeSession may run on any goroutine (child exit), so every -// clipboards/sessionTemps/sessions map access happens under t.mu. The -// collector object itself is only ever touched by the frame loop. +// Locking: closeSession may run on any goroutine (child exit), so map +// access happens under t.mu; the collector object is only touched by the +// frame loop. func (t *sessionTable) handleClipboardChunk(ctx context.Context, session uint32, payload []byte) { if ctx.Err() != nil { return } + if len(payload) < 1 { + return + } + clear := payload[0]&FrameClipboardFlagClear != 0 + + // Register the stable file for teardown while the session lives. + t.mu.Lock() + _, live := t.sessions[session] + if live { + path := remoteClipboardPath(session) + if !slices.Contains(t.sessionTemps[session], path) { + t.sessionTemps[session] = append(t.sessionTemps[session], path) + } + } + t.mu.Unlock() + if !live { + return + } + + if clear { + // The client found no image: empty the file so the child's next + // Ctrl-V is a no-op. No keystroke is injected. + if err := os.WriteFile(remoteClipboardPath(session), nil, 0o600); err != nil { + log.Warn("clipboard clear failed", "session_id", session, "error", err) + } + t.mu.Lock() + delete(t.clipboards, session) + t.mu.Unlock() + return + } + t.mu.Lock() coll, ok := t.clipboards[session] if !ok { coll = NewClipboardCollector() t.clipboards[session] = coll } - _, live := t.sessions[session] + s, live := t.sessions[session] + var ptmx *os.File + if live && s != nil { + ptmx = s.ptmx + } t.mu.Unlock() - if !live { - return // session already torn down; ignore stragglers + if !live || ptmx == nil { + return } - done, mediaType, data, err := coll.Add(payload) + done, media, data, err := coll.Add(payload) if err != nil { t.mu.Lock() delete(t.clipboards, session) @@ -220,53 +260,37 @@ func (t *sessionTable) handleClipboardChunk(ctx context.Context, session uint32, delete(t.clipboards, session) t.mu.Unlock() - tmp, err := os.CreateTemp("", "kit-clip-*"+mediaExtension(mediaType)) + // Atomic rewrite: a concurrent child read sees the old or the new + // image, never a torn one. + tmp, err := os.CreateTemp("", "kit-clip-*") if err != nil { - log.Error("daemon: clipboard tempfile failed", "session_id", session, "error", err) + log.Error("daemon: clipboard write failed", "session_id", session, "error", err) return } path := tmp.Name() - if _, err := tmp.Write(data); err != nil { - _ = tmp.Close() + _, werr := tmp.Write(data) + cerr := tmp.Close() + if werr != nil || cerr != nil { _ = os.Remove(path) - log.Error("daemon: clipboard tempfile write failed", "session_id", session, "error", err) + log.Error("daemon: clipboard write failed", "session_id", session, "error", werr, "close", cerr) return } - if err := tmp.Close(); err != nil { - _ = os.Remove(path) - log.Error("daemon: clipboard tempfile close failed", "session_id", session, "error", err) - return - } - - // Register the tempfile and re-validate the session under the same - // lock: if teardown ran while the transfer completed, the file is - // removed again instead of leaking. - t.mu.Lock() - s, live := t.sessions[session] - if live { - t.sessionTemps[session] = append(t.sessionTemps[session], path) - } - var ptmx *os.File - if live && s != nil { - ptmx = s.ptmx - } - t.mu.Unlock() - if !live { + if err := os.Rename(path, remoteClipboardPath(session)); err != nil { _ = os.Remove(path) + log.Error("daemon: clipboard publish failed", "session_id", session, "error", err) return } - // Inject as a quoted @-reference (tokenizer supports @"path") followed - // by a space, so the operator can append text and hit Enter. Written as - // raw bytes into the child's PTY — printable characters only. - if _, err := fmt.Fprintf(ptmx, "@%q ", path); err != nil { + // Synthetic Ctrl-V: the child reads the file via KIT_REMOTE_CLIPBOARD + // and runs its normal pending-image preview flow. + if _, err := fmt.Fprintf(ptmx, "%c", pasteKey); err != nil { log.Error("daemon: clipboard inject failed", "session_id", session, "error", err) return } - log.Info("clipboard image attached", "session_id", session, "path", path, "bytes", len(data), "media_type", mediaType) + log.Info("clipboard image delivered", "session_id", session, "bytes", len(data), "media_type", media) } -// handleAuthRequest stashes the handshake parameters so the signature can +// handleAuthRequest stashes the handshake parameters so the signature can// handleAuthRequest stashes the handshake parameters so the signature can // be verified when the client's AUTH_PAYLOAD arrives. func (t *sessionTable) handleAuthRequest(payload []byte) { if len(payload) < 8 { @@ -354,7 +378,7 @@ func (t *sessionTable) decideAuth(corr []byte, allow bool, reason string) { // daemon and other sessions continue. func (table *sessionTable) openSession(id uint32) { s := &remoteSession{id: id} - child, ptmx, err := spawnPickDir() + child, ptmx, err := spawnPickDir(id) if err != nil { log.Error("daemon: session spawn failed", "session_id", id, "error", err) _ = table.writeTo(Frame{Type: FrameBye, Session: id}) @@ -450,10 +474,19 @@ func (t *sessionTable) get(id uint32) *remoteSession { return t.sessions[id] } +// remoteClipboardPath is the stable per-session file the daemon streams +// client clipboard images into. The child reads it on every Ctrl-V (see +// internal/clipboard.RemoteClipboardEnv), so a paste is a file rewrite +// followed by a synthetic 0x16 keystroke — the child's own clipboard +// pipeline then renders the preview exactly like a local paste. +func remoteClipboardPath(session uint32) string { + return filepath.Join(os.TempDir(), fmt.Sprintf("kit-remote-clip-%d", session)) +} + // spawnPickDir starts a kit child with the hidden --pick-dir flag in the // daemon user's home directory, so the remote peer picks the session's // working directory from the modal rendered inside the PTY. -func spawnPickDir() (*exec.Cmd, *os.File, error) { +func spawnPickDir(session uint32) (*exec.Cmd, *os.File, error) { exe, err := os.Executable() if err != nil { return nil, nil, fmt.Errorf("resolve kit binary: %w", err) @@ -465,6 +498,7 @@ func spawnPickDir() (*exec.Cmd, *os.File, error) { if os.Getenv("TERM") == "" { env = append(env, "TERM=xterm-256color") } + env = append(env, clipboard.RemoteClipboardEnv+"="+remoteClipboardPath(session)) cmd.Env = env ptmx, err := pty.StartWithSize(cmd, &pty.Winsize{Cols: 80, Rows: 24}) From 85c61e137afc15a5bd5d999c8c621936501df55c Mon Sep 17 00:00:00 2001 From: Ed Zynda Date: Sun, 30 Aug 2026 09:36:34 +0300 Subject: [PATCH 7/7] fix(daemon): deliver an idle Escape without waiting for more input (#115) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address the CodeRabbit re-review: the lone-Escape flush only ran on the next stdin read, so an Esc pressed into an idle remote session stayed buffered indefinitely — the PTY never received it. Restructure the client's input pump around a reader goroutine and a select: a 50ms idle timer flushes the pending Escape (and any future idle-pending bytes) without a follow-up keystroke. PendingEscape/ FlushPendingEscape expose the state on the scanner; regression tests cover both the idle flush and a CSI sequence split right after ESC. --- internal/daemon/client.go | 138 ++++++++++++++++++++++++++----------- internal/daemon/keyscan.go | 22 +++++- 2 files changed, 117 insertions(+), 43 deletions(-) diff --git a/internal/daemon/client.go b/internal/daemon/client.go index 4044f9d3..0e30ce96 100644 --- a/internal/daemon/client.go +++ b/internal/daemon/client.go @@ -210,62 +210,116 @@ func RunHost(ctx context.Context, name string) error { finish := func() { once.Do(func() { close(done) }) } // Local keystrokes -> remote. A lone Ctrl-] detaches. + // Stdin reader: os.Stdin.Read blocks, so it feeds a channel and the + // pump below can also react to the Esc idle-flush timer. + readCh := make(chan []byte, 4) + readErr := make(chan error, 1) + go func() { + defer close(readCh) + rbuf := make([]byte, 256) + for { + n, err := os.Stdin.Read(rbuf) + if n > 0 { + out := make([]byte, n) + copy(out, rbuf[:n]) + readCh <- out + } + if err != nil { + readErr <- err + return + } + } + }() + go func() { defer finish() scanner := &keyScanner{} suppressRel := false // swallow the ctrl+v release after a successful image interception - buf := make([]byte, 256) + var idleTimer *time.Timer + var idleC <-chan time.Time + armIdle := func() { + if scanner.PendingEscape() { + d := max(escIdleFlush-time.Since(scanner.escAt), 0) + if idleTimer == nil { + idleTimer = time.NewTimer(d) + } else { + idleTimer.Stop() + idleTimer.Reset(d) + } + idleC = idleTimer.C + } else if idleTimer != nil { + idleTimer.Stop() + idleC = nil + } + } + forward := func(data []byte) bool { + writeMu.Lock() + defer writeMu.Unlock() + return WriteDataFrames(tun.Stdin(), 0, data) == nil + } + handle := func(ev keyEvent) bool { // false = write error, give up + if ev.Paste { + // Image paste: read the local clipboard and stream any + // image to the daemon. No image — forward the keystroke + // so the host keeps its normal Ctrl-V behavior. + img, imgErr := clipboard.ReadImage() + if imgErr == nil && len(img.Data) > 0 { + writeMu.Lock() + sent := true + for _, payload := range EncodeClipboardChunks(img.MediaType, img.Data) { + if werr := WriteFrame(tun.Stdin(), FrameClipboard, 0, payload); werr != nil { + sent = false + break + } + } + writeMu.Unlock() + if !sent { + return false + } + suppressRel = true // the matching release is ours + fmt.Fprintln(os.Stderr, "Image sent from local clipboard.") + return true // swallow the press bytes + } + suppressRel = false // forwarding the press; forward its release too + } + if ev.Release && suppressRel { + suppressRel = false + return true + } + if len(ev.Data) > 0 { + return forward(ev.Data) + } + return true + } + armIdle() for { - n, err := os.Stdin.Read(buf) - if n > 0 { - if n == 1 && buf[0] == detachKey { + select { + case chunk, ok := <-readCh: + if !ok { + return + } + if len(chunk) == 1 && chunk[0] == detachKey { writeMu.Lock() _ = WriteFrame(tun.Stdin(), FrameBye, 0, nil) writeMu.Unlock() detached.Store(true) return } - for _, ev := range scanner.Feed(buf[:n]) { - if ev.Paste { - // Image paste: read the local clipboard and stream - // any image to the daemon. No image — forward the - // keystroke so the host keeps its normal Ctrl-V - // behavior. - img, imgErr := clipboard.ReadImage() - if imgErr == nil && len(img.Data) > 0 { - writeMu.Lock() - sent := true - for _, payload := range EncodeClipboardChunks(img.MediaType, img.Data) { - if werr := WriteFrame(tun.Stdin(), FrameClipboard, 0, payload); werr != nil { - sent = false - break - } - } - writeMu.Unlock() - if !sent { - return - } - suppressRel = true // the matching release is ours - fmt.Fprintln(os.Stderr, "Image sent from local clipboard.") - continue // swallow the press bytes - } - suppressRel = false // forwarding the press; forward its release too - } - if ev.Release && suppressRel { - suppressRel = false - continue + for _, ev := range scanner.Feed(chunk) { + if !handle(ev) { + return } - if len(ev.Data) > 0 { - writeMu.Lock() - werr := WriteDataFrames(tun.Stdin(), 0, ev.Data) - writeMu.Unlock() - if werr != nil { - return - } + } + armIdle() + case <-idleC: + idleC = nil + for _, ev := range scanner.FlushPendingEscape() { + if !handle(ev) { + return } } - } - if err != nil { + armIdle() + case <-readErr: return } } diff --git a/internal/daemon/keyscan.go b/internal/daemon/keyscan.go index 0de338d5..5a95d894 100644 --- a/internal/daemon/keyscan.go +++ b/internal/daemon/keyscan.go @@ -76,10 +76,30 @@ type keyScanner struct { escAt time.Time // when the pending lone ESC was read (zero = none) } +// PendingEscape reports whether a lone ESC byte is buffered waiting for +// its idle-flush deadline. The client arms a timer on this so an Esc key +// press reaches the session even when no further input ever arrives. +func (k *keyScanner) PendingEscape() bool { + return len(k.buf) == 1 && k.buf[0] == 0x1b && !k.inCSI +} + +// FlushPendingEscape emits a buffered lone Escape as passthrough data and +// clears the pending state. No-op when nothing is pending. +func (k *keyScanner) FlushPendingEscape() []keyEvent { + if !k.PendingEscape() { + return nil + } + data := append([]byte(nil), k.buf...) + k.buf = k.buf[:0] + k.escAt = time.Time{} + return []keyEvent{{Data: data}} +} + // Feed consumes one stdin chunk and returns the decoded events in wire // order. A Ctrl-V press/repeat event is reported for both the kitty and // legacy encodings, carrying the original bytes; a lone Escape pending -// from a previous chunk is flushed as passthrough when new input arrives. +// from a previous chunk is flushed as passthrough when new input arrives +// after its idle deadline (the client also flushes it on a timer). func (k *keyScanner) Feed(chunk []byte) []keyEvent { var events []keyEvent var other []byte