Skip to content

fix(clipboard): use platform-native tools for image paste on Linux#4647

Open
CNCSMonster wants to merge 12 commits into
QwenLM:mainfrom
CNCSMonster:fix/linux-wsl2-clipboard-image-paste
Open

fix(clipboard): use platform-native tools for image paste on Linux#4647
CNCSMonster wants to merge 12 commits into
QwenLM:mainfrom
CNCSMonster:fix/linux-wsl2-clipboard-image-paste

Conversation

@CNCSMonster
Copy link
Copy Markdown

@CNCSMonster CNCSMonster commented May 30, 2026

Summary

Replaces @teddyzhu/clipboard native module with platform-native tools (wl-paste/xclip) on Linux to fix clipboard image paste in WSL2+Wayland environments.

Closes #3517, Closes #2885

Context

In PR #1525, maintainer @LaZzyMan explicitly confirmed this is a bug that needs to be fixed:

The current issue where Cmd+V does not work for images is actually a bug - the clipboard paste is being intercepted by the text paste logic. We plan to fix this bug so that Cmd+V/Ctrl+V will naturally support both text and image pasting.

This PR implements exactly that fix - making Ctrl+V naturally support image paste on Linux by using platform-native clipboard tools instead of the broken @teddyzhu/clipboard native module.

Problem

The @teddyzhu/clipboard native module uses X11 protocol to access the clipboard. In WSL2 with WSLg (Wayland), XDG_SESSION_TYPE is unset and the session uses Wayland (WAYLAND_DISPLAY=wayland-0). The native module cannot read clipboard images in this environment, causing clipboardHasImage() to return false even when the clipboard contains an image.

Additionally, the Windows clipboard exposes images as image/bmp (not image/png) when accessed from WSL2 via wl-paste, which the original implementation did not handle.

Solution

Port the platform-native clipboard implementation from Gemini CLI with WSL2-specific enhancements:

Linux clipboard detection

  • Wayland: wl-paste --list-types to check for image/* types
  • X11: xclip -selection clipboard -t TARGETS -o to check for image/* types
  • WSL2 compatibility: Detect clipboard tool via WAYLAND_DISPLAY when XDG_SESSION_TYPE is unset

Linux image saving

  • PNG: wl-paste --no-newline --type image/png or xclip -selection clipboard -t image/png -o
  • BMP to PNG conversion: When clipboard only has image/bmp (common in WSL2), save as BMP and convert to PNG using Python PIL
  • Fallback: Keep @teddyzhu/clipboard for macOS/Windows where it works correctly

Environment detection

  • XDG_SESSION_TYPE=wayland or WAYLAND_DISPLAY set: use wl-paste
  • XDG_SESSION_TYPE=x11 or DISPLAY set: use xclip

Testing

Verified on WSL2 (Ubuntu 24.04, Wayland/WSLg):

  • clipboardHasImage() correctly detects image/bmp in clipboard
  • saveClipboardImage() saves BMP and converts to PNG (3.8MB BMP to 196KB PNG)
  • Text paste still works (falls back to existing behavior)

Requirements

  • wl-clipboard package installed
  • python3-pil for BMP to PNG conversion

Notes

  • This is the same approach used by Gemini CLI (upstream), which already uses platform-native tools
  • The @teddyzhu/clipboard module is kept as fallback for macOS/Windows where it works correctly

Replace @teddyzhu/clipboard native module with wl-paste/xclip on Linux
to fix image paste in WSL2+Wayland environments.

The native module uses X11 protocol and cannot read clipboard images
when the session uses Wayland (common in WSL2 with WSLg). This causes
clipboardHasImage() to return false even when the clipboard contains
an image.

Changes:
- Use wl-paste --list-types to detect images (Wayland)
- Use xclip -selection clipboard -t TARGETS -o to detect images (X11)
- Handle image/bmp format from Windows clipboard (WSL2 exposes BMP)
- Convert BMP to PNG using Python PIL when available
- Detect clipboard tool via WAYLAND_DISPLAY when XDG_SESSION_TYPE is unset
- Keep @teddyzhu/clipboard as fallback for macOS/Windows

Fixes QwenLM#3517
Fixes QwenLM#2885
Comment thread packages/cli/src/ui/utils/clipboardUtils.ts Outdated
Comment thread packages/cli/src/ui/utils/clipboardUtils.ts Outdated
Comment thread packages/cli/src/ui/utils/clipboardUtils.ts
Comment thread packages/cli/src/ui/utils/clipboardUtils.ts Outdated
Comment thread packages/cli/src/ui/utils/clipboardUtils.ts
Comment thread packages/cli/src/ui/utils/clipboardUtils.ts
Comment thread packages/cli/src/ui/utils/clipboardUtils.ts Outdated
Comment thread packages/cli/src/ui/utils/clipboardUtils.ts Outdated
Comment thread packages/cli/src/ui/utils/clipboardUtils.ts
Comment thread packages/cli/src/ui/utils/clipboardUtils.ts
The tests were mocking @teddyzhu/clipboard but the implementation now
uses platform-native tools (wl-paste/xclip) on Linux. Update mocks
to test the spawn-based implementation.
@CNCSMonster CNCSMonster marked this pull request as ready for review May 31, 2026 13:44
1. Fix command injection in Python BMP-to-PNG conversion
   - Use sys.argv instead of string interpolation
   - Prevents path traversal via single-quote injection

2. Fix BMP fallback dead code
   - When PIL is not available, return BMP file path instead of
     deleting the only copy and returning false
   - Update saveClipboardImage to handle non-PNG return paths
- QwenLM#3: Add proper cleanup in saveFromCommand error paths (kill child, destroy stream)
- QwenLM#4: Add 5s timeout for all spawned processes to prevent TUI hangs
- QwenLM#7: Check exit code in checkClipboardForImage (code === 0)
- QwenLM#8: Move fs.mkdir inside try/catch in saveClipboardImage
- QwenLM#10: Merge checkWlPasteForImage/checkXclipForImage into checkClipboardForImage
@CNCSMonster CNCSMonster force-pushed the fix/linux-wsl2-clipboard-image-paste branch from 6accecc to accb428 Compare May 31, 2026 14:26
Comment thread packages/cli/src/ui/utils/clipboardUtils.ts Outdated
Comment thread packages/cli/src/ui/utils/clipboardUtils.test.ts Outdated
Comment thread packages/cli/src/ui/utils/clipboardUtils.ts Outdated
Comment thread packages/cli/src/ui/utils/clipboardUtils.ts Outdated
Comment thread packages/cli/src/ui/utils/clipboardUtils.ts Outdated
Comment thread packages/cli/src/ui/utils/clipboardUtils.ts Outdated
Comment thread packages/cli/src/ui/utils/clipboardUtils.ts Outdated
Comment thread packages/cli/src/ui/utils/clipboardUtils.ts
Comment thread packages/cli/src/ui/utils/clipboardUtils.test.ts
Comment thread packages/cli/src/ui/utils/clipboardUtils.ts
Source code fixes:
- QwenLM#25: Add timeout to getWlPasteImageTypes (PROCESS_TIMEOUT_MS)
- QwenLM#26: Add timeout to python3 spawn in BMP-to-PNG conversion
- QwenLM#27: Wrap child.kill() in try-catch in timeout handlers
- QwenLM#28: Replace dynamic import('node:fs/promises') with static statSync
- QwenLM#30: Export resetLinuxClipboardTool() for testability
- Add try-catch around spawn in checkClipboardForImage
- Use stdio: ['ignore', 'ignore', 'ignore'] for python3 spawn

Test fixes:
- QwenLM#24: Use vi.hoisted() for mock functions (avoids hoisting issue)
- QwenLM#31: Stub process.platform = 'linux' in beforeEach
- Add default export to node:child_process mock
- Use EventEmitter-based mock child for async behavior
- All 7 tests passing
Avoid spawning wl-paste twice on the paste hot path:
1. clipboardHasImage calls wl-paste --list-types (check)
2. saveClipboardImage calls getWlPasteImageTypes (get types)

Now the result is cached after the first call and reused.
Cache is reset via resetLinuxClipboardTool() for testing.
Comment thread packages/cli/src/ui/utils/clipboardUtils.ts
Comment thread packages/cli/src/ui/utils/clipboardUtils.test.ts
Comment thread packages/cli/src/ui/utils/clipboardUtils.ts
- QwenLM#1: Add child.stdout error handler in saveFromCommand
- QwenLM#2: Add macOS/Windows test coverage for @teddyzhu/clipboard fallback
- QwenLM#3: Fix .replace('.png', '.bmp') to use regex /\.png$/ to prevent path corruption
Comment thread packages/cli/src/ui/utils/clipboardUtils.ts
Comment thread packages/cli/src/ui/utils/clipboardUtils.ts Outdated
Comment thread packages/cli/src/ui/utils/clipboardUtils.ts Outdated
Comment thread packages/cli/src/ui/utils/clipboardUtils.ts Outdated
Comment thread packages/cli/src/ui/utils/clipboardUtils.ts Outdated
Comment thread packages/cli/src/ui/utils/clipboardUtils.ts Outdated
Comment thread packages/cli/src/ui/utils/clipboardUtils.test.ts
- QwenLM#1 Critical: Reset cachedWlPasteImageTypes at start of clipboardHasImage
  to prevent stale data between paste operations
- QwenLM#1 Critical: Check exit code in getWlPasteImageTypes close handler,
  do not cache failed results
- QwenLM#2: Replace statSync with async fs.stat to avoid blocking event loop
- QwenLM#3: Remove async from close handler, use promise chain instead
- QwenLM#4: Return false instead of bmpPath when PIL conversion fails,
  as downstream expects .png files
- QwenLM#5: Capture stderr from spawned processes for diagnostics
Comment thread packages/cli/src/ui/utils/clipboardUtils.ts
Comment thread packages/cli/src/ui/utils/clipboardUtils.ts
Comment thread packages/cli/src/ui/utils/clipboardUtils.ts
Comment thread packages/cli/src/ui/utils/clipboardUtils.ts
Comment thread packages/cli/src/ui/utils/clipboardUtils.ts
Comment thread packages/cli/src/ui/utils/clipboardUtils.ts
Comment thread packages/cli/src/ui/utils/clipboardUtils.ts
Comment thread packages/cli/src/ui/utils/clipboardUtils.ts
- QwenLM#1: Narrow detection to only report supported formats (png/bmp)
- QwenLM#2: Do not cache results on timeout or error
- QwenLM#3: Use line-level matching instead of includes('image/')
- QwenLM#4: Replace execSync with execFileSync to avoid shell injection
- QwenLM#5: Upgrade BMP→PNG failure log to warn level with install hint
The original Qwen Code cached the @teddyzhu/clipboard module import via
getClipboardModule() with cachedClipboardModule and clipboardLoadAttempted.
Our refactoring removed this caching, causing the module to be re-imported
on every clipboardHasImage/saveClipboardImage call.

Restored the original caching mechanism for macOS/Windows fallback path.
- Add test for successful PNG save path
- Add test for cache invalidation between clipboardHasImage calls
- All 11 tests passing
execFileSync('command', ['-v', 'wl-paste']) fails because 'command'
is a shell built-in, not an executable. execSync runs through a shell
so it can find 'command'. Reverted to execSync to restore clipboard
tool detection on WSL2.

Also fixed TypeScript errors in tests by using (child as any) for
mock event emitter properties.
@wenshao
Copy link
Copy Markdown
Collaborator

wenshao commented Jun 1, 2026

Verification Report

Branch: fix/linux-wsl2-clipboard-image-paste
Base: main
Environment: macOS Darwin 25.4.0, Node.js v22.17.0

Test Results

Check Command Result
clipboardUtils Tests vitest run clipboardUtils.test.ts ✅ 11 tests passed (8ms)
CLI Type Check npm run typecheck --workspace=packages/cli ✅ Clean (0 errors)
ESLint eslint clipboardUtils.ts clipboardUtils.test.ts --max-warnings 0 ✅ No errors
Core Build npm run build --workspace=packages/core ✅ Success
CLI Build npm run build --workspace=packages/cli ✅ Success (after clearing stale core dist — pre-existing TS5055 issue)
Whitespace git diff --check ✅ Clean

Test Coverage Review

The 11 tests cover:

  • Linux Wayland path: clipboardHasImage detecting image via wl-paste --list-types (image/png present → true, text/plain only → false)
  • Tool not found: Graceful fallback when wl-paste is not installed (execSync throws → returns false)
  • Save path: saveClipboardImage exercising the PNG save path via spawn, and null return when no tool available
  • Spawn error: Graceful null return on spawn failure
  • macOS/Windows fallback: Non-Linux platforms fall through to @teddyzhu/clipboard module (returns false/null when mock fails)
  • Cache behavior: wl-paste type cache resets between clipboardHasImage calls — verified with sequential calls returning different results

Code Review Notes

  • Environment detection logic is correct: Wayland detected via XDG_SESSION_TYPE=wayland OR WAYLAND_DISPLAY (WSL2 case where XDG_SESSION_TYPE is unset); X11 via XDG_SESSION_TYPE=x11 OR DISPLAY
  • Tool existence check uses execSync('command -v ...') — POSIX-portable, correct approach
  • BMP→PNG conversion via Python PIL is reasonable for WSL2 where Windows clipboard exposes image/bmp; the dependency on python3-pil is documented
  • saveFromCommand helper is well-structured: proper timeout (5s), cleanup of child process and file stream, stderr capture for debugging
  • Cache invalidation: cachedWlPasteImageTypes is reset at the start of each clipboardHasImage() call and only cached on success — correct to avoid stale state
  • @teddyzhu/clipboard kept as fallback for macOS/Windows where it works — no regression for those platforms
  • package-lock.json change: Only removes "peer": true from fsevents — minor, harmless

Caveats

  • Full end-to-end verification requires a WSL2+Wayland environment with wl-clipboard and python3-pil installed. This report covers code correctness, mocked behavior, and build integrity.
  • The linuxClipboardTool cache is module-level and persists for the process lifetime. If a user connects an external display (switching from Wayland to X11 mid-session), they would need to restart. This is acceptable for the use case.

Verdict

✅ Ready to merge — 11 tests pass, typecheck clean, builds succeed. The implementation correctly uses platform-native tools on Linux while preserving the existing @teddyzhu/clipboard fallback for macOS/Windows.


Verified by wenshao

@CNCSMonster CNCSMonster requested a review from wenshao June 1, 2026 17:58
@tanzhenxin tanzhenxin added the type/bug Something isn't working as expected label Jun 2, 2026
Copy link
Copy Markdown
Collaborator

@wenshao wenshao left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Test Coverage Gaps

The PR adds ~280 lines of new Linux clipboard code. While tests have been added (11 passing), significant paths remain untested:

  • xclip/X11 path: All tests stub WAYLAND_DISPLAY: 'wayland-0', exclusively exercising the wl-paste path. No test sets XDG_SESSION_TYPE: 'x11' to verify xclip detection, saveFileWithXclip, or the xclip branch of saveClipboardImage.
  • BMP-to-PNG conversion: The WSL2 headline feature (image/bmp branch in saveFileWithWlPaste, python3 PIL spawn) has zero test coverage for both success and failure paths.
  • saveFromCommand error paths: Timeout, child spawn error, stdout error, and fileStream error branches have no test coverage.

}
return tempFilePath;
} catch (err) {
debugLogger.warn(
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Critical] BMP file leak on PIL conversion failure

When bmpSuccess is true but the python3 PIL conversion throws (caught here), the catch block returns false without unlinking bmpPath. The cleanup at line 393 (success path) and line 407 (outer bmpSuccess===false path) are both unreachable from this catch block.

Each failed WSL2 BMP paste leaks a .bmp file (potentially several MB for a screenshot) in the clipboard temp directory.

Also: typo python3-pyl should be python3-pil (Python Imaging Library, from Pillow).

Suggested change
debugLogger.warn(
} catch (err) {
debugLogger.warn(
'BMP-to-PNG conversion failed (install python3-pil for BMP support):',
err,
);
try {
await fs.unlink(bmpPath);
} catch {
/* ignore */
}
return false;
}

— qwen3.7-max via Qwen Code /review

const types = stdout
.trim()
.split('\n')
.filter((t) => t.startsWith('image/'));
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Critical] getWlPasteImageTypes filter accepts any image/* but save only handles PNG/BMP

The filter t.startsWith('image/') matches image/jpeg, image/webp, image/gif, etc. — but saveFileWithWlPaste() only has branches for image/png and image/bmp. This causes clipboardHasImage() to return true for clipboard types that saveClipboardImage() silently cannot handle, producing a UX dead-end (paste affordance shown, paste does nothing).

Notably, the xclip path in checkClipboardForImage correctly restricts matching to image/png and image/bmp only (line ~228), making this an internal inconsistency within the same file.

Suggested change
.filter((t) => t.startsWith('image/'));
.filter((t) => t === 'image/png' || t === 'image/bmp');

— qwen3.7-max via Qwen Code /review

// Result should be null because fileStream.writableFinished is false
// (we can't easily mock the fs.createWriteStream behavior)
// This test verifies the save path is exercised without errors
expect(result === null || result?.includes('clipboard-')).toBe(true);
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] Tautological assertion in "successful PNG save" test

expect(result === null || result?.includes('clipboard-')).toBe(true) passes whether the save succeeds OR fails. The test comment acknowledges "Result should be null because fileStream.writableFinished is false." A test named for success that accepts failure provides false confidence — if saveFileWithWlPaste is broken to always return false, this test still passes.

Consider mocking createWriteStream from node:fs to properly simulate writableFinished/finish events so the save path actually completes.

— qwen3.7-max via Qwen Code /review

if (savedPath) {
try {
const stats = await fs.stat(savedPath);
if (stats.size > 0) return savedPath;
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] Empty PNG file leaked when size guard fails

When saveFileWithWlPaste returns a path but fs.stat finds size === 0, execution falls through to return null without unlinking the empty file. This can occur when python3 PIL exits 0 but produces empty output from a zero-byte BMP input.

Suggested change
if (stats.size > 0) return savedPath;
if (tool === 'wl-paste') {
const savedPath = await saveFileWithWlPaste(pngPath);
if (savedPath) {
try {
const stats = await fs.stat(savedPath);
if (stats.size > 0) return savedPath;
await fs.unlink(savedPath);
} catch {
/* ignore */
}
}
return null;
}

— qwen3.7-max via Qwen Code /review

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

type/bug Something isn't working as expected

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Clipboard image paste (Cmd+V) silently fails on macOS — two root causes Ctrl+V image paste from clipboard broken in 0.14.0 (Linux/Wayland)

3 participants