#!/usr/bin/env bash set -euo pipefail # macOS + iTerm2 client manager for the Team VM Claude image-paste bridge. # Public entrypoint: # bash <(curl -fsSL https://tumpai-handbook.pages.dev/team-vm-image-paste.sh) install PACKAGE_VERSION="1.0.0" ASSET_VERSION="v1" HANDBOOK_BASE_URL="${SIYUN_HANDBOOK_BASE_URL:-https://tumpai-handbook.pages.dev}" HANDBOOK_BASE_URL="${HANDBOOK_BASE_URL%/}" ASSET_BASE_URL="$HANDBOOK_BASE_URL/vm-image-paste/$ASSET_VERSION" SSH_ALIAS="siyun-team-vm" MAX_BYTES=20971520 [ -z "${SIYUN_VM_IMAGE_PASTE_SSH_ALIAS:-}" ] \ || [ "$SIYUN_VM_IMAGE_PASTE_SSH_ALIAS" = "$SSH_ALIAS" ] \ || { printf 'SSH alias 固定为 %s,不支持临时覆盖。\n' "$SSH_ALIAS" >&2; exit 1; } [[ "$SSH_ALIAS" =~ ^[A-Za-z0-9][A-Za-z0-9._-]*$ ]] \ && [ "${#SSH_ALIAS}" -le 64 ] \ || { printf '无效的 SSH alias: %s\n' "$SSH_ALIAS" >&2; exit 1; } # These hashes are pinned to the immutable vm-image-paste/v1 payload. They are # filled from the repository assets and must change when the payload changes. HASH_CLIPBOARD_TO_PNG="bf8284c9bcc4964fec2f8794db87a79318d03c2bc561a5bfcc246fa63cbee0a8" HASH_LOCAL_HELPER="3e3b46607abd56b5e8981275045332875f8c5faf85588b91b6fc5041ab8fb294" HASH_RUNTIME="d57e19de7fe91d53ffc8232cca9206c9d79174b7f066735e748606d3adba7838" HASH_STAGE="4ff4929a49d3a21b9863356992431fafa87b49e65eebe6724bf9c35ad091cf37" HASH_XCLIP="df02c491843743385d3ac77daaf062acf6d79bd2fae8b99df9e6f658212746f6" HASH_REMOTE_TEST="bb107ee7c4fa5acaf7b8ff3d8ea7dad19088f002f796e34c396809500986462f" HASH_VM_WRAPPER="1735e5aa495f76dffccd5375869adec9a6f6c188d6988651955fbc26d5334ef7" HASH_VMT_WRAPPER="539ba9b3417467003f7516c700579505f1c0d02554f3c4adf941b54e6185f2b1" GREEN=$'\033[32m' YELLOW=$'\033[33m' RED=$'\033[31m' BLUE=$'\033[36m' BOLD=$'\033[1m' RESET=$'\033[0m' info() { printf '%sℹ%s %s\n' "$BLUE" "$RESET" "$*"; } ok() { printf '%s✓%s %s\n' "$GREEN" "$RESET" "$*"; } warn() { printf '%s⚠%s %s\n' "$YELLOW" "$RESET" "$*"; } die() { printf '%s✗%s %s\n' "$RED" "$RESET" "$*" >&2; exit 1; } ACTION="${1:-install}" if [ "$#" -gt 0 ]; then shift fi if [ "$#" -ne 0 ]; then die "不支持额外参数。用法: $0 [install|doctor|uninstall|help]" fi case "$ACTION" in install|doctor|status|uninstall) ;; help|-h|--help) cat <<'EOF' 用法: team-vm-image-paste.sh install 安装或幂等更新 Mac 图片粘贴桥 team-vm-image-paste.sh doctor 非破坏性检查本机和 VM,只使用内置合成 PNG team-vm-image-paste.sh uninstall 退出活动会话后归档本机组件,不删除共享 VM 组件 前置条件:先运行团队 VM 基础脚本,并在 macOS iTerm2 中使用 vm / vmt。 EOF exit 0 ;; *) die "未知操作: $ACTION。支持 install、doctor、uninstall。" ;; esac CONFIG_HOME="$HOME/.config" # Team VM and image-paste config paths are fixed so install, doctor, runtime # and uninstall always operate on the same files without ambient XDG state. TEAM_CONFIG="$CONFIG_HOME/siyun-team-vm/config" BRIDGE_CONFIG="$CONFIG_HOME/vm-claude-image-paste/config" LOCAL_BIN_DIR="$HOME/.local/bin" LOCAL_LIB_DIR="$HOME/.local/libexec/vm-claude-image-paste" TEAM_LIB_DIR="$HOME/.local/libexec/siyun-team-vm" LOCAL_HELPER="$LOCAL_BIN_DIR/vm-claude-image-paste" LOCAL_CONVERTER="$LOCAL_LIB_DIR/clipboard-to-png.js" LOCAL_RUNTIME="$TEAM_LIB_DIR/image-paste-runtime.sh" PROFILE_DIR="$HOME/Library/Application Support/iTerm2/DynamicProfiles" PROFILE_FILE="$PROFILE_DIR/vm-claude-image-paste.json" SSH_DIR="$HOME/.ssh" SSH_CONFIG="$SSH_DIR/config" STATE_DIR="$HOME/.local/share/siyun-team-vm-image-paste" STATE_FILE="$STATE_DIR/install-state" ORIGINALS_DIR="$STATE_DIR/originals" ORIGINAL_VM="$ORIGINALS_DIR/vm" ORIGINAL_VMT="$ORIGINALS_DIR/vmt" ORIGINAL_VM_ABSENT="$ORIGINALS_DIR/vm.absent" ORIGINAL_VMT_ABSENT="$ORIGINALS_DIR/vmt.absent" LOG_FILE="$HOME/Library/Logs/vm-claude-image-paste.log" LOCK_ROOT="${TMPDIR:-/tmp}" LOCK_ROOT="${LOCK_ROOT%/}" LOCK_DIR="$LOCK_ROOT/siyun-team-vm-image-paste-manager.lock" WORK_DIR="" LOCK_OWNED=0 INSTALL_IN_PROGRESS=0 BACKUP_DIR="" BACKUP_TARGETS=() BACKUP_FILES=() BACKUP_EXISTED=() MIGRATED_TEAM_CONFIG=0 UNINSTALL_IN_PROGRESS=0 UNINSTALL_ARCHIVE_ROOT="" UNINSTALL_SSH_BACKUP="" UNINSTALL_SSH_CHANGED=0 UNINSTALL_MOVED_TARGETS=() UNINSTALL_MOVED_ARCHIVES=() UNINSTALL_RESTORED_TARGETS=() cleanup_lock() { if [ "$LOCK_OWNED" -ne 1 ] || [ ! -d "$LOCK_DIR" ]; then return fi local owner="" if [ -r "$LOCK_DIR/pid" ]; then IFS= read -r owner < "$LOCK_DIR/pid" || true fi if [ "$owner" = "$$" ]; then rm -f -- "$LOCK_DIR/pid" rmdir "$LOCK_DIR" 2>/dev/null || true fi LOCK_OWNED=0 } rollback_local_files() { local i target backup existed [ -n "$BACKUP_DIR" ] || return warn "安装未完成,正在恢复本次修改的本机文件。" for ((i=${#BACKUP_TARGETS[@]}-1; i>=0; i--)); do target="${BACKUP_TARGETS[$i]}" backup="${BACKUP_FILES[$i]}" existed="${BACKUP_EXISTED[$i]}" mkdir -p "$(dirname "$target")" if [ "$existed" = "1" ]; then cp -pP -- "$backup" "$target" else rm -f -- "$target" fi done } rollback_uninstall() { local i target archived [ "$UNINSTALL_IN_PROGRESS" -eq 1 ] || return 0 warn "卸载未完成,正在恢复本次移动的本机文件。" for ((i=${#UNINSTALL_RESTORED_TARGETS[@]}-1; i>=0; i--)); do target="${UNINSTALL_RESTORED_TARGETS[$i]}" rm -f -- "$target" || true done for ((i=${#UNINSTALL_MOVED_TARGETS[@]}-1; i>=0; i--)); do target="${UNINSTALL_MOVED_TARGETS[$i]}" archived="${UNINSTALL_MOVED_ARCHIVES[$i]}" if [ -e "$archived" ] || [ -L "$archived" ]; then mkdir -p "$(dirname "$target")" if [ -e "$target" ] || [ -L "$target" ]; then warn "回滚目标已被其他进程占用,请手工恢复: $archived -> $target" else mv -- "$archived" "$target" || true fi fi done if [ "$UNINSTALL_SSH_CHANGED" -eq 1 ] && [ -f "$UNINSTALL_SSH_BACKUP" ]; then cp -pP -- "$UNINSTALL_SSH_BACKUP" "$SSH_CONFIG" || true fi } on_exit() { local status=$? trap - EXIT HUP INT TERM if [ "$status" -ne 0 ] && [ "$INSTALL_IN_PROGRESS" -eq 1 ]; then rollback_local_files || true fi if [ "$status" -ne 0 ] && [ "$UNINSTALL_IN_PROGRESS" -eq 1 ]; then rollback_uninstall || true fi if [ -n "$WORK_DIR" ] && [ -d "$WORK_DIR" ]; then rm -rf -- "$WORK_DIR" fi cleanup_lock exit "$status" } trap on_exit EXIT trap 'exit 129' HUP trap 'exit 130' INT trap 'exit 143' TERM acquire_lock() { if mkdir "$LOCK_DIR" 2>/dev/null; then LOCK_OWNED=1 printf '%s\n' "$$" > "$LOCK_DIR/pid" return fi local owner="" if [ ! -r "$LOCK_DIR/pid" ]; then die "另一个安装或卸载进程正在运行。" fi IFS= read -r owner < "$LOCK_DIR/pid" || true if [[ "$owner" =~ ^[0-9]+$ ]] && kill -0 "$owner" 2>/dev/null; then die "另一个安装或卸载进程正在运行。" fi rm -f -- "$LOCK_DIR/pid" rmdir "$LOCK_DIR" 2>/dev/null || die "无法接管旧安装锁: $LOCK_DIR" mkdir "$LOCK_DIR" || die "无法创建安装锁: $LOCK_DIR" LOCK_OWNED=1 printf '%s\n' "$$" > "$LOCK_DIR/pid" } need_cmd() { command -v "$1" >/dev/null 2>&1 || die "缺少命令: $1" } require_macos() { [ "$(uname -s)" = "Darwin" ] || die "图片粘贴桥只支持 macOS + iTerm2。" } require_macos_iterm() { require_macos if [ ! -d /Applications/iTerm.app ] && [ ! -d "$HOME/Applications/iTerm.app" ]; then die "没有找到 iTerm2。请先安装 iTerm2,再重新运行。" fi } secure_shell_config() { local file="$1" owner mode [ -f "$file" ] && [ ! -L "$file" ] || return 1 owner="$(stat -f '%u' "$file" 2>/dev/null || true)" mode="$(stat -f '%Lp' "$file" 2>/dev/null || true)" [ "$owner" = "$(id -u)" ] && [ "$mode" = "600" ] } decode_config_word() { local input="$1" output="" char if [[ "$input" == \'*\' ]]; then input="${input#\'}" input="${input%\'}" [[ "$input" != *\'* ]] || return 1 DECODED_CONFIG_WORD="$input" return 0 fi if [[ "$input" == \"*\" ]]; then input="${input#\"}" input="${input%\"}" [[ "$input" != *\"* && "$input" != *'`'* && "$input" != *'$'* && "$input" != *'\\'* ]] || return 1 DECODED_CONFIG_WORD="$input" return 0 fi [[ "$input" != \$\'* ]] || return 1 while [ -n "$input" ]; do char="${input:0:1}" input="${input:1}" if [ "$char" = "\\" ]; then [ -n "$input" ] || return 1 char="${input:0:1}" input="${input:1}" fi output+="$char" done DECODED_CONFIG_WORD="$output" } legacy_wrapper_value() { local key="$1" file="$2" line raw line="$(awk -v prefix="$key=" 'index($0, prefix) == 1 {print; exit}' "$file")" [ -n "$line" ] || return 1 raw="${line#*=}" decode_config_word "$raw" || return 1 printf '%s' "$DECODED_CONFIG_WORD" } parse_team_config() { local line raw key seen_host=0 seen_port=0 seen_team=0 seen_key=0 SIYUN_TEAM_VM_HOST="" SIYUN_TEAM_VM_PORT="" SIYUN_TEAM_VM_TEAM_ID="" SIYUN_TEAM_VM_LOGIN_KEY="" while IFS= read -r line || [ -n "$line" ]; do case "$line" in ''|'# '*) continue ;; SIYUN_TEAM_VM_HOST=*) key=host; seen_host=$((seen_host + 1)) ;; SIYUN_TEAM_VM_PORT=*) key=port; seen_port=$((seen_port + 1)) ;; SIYUN_TEAM_VM_TEAM_ID=*) key=team; seen_team=$((seen_team + 1)) ;; SIYUN_TEAM_VM_LOGIN_KEY=*) key=login_key; seen_key=$((seen_key + 1)) ;; *) return 1 ;; esac raw="${line#*=}" decode_config_word "$raw" || return 1 case "$key" in host) SIYUN_TEAM_VM_HOST="$DECODED_CONFIG_WORD" ;; port) SIYUN_TEAM_VM_PORT="$DECODED_CONFIG_WORD" ;; team) SIYUN_TEAM_VM_TEAM_ID="$DECODED_CONFIG_WORD" ;; login_key) SIYUN_TEAM_VM_LOGIN_KEY="$DECODED_CONFIG_WORD" ;; esac done < "$TEAM_CONFIG" [ "$seen_host" -eq 1 ] && [ "$seen_port" -eq 1 ] \ && [ "$seen_team" -eq 1 ] && [ "$seen_key" -eq 1 ] } load_team_config() { local input_host="${SIYUN_VM_HOST:-}" local input_port="${SIYUN_VM_PORT:-}" local input_team="${SIYUN_TEAM_ID:-}" local input_key="${SIYUN_VM_LOGIN_KEY:-}" local legacy_vm="$HOME/.local/bin/vm" if [ -r "$TEAM_CONFIG" ]; then secure_shell_config "$TEAM_CONFIG" || die "团队 VM 配置必须是当前用户拥有的 0600 普通文件: $TEAM_CONFIG" parse_team_config || die "团队 VM 配置包含未知字段或不安全的值: $TEAM_CONFIG" [ -z "$input_host" ] || [ "$input_host" = "$SIYUN_TEAM_VM_HOST" ] \ || die "不支持临时覆盖已有 VM host;请重新运行 team-vm.sh 更新配置。" [ -z "$input_port" ] || [ "$input_port" = "$SIYUN_TEAM_VM_PORT" ] \ || die "不支持临时覆盖已有 VM port;请重新运行 team-vm.sh 更新配置。" [ -z "$input_team" ] || [ "$input_team" = "$SIYUN_TEAM_VM_TEAM_ID" ] \ || die "不支持临时覆盖已有成员 ID;请重新运行 team-vm.sh 更新配置。" [ -z "$input_key" ] || [ "$input_key" = "$SIYUN_TEAM_VM_LOGIN_KEY" ] \ || die "不支持临时覆盖已有登录密钥;请重新运行 team-vm.sh 更新配置。" input_host="" input_port="" input_team="" input_key="" fi if [ ! -r "$TEAM_CONFIG" ] && [ -z "$input_host" ] && [ -f "$legacy_vm" ] && [ ! -L "$legacy_vm" ]; then input_host="$(legacy_wrapper_value VM_HOST "$legacy_vm" || true)" input_port="$(legacy_wrapper_value VM_PORT "$legacy_vm" || true)" input_team="$(legacy_wrapper_value TEAM_ID "$legacy_vm" || true)" input_key="${input_key:-$HOME/.ssh/id_ed25519}" if [ -n "$input_host" ] && [ -n "$input_team" ]; then MIGRATED_TEAM_CONFIG=1 info "检测到旧版 vm 快捷命令,将迁移为结构化团队 VM 配置。" fi fi VM_HOST="${input_host:-${SIYUN_TEAM_VM_HOST:-}}" VM_PORT="${input_port:-${SIYUN_TEAM_VM_PORT:-22}}" TEAM_ID="${input_team:-${SIYUN_TEAM_VM_TEAM_ID:-}}" LOGIN_KEY="${input_key:-${SIYUN_TEAM_VM_LOGIN_KEY:-$HOME/.ssh/id_ed25519}}" if [ ! -r "$TEAM_CONFIG" ] && [ "$MIGRATED_TEAM_CONFIG" -eq 0 ]; then MIGRATED_TEAM_CONFIG=1 info "将把本次有效连接参数写入固定的团队 VM 配置。" fi [ -n "$VM_HOST" ] && [ -n "$TEAM_ID" ] || die "缺少团队 VM 配置。请先运行 team-vm.sh。" [[ "$VM_PORT" =~ ^[0-9]+$ ]] && [ "$VM_PORT" -ge 1 ] && [ "$VM_PORT" -le 65535 ] || die "VM 端口无效。" [[ "$TEAM_ID" =~ ^[A-Za-z0-9._-]+$ ]] || die "成员 ID 格式无效。" [[ "$VM_HOST" == *@* ]] || die "VM host 必须使用 user@host 格式。" VM_USER="${VM_HOST%%@*}" VM_HOSTNAME="${VM_HOST#*@}" [[ "$VM_USER" =~ ^[A-Za-z0-9._-]+$ ]] || die "VM 用户名无效。" [[ "$VM_HOSTNAME" =~ ^[A-Za-z0-9._:-]+$ ]] || die "VM 主机名无效。" case "$LOGIN_KEY" in /*) ;; *) die "团队 VM 登录密钥必须使用绝对路径: $LOGIN_KEY" ;; esac case "$LOGIN_KEY" in *$'\n'*|*$'\r'*|*$'\t'*|*$'\b'*|*$'\f'*|*'"'*|*'\'*|*'%'*) die "登录密钥路径包含 SSH config 不支持的字符。" ;; esac [ -f "$LOGIN_KEY" ] && [ -f "$LOGIN_KEY.pub" ] || die "找不到团队 VM 登录密钥: $LOGIN_KEY" } persist_migrated_team_config() { local tmp [ "$MIGRATED_TEAM_CONFIG" -eq 1 ] || return 0 backup_target "$TEAM_CONFIG" mkdir -p "$(dirname "$TEAM_CONFIG")" tmp="$(mktemp "$(dirname "$TEAM_CONFIG")/.config.XXXXXX")" { printf 'SIYUN_TEAM_VM_HOST=%q\n' "$VM_HOST" printf 'SIYUN_TEAM_VM_PORT=%q\n' "$VM_PORT" printf 'SIYUN_TEAM_VM_TEAM_ID=%q\n' "$TEAM_ID" printf 'SIYUN_TEAM_VM_LOGIN_KEY=%q\n' "$LOGIN_KEY" } > "$tmp" chmod 600 "$tmp" mv -f -- "$tmp" "$TEAM_CONFIG" } expected_hash() { case "$1" in clipboard-to-png.js) printf '%s' "$HASH_CLIPBOARD_TO_PNG" ;; vm-claude-image-paste) printf '%s' "$HASH_LOCAL_HELPER" ;; image-paste-runtime.sh) printf '%s' "$HASH_RUNTIME" ;; stage) printf '%s' "$HASH_STAGE" ;; xclip) printf '%s' "$HASH_XCLIP" ;; test-remote.sh) printf '%s' "$HASH_REMOTE_TEST" ;; vm) printf '%s' "$HASH_VM_WRAPPER" ;; vmt) printf '%s' "$HASH_VMT_WRAPPER" ;; *) return 1 ;; esac } download_assets() { local name expected actual url WORK_DIR="$(mktemp -d "$LOCK_ROOT/siyun-team-vm-image-paste.XXXXXX")" case "$ASSET_BASE_URL" in https://*) ;; *) [ "${SIYUN_VM_IMAGE_PASTE_ALLOW_INSECURE_ASSETS:-0}" = "1" ] || die "资产地址必须使用 HTTPS。" ;; esac for name in clipboard-to-png.js vm-claude-image-paste image-paste-runtime.sh stage xclip test-remote.sh vm vmt; do expected="$(expected_hash "$name")" [[ "$expected" =~ ^[a-f0-9]{64}$ ]] || die "安装器内置资产摘要无效: $name" url="$ASSET_BASE_URL/$name" curl -fsSL --retry 2 --connect-timeout 10 "$url" -o "$WORK_DIR/$name" || die "下载失败: $url" actual="$(shasum -a 256 "$WORK_DIR/$name" | awk '{print $1}')" [ "$actual" = "$expected" ] || die "资产摘要不匹配: $name" done } start_transaction() { BACKUP_DIR="$STATE_DIR/backups/$(date '+%Y%m%d-%H%M%S')-$$" mkdir -p "$BACKUP_DIR/files" chmod 700 "$STATE_DIR" "$STATE_DIR/backups" "$BACKUP_DIR" "$BACKUP_DIR/files" 2>/dev/null || true INSTALL_IN_PROGRESS=1 } backup_target() { local target="$1" idx backup existed=0 [ ! -L "$target" ] || die "拒绝覆盖符号链接: $target" idx="${#BACKUP_TARGETS[@]}" backup="$BACKUP_DIR/files/$idx" if [ -e "$target" ]; then cp -pP -- "$target" "$backup" existed=1 fi BACKUP_TARGETS[$idx]="$target" BACKUP_FILES[$idx]="$backup" BACKUP_EXISTED[$idx]="$existed" } install_asset() { local source="$1" target="$2" mode="$3" tmp backup_target "$target" mkdir -p "$(dirname "$target")" tmp="$target.tmp.$$" install -m "$mode" "$source" "$tmp" mv -f -- "$tmp" "$target" } known_team_wrapper() { local path="$1" payload_name="$2" actual expected [ -f "$path" ] && [ ! -L "$path" ] || return 1 actual="$(shasum -a 256 "$path" | awk '{print $1}')" expected="$(expected_hash "$payload_name")" [ "$actual" = "$expected" ] && return 0 grep -q '^VM_HOST=' "$path" \ && grep -q '^TEAM_ID=' "$path" \ && grep -q 'REMOTE_CMD=' "$path" \ && grep -Eq '(^|/)(usr/bin/)?ssh |exec ssh |ssh "' "$path" } preserve_original_wrapper() { local target="$1" payload_name="$2" original="$3" absent_marker="$4" tmp [ ! -L "$ORIGINALS_DIR" ] || die "拒绝使用符号链接形式的 originals 目录: $ORIGINALS_DIR" mkdir -p "$ORIGINALS_DIR" chmod 700 "$STATE_DIR" "$ORIGINALS_DIR" 2>/dev/null || true if [ -e "$original" ] && [ -e "$absent_marker" ]; then die "wrapper 原始状态记录冲突: $payload_name" fi if [ -e "$target" ] || [ -L "$target" ]; then known_team_wrapper "$target" "$payload_name" \ || die "拒绝覆盖未知的 $target;请先备份或恢复 team-vm 生成的 wrapper。" fi if [ -e "$original" ] || [ -e "$absent_marker" ]; then return 0 fi if [ -e "$target" ]; then backup_target "$original" tmp="$original.tmp.$$" cp -pP -- "$target" "$tmp" mv -f -- "$tmp" "$original" else backup_target "$absent_marker" tmp="$absent_marker.tmp.$$" : > "$tmp" chmod 600 "$tmp" mv -f -- "$tmp" "$absent_marker" fi } validate_existing_managed_file() { local target="$1" payload_name="$2" marker="$3" actual expected [ -e "$target" ] || [ -L "$target" ] || return 0 [ -f "$target" ] && [ ! -L "$target" ] \ || die "拒绝覆盖非普通文件: $target" expected="$(expected_hash "$payload_name")" actual="$(shasum -a 256 "$target" | awk '{print $1}')" [ "$actual" = "$expected" ] && return 0 grep -Fq "$marker" "$target" \ || die "拒绝覆盖未知文件: $target" } parse_bridge_config() { local line raw seen_host=0 seen_token=0 seen_max=0 secure_shell_config "$BRIDGE_CONFIG" || return 1 VM_CLAUDE_CLIPBOARD_HOST="" VM_CLAUDE_CLIPBOARD_TOKEN="" VM_CLAUDE_CLIPBOARD_MAX_BYTES="" while IFS= read -r line || [ -n "$line" ]; do case "$line" in ''|'# '*) continue ;; VM_CLAUDE_CLIPBOARD_HOST=*) [ "$seen_host" -eq 0 ] || return 1 raw="${line#*=}" seen_host=1 ;; VM_CLAUDE_CLIPBOARD_TOKEN=*) [ "$seen_token" -eq 0 ] || return 1 raw="${line#*=}" seen_token=1 ;; VM_CLAUDE_CLIPBOARD_MAX_BYTES=*) [ "$seen_max" -eq 0 ] || return 1 raw="${line#*=}" seen_max=1 ;; *) return 1 ;; esac if [[ "$raw" == \'*\' ]]; then raw="${raw#\'}" raw="${raw%\'}" fi case "$line" in VM_CLAUDE_CLIPBOARD_HOST=*) [[ "$raw" =~ ^[A-Za-z0-9][A-Za-z0-9._-]*$ ]] || return 1 VM_CLAUDE_CLIPBOARD_HOST="$raw" ;; VM_CLAUDE_CLIPBOARD_TOKEN=*) [[ "$raw" =~ ^[a-f0-9]{64}$ ]] || return 1 VM_CLAUDE_CLIPBOARD_TOKEN="$raw" ;; VM_CLAUDE_CLIPBOARD_MAX_BYTES=*) [[ "$raw" =~ ^[0-9]+$ ]] || return 1 VM_CLAUDE_CLIPBOARD_MAX_BYTES="$raw" ;; esac done < "$BRIDGE_CONFIG" [ "$seen_host" -eq 1 ] && [ "$seen_token" -eq 1 ] && [ "$seen_max" -eq 1 ] } load_or_create_slot() { CLIPBOARD_SLOT="" if [ -r "$BRIDGE_CONFIG" ]; then parse_bridge_config || die "现有图片桥配置必须是合法的当前用户 0600 普通文件。" if [[ "${VM_CLAUDE_CLIPBOARD_TOKEN:-}" =~ ^[a-f0-9]{64}$ ]]; then CLIPBOARD_SLOT="$VM_CLAUDE_CLIPBOARD_TOKEN" fi fi if [ -z "$CLIPBOARD_SLOT" ]; then CLIPBOARD_SLOT="$(openssl rand -hex 32)" fi [[ "$CLIPBOARD_SLOT" =~ ^[a-f0-9]{64}$ ]] || die "无法生成有效 slot。" } write_bridge_config() { local tmp backup_target "$BRIDGE_CONFIG" mkdir -p "$(dirname "$BRIDGE_CONFIG")" tmp="$(mktemp "$(dirname "$BRIDGE_CONFIG")/.config.XXXXXX")" { printf '# Managed by team-vm-image-paste.sh. Mode 0600.\n' printf 'VM_CLAUDE_CLIPBOARD_HOST=%q\n' "$SSH_ALIAS" printf 'VM_CLAUDE_CLIPBOARD_TOKEN=%q\n' "$CLIPBOARD_SLOT" printf 'VM_CLAUDE_CLIPBOARD_MAX_BYTES=%q\n' "$MAX_BYTES" } > "$tmp" chmod 600 "$tmp" mv -f -- "$tmp" "$BRIDGE_CONFIG" } validate_managed_block() { local file="$1" start="$2" end="$3" [ -e "$file" ] || return 0 awk -v start="$start" -v end="$end" ' $0 == start { if (inside || starts >= 1) exit 1 inside=1; starts++; next } $0 == end { if (!inside || ends >= 1) exit 1 inside=0; ends++; next } END { if (inside || starts != ends || starts > 1 || ends > 1) exit 1 } ' "$file" } rewrite_ssh_config() { local mode="${1:-install}" tmp clean mkdir -p "$SSH_DIR" chmod 700 "$SSH_DIR" [ ! -L "$SSH_CONFIG" ] || die "拒绝修改符号链接形式的 SSH config。" validate_managed_block "$SSH_CONFIG" \ '# >>> siyun team vm image paste >>>' \ '# <<< siyun team vm image paste <<<' \ || die "SSH config 中的图片桥受管标记不完整或重复,请先手工检查: $SSH_CONFIG" backup_target "$SSH_CONFIG" [ -e "$SSH_CONFIG" ] || : > "$SSH_CONFIG" clean="$(mktemp "$SSH_DIR/.config-clean.XXXXXX")" awk ' $0 == "# >>> siyun team vm image paste >>>" {skip=1; next} $0 == "# <<< siyun team vm image paste <<<" {skip=0; next} !skip {print} ' "$SSH_CONFIG" > "$clean" tmp="$(mktemp "$SSH_DIR/.config-new.XXXXXX")" if [ "$mode" = "install" ]; then { printf '%s\n' '# >>> siyun team vm image paste >>>' printf 'Host %s\n' "$SSH_ALIAS" printf ' HostName %s\n' "$VM_HOSTNAME" printf ' User %s\n' "$VM_USER" printf ' Port %s\n' "$VM_PORT" printf ' IdentityFile "%s"\n' "$LOGIN_KEY" printf '%s\n' ' IdentitiesOnly yes' ' BatchMode yes' ' StrictHostKeyChecking yes' printf '%s\n' ' ControlMaster auto' ' ControlPersist 10m' ' ControlPath ~/.ssh/cm-siyun-team-vm-%C' printf '%s\n\n' '# <<< siyun team vm image paste <<<' cat "$clean" } > "$tmp" else cat "$clean" > "$tmp" fi chmod 600 "$tmp" mv -f -- "$tmp" "$SSH_CONFIG" rm -f -- "$clean" } verify_ssh_alias() { local resolved resolved="$(ssh -G "$SSH_ALIAS" 2>/dev/null)" || die "无法解析 SSH alias: $SSH_ALIAS" printf '%s\n' "$resolved" | grep -Fqx "hostname $VM_HOSTNAME" || die "SSH alias HostName 校验失败。" printf '%s\n' "$resolved" | grep -Fqx "user $VM_USER" || die "SSH alias User 校验失败。" printf '%s\n' "$resolved" | grep -Fqx "port $VM_PORT" || die "SSH alias Port 校验失败。" ssh -o BatchMode=yes -o StrictHostKeyChecking=yes "$SSH_ALIAS" true >/dev/null || die "SSH 非交互公钥登录失败;请先核对主机指纹并完成 team-vm.sh 基础安装。" } calculate_remote_release_id() { local bundle_id bundle_id="$(printf '%s\n' "$HASH_STAGE" "$HASH_XCLIP" "$HASH_REMOTE_TEST" | shasum -a 256 | awk '{print substr($1,1,16)}')" REMOTE_RELEASE_ID="$ASSET_VERSION-$bundle_id" } write_remote_installer() { calculate_remote_release_id cat > "$WORK_DIR/install-remote.sh" </dev/null 2>&1 || { echo 'remote missing sha256sum' >&2; exit 1; } command -v flock >/dev/null 2>&1 || { echo 'remote missing flock' >&2; exit 1; } command -v tar >/dev/null 2>&1 || { echo 'remote missing tar' >&2; exit 1; } command -v readlink >/dev/null 2>&1 || { echo 'remote missing readlink' >&2; exit 1; } [ -x /usr/bin/openssl ] || { echo 'remote missing /usr/bin/openssl' >&2; exit 1; } mkdir -p "\$ROOT/releases" "\$HOME/.local/bin" "\$HOME/.cache/claude-vm-image-paste/sessions" chmod 700 "\$ROOT" "\$ROOT/releases" "\$HOME/.cache/claude-vm-image-paste" "\$HOME/.cache/claude-vm-image-paste/sessions" touch "\$ROOT/.install.lock" chmod 600 "\$ROOT/.install.lock" exec 9<>"\$ROOT/.install.lock" flock -x -w 10 9 if [ -e "\$ROOT/current" ] || [ -L "\$ROOT/current" ]; then [ -L "\$ROOT/current" ] || { echo 'refusing to replace non-symlink remote current' >&2; exit 1; } current_target="\$(readlink "\$ROOT/current")" [[ "\$current_target" =~ ^releases/v[0-9]+-[a-f0-9]{16}$ ]] \ || { echo 'refusing to replace unmanaged remote current symlink' >&2; exit 1; } fi verify_release() { [ -d "\$RELEASE" ] && [ ! -L "\$RELEASE" ] || return 1 [ -f "\$RELEASE/stage" ] && [ ! -L "\$RELEASE/stage" ] && [ -x "\$RELEASE/stage" ] || return 1 [ -f "\$RELEASE/bin/xclip" ] && [ ! -L "\$RELEASE/bin/xclip" ] && [ -x "\$RELEASE/bin/xclip" ] || return 1 [ -f "\$RELEASE/test-remote.sh" ] && [ ! -L "\$RELEASE/test-remote.sh" ] && [ -x "\$RELEASE/test-remote.sh" ] || return 1 [ "\$(sha256sum "\$RELEASE/stage" | awk '{print \$1}')" = "\$EXPECTED_STAGE" ] || return 1 [ "\$(sha256sum "\$RELEASE/bin/xclip" | awk '{print \$1}')" = "\$EXPECTED_XCLIP" ] || return 1 [ "\$(sha256sum "\$RELEASE/test-remote.sh" | awk '{print \$1}')" = "\$EXPECTED_TEST" ] || return 1 } if [ -e "\$RELEASE" ] || [ -L "\$RELEASE" ]; then verify_release || { echo 'existing remote release hash mismatch' >&2; exit 1; } else incoming="\$(mktemp -d "\$ROOT/releases/.incoming.XXXXXXXX")" mkdir -p "\$incoming/bin" install -m 755 "\$PAYLOAD_DIR/stage" "\$incoming/stage" install -m 755 "\$PAYLOAD_DIR/xclip" "\$incoming/bin/xclip" install -m 755 "\$PAYLOAD_DIR/test-remote.sh" "\$incoming/test-remote.sh" if ! mv "\$incoming" "\$RELEASE"; then rm -rf -- "\$incoming" exit 1 fi fi verify_release || { echo 'candidate remote release verification failed' >&2; exit 1; } # Test the candidate through its release path. Nothing shared points at this # release until the complete synthetic PNG state-machine test has passed. CLAUDE_VM_TEST_SHIM="\$RELEASE/bin/xclip" \ CLAUDE_VM_TEST_STAGE="\$RELEASE/stage" \ "\$RELEASE/test-remote.sh" rollback_dir="\$(mktemp -d "\$ROOT/.rollback.XXXXXXXX")" committed=0 stable_changed=0 stable_collision=0 migration_archive='' current_tmp='' stable_tmp='' tmux_path_before='' tmux_path_known=0 snapshot_file() { file="\$1" label="\$2" if [ -L "\$file" ] && [ ! -e "\$file" ]; then echo "refusing to modify broken symlink: \$file" >&2 return 1 fi if [ -e "\$file" ]; then [ ! -d "\$file" ] || { echo "refusing to replace directory: \$file" >&2; return 1; } cp -p -- "\$file" "\$rollback_dir/\$label" : > "\$rollback_dir/\$label.existed" fi : > "\$rollback_dir/\$label.snapshotted" } restore_file() { file="\$1" label="\$2" [ -f "\$rollback_dir/\$label.snapshotted" ] || return 0 if [ -f "\$rollback_dir/\$label.existed" ]; then cp -p -- "\$rollback_dir/\$label" "\$file" else rm -f -- "\$file" fi } validate_marker_block() { file="\$1" start="\$2" end="\$3" [ -e "\$file" ] || return 0 awk -v start="\$start" -v end="\$end" ' \$0 == start { if (inside || starts >= 1) exit 1 inside=1; starts++; next } \$0 == end { if (!inside || ends >= 1) exit 1 inside=0; ends++; next } END { if (inside || starts != ends || starts > 1 || ends > 1) exit 1 } ' "\$file" } rollback_remote_changes() { restore_file "\$HOME/.bashrc" bashrc || true restore_file "\$HOME/.profile" profile || true restore_file "\$HOME/.tmux.conf" tmux-conf || true if [ "\$stable_changed" -eq 1 ]; then rm -f -- "\$STABLE_XCLIP" if [ -n "\$migration_archive" ] && [ -e "\$migration_archive" ]; then mv -- "\$migration_archive" "\$STABLE_XCLIP" || true fi fi if [ "\$tmux_path_known" -eq 1 ] && command -v tmux >/dev/null 2>&1; then case "\$tmux_path_before" in PATH=*) tmux set-environment -g PATH "\${tmux_path_before#PATH=}" 2>/dev/null || true ;; *) tmux set-environment -gu PATH 2>/dev/null || true ;; esac fi } finish() { status=\$? trap - EXIT HUP INT TERM [ -z "\$current_tmp" ] || rm -f -- "\$current_tmp" [ -z "\$stable_tmp" ] || rm -f -- "\$stable_tmp" if [ "\$status" -ne 0 ] && [ "\$committed" -ne 1 ]; then rollback_remote_changes fi rm -rf -- "\$rollback_dir" exit "\$status" } trap finish EXIT trap 'exit 129' HUP trap 'exit 130' INT trap 'exit 143' TERM snapshot_file "\$HOME/.bashrc" bashrc snapshot_file "\$HOME/.profile" profile snapshot_file "\$HOME/.tmux.conf" tmux-conf validate_marker_block "\$HOME/.bashrc" "\$path_start" "\$path_end" \ || { echo 'unbalanced image-paste marker in ~/.bashrc' >&2; exit 1; } validate_marker_block "\$HOME/.profile" "\$path_start" "\$path_end" \ || { echo 'unbalanced image-paste marker in ~/.profile' >&2; exit 1; } validate_marker_block "\$HOME/.tmux.conf" "\$path_start" "\$path_end" \ || { echo 'unbalanced image-paste marker in ~/.tmux.conf' >&2; exit 1; } if command -v tmux >/dev/null 2>&1; then tmux_path_before="\$(tmux show-environment -g PATH 2>/dev/null || true)" tmux_path_known=1 fi # Add a stable name for shells and Claude processes whose PATH predates this # release. Never replace an unrelated user xclip. if [ -L "\$STABLE_XCLIP" ]; then if [ "\$(readlink "\$STABLE_XCLIP")" != "\$MANAGED_XCLIP_TARGET" ]; then stable_collision=1 fi elif [ -e "\$STABLE_XCLIP" ]; then if [ -f "\$STABLE_XCLIP" ] \ && [ "\$(sha256sum "\$STABLE_XCLIP" | awk '{print \$1}')" = "\$EXPECTED_XCLIP" ]; then migration_dir="\$ROOT/migrated/\$(date '+%Y%m%d-%H%M%S')-\$\$" mkdir -p "\$migration_dir" migration_archive="\$migration_dir/xclip" mv -- "\$STABLE_XCLIP" "\$migration_archive" stable_changed=1 stable_tmp="\$HOME/.local/bin/.xclip.\$\$" ln -s "\$MANAGED_XCLIP_TARGET" "\$stable_tmp" mv -Tf "\$stable_tmp" "\$STABLE_XCLIP" stable_tmp='' else stable_collision=1 fi else stable_tmp="\$HOME/.local/bin/.xclip.\$\$" ln -s "\$MANAGED_XCLIP_TARGET" "\$stable_tmp" mv -Tf "\$stable_tmp" "\$STABLE_XCLIP" stable_tmp='' stable_changed=1 fi write_path_block() { file="\$1" touch "\$file" temp="\$(mktemp)" awk -v start="\$path_start" -v end="\$path_end" '\$0==start{skip=1;next} \$0==end{skip=0;next} !skip{print}' "\$file" > "\$temp" { cat "\$temp" printf '\n%s\nexport PATH="\$HOME/.local/libexec/claude-vm-image-paste/current/bin:\$HOME/.local/bin:\$PATH"\n%s\n' "\$path_start" "\$path_end" } > "\$file" rm -f "\$temp" } write_path_block "\$HOME/.bashrc" write_path_block "\$HOME/.profile" tmux_conf="\$HOME/.tmux.conf" touch "\$tmux_conf" temp="\$(mktemp)" awk -v start="\$path_start" -v end="\$path_end" '\$0==start{skip=1;next} \$0==end{skip=0;next} !skip{print}' "\$tmux_conf" > "\$temp" { cat "\$temp" printf '\n%s\nset-environment -g PATH "\$HOME/.local/libexec/claude-vm-image-paste/current/bin:\$HOME/.local/bin:\$PATH"\n%s\n' "\$path_start" "\$path_end" } > "\$tmux_conf" rm -f "\$temp" if command -v tmux >/dev/null 2>&1; then tmux set-environment -g PATH "\$HOME/.local/libexec/claude-vm-image-paste/current/bin:\$HOME/.local/bin:\$PATH" 2>/dev/null || true fi # This atomic rename is intentionally the final state-changing step. If any # candidate test or local configuration step failed, current remains untouched. current_tmp="\$ROOT/.current.\$\$" ln -s "releases/\$RELEASE_ID" "\$current_tmp" mv -Tf "\$current_tmp" "\$ROOT/current" current_tmp='' committed=1 printf 'REMOTE_IMAGE_PASTE_READY=%s\n' "\$RELEASE_ID" if [ "\$stable_collision" -eq 1 ]; then printf 'REMOTE_STABLE_XCLIP=collision\n' printf 'WARN: ~/.local/bin/xclip belongs to another installation; it was not changed. Restart tmux/Claude so the managed PATH takes effect.\n' >&2 else printf 'REMOTE_STABLE_XCLIP=managed\n' fi EOF chmod 700 "$WORK_DIR/install-remote.sh" } install_remote_release() { local archive remote_cmd write_remote_installer archive="$WORK_DIR/remote-payload.tar" tar -cf "$archive" -C "$WORK_DIR" install-remote.sh stage xclip test-remote.sh remote_cmd='set -e; umask 077; upload_root="$HOME/.cache/claude-vm-image-paste-installer"; mkdir -p "$upload_root"; chmod 700 "$upload_root"; upload_dir=$(mktemp -d "$upload_root/upload.XXXXXXXX"); cleanup(){ rm -rf -- "$upload_dir"; }; trap cleanup EXIT HUP INT TERM; tar -xf - -C "$upload_dir"; bash "$upload_dir/install-remote.sh"' ssh "$SSH_ALIAS" "$remote_cmd" < "$archive" } existing_profile_guid() { local guid="" if [ -f "$PROFILE_FILE" ] && [ ! -L "$PROFILE_FILE" ]; then guid="$(/usr/bin/plutil -extract Profiles.0.Guid raw -o - "$PROFILE_FILE" 2>/dev/null || true)" fi if [[ "$guid" =~ ^[A-Fa-f0-9]{8}-[A-Fa-f0-9]{4}-[A-Fa-f0-9]{4}-[A-Fa-f0-9]{4}-[A-Fa-f0-9]{12}$ ]]; then printf '%s' "$guid" else /usr/bin/uuidgen fi } validate_existing_dynamic_profile() { if [ -e "$PROFILE_FILE" ] || [ -L "$PROFILE_FILE" ]; then [ -f "$PROFILE_FILE" ] && [ ! -L "$PROFILE_FILE" ] \ || die "拒绝覆盖非普通 iTerm Dynamic Profile: $PROFILE_FILE" [ "$(/usr/bin/plutil -extract Profiles.0.Name raw -o - "$PROFILE_FILE" 2>/dev/null || true)" = "VM Claude" ] \ && [ "$(/usr/bin/plutil -extract 'Profiles.0.Keyboard Map.0x76-0x40000-0x9.Text' raw -o - "$PROFILE_FILE" 2>/dev/null || true)" = "$LOCAL_HELPER" ] \ || die "拒绝覆盖不是本图片桥管理的 iTerm Dynamic Profile。" fi } write_dynamic_profile() { local guid parent helper_path tmp tmp_base validate_existing_dynamic_profile guid="$(existing_profile_guid)" parent="${SIYUN_ITERM_PARENT_PROFILE:-Default}" helper_path="$LOCAL_HELPER" case "$parent$helper_path" in *$'\n'*|*$'\r'*|*$'\t'*|*$'\b'*|*$'\f'*|*'"'*|*'\\'*) die "iTerm Profile 名称或 helper 路径包含不支持的字符。" ;; esac backup_target "$PROFILE_FILE" mkdir -p "$PROFILE_DIR" tmp_base="$(mktemp "$PROFILE_DIR/.vm-claude-image-paste.XXXXXX")" tmp="$tmp_base.json" mv -- "$tmp_base" "$tmp" cat > "$tmp" </dev/null || true)" = "$guid" ] || die "生成的 iTerm Dynamic Profile JSON 无效。" chmod 600 "$tmp" mv -f -- "$tmp" "$PROFILE_FILE" PROFILE_GUID="$guid" PROFILE_PARENT="$parent" } write_state() { local tmp profile_parent_sha256 backup_target "$STATE_FILE" mkdir -p "$STATE_DIR" tmp="$(mktemp "$STATE_DIR/.install-state.XXXXXX")" profile_parent_sha256="$(printf '%s' "$PROFILE_PARENT" | shasum -a 256 | awk '{print $1}')" { printf 'STATE_PACKAGE_VERSION=%q\n' "$PACKAGE_VERSION" printf 'STATE_ASSET_VERSION=%q\n' "$ASSET_VERSION" printf 'STATE_REMOTE_RELEASE_ID=%q\n' "$REMOTE_RELEASE_ID" printf 'STATE_PROFILE_GUID=%q\n' "$PROFILE_GUID" printf 'STATE_PROFILE_PARENT=%q\n' "$PROFILE_PARENT" printf 'STATE_PROFILE_PARENT_SHA256=%q\n' "$profile_parent_sha256" printf 'STATE_SSH_ALIAS=%q\n' "$SSH_ALIAS" printf 'STATE_BACKUP_DIR=%q\n' "$BACKUP_DIR" } > "$tmp" chmod 600 "$tmp" mv -f -- "$tmp" "$STATE_FILE" } read_bridge_config() { parse_bridge_config || return 1 [ "$VM_CLAUDE_CLIPBOARD_HOST" = "$SSH_ALIAS" ] || return 1 [[ "$VM_CLAUDE_CLIPBOARD_TOKEN" =~ ^[a-f0-9]{64}$ ]] || return 1 [ "$VM_CLAUDE_CLIPBOARD_MAX_BYTES" = "$MAX_BYTES" ] } state_value() { local key="$1" awk -v key="$key" ' index($0, key "=") == 1 { value=substr($0, length(key) + 2); count++ } END { if (count == 1) print value; else exit 1 } ' "$STATE_FILE" } verify_installed_asset() { local path="$1" name="$2" expected actual [ -f "$path" ] && [ ! -L "$path" ] || die "缺少或拒绝符号链接安装文件: $path" expected="$(expected_hash "$name")" actual="$(shasum -a 256 "$path" | awk '{print $1}')" [ "$actual" = "$expected" ] || die "本机安装文件摘要不匹配: $path" } verify_profile_and_state() { local state_version state_asset state_release state_guid state_alias state_parent_hash local profile_name profile_guid profile_parent profile_action profile_text actual_parent_hash secure_shell_config "$STATE_FILE" || die "安装状态必须是当前用户拥有的 0600 普通文件: $STATE_FILE" secure_shell_config "$PROFILE_FILE" || die "iTerm Dynamic Profile 必须是当前用户拥有的 0600 普通文件。" state_version="$(state_value STATE_PACKAGE_VERSION)" || die "安装状态缺少 PACKAGE_VERSION。" state_asset="$(state_value STATE_ASSET_VERSION)" || die "安装状态缺少 ASSET_VERSION。" state_release="$(state_value STATE_REMOTE_RELEASE_ID)" || die "安装状态缺少 REMOTE_RELEASE_ID。" state_guid="$(state_value STATE_PROFILE_GUID)" || die "安装状态缺少 PROFILE_GUID。" state_alias="$(state_value STATE_SSH_ALIAS)" || die "安装状态缺少 SSH_ALIAS。" state_parent_hash="$(state_value STATE_PROFILE_PARENT_SHA256)" || die "安装状态缺少 Profile Parent 摘要。" [ "$state_version" = "$PACKAGE_VERSION" ] || die "本机 manager 版本状态不匹配,请重新 install。" [ "$state_asset" = "$ASSET_VERSION" ] || die "本机 payload 版本状态不匹配,请重新 install。" [ "$state_release" = "$REMOTE_RELEASE_ID" ] || die "本机记录的 VM release 不匹配,请重新 install。" [ "$state_alias" = "$SSH_ALIAS" ] || die "本机记录的 SSH alias 不匹配。" [[ "$state_guid" =~ ^[A-Fa-f0-9]{8}-[A-Fa-f0-9]{4}-[A-Fa-f0-9]{4}-[A-Fa-f0-9]{4}-[A-Fa-f0-9]{12}$ ]] || die "本机记录的 iTerm Profile GUID 无效。" [[ "$state_parent_hash" =~ ^[a-f0-9]{64}$ ]] || die "本机记录的 Profile Parent 摘要无效。" profile_name="$(/usr/bin/plutil -extract Profiles.0.Name raw -o - "$PROFILE_FILE" 2>/dev/null || true)" profile_guid="$(/usr/bin/plutil -extract Profiles.0.Guid raw -o - "$PROFILE_FILE" 2>/dev/null || true)" profile_parent="$(/usr/bin/plutil -extract 'Profiles.0.Dynamic Profile Parent Name' raw -o - "$PROFILE_FILE" 2>/dev/null || true)" profile_action="$(/usr/bin/plutil -extract 'Profiles.0.Keyboard Map.0x76-0x40000-0x9.Action' raw -o - "$PROFILE_FILE" 2>/dev/null || true)" profile_text="$(/usr/bin/plutil -extract 'Profiles.0.Keyboard Map.0x76-0x40000-0x9.Text' raw -o - "$PROFILE_FILE" 2>/dev/null || true)" actual_parent_hash="$(printf '%s' "$profile_parent" | shasum -a 256 | awk '{print $1}')" [ "$profile_name" = "VM Claude" ] || die "iTerm Dynamic Profile 名称不匹配。" [ "$profile_guid" = "$state_guid" ] || die "iTerm Dynamic Profile GUID 不匹配。" [ -n "$profile_parent" ] && [ "$actual_parent_hash" = "$state_parent_hash" ] || die "iTerm Dynamic Profile Parent 不匹配。" [ "$profile_action" = "35" ] || die "iTerm Ctrl+V 键映射 Action 不匹配。" [ "$profile_text" = "$LOCAL_HELPER" ] || die "iTerm Ctrl+V 键映射 helper 路径不匹配。" } verify_remote_release() { local remote_output stable_status remote_output="$(ssh -o BatchMode=yes "$SSH_ALIAS" /bin/bash -s -- \ "$REMOTE_RELEASE_ID" "$HASH_STAGE" "$HASH_XCLIP" "$HASH_REMOTE_TEST" <<'REMOTE' set -euo pipefail release_id="$1" expected_stage="$2" expected_xclip="$3" expected_test="$4" root="$HOME/.local/libexec/claude-vm-image-paste" release="$root/releases/$release_id" current="$root/current" stable="$HOME/.local/bin/xclip" managed_target='../libexec/claude-vm-image-paste/current/bin/xclip' path_line='export PATH="$HOME/.local/libexec/claude-vm-image-paste/current/bin:$HOME/.local/bin:$PATH"' tmux_path_line='set-environment -g PATH "$HOME/.local/libexec/claude-vm-image-paste/current/bin:$HOME/.local/bin:$PATH"' command -v sha256sum >/dev/null command -v readlink >/dev/null [ -L "$current" ] [ "$(readlink "$current")" = "releases/$release_id" ] [ -d "$release" ] && [ ! -L "$release" ] [ "$(readlink -f "$current")" = "$(readlink -f "$release")" ] [ -f "$release/stage" ] && [ ! -L "$release/stage" ] && [ -x "$release/stage" ] [ -f "$release/bin/xclip" ] && [ ! -L "$release/bin/xclip" ] && [ -x "$release/bin/xclip" ] [ -f "$release/test-remote.sh" ] && [ ! -L "$release/test-remote.sh" ] && [ -x "$release/test-remote.sh" ] [ "$(sha256sum "$release/stage" | awk '{print $1}')" = "$expected_stage" ] [ "$(sha256sum "$release/bin/xclip" | awk '{print $1}')" = "$expected_xclip" ] [ "$(sha256sum "$release/test-remote.sh" | awk '{print $1}')" = "$expected_test" ] grep -Fqx "$path_line" "$HOME/.bashrc" grep -Fqx "$path_line" "$HOME/.profile" grep -Fqx "$tmux_path_line" "$HOME/.tmux.conf" stable_status=missing if [ -L "$stable" ] && [ "$(readlink "$stable")" = "$managed_target" ]; then [ "$(readlink -f "$stable")" = "$(readlink -f "$release/bin/xclip")" ] stable_status=managed elif [ -e "$stable" ] || [ -L "$stable" ]; then stable_status=collision else echo 'remote stable xclip entry is missing' >&2 exit 1 fi login_shell="${SHELL:-/bin/bash}" [ -x "$login_shell" ] login_output="$("$login_shell" -lc 'command -v xclip' /dev/null)" login_xclip="$(printf '%s\n' "$login_output" | tail -n 1)" [[ "$login_xclip" == /* ]] [ "$(readlink -f "$login_xclip")" = "$(readlink -f "$release/bin/xclip")" ] CLAUDE_VM_TEST_SHIM="$release/bin/xclip" \ CLAUDE_VM_TEST_STAGE="$release/stage" \ "$release/test-remote.sh" printf 'REMOTE_DOCTOR_RELEASE=%s\n' "$release_id" printf 'REMOTE_DOCTOR_STABLE=%s\n' "$stable_status" REMOTE )" || die "VM release、PATH 或合成 PNG 状态机检查失败。" printf '%s\n' "$remote_output" stable_status="$(printf '%s\n' "$remote_output" | awk -F= '$1=="REMOTE_DOCTOR_STABLE" {print $2; exit}')" if [ "$stable_status" = "collision" ]; then warn "VM 的 ~/.local/bin/xclip 是成员已有文件,未覆盖;请重启 tmux/Claude,使用前置的版本化 PATH。" fi } doctor() { require_macos_iterm load_team_config need_cmd shasum local resolved calculate_remote_release_id verify_installed_asset "$LOCAL_BIN_DIR/vm" vm verify_installed_asset "$LOCAL_BIN_DIR/vmt" vmt verify_installed_asset "$LOCAL_HELPER" vm-claude-image-paste verify_installed_asset "$LOCAL_CONVERTER" clipboard-to-png.js verify_installed_asset "$LOCAL_RUNTIME" image-paste-runtime.sh [ -x "$LOCAL_HELPER" ] || die "本机 helper 不可执行。" read_bridge_config || die "本机图片桥配置无效。" verify_profile_and_state resolved="$(ssh -G "$SSH_ALIAS" 2>/dev/null)" || die "SSH alias 无法解析。" printf '%s\n' "$resolved" | grep -Fqx "hostname $VM_HOSTNAME" || die "SSH alias HostName 不匹配。" printf '%s\n' "$resolved" | grep -Fqx "user $VM_USER" || die "SSH alias User 不匹配。" printf '%s\n' "$resolved" | grep -Fqx "port $VM_PORT" || die "SSH alias Port 不匹配。" printf '%s\n' "$resolved" | grep -Fqx 'stricthostkeychecking true' || die "SSH alias 未启用严格主机指纹检查。" verify_remote_release ok "本机摘要、iTerm Profile 文件、SSH alias、VM release/PATH 和合成 PNG 状态机检查通过。" warn "doctor 不读取当前剪贴板,也不会启动 Claude;只会在 VM cache 创建并删除一个随机 slot 的合成 PNG。" warn "iTerm Dynamic Profile 已做静态键映射校验;请在新的 vm/vmt 会话中完成真实交互验证。" warn "同一台 Mac 的多个 tab 共享一个 slot;请等待 [Image #n] 出现后再粘贴下一张。" } install_bridge() { require_macos_iterm load_team_config need_cmd curl need_cmd shasum need_cmd openssl need_cmd ssh need_cmd tar need_cmd install acquire_lock download_assets start_transaction persist_migrated_team_config preserve_original_wrapper "$LOCAL_BIN_DIR/vm" vm "$ORIGINAL_VM" "$ORIGINAL_VM_ABSENT" preserve_original_wrapper "$LOCAL_BIN_DIR/vmt" vmt "$ORIGINAL_VMT" "$ORIGINAL_VMT_ABSENT" validate_existing_managed_file "$LOCAL_HELPER" vm-claude-image-paste 'vm-claude-image-paste.log' validate_existing_managed_file "$LOCAL_CONVERTER" clipboard-to-png.js 'NSPasteboard.generalPasteboard' validate_existing_managed_file "$LOCAL_RUNTIME" image-paste-runtime.sh 'siyun_vm_image_paste_prepare' validate_existing_dynamic_profile install_asset "$WORK_DIR/vm" "$LOCAL_BIN_DIR/vm" 700 install_asset "$WORK_DIR/vmt" "$LOCAL_BIN_DIR/vmt" 700 install_asset "$WORK_DIR/vm-claude-image-paste" "$LOCAL_HELPER" 700 install_asset "$WORK_DIR/clipboard-to-png.js" "$LOCAL_CONVERTER" 600 install_asset "$WORK_DIR/image-paste-runtime.sh" "$LOCAL_RUNTIME" 600 load_or_create_slot write_bridge_config rewrite_ssh_config install verify_ssh_alias install_remote_release write_dynamic_profile write_state doctor INSTALL_IN_PROGRESS=0 printf '\n%s安装完成。%s 在 iTerm2 重新执行 vm 或 vmt,进入 Claude 后用 Ctrl+V 粘贴图片。\n' "$GREEN" "$RESET" printf '普通文字继续使用 Command+V;图片不会写入项目目录。\n' } remove_ssh_block_without_transaction() { local clean [ -f "$SSH_CONFIG" ] || return [ ! -L "$SSH_CONFIG" ] || die "拒绝修改符号链接形式的 SSH config。" validate_managed_block "$SSH_CONFIG" \ '# >>> siyun team vm image paste >>>' \ '# <<< siyun team vm image paste <<<' \ || die "SSH config 中的图片桥受管标记不完整或重复,请先手工检查: $SSH_CONFIG" clean="$(mktemp "$SSH_DIR/.config-uninstall.XXXXXX")" awk ' $0 == "# >>> siyun team vm image paste >>>" {skip=1; next} $0 == "# <<< siyun team vm image paste <<<" {skip=0; next} !skip {print} ' "$SSH_CONFIG" > "$clean" chmod 600 "$clean" mv -f -- "$clean" "$SSH_CONFIG" } archive_local_target() { local target="$1" archive_root="$2" relative="$3" archived idx [ -e "$target" ] || [ -L "$target" ] || return 0 case "/$relative/" in *'/../'*|*'/./'*) die "无效的卸载归档标签: $relative" ;; esac [[ "$relative" != /* ]] || die "卸载归档标签必须是相对路径: $relative" archived="$archive_root/$relative" mkdir -p "$(dirname "$archived")" idx="${#UNINSTALL_MOVED_TARGETS[@]}" UNINSTALL_MOVED_TARGETS[$idx]="$target" UNINSTALL_MOVED_ARCHIVES[$idx]="$archived" mv -- "$target" "$archived" } payload_hash_matches() { local path="$1" name="$2" expected actual [ -f "$path" ] && [ ! -L "$path" ] || return 1 expected="$(expected_hash "$name")" actual="$(shasum -a 256 "$path" | awk '{print $1}')" [ "$actual" = "$expected" ] } restore_original_wrapper() { local target="$1" payload_name="$2" original="$3" absent_marker="$4" archive_root="$5" label="$6" local idx tmp if [ -e "$target" ] || [ -L "$target" ]; then if ! payload_hash_matches "$target" "$payload_name"; then warn "基础 wrapper 已在安装后被其他工具修改,卸载不会覆盖: $target" return 0 fi archive_local_target "$target" "$archive_root" "$label" fi if [ -f "$original" ] && [ ! -L "$original" ]; then idx="${#UNINSTALL_RESTORED_TARGETS[@]}" UNINSTALL_RESTORED_TARGETS[$idx]="$target" mkdir -p "$(dirname "$target")" tmp="$target.tmp.$$" cp -pP -- "$original" "$tmp" mv -f -- "$tmp" "$target" elif [ -f "$absent_marker" ] && [ ! -L "$absent_marker" ]; then : else warn "没有找到 $payload_name 的首次安装状态记录,保持当前缺失状态。" fi } uninstall_bridge() { require_macos warn "请先退出这台 Mac 上所有正在运行的 vm/vmt/Claude 会话;活动进程中的 Profile guard 不会被文件归档立即停止。" need_cmd shasum acquire_lock local archive_root archive_root="$STATE_DIR/uninstalled/$(date '+%Y%m%d-%H%M%S')-$$" UNINSTALL_ARCHIVE_ROOT="$archive_root" mkdir -p "$archive_root" chmod 700 "$STATE_DIR" "$STATE_DIR/uninstalled" "$archive_root" 2>/dev/null || true UNINSTALL_IN_PROGRESS=1 if [ -f "$SSH_CONFIG" ]; then mkdir -p "$archive_root/.ssh" UNINSTALL_SSH_BACKUP="$archive_root/.ssh/config.before-removing-image-paste-block" cp -pP -- "$SSH_CONFIG" "$UNINSTALL_SSH_BACKUP" UNINSTALL_SSH_CHANGED=1 remove_ssh_block_without_transaction fi restore_original_wrapper "$LOCAL_BIN_DIR/vm" vm "$ORIGINAL_VM" "$ORIGINAL_VM_ABSENT" \ "$archive_root" "wrappers/vm.image-paste" restore_original_wrapper "$LOCAL_BIN_DIR/vmt" vmt "$ORIGINAL_VMT" "$ORIGINAL_VMT_ABSENT" \ "$archive_root" "wrappers/vmt.image-paste" archive_local_target "$PROFILE_FILE" "$archive_root" "iterm/vm-claude-image-paste.json" archive_local_target "$LOCAL_HELPER" "$archive_root" "local-bin/vm-claude-image-paste" archive_local_target "$LOCAL_CONVERTER" "$archive_root" "libexec/clipboard-to-png.js" archive_local_target "$LOCAL_RUNTIME" "$archive_root" "libexec/image-paste-runtime.sh" archive_local_target "$BRIDGE_CONFIG" "$archive_root" "config/vm-claude-image-paste/config" archive_local_target "$STATE_FILE" "$archive_root" "state/install-state" archive_local_target "$LOG_FILE" "$archive_root" "logs/vm-claude-image-paste.log" UNINSTALL_IN_PROGRESS=0 ok "本机图片粘贴桥已停用,文件已归档到: $archive_root" warn "首次安装前的基础 vm/vmt 状态已恢复;共享 VM 的版本化 stage/xclip 和其他成员目录没有删除。" warn "请关闭并重新打开相关 iTerm tab(必要时手工切回原 Profile),之后 vm/vmt 会退回普通 SSH 模式。" } case "$ACTION" in install) install_bridge ;; doctor|status) acquire_lock doctor ;; uninstall) uninstall_bridge ;; esac