Skip to content

Commit 3f251fd

Browse files
authored
fix(actions): make copy-to-clipboard resilient (#242)
* fix(actions): make copy-to-clipboard resilient Extract clipboard logic into actions/clipboard.go and harden it: - Bias OSC 52 first when SSH_TTY/SSH_CONNECTION is set; native tools would otherwise target the SSH server's clipboard, not the user's. - Bound each native attempt with a 2s timeout and 1s WaitDelay; treat exec.ErrWaitDelay as success since xclip/xsel daemonize and hold the inherited stdin pipe (golang/go#13155). - Gate wl-copy on a live Wayland socket via XDG_RUNTIME_DIR/WAYLAND_DISPLAY. - Wrap OSC 52 for tmux (\$TMUX) and GNU screen (\$STY+screen TERM) using the go-osc52 library so it works under tmux set-clipboard on / screen DCS pass-through. - Skip OSC 52 when the payload exceeds 64 KB (most terminals silently drop oversized sequences) and surface a clear toast pointing to 'w' (save to file). - Emit OSC 52 to stderr instead of stdout so the escape cannot race with the BubbleTea inline renderer. - Restrict the macOS ladder to pbcopy (no Linux tools). - Add unit tests covering the candidate ladder, SSH bias, OSC 52 length cap, tmux/screen wrapping, and WaitDelay handling. * fix(actions): address PR review on clipboard resilience - Tighten isWaylandLive: skip wl-copy when XDG_RUNTIME_DIR is unset rather than attempting a doomed 2 s invocation. - Thread the resolved binary path from lookPath into exec.CommandContext to eliminate the duplicate PATH search and TOCTOU window. - For oversized payloads OSC 52 now copies the last 64 KB instead of refusing — the tail is where task-log errors usually live, and the full log is still one keypress away via 'w'. Truncation respects UTF-8 rune boundaries. - Toast surfaces truncation explicitly when OSC 52 was used. * fix(actions): tighten OSC 52 truncation tests - Decode the OSC 52 payload in the tail-truncation test rather than doing a base64 substring match. The substring check only worked because the kept portion happened to start on a 3-byte block boundary; with different sizes it would silently flake. - Reconstruct the rune-boundary test so the naive cut lands on a UTF-8 continuation byte (asymmetric padding around a single "é"), and assert the decoded length to prove the cut walked forward.
1 parent 926b877 commit 3f251fd

5 files changed

Lines changed: 763 additions & 74 deletions

File tree

actions/clipboard.go

Lines changed: 217 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,217 @@
1+
package actions
2+
3+
import (
4+
"context"
5+
"errors"
6+
"io"
7+
"os"
8+
"os/exec"
9+
"runtime"
10+
"strings"
11+
"time"
12+
"unicode/utf8"
13+
14+
osc52 "github.com/aymanbagabas/go-osc52/v2"
15+
"golang.org/x/term"
16+
)
17+
18+
// osc52SafeLimit is the largest plaintext size where OSC 52 is reliably
19+
// accepted across terminals. xterm caps the OSC 52 sequence around 100 KB
20+
// (~74 KB of plaintext); st and older kitty are smaller. 64 KB is a
21+
// safe-everywhere ceiling — above it, fall back to a native tool or to the
22+
// "save to file" keybinding.
23+
const osc52SafeLimit = 64 * 1024
24+
25+
// copyMethod identifies which backend handled a successful copy.
26+
type copyMethod int
27+
28+
const (
29+
copyMethodUnknown copyMethod = iota
30+
copyMethodNative // local clipboard tool (pbcopy/xclip/...)
31+
copyMethodOSC52 // raw OSC 52 escape sequence
32+
copyMethodOSC52Tmux // OSC 52 wrapped for tmux pass-through
33+
copyMethodOSC52Screen // OSC 52 chunked through GNU screen DCS
34+
)
35+
36+
// Test seams. Production code uses the real OS implementations.
37+
var (
38+
osc52Out io.Writer = os.Stderr
39+
osc52IsTTY = func() bool { return term.IsTerminal(int(os.Stderr.Fd())) }
40+
getEnv = os.Getenv
41+
statFile = func(name string) error { _, err := os.Stat(name); return err }
42+
runtimeOS = runtime.GOOS
43+
lookPath = exec.LookPath
44+
nativeRun = realNativeRun
45+
)
46+
47+
// nativeCmd is a single clipboard backend invocation recipe.
48+
type nativeCmd struct {
49+
name string
50+
args []string
51+
stdin bool // true: pipe text via stdin; false: pass text as final argv
52+
path string // resolved by tryNativeClipboard before nativeRun is called
53+
}
54+
55+
// copyToClipboard copies text using the most appropriate backend.
56+
//
57+
// Order is environment-aware:
58+
// - In an SSH session (SSH_TTY/SSH_CONNECTION set), OSC 52 first — local
59+
// tools would target the *server's* clipboard, not the user's.
60+
// - Otherwise, native first, then OSC 52 fallback.
61+
//
62+
// Each native attempt is bounded by a per-attempt timeout. OSC 52 sequences
63+
// are emitted to stderr (not stdout) so they cannot race with a BubbleTea
64+
// inline renderer that owns stdout. Both streams reach the same TTY.
65+
func copyToClipboard(ctx context.Context, text string) (copyMethod, error) {
66+
cands := nativeCandidates()
67+
if inSSH() {
68+
if m, ok := tryOSC52(text); ok {
69+
return m, nil
70+
}
71+
if tryNativeClipboard(ctx, cands, text) {
72+
return copyMethodNative, nil
73+
}
74+
} else {
75+
if tryNativeClipboard(ctx, cands, text) {
76+
return copyMethodNative, nil
77+
}
78+
if m, ok := tryOSC52(text); ok {
79+
return m, nil
80+
}
81+
}
82+
return copyMethodUnknown, errors.New("no clipboard tool available")
83+
}
84+
85+
func inSSH() bool {
86+
return getEnv("SSH_TTY") != "" || getEnv("SSH_CONNECTION") != ""
87+
}
88+
89+
// tryOSC52 emits an OSC 52 escape sequence using the go-osc52 library,
90+
// auto-wrapping for tmux ($TMUX) or GNU screen ($STY + $TERM=screen*).
91+
//
92+
// Skips (without writing) when stderr is not a TTY. Payloads over the safe
93+
// limit are truncated to the *last* osc52SafeLimit bytes — for build/task
94+
// logs the tail is where the error usually lives, so it is the most useful
95+
// part to recover. The view-layer toast tells the user that truncation
96+
// occurred and the 'w' keybinding is still available for the full log.
97+
func tryOSC52(text string) (copyMethod, bool) {
98+
if !osc52IsTTY() {
99+
return copyMethodUnknown, false
100+
}
101+
if len(text) > osc52SafeLimit {
102+
start := len(text) - osc52SafeLimit
103+
// Walk forward to the next valid UTF-8 rune start so we never split
104+
// a multi-byte rune and produce U+FFFD on paste.
105+
for start < len(text) && !utf8.RuneStart(text[start]) {
106+
start++
107+
}
108+
text = text[start:]
109+
}
110+
111+
seq := osc52.New(text)
112+
method := copyMethodOSC52
113+
switch {
114+
case getEnv("TMUX") != "":
115+
seq = seq.Tmux()
116+
method = copyMethodOSC52Tmux
117+
case getEnv("STY") != "" && strings.HasPrefix(getEnv("TERM"), "screen"):
118+
seq = seq.Screen()
119+
method = copyMethodOSC52Screen
120+
}
121+
if _, err := seq.WriteTo(osc52Out); err != nil {
122+
return copyMethodUnknown, false
123+
}
124+
return method, true
125+
}
126+
127+
// nativeCandidates returns the native-tool ladder, ordered for the current
128+
// environment. Tools whose runtime requirements are clearly absent (e.g.
129+
// wl-copy without a live Wayland socket) are omitted to avoid wasted
130+
// attempts and timeout-bounded hangs.
131+
func nativeCandidates() []nativeCmd {
132+
var c []nativeCmd
133+
134+
switch runtimeOS {
135+
case "darwin":
136+
c = append(c, nativeCmd{name: "pbcopy", stdin: true})
137+
case "linux":
138+
if isWaylandLive() {
139+
c = append(c, nativeCmd{name: "wl-copy", stdin: true})
140+
}
141+
if getEnv("DISPLAY") != "" {
142+
c = append(c,
143+
nativeCmd{name: "xclip", args: []string{"-selection", "clipboard"}, stdin: true},
144+
nativeCmd{name: "xsel", args: []string{"--clipboard", "--input"}, stdin: true},
145+
)
146+
}
147+
c = append(c, nativeCmd{
148+
name: "qdbus",
149+
args: []string{
150+
"org.kde.klipper", "/klipper",
151+
"org.kde.klipper.klipper.setClipboardContents",
152+
},
153+
stdin: false,
154+
})
155+
c = append(c, nativeCmd{name: "termux-clipboard-set", stdin: true})
156+
}
157+
return c
158+
}
159+
160+
func isWaylandLive() bool {
161+
wd := getEnv("WAYLAND_DISPLAY")
162+
if wd == "" {
163+
return false
164+
}
165+
if strings.HasPrefix(wd, "/") {
166+
return statFile(wd) == nil
167+
}
168+
rd := getEnv("XDG_RUNTIME_DIR")
169+
if rd == "" {
170+
// Can't verify the socket. Modern user sessions always set
171+
// XDG_RUNTIME_DIR, so its absence reliably indicates wl-copy will
172+
// fail. Skip rather than waste a 2 s timeout on a doomed attempt.
173+
return false
174+
}
175+
return statFile(rd+"/"+wd) == nil
176+
}
177+
178+
// tryNativeClipboard runs the candidate ladder, returning true on the first
179+
// success. exec.ErrWaitDelay is treated as success: xclip/xsel daemonize and
180+
// hold the inherited stdin pipe open, so the foreground process exits cleanly
181+
// while WaitDelay force-closes pipes — the clipboard already received the data.
182+
func tryNativeClipboard(parent context.Context, cands []nativeCmd, text string) bool {
183+
for _, c := range cands {
184+
p, err := lookPath(c.name)
185+
if err != nil {
186+
continue
187+
}
188+
c.path = p
189+
if err := nativeRun(parent, c, text); err == nil || errors.Is(err, exec.ErrWaitDelay) {
190+
return true
191+
}
192+
}
193+
return false
194+
}
195+
196+
// realNativeRun runs a single clipboard tool with a tight per-attempt timeout
197+
// and WaitDelay so background-forking tools cannot wedge the caller.
198+
func realNativeRun(parent context.Context, c nativeCmd, text string) error {
199+
ctx, cancel := context.WithTimeout(parent, 2*time.Second)
200+
defer cancel()
201+
202+
argv := append([]string{}, c.args...)
203+
if !c.stdin {
204+
argv = append(argv, text)
205+
}
206+
// c.path was resolved by tryNativeClipboard via lookPath so
207+
// exec.CommandContext does not repeat the PATH search and there is no
208+
// TOCTOU between lookup and invocation.
209+
cmd := exec.CommandContext(ctx, c.path, argv...) //nolint:gosec
210+
cmd.WaitDelay = time.Second
211+
cmd.Stdout = io.Discard
212+
cmd.Stderr = io.Discard
213+
if c.stdin {
214+
cmd.Stdin = strings.NewReader(text)
215+
}
216+
return cmd.Run()
217+
}

0 commit comments

Comments
 (0)