diff --git a/contrib/kit-tunnel/src/main.rs b/contrib/kit-tunnel/src/main.rs index d9e49e8f..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,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, "secret-hex", "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) @@ -775,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"); } @@ -873,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"); } @@ -1052,7 +1059,14 @@ 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, + "pair-seed-hex", + "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/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/client.go b/internal/daemon/client.go index 44c2a7c1..0e30ce96 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 @@ -201,27 +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 finish() - buf := make([]byte, 256) + defer close(readCh) + rbuf := make([]byte, 256) for { - n, err := os.Stdin.Read(buf) + n, err := os.Stdin.Read(rbuf) if n > 0 { - if n == 1 && buf[0] == detachKey { + 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 + 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 { + 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 } - writeMu.Lock() - werr := WriteDataFrames(tun.Stdin(), 0, buf[:n]) - writeMu.Unlock() - if werr != nil { - return + for _, ev := range scanner.Feed(chunk) { + if !handle(ev) { + return + } } - } - if err != nil { + armIdle() + case <-idleC: + idleC = nil + for _, ev := range scanner.FlushPendingEscape() { + if !handle(ev) { + return + } + } + armIdle() + case <-readErr: return } } diff --git a/internal/daemon/clipboard.go b/internal/daemon/clipboard.go new file mode 100644 index 00000000..828e7308 --- /dev/null +++ b/internal/daemon/clipboard.go @@ -0,0 +1,149 @@ +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 + // 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. + 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 +} diff --git a/internal/daemon/clipboard_test.go b/internal/daemon/clipboard_test.go new file mode 100644 index 00000000..2455edc4 --- /dev/null +++ b/internal/daemon/clipboard_test.go @@ -0,0 +1,150 @@ +package daemon + +import ( + "bytes" + "encoding/hex" + "strings" + "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 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) + } +} + +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/keyscan.go b/internal/daemon/keyscan.go new file mode 100644 index 00000000..5a95d894 --- /dev/null +++ b/internal/daemon/keyscan.go @@ -0,0 +1,242 @@ +package daemon + +import ( + "strconv" + "strings" + "time" +) + +// 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, 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'. + keyV = 118 + // 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 + // 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. +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. Data holds the original wire bytes. + Paste bool + // 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 +} + +// 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 + 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 +// 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 + var emitOther = func() { + if len(other) > 0 { + events = append(events, keyEvent{Data: other}) + other = nil + } + } + + // 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 + 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 + 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, + // 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. + 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, Data: seq}) + continue + } + if release { + events = append(events, keyEvent{Release: true, Data: seq}) + continue + } + } + other = append(other, seq...) + continue + } + 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() + events = append(events, keyEvent{Paste: true, Data: []byte{pasteKey}}) + 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: + 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..0e7a0ca7 --- /dev/null +++ b/internal/daemon/keyscan_test.go @@ -0,0 +1,177 @@ +package daemon + +import ( + "bytes" + "testing" + "time" +) + +// 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 || !bytes.Equal(evs[0].Data, kittyCtrlVPress) { + t.Fatalf("expected paste event with original bytes, got %+v", evs) + } +} + +func TestKeyScannerKittyCtrlVReleaseCarriesData(t *testing.T) { + k := &keyScanner{} + _ = k.Feed(kittyCtrlVPress) + evs := k.Feed(kittyCtrlVRelease) + // 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) + } +} + +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 || !bytes.Equal(evs[0].Data, []byte{pasteKey}) { + t.Fatalf("legacy ctrl+v should be a paste event with bytes, 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]) + } +} + +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("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) + } +} + +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)) + } +} 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..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 @@ -117,8 +121,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 +141,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 +159,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(ctx, frame.Session, frame.Payload) case FrameSessionOpen: table.openSession(frame.Session) case FrameSessionClosed, FrameBye: @@ -174,7 +184,113 @@ func runSessions(ctx context.Context, tun *Tunnel, rt *daemonRuntime) error { } } -// handleAuthRequest stashes the handshake parameters so the signature can +// 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 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 + } + s, live := t.sessions[session] + var ptmx *os.File + if live && s != nil { + ptmx = s.ptmx + } + t.mu.Unlock() + if !live || ptmx == nil { + return + } + + done, media, data, err := coll.Add(payload) + if err != nil { + 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() + + // 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 write failed", "session_id", session, "error", err) + return + } + path := tmp.Name() + _, werr := tmp.Write(data) + cerr := tmp.Close() + if werr != nil || cerr != nil { + _ = os.Remove(path) + log.Error("daemon: clipboard write failed", "session_id", session, "error", werr, "close", cerr) + return + } + if err := os.Rename(path, remoteClipboardPath(session)); err != nil { + _ = os.Remove(path) + log.Error("daemon: clipboard publish failed", "session_id", session, "error", err) + return + } + + // 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 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 // be verified when the client's AUTH_PAYLOAD arrives. func (t *sessionTable) handleAuthRequest(payload []byte) { if len(payload) < 8 { @@ -262,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}) @@ -307,17 +423,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}) @@ -348,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) @@ -363,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})