mirror of
https://github.com/wirenboard/agent-vm.git
synced 2026-07-09 16:00:54 +00:00
Intercept Ctrl+V to forward host clipboard images into the VM
Add clipboard-pty.py, a pty wrapper that detects Ctrl+V (0x16) in the host terminal input stream and saves the host clipboard image as a PNG to the shared mount. Inside the VM, xclip and wl-paste shims read that file, so Claude Code and OpenCode pick up pasted images transparently. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
3e0b2f297f
commit
e3492eae4c
2 changed files with 264 additions and 3 deletions
82
claude-vm.sh
82
claude-vm.sh
|
|
@ -254,6 +254,73 @@ _claude_vm_start_proxy() {
|
|||
echo "API proxy listening on port $_claude_vm_proxy_port"
|
||||
}
|
||||
|
||||
_claude_vm_inject_clipboard_shim() {
|
||||
local vm_name="$1"
|
||||
local state_dir="$2"
|
||||
limactl shell "$vm_name" bash -c '
|
||||
mkdir -p ~/.local/bin
|
||||
cat > ~/.local/bin/wl-paste << '\''SHIM'\''
|
||||
#!/bin/sh
|
||||
# Shim for clipboard image paste from host via shared mount.
|
||||
# Claude Code calls: wl-paste -l (list types), wl-paste --type image/png (read)
|
||||
CLIPBOARD_FILE="'"$state_dir"'/clipboard.png"
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
-l|--list-types)
|
||||
if [ -f "$CLIPBOARD_FILE" ]; then
|
||||
echo "image/png"
|
||||
exit 0
|
||||
else
|
||||
exit 1
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
done
|
||||
# Default: output the image data
|
||||
if [ -f "$CLIPBOARD_FILE" ]; then
|
||||
exec cat "$CLIPBOARD_FILE"
|
||||
else
|
||||
exit 1
|
||||
fi
|
||||
SHIM
|
||||
chmod +x ~/.local/bin/wl-paste
|
||||
cat > ~/.local/bin/xclip << '\''XSHIM'\''
|
||||
#!/bin/sh
|
||||
# Shim for clipboard image paste from host via shared mount.
|
||||
# Claude Code calls: xclip -selection clipboard -t TARGETS -o (list types)
|
||||
# xclip -selection clipboard -t image/png -o (read)
|
||||
CLIPBOARD_FILE="'"$state_dir"'/clipboard.png"
|
||||
has_targets=false
|
||||
has_output=false
|
||||
mime_type=""
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
TARGETS) has_targets=true ;;
|
||||
-o) has_output=true ;;
|
||||
image/*) mime_type="$arg" ;;
|
||||
esac
|
||||
done
|
||||
if $has_targets && $has_output; then
|
||||
if [ -f "$CLIPBOARD_FILE" ]; then
|
||||
echo "image/png"
|
||||
exit 0
|
||||
else
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
if $has_output; then
|
||||
if [ -f "$CLIPBOARD_FILE" ]; then
|
||||
exec cat "$CLIPBOARD_FILE"
|
||||
else
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
exit 1
|
||||
XSHIM
|
||||
chmod +x ~/.local/bin/xclip
|
||||
'
|
||||
}
|
||||
|
||||
_claude_vm_write_dummy_credentials() {
|
||||
local vm_name="$1"
|
||||
# Write dummy credentials so Claude Code detects a Max subscription
|
||||
|
|
@ -697,6 +764,7 @@ _agent_vm_run() {
|
|||
limactl start "$vm_name" &>/dev/null
|
||||
|
||||
_claude_vm_write_dummy_credentials "$vm_name"
|
||||
_claude_vm_inject_clipboard_shim "$vm_name" "$state_dir"
|
||||
_claude_vm_setup_session_persistence "$vm_name" "$state_dir"
|
||||
_claude_vm_ensure_onboarding_config "$vm_name" "$host_dir"
|
||||
|
||||
|
|
@ -727,13 +795,17 @@ _agent_vm_run() {
|
|||
limactl shell --workdir "$host_dir" "$vm_name" bash -l < "${host_dir}/.claude-vm.runtime.sh"
|
||||
fi
|
||||
|
||||
local script_dir
|
||||
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
if [ "$agent" = "opencode" ]; then
|
||||
limactl shell --workdir "$host_dir" "$vm_name" \
|
||||
CLIPBOARD_DIR="$state_dir" python3 "$script_dir/clipboard-pty.py" \
|
||||
limactl shell --workdir "$host_dir" "$vm_name" \
|
||||
env OPENCODE_CONFIG="${state_dir}/opencode-config/opencode.json" \
|
||||
opencode "${args[@]}"
|
||||
else
|
||||
local claude_args=("${args[@]}")
|
||||
limactl shell --workdir "$host_dir" "$vm_name" \
|
||||
CLIPBOARD_DIR="$state_dir" python3 "$script_dir/clipboard-pty.py" \
|
||||
limactl shell --workdir "$host_dir" "$vm_name" \
|
||||
env ANTHROPIC_BASE_URL="http://host.lima.internal:${_claude_vm_proxy_port}" \
|
||||
IS_SANDBOX=1 \
|
||||
claude --dangerously-skip-permissions "${claude_args[@]}"
|
||||
|
|
@ -813,6 +885,7 @@ _agent_vm_shell() {
|
|||
limactl start "$vm_name" &>/dev/null
|
||||
|
||||
_claude_vm_write_dummy_credentials "$vm_name"
|
||||
_claude_vm_inject_clipboard_shim "$vm_name" "$state_dir"
|
||||
_claude_vm_setup_session_persistence "$vm_name" "$state_dir"
|
||||
_claude_vm_ensure_onboarding_config "$vm_name" "$host_dir"
|
||||
|
||||
|
|
@ -858,7 +931,10 @@ _agent_vm_shell() {
|
|||
if [ "$agent" = "opencode" ]; then
|
||||
shell_env+=(OPENCODE_CONFIG="${state_dir}/opencode-config/opencode.json")
|
||||
fi
|
||||
limactl shell --workdir "$host_dir" "$vm_name" \
|
||||
local script_dir
|
||||
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
CLIPBOARD_DIR="$state_dir" python3 "$script_dir/clipboard-pty.py" \
|
||||
limactl shell --workdir "$host_dir" "$vm_name" \
|
||||
env "${shell_env[@]}" \
|
||||
bash -l
|
||||
_claude_vm_security_check "$host_dir" "$security_snapshot"
|
||||
|
|
|
|||
185
clipboard-pty.py
Executable file
185
clipboard-pty.py
Executable file
|
|
@ -0,0 +1,185 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
PTY wrapper that intercepts Ctrl+V to save host clipboard images to a shared mount.
|
||||
|
||||
Sits between the host terminal and a child process (typically limactl shell).
|
||||
When Ctrl+V (0x16) is detected in stdin, reads the clipboard image on the host
|
||||
and writes it to $CLIPBOARD_DIR/clipboard.png. All bytes (including 0x16) are
|
||||
always forwarded to the child.
|
||||
|
||||
Usage:
|
||||
CLIPBOARD_DIR=/path/to/shared python3 clipboard-pty.py limactl shell ...
|
||||
|
||||
Supports macOS (osascript/pbpaste) and Linux (wl-paste / xclip).
|
||||
"""
|
||||
|
||||
import os
|
||||
import pty
|
||||
import select
|
||||
import shutil
|
||||
import signal
|
||||
import struct
|
||||
import subprocess
|
||||
import sys
|
||||
import fcntl
|
||||
import termios
|
||||
|
||||
|
||||
CTRL_V = 0x16
|
||||
|
||||
|
||||
def _read_clipboard_image():
|
||||
"""Try to read PNG image data from the host clipboard. Returns bytes or None."""
|
||||
if sys.platform == "darwin":
|
||||
# macOS: use osascript to write clipboard PNGf data to a temp file
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[
|
||||
"osascript", "-e",
|
||||
'set png to the clipboard as «class PNGf»',
|
||||
"-e",
|
||||
'set f to open for access POSIX file "/tmp/.clipboard-pty.png" with write permission',
|
||||
"-e",
|
||||
'set eof f to 0',
|
||||
"-e",
|
||||
'write png to f',
|
||||
"-e",
|
||||
'close access f',
|
||||
],
|
||||
capture_output=True, timeout=3,
|
||||
)
|
||||
if result.returncode == 0 and os.path.exists("/tmp/.clipboard-pty.png"):
|
||||
with open("/tmp/.clipboard-pty.png", "rb") as fh:
|
||||
data = fh.read()
|
||||
os.unlink("/tmp/.clipboard-pty.png")
|
||||
if data[:4] == b"\x89PNG":
|
||||
return data
|
||||
except (subprocess.TimeoutExpired, OSError):
|
||||
pass
|
||||
return None
|
||||
|
||||
# Linux: try wl-paste (Wayland), then xclip (X11)
|
||||
for cmd in (
|
||||
["wl-paste", "-t", "image/png"],
|
||||
["xclip", "-selection", "clipboard", "-t", "image/png", "-o"],
|
||||
):
|
||||
if not shutil.which(cmd[0]):
|
||||
continue
|
||||
try:
|
||||
result = subprocess.run(cmd, capture_output=True, timeout=3)
|
||||
if result.returncode == 0 and result.stdout[:4] == b"\x89PNG":
|
||||
return result.stdout
|
||||
except (subprocess.TimeoutExpired, OSError):
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def _save_clipboard(clipboard_dir):
|
||||
"""Read clipboard image and save to clipboard_dir/clipboard.png."""
|
||||
data = _read_clipboard_image()
|
||||
if data:
|
||||
path = os.path.join(clipboard_dir, "clipboard.png")
|
||||
with open(path, "wb") as fh:
|
||||
fh.write(data)
|
||||
|
||||
|
||||
def _resize_pty(child_fd):
|
||||
"""Forward the current terminal size to the child pty."""
|
||||
try:
|
||||
sz = fcntl.ioctl(sys.stdin.fileno(), termios.TIOCGWINSZ, b"\x00" * 8)
|
||||
fcntl.ioctl(child_fd, termios.TIOCSWINSZ, sz)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def main():
|
||||
if len(sys.argv) < 2:
|
||||
print("Usage: CLIPBOARD_DIR=/path python3 clipboard-pty.py <command> [args...]",
|
||||
file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
clipboard_dir = os.environ.get("CLIPBOARD_DIR", "")
|
||||
if not clipboard_dir:
|
||||
print("Error: CLIPBOARD_DIR not set", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
os.makedirs(clipboard_dir, exist_ok=True)
|
||||
|
||||
# Fork a child with a pty
|
||||
child_pid, child_fd = pty.fork()
|
||||
|
||||
if child_pid == 0:
|
||||
# Child: exec the command
|
||||
os.execvp(sys.argv[1], sys.argv[1:])
|
||||
|
||||
# Parent: relay I/O between terminal and child pty
|
||||
is_tty = os.isatty(sys.stdin.fileno())
|
||||
|
||||
if is_tty:
|
||||
# Forward SIGWINCH to child
|
||||
def on_winch(signum, frame):
|
||||
_resize_pty(child_fd)
|
||||
os.kill(child_pid, signal.SIGWINCH)
|
||||
|
||||
signal.signal(signal.SIGWINCH, on_winch)
|
||||
|
||||
# Set initial size
|
||||
_resize_pty(child_fd)
|
||||
|
||||
# Save and set raw mode on stdin (only when stdin is a real terminal)
|
||||
old_attrs = termios.tcgetattr(sys.stdin) if is_tty else None
|
||||
try:
|
||||
if is_tty:
|
||||
new_attrs = termios.tcgetattr(sys.stdin)
|
||||
# Enter raw mode: disable echo, canonical mode, signals, etc.
|
||||
new_attrs[0] = 0 # iflag
|
||||
new_attrs[1] = 0 # oflag
|
||||
new_attrs[2] &= ~(termios.CSIZE | termios.PARENB) # cflag
|
||||
new_attrs[2] |= termios.CS8
|
||||
new_attrs[3] = 0 # lflag
|
||||
new_attrs[6][termios.VMIN] = 1
|
||||
new_attrs[6][termios.VTIME] = 0
|
||||
termios.tcsetattr(sys.stdin, termios.TCSAFLUSH, new_attrs)
|
||||
|
||||
stdin_fd = sys.stdin.fileno()
|
||||
stdout_fd = sys.stdout.fileno()
|
||||
watch_fds = [stdin_fd, child_fd]
|
||||
|
||||
while child_fd in watch_fds:
|
||||
try:
|
||||
rlist, _, _ = select.select(watch_fds, [], [])
|
||||
except (OSError, ValueError):
|
||||
break
|
||||
|
||||
if stdin_fd in rlist:
|
||||
try:
|
||||
data = os.read(stdin_fd, 1024)
|
||||
except OSError:
|
||||
data = b""
|
||||
if not data:
|
||||
watch_fds.remove(stdin_fd)
|
||||
else:
|
||||
if CTRL_V in data:
|
||||
_save_clipboard(clipboard_dir)
|
||||
os.write(child_fd, data)
|
||||
|
||||
if child_fd in rlist:
|
||||
try:
|
||||
data = os.read(child_fd, 1024)
|
||||
except OSError:
|
||||
break
|
||||
if not data:
|
||||
break
|
||||
os.write(stdout_fd, data)
|
||||
|
||||
finally:
|
||||
if old_attrs is not None:
|
||||
termios.tcsetattr(sys.stdin, termios.TCSAFLUSH, old_attrs)
|
||||
|
||||
# Wait for child and exit with its status
|
||||
_, status = os.waitpid(child_pid, 0)
|
||||
sys.exit(os.waitstatus_to_exitcode(status))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Loading…
Add table
Add a link
Reference in a new issue