From 6906323080e799fb385c0b26fcbc2395b316e993 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BC=A0=E5=AE=89=E5=93=B2?= Date: Mon, 11 May 2026 01:29:08 +0800 Subject: [PATCH 01/37] feat: add agent profile migration package --- CLAUDE.md | 1 + docs/agent-profile.md | 64 ++++++ scripts/arm64/make-rootfs-hermes.sh | 4 +- scripts/arm64/make-rootfs-openclaw.sh | 4 +- scripts/rootfs-scripts/tenbox-agent-profile | 234 ++++++++++++++++++++ scripts/x86_64/make-rootfs-hermes.sh | 4 +- scripts/x86_64/make-rootfs-openclaw.sh | 5 +- 7 files changed, 312 insertions(+), 4 deletions(-) create mode 100644 docs/agent-profile.md create mode 100755 scripts/rootfs-scripts/tenbox-agent-profile diff --git a/CLAUDE.md b/CLAUDE.md index 5feceee..3968710 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -88,6 +88,7 @@ Win/macOS: tenbox-manager ──IPC v1──► tenbox-vm-runtime (WHVP / HVF) - **LLM proxy** exists in two places: `src/daemon/llm_proxy.cpp` (Linux) and `src/manager/llm_proxy.cpp` (Windows); change both when the protocol changes. - **RemoteSession** is single-instance per VM. Read `remote_webrtc.cpp`'s `force` takeover path before adding DataChannels. - **macOS Caps Lock forwarding**: send Caps Lock as a tap (`down` then `up`) on each `flagsChanged` event; AppKit exposes it as a toggle state, but the guest input stack needs a full key press for every switch. +- **Agent data profile packages**: Hermes/OpenClaw images include `tenbox-agent-profile export|import` for `/mnt/shared/*.tar.zst` migration packages. Keep the format documented in `docs/agent-profile.md` and reject cross-agent imports. - **Static build** (`TENBOX_STATIC_FFMPEG=ON`) requires `/opt/tenbox-deps` (only present inside the CI/packaging container). Dev builds use system shared libs — keep `ON` off by default. - **Release**: `docs/release.md` — VERSION bump → commit → push → tag → push tag. Always push commit before tag. diff --git a/docs/agent-profile.md b/docs/agent-profile.md new file mode 100644 index 0000000..2f70151 --- /dev/null +++ b/docs/agent-profile.md @@ -0,0 +1,64 @@ +# Agent Data Profile Packages + +TenBox Agent data export/import uses a versioned archive so migration, +backup, and restore flows can share one artifact shape without exposing +guest dotfile paths to non-technical users. + +## Package Format + +`tenbox-agent-profile.tar.zst` is a zstd-compressed tar archive: + +```text +tenbox-agent-profile.tar.zst +├── manifest.json +├── files/ +└── checksums.txt +``` + +`manifest.json` records: + +- `format`: `tenbox-agent-profile` +- `format_version`: currently `1` +- `agent_type`: `hermes` or `openclaw` +- `tenbox_version` +- `created_at` +- `home` +- `source_path` +- `paths` +- `excluded` +- `checksums` + +`checksums.txt` stores SHA-256 hashes for files under `files/`. It never +contains secret values directly. + +## Supported Agents + +Hermes profile: + +- Includes `/home/tenbox/.hermes` +- Excludes `.hermes/logs`, `.hermes/image_cache`, `.hermes/audio_cache` +- Sensitive files such as API keys remain inside the package payload; logs + and manifest output must not print their values. + +OpenClaw profile: + +- Includes `/home/tenbox/.openclaw` +- Excludes `.openclaw/cache`, `.openclaw/.cache`, `.openclaw/workspace/.cache` +- Sensitive files remain inside the package payload; manifest and logs must + not print token values. + +QwenPaw is intentionally not included in this version because `.qwenpaw.secret` +needs a separate sensitivity policy. + +## Guest CLI + +Inside Hermes and OpenClaw images: + +```sh +tenbox-agent-profile export --agent hermes --output /mnt/shared/agent-data.tar.zst +tenbox-agent-profile import --agent hermes --input /mnt/shared/agent-data.tar.zst +``` + +The import path verifies the archive manifest and checksums, rejects cross-agent +imports, backs up existing data to `*.pre-import-*`, and then restores ownership +and permissions for the `tenbox` user. diff --git a/scripts/arm64/make-rootfs-hermes.sh b/scripts/arm64/make-rootfs-hermes.sh index fdf0fb4..c49a934 100755 --- a/scripts/arm64/make-rootfs-hermes.sh +++ b/scripts/arm64/make-rootfs-hermes.sh @@ -36,7 +36,7 @@ less,vim,bash-completion,\ openssh-client,gnupg,apt-transport-https,\ lsof,strace,sysstat,\ kmod,pciutils,usbutils,\ -coreutils,findutils,grep,gawk,sed,tar,gzip,bzip2,xz-utils,\ +coreutils,findutils,grep,gawk,sed,tar,gzip,bzip2,xz-utils,zstd,\ linux-image-arm64,iptables,util-linux,util-linux-extra" SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" @@ -1027,6 +1027,8 @@ cp /tmp/rootfs-scripts/virtiofs-automount /usr/local/bin/ chmod +x /usr/local/bin/virtiofs-automount cp /tmp/rootfs-scripts/virtiofs-desktop-sync /usr/local/bin/ chmod +x /usr/local/bin/virtiofs-desktop-sync +cp /tmp/rootfs-scripts/tenbox-agent-profile /usr/local/bin/ +chmod +x /usr/local/bin/tenbox-agent-profile cp /tmp/rootfs-services/virtiofs-automount.service /etc/systemd/system/ systemctl enable virtiofs-automount.service 2>/dev/null || true cp /tmp/rootfs-services/virtiofs-desktop-sync.service /etc/systemd/system/ diff --git a/scripts/arm64/make-rootfs-openclaw.sh b/scripts/arm64/make-rootfs-openclaw.sh index 9a944b7..16f7896 100755 --- a/scripts/arm64/make-rootfs-openclaw.sh +++ b/scripts/arm64/make-rootfs-openclaw.sh @@ -36,7 +36,7 @@ less,vim,bash-completion,\ openssh-client,gnupg,apt-transport-https,\ lsof,strace,sysstat,\ kmod,pciutils,usbutils,\ -coreutils,findutils,grep,gawk,sed,tar,gzip,bzip2,xz-utils,\ +coreutils,findutils,grep,gawk,sed,tar,gzip,bzip2,xz-utils,zstd,\ linux-image-arm64,iptables,util-linux,util-linux-extra" SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" @@ -768,6 +768,8 @@ cp /tmp/rootfs-scripts/virtiofs-automount /usr/local/bin/ chmod +x /usr/local/bin/virtiofs-automount cp /tmp/rootfs-scripts/virtiofs-desktop-sync /usr/local/bin/ chmod +x /usr/local/bin/virtiofs-desktop-sync +cp /tmp/rootfs-scripts/tenbox-agent-profile /usr/local/bin/ +chmod +x /usr/local/bin/tenbox-agent-profile cp /tmp/rootfs-services/virtiofs-automount.service /etc/systemd/system/ systemctl enable virtiofs-automount.service 2>/dev/null || true cp /tmp/rootfs-services/virtiofs-desktop-sync.service /etc/systemd/system/ diff --git a/scripts/rootfs-scripts/tenbox-agent-profile b/scripts/rootfs-scripts/tenbox-agent-profile new file mode 100755 index 0000000..e0cd653 --- /dev/null +++ b/scripts/rootfs-scripts/tenbox-agent-profile @@ -0,0 +1,234 @@ +#!/bin/sh +set -eu + +USER_NAME="${TENBOX_USER:-tenbox}" +USER_GROUP="${TENBOX_GROUP:-$USER_NAME}" +HOME_DIR="${TENBOX_HOME_DIR:-/home/$USER_NAME}" +FORMAT_NAME="tenbox-agent-profile" +FORMAT_VERSION="1" +SHARED_DIR="${TENBOX_SHARED_DIR:-/mnt/shared}" + +usage() { + cat >&2 <&2 + exit 1 +} + +need_tool() { + command -v "$1" >/dev/null 2>&1 || die "missing required tool: $1" +} + +agent_path() { + case "$1" in + hermes) printf '%s/.hermes\n' "$HOME_DIR" ;; + openclaw) printf '%s/.openclaw\n' "$HOME_DIR" ;; + *) die "unsupported agent: $1" ;; + esac +} + +agent_excludes() { + case "$1" in + hermes) + printf '%s\n' \ + ".hermes/logs" \ + ".hermes/image_cache" \ + ".hermes/audio_cache" + ;; + openclaw) + printf '%s\n' \ + ".openclaw/cache" \ + ".openclaw/.cache" \ + ".openclaw/workspace/.cache" + ;; + esac +} + +default_agent() { + if [ -d "$HOME_DIR/.hermes" ] && [ ! -d "$HOME_DIR/.openclaw" ]; then + echo hermes + elif [ -d "$HOME_DIR/.openclaw" ] && [ ! -d "$HOME_DIR/.hermes" ]; then + echo openclaw + else + die "use --agent hermes or --agent openclaw" + fi +} + +created_at() { + date -u +"%Y-%m-%dT%H:%M:%SZ" +} + +safe_output_path() { + case "$1" in + "$SHARED_DIR"/*) printf '%s\n' "$1" ;; + *) die "output/input must be under $SHARED_DIR" ;; + esac +} + +parse_agent_from_manifest() { + sed -n 's/^[[:space:]]*"agent_type":[[:space:]]*"\([^"]*\)".*/\1/p' "$1" | head -n 1 +} + +write_manifest() { + agent="$1" + manifest="$2" + source_path="$(agent_path "$agent")" + excludes_json="" + while IFS= read -r item; do + [ -n "$item" ] || continue + if [ -n "$excludes_json" ]; then + excludes_json="$excludes_json," + fi + excludes_json="$excludes_json\"$item\"" + done < "$manifest" < checksums.txt) + + mkdir -p "$(dirname "$output")" + (cd "$tmp" && tar --zstd -cf "$output.tmp" manifest.json files checksums.txt) + mv "$output.tmp" "$output" + chmod 600 "$output" + echo "$output" +} + +restore_backup() { + target="$1" + backup="$2" + if [ -d "$backup" ] && [ ! -e "$target" ]; then + mv "$backup" "$target" + fi +} + +import_profile() { + input="$(safe_output_path "$1")" + requested_agent="$2" + [ -f "$input" ] || die "package not found: $input" + + need_tool tar + need_tool zstd + need_tool sha256sum + + tmp="$(mktemp -d)" + trap 'rm -rf "$tmp"' EXIT INT TERM + tar --zstd -xf "$input" -C "$tmp" + + [ -f "$tmp/manifest.json" ] || die "manifest.json missing" + [ -f "$tmp/checksums.txt" ] || die "checksums.txt missing" + agent="$(parse_agent_from_manifest "$tmp/manifest.json")" + [ -n "$agent" ] || die "agent_type missing in manifest" + case "$agent" in hermes|openclaw) ;; *) die "unsupported package agent: $agent" ;; esac + if [ -n "$requested_agent" ] && [ "$requested_agent" != "$agent" ]; then + die "package is for $agent, not $requested_agent" + fi + + if [ -s "$tmp/checksums.txt" ]; then + (cd "$tmp" && sha256sum -c checksums.txt >/dev/null) + fi + target="$(agent_path "$agent")" + rel_path=".$agent" + [ -d "$tmp/files/$rel_path" ] || die "package files missing for $agent" + + backup="" + if [ -e "$target" ]; then + backup="$target.pre-import-$(date -u +%Y%m%d%H%M%S)" + mv "$target" "$backup" + fi + + if ! (cd "$tmp/files" && tar -cf - "$rel_path") | (cd "$HOME_DIR" && tar -xf -); then + rm -rf "$target" + restore_backup "$target" "$backup" + die "failed to restore Agent data" + fi + chown -R "$USER_NAME:$USER_GROUP" "$target" + chmod 700 "$target" 2>/dev/null || true + + if [ -n "$backup" ]; then + echo "$backup" + else + echo "imported" + fi +} + +cmd="${1:-}" +[ -n "$cmd" ] || { usage; exit 2; } +shift || true + +agent="" +output="" +input="" + +while [ "$#" -gt 0 ]; do + case "$1" in + --agent) agent="${2:-}"; shift 2 ;; + --output) output="${2:-}"; shift 2 ;; + --input) input="${2:-}"; shift 2 ;; + -h|--help) usage; exit 0 ;; + *) die "unknown option: $1" ;; + esac +done + +case "$cmd" in + export) + [ -n "$agent" ] || agent="$(default_agent)" + [ -n "$output" ] || die "--output is required" + export_profile "$agent" "$output" + ;; + import) + [ -n "$input" ] || die "--input is required" + import_profile "$input" "$agent" + ;; + *) usage; exit 2 ;; +esac diff --git a/scripts/x86_64/make-rootfs-hermes.sh b/scripts/x86_64/make-rootfs-hermes.sh index a5655e6..5aa0805 100755 --- a/scripts/x86_64/make-rootfs-hermes.sh +++ b/scripts/x86_64/make-rootfs-hermes.sh @@ -36,7 +36,7 @@ less,vim,bash-completion,\ openssh-client,gnupg,apt-transport-https,\ lsof,strace,sysstat,\ kmod,pciutils,usbutils,\ -coreutils,findutils,grep,gawk,sed,tar,gzip,bzip2,xz-utils,\ +coreutils,findutils,grep,gawk,sed,tar,gzip,bzip2,xz-utils,zstd,\ linux-image-amd64,iptables,util-linux,util-linux-extra" SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" @@ -1031,6 +1031,8 @@ cp /tmp/rootfs-scripts/virtiofs-automount /usr/local/bin/ chmod +x /usr/local/bin/virtiofs-automount cp /tmp/rootfs-scripts/virtiofs-desktop-sync /usr/local/bin/ chmod +x /usr/local/bin/virtiofs-desktop-sync +cp /tmp/rootfs-scripts/tenbox-agent-profile /usr/local/bin/ +chmod +x /usr/local/bin/tenbox-agent-profile cp /tmp/rootfs-services/virtiofs-automount.service /etc/systemd/system/ systemctl enable virtiofs-automount.service 2>/dev/null || true cp /tmp/rootfs-services/virtiofs-desktop-sync.service /etc/systemd/system/ diff --git a/scripts/x86_64/make-rootfs-openclaw.sh b/scripts/x86_64/make-rootfs-openclaw.sh index 911d2da..cba75b1 100755 --- a/scripts/x86_64/make-rootfs-openclaw.sh +++ b/scripts/x86_64/make-rootfs-openclaw.sh @@ -32,7 +32,7 @@ less,vim,bash-completion,\ openssh-client,gnupg,apt-transport-https,\ lsof,strace,sysstat,\ kmod,pciutils,usbutils,\ -coreutils,findutils,grep,gawk,sed,tar,gzip,bzip2,xz-utils,\ +coreutils,findutils,grep,gawk,sed,tar,gzip,bzip2,xz-utils,zstd,\ linux-image-amd64,iptables,util-linux,util-linux-extra" SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" @@ -875,6 +875,9 @@ chmod +x /usr/local/bin/virtiofs-automount cp /tmp/rootfs-scripts/virtiofs-desktop-sync /usr/local/bin/ chmod +x /usr/local/bin/virtiofs-desktop-sync +cp /tmp/rootfs-scripts/tenbox-agent-profile /usr/local/bin/ +chmod +x /usr/local/bin/tenbox-agent-profile + cp /tmp/rootfs-services/virtiofs-automount.service /etc/systemd/system/ systemctl enable virtiofs-automount.service 2>/dev/null || true From 28ea32d3357180f59ea7a550dbd1d83f9a593154 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BC=A0=E5=AE=89=E5=93=B2?= Date: Mon, 11 May 2026 01:33:01 +0800 Subject: [PATCH 02/37] feat: add agent data backup snapshots --- CLAUDE.md | 1 + docs/agent-profile.md | 26 +++ scripts/arm64/make-rootfs-hermes.sh | 11 +- scripts/arm64/make-rootfs-openclaw.sh | 11 +- scripts/rootfs-scripts/tenbox-agent-backup | 210 ++++++++++++++++++ .../tenbox-agent-backup.service | 6 + .../rootfs-services/tenbox-agent-backup.timer | 10 + scripts/x86_64/make-rootfs-hermes.sh | 11 +- scripts/x86_64/make-rootfs-openclaw.sh | 12 +- 9 files changed, 285 insertions(+), 13 deletions(-) create mode 100755 scripts/rootfs-scripts/tenbox-agent-backup create mode 100644 scripts/rootfs-services/tenbox-agent-backup.service create mode 100644 scripts/rootfs-services/tenbox-agent-backup.timer diff --git a/CLAUDE.md b/CLAUDE.md index 3968710..b3d3842 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -89,6 +89,7 @@ Win/macOS: tenbox-manager ──IPC v1──► tenbox-vm-runtime (WHVP / HVF) - **RemoteSession** is single-instance per VM. Read `remote_webrtc.cpp`'s `force` takeover path before adding DataChannels. - **macOS Caps Lock forwarding**: send Caps Lock as a tap (`down` then `up`) on each `flagsChanged` event; AppKit exposes it as a toggle state, but the guest input stack needs a full key press for every switch. - **Agent data profile packages**: Hermes/OpenClaw images include `tenbox-agent-profile export|import` for `/mnt/shared/*.tar.zst` migration packages. Keep the format documented in `docs/agent-profile.md` and reject cross-agent imports. +- **Agent data backups**: Hermes/OpenClaw images include `tenbox-agent-backup` and a systemd timer that writes profile packages to `/mnt/shared/tenbox-agent-backups///`, retaining the latest 5 packages by default. - **Static build** (`TENBOX_STATIC_FFMPEG=ON`) requires `/opt/tenbox-deps` (only present inside the CI/packaging container). Dev builds use system shared libs — keep `ON` off by default. - **Release**: `docs/release.md` — VERSION bump → commit → push → tag → push tag. Always push commit before tag. diff --git a/docs/agent-profile.md b/docs/agent-profile.md index 2f70151..63c5ea8 100644 --- a/docs/agent-profile.md +++ b/docs/agent-profile.md @@ -62,3 +62,29 @@ tenbox-agent-profile import --agent hermes --input /mnt/shared/agent-data.tar.zs The import path verifies the archive manifest and checksums, rejects cross-agent imports, backs up existing data to `*.pre-import-*`, and then restores ownership and permissions for the `tenbox` user. + +## Automatic Agent Data Backups + +Hermes and OpenClaw images also include `tenbox-agent-backup`, which reuses the +profile package format instead of creating a second backup format. + +```sh +tenbox-agent-backup snapshot --agent hermes --vm-id +tenbox-agent-backup status --agent hermes --vm-id +tenbox-agent-backup restore --agent hermes --vm-id +``` + +By default backups are written under: + +```text +/mnt/shared/tenbox-agent-backups/// +├── agent-data-YYYYMMDDHHMMSS.tar.zst +├── agent-data-YYYYMMDDHHMMSS.tar.zst.manifest.json +└── status.json +``` + +The default retention is the latest 5 packages per VM and Agent. The snapshot +path estimates source size before export and stops with a clear `space_low` +status when the host-side shared folder does not have enough free space. Restore +uses `tenbox-agent-profile import`, so existing Agent data is still protected by +the `*.pre-import-*` backup before replacement. diff --git a/scripts/arm64/make-rootfs-hermes.sh b/scripts/arm64/make-rootfs-hermes.sh index c49a934..2402e01 100755 --- a/scripts/arm64/make-rootfs-hermes.sh +++ b/scripts/arm64/make-rootfs-hermes.sh @@ -891,7 +891,6 @@ OVERRIDE mkdir -p "\$UNIT_DIR/default.target.wants" ln -sf ../hermes-gateway.service "\$UNIT_DIR/default.target.wants/hermes-gateway.service" - chown -R $USER_NAME:$USER_NAME \$USER_HOME/.config mkdir -p /var/lib/systemd/linger @@ -1018,6 +1017,14 @@ EOF do_config_virtiofs() { sudo chroot "$MOUNT_DIR" /bin/bash -e << 'EOF' +cp /tmp/rootfs-scripts/tenbox-agent-profile /usr/local/bin/ +chmod +x /usr/local/bin/tenbox-agent-profile +cp /tmp/rootfs-scripts/tenbox-agent-backup /usr/local/bin/ +chmod +x /usr/local/bin/tenbox-agent-backup +cp /tmp/rootfs-services/tenbox-agent-backup.service /etc/systemd/system/ +cp /tmp/rootfs-services/tenbox-agent-backup.timer /etc/systemd/system/ +systemctl enable tenbox-agent-backup.timer 2>/dev/null || true + if [ -f /etc/systemd/system/virtiofs-automount.service ]; then echo " Virtio-FS already configured" exit 0 @@ -1027,8 +1034,6 @@ cp /tmp/rootfs-scripts/virtiofs-automount /usr/local/bin/ chmod +x /usr/local/bin/virtiofs-automount cp /tmp/rootfs-scripts/virtiofs-desktop-sync /usr/local/bin/ chmod +x /usr/local/bin/virtiofs-desktop-sync -cp /tmp/rootfs-scripts/tenbox-agent-profile /usr/local/bin/ -chmod +x /usr/local/bin/tenbox-agent-profile cp /tmp/rootfs-services/virtiofs-automount.service /etc/systemd/system/ systemctl enable virtiofs-automount.service 2>/dev/null || true cp /tmp/rootfs-services/virtiofs-desktop-sync.service /etc/systemd/system/ diff --git a/scripts/arm64/make-rootfs-openclaw.sh b/scripts/arm64/make-rootfs-openclaw.sh index 16f7896..7c39320 100755 --- a/scripts/arm64/make-rootfs-openclaw.sh +++ b/scripts/arm64/make-rootfs-openclaw.sh @@ -631,7 +631,6 @@ OVERRIDE mkdir -p "\$UNIT_DIR/default.target.wants" ln -sf ../openclaw-gateway.service "\$UNIT_DIR/default.target.wants/openclaw-gateway.service" - chown -R $USER_NAME:$USER_NAME \$USER_HOME/.config fi @@ -759,6 +758,14 @@ EOF do_config_virtiofs() { sudo chroot "$MOUNT_DIR" /bin/bash -e << 'EOF' +cp /tmp/rootfs-scripts/tenbox-agent-profile /usr/local/bin/ +chmod +x /usr/local/bin/tenbox-agent-profile +cp /tmp/rootfs-scripts/tenbox-agent-backup /usr/local/bin/ +chmod +x /usr/local/bin/tenbox-agent-backup +cp /tmp/rootfs-services/tenbox-agent-backup.service /etc/systemd/system/ +cp /tmp/rootfs-services/tenbox-agent-backup.timer /etc/systemd/system/ +systemctl enable tenbox-agent-backup.timer 2>/dev/null || true + if [ -f /etc/systemd/system/virtiofs-automount.service ]; then echo " Virtio-FS already configured" exit 0 @@ -768,8 +775,6 @@ cp /tmp/rootfs-scripts/virtiofs-automount /usr/local/bin/ chmod +x /usr/local/bin/virtiofs-automount cp /tmp/rootfs-scripts/virtiofs-desktop-sync /usr/local/bin/ chmod +x /usr/local/bin/virtiofs-desktop-sync -cp /tmp/rootfs-scripts/tenbox-agent-profile /usr/local/bin/ -chmod +x /usr/local/bin/tenbox-agent-profile cp /tmp/rootfs-services/virtiofs-automount.service /etc/systemd/system/ systemctl enable virtiofs-automount.service 2>/dev/null || true cp /tmp/rootfs-services/virtiofs-desktop-sync.service /etc/systemd/system/ diff --git a/scripts/rootfs-scripts/tenbox-agent-backup b/scripts/rootfs-scripts/tenbox-agent-backup new file mode 100755 index 0000000..1a52fe7 --- /dev/null +++ b/scripts/rootfs-scripts/tenbox-agent-backup @@ -0,0 +1,210 @@ +#!/bin/sh +set -eu + +USER_NAME="${TENBOX_USER:-tenbox}" +HOME_DIR="${TENBOX_HOME_DIR:-/home/$USER_NAME}" +SHARED_DIR="${TENBOX_SHARED_DIR:-/mnt/shared}" +RETENTION="${TENBOX_BACKUP_RETENTION:-5}" +VM_ID="${TENBOX_VM_ID:-$(hostname 2>/dev/null || echo local-vm)}" +BACKUP_ROOT="${TENBOX_BACKUP_ROOT:-$SHARED_DIR/tenbox-agent-backups}" +PROFILE_TOOL="${TENBOX_PROFILE_TOOL:-tenbox-agent-profile}" + +usage() { + cat >&2 <&2 + exit 1 +} + +now_utc() { + date -u +"%Y-%m-%dT%H:%M:%SZ" +} + +stamp() { + date -u +"%Y%m%d%H%M%S" +} + +agent_path() { + case "$1" in + hermes) printf '%s/.hermes\n' "$HOME_DIR" ;; + openclaw) printf '%s/.openclaw\n' "$HOME_DIR" ;; + *) die "unsupported agent: $1" ;; + esac +} + +default_agent() { + if [ -d "$HOME_DIR/.hermes" ] && [ ! -d "$HOME_DIR/.openclaw" ]; then + echo hermes + elif [ -d "$HOME_DIR/.openclaw" ] && [ ! -d "$HOME_DIR/.hermes" ]; then + echo openclaw + else + die "use --agent hermes or --agent openclaw" + fi +} + +backup_dir() { + printf '%s/%s/%s\n' "$BACKUP_ROOT" "$VM_ID" "$1" +} + +profile_size_kb() { + path="$(agent_path "$1")" + [ -d "$path" ] || { echo 0; return; } + du -sk "$path" 2>/dev/null | awk '{print $1}' +} + +free_kb() { + df -Pk "$BACKUP_ROOT" 2>/dev/null | awk 'NR==2 {print $4}' +} + +write_status() { + agent="$1" + state="$2" + message="$3" + package="${4:-}" + dir="$(backup_dir "$agent")" + mkdir -p "$dir" + cat > "$dir/status.json" < "$manifest" < keep' | + while IFS= read -r old_pkg; do + rm -f "$old_pkg" "$old_pkg.manifest.json" + done +} + +snapshot() { + agent="$1" + [ -d "$SHARED_DIR" ] || die "shared folder is not mounted" + if [ -z "${TENBOX_SHARED_DIR:-}" ] && command -v mountpoint >/dev/null 2>&1 && ! mountpoint -q "$SHARED_DIR"; then + die "shared folder is not mounted" + fi + [ -d "$(agent_path "$agent")" ] || die "Agent data is not initialized" + command -v "$PROFILE_TOOL" >/dev/null 2>&1 || die "missing $PROFILE_TOOL" + + dir="$(backup_dir "$agent")" + mkdir -p "$dir" + chmod 700 "$dir" 2>/dev/null || true + + estimated="$(profile_size_kb "$agent")" + available="$(free_kb || echo 0)" + if [ "${available:-0}" -gt 0 ] && [ "$estimated" -gt 0 ] && [ "$available" -lt $((estimated + 102400)) ]; then + write_status "$agent" "space_low" "Not enough host space for a new Agent data backup" "" + die "not enough host space for a new Agent data backup" + fi + + package="$dir/agent-data-$(stamp).tar.zst" + "$PROFILE_TOOL" export --agent "$agent" --output "$package" >/dev/null + write_backup_manifest "$agent" "$package" "$package.manifest.json" + rotate_backups "$dir" "$RETENTION" + write_status "$agent" "protected" "Agent data is protected" "$package" + echo "$package" +} + +latest_package() { + dir="$(backup_dir "$1")" + find "$dir" -maxdepth 1 -type f -name '*.tar.zst' 2>/dev/null | LC_ALL=C sort -r | head -n 1 +} + +status() { + agent="$1" + if [ -n "$agent" ]; then + dir="$(backup_dir "$agent")" + if [ -f "$dir/status.json" ]; then + cat "$dir/status.json" + else + write_status "$agent" "never_backed_up" "Agent data has not been backed up yet" "" + cat "$dir/status.json" + fi + return + fi + first=1 + printf '[' + for item in hermes openclaw; do + [ -d "$(agent_path "$item")" ] || continue + [ "$first" -eq 1 ] || printf ',' + status "$item" + first=0 + done + printf ']\n' +} + +restore() { + agent="$1" + input="$2" + [ -n "$agent" ] || die "--agent is required" + command -v "$PROFILE_TOOL" >/dev/null 2>&1 || die "missing $PROFILE_TOOL" + if [ -z "$input" ]; then + input="$(latest_package "$agent")" + fi + [ -n "$input" ] || die "no backup package found" + "$PROFILE_TOOL" import --agent "$agent" --input "$input" + write_status "$agent" "protected" "Agent data restored from backup" "$input" +} + +cmd="${1:-}" +[ -n "$cmd" ] || { usage; exit 2; } +shift || true + +agent="" +input="" + +while [ "$#" -gt 0 ]; do + case "$1" in + --agent) agent="${2:-}"; shift 2 ;; + --vm-id) VM_ID="${2:-}"; shift 2 ;; + --input) input="${2:-}"; shift 2 ;; + -h|--help) usage; exit 0 ;; + *) die "unknown option: $1" ;; + esac +done + +case "$cmd" in + snapshot) + [ -n "$agent" ] || agent="$(default_agent)" + snapshot "$agent" + ;; + status) + status "$agent" + ;; + restore) + restore "$agent" "$input" + ;; + *) usage; exit 2 ;; +esac diff --git a/scripts/rootfs-services/tenbox-agent-backup.service b/scripts/rootfs-services/tenbox-agent-backup.service new file mode 100644 index 0000000..ac15876 --- /dev/null +++ b/scripts/rootfs-services/tenbox-agent-backup.service @@ -0,0 +1,6 @@ +[Unit] +Description=Protect TenBox Agent data + +[Service] +Type=oneshot +ExecStart=/usr/local/bin/tenbox-agent-backup snapshot diff --git a/scripts/rootfs-services/tenbox-agent-backup.timer b/scripts/rootfs-services/tenbox-agent-backup.timer new file mode 100644 index 0000000..2245a7e --- /dev/null +++ b/scripts/rootfs-services/tenbox-agent-backup.timer @@ -0,0 +1,10 @@ +[Unit] +Description=Periodic TenBox Agent data protection + +[Timer] +OnBootSec=5min +OnUnitActiveSec=6h +Persistent=true + +[Install] +WantedBy=timers.target diff --git a/scripts/x86_64/make-rootfs-hermes.sh b/scripts/x86_64/make-rootfs-hermes.sh index 5aa0805..467424f 100755 --- a/scripts/x86_64/make-rootfs-hermes.sh +++ b/scripts/x86_64/make-rootfs-hermes.sh @@ -891,7 +891,6 @@ OVERRIDE mkdir -p "\$UNIT_DIR/default.target.wants" ln -sf ../hermes-gateway.service "\$UNIT_DIR/default.target.wants/hermes-gateway.service" - chown -R $USER_NAME:$USER_NAME \$USER_HOME/.config mkdir -p /var/lib/systemd/linger @@ -1022,6 +1021,14 @@ EOF do_config_virtiofs() { sudo chroot "$MOUNT_DIR" /bin/bash -e << 'EOF' +cp /tmp/rootfs-scripts/tenbox-agent-profile /usr/local/bin/ +chmod +x /usr/local/bin/tenbox-agent-profile +cp /tmp/rootfs-scripts/tenbox-agent-backup /usr/local/bin/ +chmod +x /usr/local/bin/tenbox-agent-backup +cp /tmp/rootfs-services/tenbox-agent-backup.service /etc/systemd/system/ +cp /tmp/rootfs-services/tenbox-agent-backup.timer /etc/systemd/system/ +systemctl enable tenbox-agent-backup.timer 2>/dev/null || true + if [ -f /etc/systemd/system/virtiofs-automount.service ]; then echo " Virtio-FS already configured" exit 0 @@ -1031,8 +1038,6 @@ cp /tmp/rootfs-scripts/virtiofs-automount /usr/local/bin/ chmod +x /usr/local/bin/virtiofs-automount cp /tmp/rootfs-scripts/virtiofs-desktop-sync /usr/local/bin/ chmod +x /usr/local/bin/virtiofs-desktop-sync -cp /tmp/rootfs-scripts/tenbox-agent-profile /usr/local/bin/ -chmod +x /usr/local/bin/tenbox-agent-profile cp /tmp/rootfs-services/virtiofs-automount.service /etc/systemd/system/ systemctl enable virtiofs-automount.service 2>/dev/null || true cp /tmp/rootfs-services/virtiofs-desktop-sync.service /etc/systemd/system/ diff --git a/scripts/x86_64/make-rootfs-openclaw.sh b/scripts/x86_64/make-rootfs-openclaw.sh index cba75b1..c280266 100755 --- a/scripts/x86_64/make-rootfs-openclaw.sh +++ b/scripts/x86_64/make-rootfs-openclaw.sh @@ -726,7 +726,6 @@ OVERRIDE # Enable the service (create symlink since systemctl --user is unavailable in chroot) mkdir -p "\$UNIT_DIR/default.target.wants" ln -sf ../openclaw-gateway.service "\$UNIT_DIR/default.target.wants/openclaw-gateway.service" - chown -R $USER_NAME:$USER_NAME \$USER_HOME/.config fi @@ -861,6 +860,14 @@ EOF do_config_virtiofs() { sudo chroot "$MOUNT_DIR" /bin/bash -e << 'EOF' +cp /tmp/rootfs-scripts/tenbox-agent-profile /usr/local/bin/ +chmod +x /usr/local/bin/tenbox-agent-profile +cp /tmp/rootfs-scripts/tenbox-agent-backup /usr/local/bin/ +chmod +x /usr/local/bin/tenbox-agent-backup +cp /tmp/rootfs-services/tenbox-agent-backup.service /etc/systemd/system/ +cp /tmp/rootfs-services/tenbox-agent-backup.timer /etc/systemd/system/ +systemctl enable tenbox-agent-backup.timer 2>/dev/null || true + if [ -f /etc/systemd/system/virtiofs-automount.service ]; then echo " Virtio-FS already configured" exit 0 @@ -875,9 +882,6 @@ chmod +x /usr/local/bin/virtiofs-automount cp /tmp/rootfs-scripts/virtiofs-desktop-sync /usr/local/bin/ chmod +x /usr/local/bin/virtiofs-desktop-sync -cp /tmp/rootfs-scripts/tenbox-agent-profile /usr/local/bin/ -chmod +x /usr/local/bin/tenbox-agent-profile - cp /tmp/rootfs-services/virtiofs-automount.service /etc/systemd/system/ systemctl enable virtiofs-automount.service 2>/dev/null || true From e5efe087651a60a73fbb5e59f04783bc873db39d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BC=A0=E5=AE=89=E5=93=B2?= Date: Mon, 11 May 2026 01:34:47 +0800 Subject: [PATCH 03/37] feat: add agent health diagnostics --- CLAUDE.md | 1 + docs/agent-profile.md | 24 ++ scripts/arm64/make-rootfs-hermes.sh | 2 + scripts/arm64/make-rootfs-openclaw.sh | 2 + scripts/rootfs-scripts/tenbox-agent-health | 274 +++++++++++++++++++++ scripts/x86_64/make-rootfs-hermes.sh | 2 + scripts/x86_64/make-rootfs-openclaw.sh | 2 + 7 files changed, 307 insertions(+) create mode 100755 scripts/rootfs-scripts/tenbox-agent-health diff --git a/CLAUDE.md b/CLAUDE.md index b3d3842..f7ef6b1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -90,6 +90,7 @@ Win/macOS: tenbox-manager ──IPC v1──► tenbox-vm-runtime (WHVP / HVF) - **macOS Caps Lock forwarding**: send Caps Lock as a tap (`down` then `up`) on each `flagsChanged` event; AppKit exposes it as a toggle state, but the guest input stack needs a full key press for every switch. - **Agent data profile packages**: Hermes/OpenClaw images include `tenbox-agent-profile export|import` for `/mnt/shared/*.tar.zst` migration packages. Keep the format documented in `docs/agent-profile.md` and reject cross-agent imports. - **Agent data backups**: Hermes/OpenClaw images include `tenbox-agent-backup` and a systemd timer that writes profile packages to `/mnt/shared/tenbox-agent-backups///`, retaining the latest 5 packages by default. +- **Agent health checks**: Hermes/OpenClaw images include `tenbox-agent-health status|restart|test-model|reset-config|diagnostics`; repair actions must snapshot Agent data first and keep user-facing messages in "Agent/model/browser/disk" terms. - **Static build** (`TENBOX_STATIC_FFMPEG=ON`) requires `/opt/tenbox-deps` (only present inside the CI/packaging container). Dev builds use system shared libs — keep `ON` off by default. - **Release**: `docs/release.md` — VERSION bump → commit → push → tag → push tag. Always push commit before tag. diff --git a/docs/agent-profile.md b/docs/agent-profile.md index 63c5ea8..73acecc 100644 --- a/docs/agent-profile.md +++ b/docs/agent-profile.md @@ -88,3 +88,27 @@ path estimates source size before export and stops with a clear `space_low` status when the host-side shared folder does not have enough free space. Restore uses `tenbox-agent-profile import`, so existing Agent data is still protected by the `*.pre-import-*` backup before replacement. + +## Agent Health and Repair + +`tenbox-agent-health` provides a deterministic health report and a small set of +operator actions: + +```sh +tenbox-agent-health status --agent hermes +tenbox-agent-health restart --agent hermes +tenbox-agent-health test-model --agent hermes +tenbox-agent-health reset-config --agent hermes +tenbox-agent-health diagnostics --agent hermes +``` + +The status report separates Agent service state, gateway reachability, TenBox +LLM proxy reachability, browser availability, disk space, and the latest +redacted error summary. User-facing messages use "Agent" and "model +configuration" wording instead of requiring users to understand systemd, QEMU, +or dotfile paths. + +Repair actions create an Agent data snapshot through `tenbox-agent-backup` +before changing service state or restoring default model configuration. +Diagnostics are exported to `/mnt/shared/tenbox-agent-diagnostics-*.tar.gz` with +common token patterns redacted. diff --git a/scripts/arm64/make-rootfs-hermes.sh b/scripts/arm64/make-rootfs-hermes.sh index 2402e01..d6f581b 100755 --- a/scripts/arm64/make-rootfs-hermes.sh +++ b/scripts/arm64/make-rootfs-hermes.sh @@ -1021,6 +1021,8 @@ cp /tmp/rootfs-scripts/tenbox-agent-profile /usr/local/bin/ chmod +x /usr/local/bin/tenbox-agent-profile cp /tmp/rootfs-scripts/tenbox-agent-backup /usr/local/bin/ chmod +x /usr/local/bin/tenbox-agent-backup +cp /tmp/rootfs-scripts/tenbox-agent-health /usr/local/bin/ +chmod +x /usr/local/bin/tenbox-agent-health cp /tmp/rootfs-services/tenbox-agent-backup.service /etc/systemd/system/ cp /tmp/rootfs-services/tenbox-agent-backup.timer /etc/systemd/system/ systemctl enable tenbox-agent-backup.timer 2>/dev/null || true diff --git a/scripts/arm64/make-rootfs-openclaw.sh b/scripts/arm64/make-rootfs-openclaw.sh index 7c39320..e5dd910 100755 --- a/scripts/arm64/make-rootfs-openclaw.sh +++ b/scripts/arm64/make-rootfs-openclaw.sh @@ -762,6 +762,8 @@ cp /tmp/rootfs-scripts/tenbox-agent-profile /usr/local/bin/ chmod +x /usr/local/bin/tenbox-agent-profile cp /tmp/rootfs-scripts/tenbox-agent-backup /usr/local/bin/ chmod +x /usr/local/bin/tenbox-agent-backup +cp /tmp/rootfs-scripts/tenbox-agent-health /usr/local/bin/ +chmod +x /usr/local/bin/tenbox-agent-health cp /tmp/rootfs-services/tenbox-agent-backup.service /etc/systemd/system/ cp /tmp/rootfs-services/tenbox-agent-backup.timer /etc/systemd/system/ systemctl enable tenbox-agent-backup.timer 2>/dev/null || true diff --git a/scripts/rootfs-scripts/tenbox-agent-health b/scripts/rootfs-scripts/tenbox-agent-health new file mode 100755 index 0000000..7b19ae0 --- /dev/null +++ b/scripts/rootfs-scripts/tenbox-agent-health @@ -0,0 +1,274 @@ +#!/bin/sh +set -eu + +USER_NAME="${TENBOX_USER:-tenbox}" +HOME_DIR="${TENBOX_HOME_DIR:-/home/$USER_NAME}" +SHARED_DIR="${TENBOX_SHARED_DIR:-/mnt/shared}" +BACKUP_TOOL="${TENBOX_BACKUP_TOOL:-tenbox-agent-backup}" + +usage() { + cat >&2 <&2 + exit 1 +} + +json_quote() { + python3 -c 'import json,sys; print(json.dumps(sys.stdin.read().strip()))' +} + +now_utc() { + date -u +"%Y-%m-%dT%H:%M:%SZ" +} + +agent_path() { + case "$1" in + hermes) printf '%s/.hermes\n' "$HOME_DIR" ;; + openclaw) printf '%s/.openclaw\n' "$HOME_DIR" ;; + *) die "unsupported agent: $1" ;; + esac +} + +service_name() { + case "$1" in + hermes) echo hermes-gateway.service ;; + openclaw) echo openclaw-gateway.service ;; + esac +} + +gateway_port() { + case "$1" in + hermes) echo "" ;; + openclaw) echo 18789 ;; + esac +} + +default_agent() { + if [ -d "$HOME_DIR/.hermes" ] && [ ! -d "$HOME_DIR/.openclaw" ]; then + echo hermes + elif [ -d "$HOME_DIR/.openclaw" ] && [ ! -d "$HOME_DIR/.hermes" ]; then + echo openclaw + else + die "use --agent hermes or --agent openclaw" + fi +} + +systemctl_user() { + XDG_RUNTIME_DIR="${XDG_RUNTIME_DIR:-/run/user/$(id -u)}" systemctl --user "$@" 2>/dev/null +} + +check_service() { + svc="$(service_name "$1")" + if systemctl_user is-active --quiet "$svc"; then + echo ok + elif systemctl_user is-enabled --quiet "$svc"; then + echo starting + else + echo error + fi +} + +check_port() { + port="$(gateway_port "$1")" + if [ -z "$port" ]; then + echo skipped + elif nc -z 127.0.0.1 "$port" >/dev/null 2>&1; then + echo ok + else + echo error + fi +} + +check_model() { + if curl -fsS --max-time 5 http://10.0.2.3/v1/models >/dev/null 2>&1; then + echo ok + else + echo error + fi +} + +check_browser() { + if command -v chromium >/dev/null 2>&1 || command -v chromium-browser >/dev/null 2>&1; then + echo ok + else + echo error + fi +} + +check_disk() { + free_kb="$(df -Pk "$HOME_DIR" 2>/dev/null | awk 'NR==2 {print $4}')" + if [ "${free_kb:-0}" -gt 1048576 ]; then + echo ok + else + echo space_low + fi +} + +last_error() { + svc="$(service_name "$1")" + journalctl --user -u "$svc" -n 80 --no-pager 2>/dev/null | + sed -n '/error\|Error\|ERROR\|failed\|Failed\|FAILED/p' | + tail -n 1 | + sed -E 's/(sk-[A-Za-z0-9_-]{8})[A-Za-z0-9_-]+/\\1***/g; s/(api[_-]?key[=: ]+)[^ ]+/\\1***/Ig' +} + +summary_state() { + service="$1" + port="$2" + model="$3" + browser="$4" + disk="$5" + if [ "$disk" = "space_low" ]; then + echo "error|磁盘空间不足,请先清理空间或调整备份位置" + elif [ "$service" = "error" ]; then + echo "error|Agent 没有正常启动,可以点“重新启动 Agent”" + elif [ "$port" = "error" ]; then + echo "error|Agent 本地入口暂时不可用,可以点“重新启动 Agent”" + elif [ "$model" = "error" ]; then + echo "error|模型连接不可用,请先重新测试模型或检查模型配置" + elif [ "$browser" = "error" ]; then + echo "error|浏览器不可用,请修复镜像或重新下载镜像" + elif [ "$service" = "starting" ]; then + echo "starting|Agent 正在启动,请稍后再试" + else + echo "ok|Agent 正常" + fi +} + +status() { + agent="$1" + service="$(check_service "$agent")" + port="$(check_port "$agent")" + model="$(check_model)" + browser="$(check_browser)" + disk="$(check_disk)" + summary="$(summary_state "$service" "$port" "$model" "$browser" "$disk")" + state="${summary%%|*}" + message="${summary#*|}" + error="$(last_error "$agent")" + svc="$(service_name "$agent")" + port_value="$(gateway_port "$agent")" + + cat </dev/null 2>&1 || die "missing $BACKUP_TOOL" + "$BACKUP_TOOL" snapshot --agent "$agent" >/dev/null +} + +restart_agent() { + agent="$1" + snapshot_before_repair "$agent" + systemctl_user restart "$(service_name "$agent")" || die "Agent restart failed" + status "$agent" +} + +test_model() { + agent="$1" + result="$(check_model)" + if [ "$result" = "ok" ]; then + printf '{"agent_type":"%s","state":"ok","message":"模型连接正常"}\n' "$agent" + else + printf '{"agent_type":"%s","state":"error","message":"模型连接不可用,请检查模型配置"}\n' "$agent" + exit 1 + fi +} + +reset_config() { + agent="$1" + snapshot_before_repair "$agent" + case "$agent" in + hermes) + mkdir -p "$HOME_DIR/.hermes" + cat > "$HOME_DIR/.hermes/config.yaml" <<'EOF' +model: + default: "default" + provider: "custom" + base_url: "http://10.0.2.3/v1" + +terminal: + backend: local + +approvals: + mode: off + timeout: 60 + +display: + streaming: true +EOF + ;; + openclaw) + command -v openclaw >/dev/null 2>&1 || die "OpenClaw command is missing" + openclaw config set models.providers.tenbox '{"baseUrl":"http://10.0.2.3/v1","apiKey":"tenbox","api":"openai-completions","models":[{"id":"default","name":"Default (TenBox Proxy)","reasoning":false,"input":["text","image"],"contextWindow":200000,"maxTokens":65536,"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0}}]}' >/dev/null + openclaw config set models.mode merge >/dev/null + openclaw config set agents.defaults '{"model":{"primary":"tenbox/default"},"compaction":{"mode":"safeguard"},"workspace":"'"$HOME_DIR"'/.openclaw/workspace","models":{"tenbox/default":{}}}' >/dev/null + ;; + esac + status "$agent" +} + +diagnostics() { + agent="$1" + [ -d "$SHARED_DIR" ] || die "shared folder is not mounted" + out="$SHARED_DIR/tenbox-agent-diagnostics-$agent-$(date -u +%Y%m%d%H%M%S).tar.gz" + tmp="$(mktemp -d)" + trap 'rm -rf "$tmp"' EXIT INT TERM + status "$agent" > "$tmp/health.json" + systemctl_user status "$(service_name "$agent")" --no-pager > "$tmp/service.txt" 2>&1 || true + journalctl --user -u "$(service_name "$agent")" -n 200 --no-pager > "$tmp/journal.txt" 2>&1 || true + df -h > "$tmp/disk.txt" 2>&1 || true + sed -Ei 's/(sk-[A-Za-z0-9_-]{8})[A-Za-z0-9_-]+/\1***/g; s/(api[_-]?key[=: ]+)[^ ]+/\1***/Ig' "$tmp"/*.txt "$tmp"/*.json 2>/dev/null || true + tar -czf "$out" -C "$tmp" . + chmod 600 "$out" + echo "$out" +} + +cmd="${1:-}" +[ -n "$cmd" ] || { usage; exit 2; } +shift || true + +agent="" +while [ "$#" -gt 0 ]; do + case "$1" in + --agent) agent="${2:-}"; shift 2 ;; + -h|--help) usage; exit 0 ;; + *) die "unknown option: $1" ;; + esac +done +[ -n "$agent" ] || agent="$(default_agent)" + +case "$cmd" in + status) status "$agent" ;; + restart) restart_agent "$agent" ;; + test-model) test_model "$agent" ;; + reset-config) reset_config "$agent" ;; + diagnostics) diagnostics "$agent" ;; + *) usage; exit 2 ;; +esac diff --git a/scripts/x86_64/make-rootfs-hermes.sh b/scripts/x86_64/make-rootfs-hermes.sh index 467424f..26033b1 100755 --- a/scripts/x86_64/make-rootfs-hermes.sh +++ b/scripts/x86_64/make-rootfs-hermes.sh @@ -1025,6 +1025,8 @@ cp /tmp/rootfs-scripts/tenbox-agent-profile /usr/local/bin/ chmod +x /usr/local/bin/tenbox-agent-profile cp /tmp/rootfs-scripts/tenbox-agent-backup /usr/local/bin/ chmod +x /usr/local/bin/tenbox-agent-backup +cp /tmp/rootfs-scripts/tenbox-agent-health /usr/local/bin/ +chmod +x /usr/local/bin/tenbox-agent-health cp /tmp/rootfs-services/tenbox-agent-backup.service /etc/systemd/system/ cp /tmp/rootfs-services/tenbox-agent-backup.timer /etc/systemd/system/ systemctl enable tenbox-agent-backup.timer 2>/dev/null || true diff --git a/scripts/x86_64/make-rootfs-openclaw.sh b/scripts/x86_64/make-rootfs-openclaw.sh index c280266..b2e1e31 100755 --- a/scripts/x86_64/make-rootfs-openclaw.sh +++ b/scripts/x86_64/make-rootfs-openclaw.sh @@ -864,6 +864,8 @@ cp /tmp/rootfs-scripts/tenbox-agent-profile /usr/local/bin/ chmod +x /usr/local/bin/tenbox-agent-profile cp /tmp/rootfs-scripts/tenbox-agent-backup /usr/local/bin/ chmod +x /usr/local/bin/tenbox-agent-backup +cp /tmp/rootfs-scripts/tenbox-agent-health /usr/local/bin/ +chmod +x /usr/local/bin/tenbox-agent-health cp /tmp/rootfs-services/tenbox-agent-backup.service /etc/systemd/system/ cp /tmp/rootfs-services/tenbox-agent-backup.timer /etc/systemd/system/ systemctl enable tenbox-agent-backup.timer 2>/dev/null || true From 26e6bc5849723d9d4ca187677aa2674fc877a0f4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BC=A0=E5=AE=89=E5=93=B2?= Date: Mon, 11 May 2026 01:39:47 +0800 Subject: [PATCH 04/37] fix: target writable shared folder for backups --- CLAUDE.md | 2 +- docs/agent-profile.md | 5 +++-- scripts/rootfs-scripts/tenbox-agent-backup | 17 ++++++++++++++++- 3 files changed, 20 insertions(+), 4 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index f7ef6b1..3ce2304 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -89,7 +89,7 @@ Win/macOS: tenbox-manager ──IPC v1──► tenbox-vm-runtime (WHVP / HVF) - **RemoteSession** is single-instance per VM. Read `remote_webrtc.cpp`'s `force` takeover path before adding DataChannels. - **macOS Caps Lock forwarding**: send Caps Lock as a tap (`down` then `up`) on each `flagsChanged` event; AppKit exposes it as a toggle state, but the guest input stack needs a full key press for every switch. - **Agent data profile packages**: Hermes/OpenClaw images include `tenbox-agent-profile export|import` for `/mnt/shared/*.tar.zst` migration packages. Keep the format documented in `docs/agent-profile.md` and reject cross-agent imports. -- **Agent data backups**: Hermes/OpenClaw images include `tenbox-agent-backup` and a systemd timer that writes profile packages to `/mnt/shared/tenbox-agent-backups///`, retaining the latest 5 packages by default. +- **Agent data backups**: Hermes/OpenClaw images include `tenbox-agent-backup` and a systemd timer that writes profile packages to the first writable `/mnt/shared/*` folder, retaining the latest 5 packages by default. - **Agent health checks**: Hermes/OpenClaw images include `tenbox-agent-health status|restart|test-model|reset-config|diagnostics`; repair actions must snapshot Agent data first and keep user-facing messages in "Agent/model/browser/disk" terms. - **Static build** (`TENBOX_STATIC_FFMPEG=ON`) requires `/opt/tenbox-deps` (only present inside the CI/packaging container). Dev builds use system shared libs — keep `ON` off by default. - **Release**: `docs/release.md` — VERSION bump → commit → push → tag → push tag. Always push commit before tag. diff --git a/docs/agent-profile.md b/docs/agent-profile.md index 73acecc..71f8ee0 100644 --- a/docs/agent-profile.md +++ b/docs/agent-profile.md @@ -74,10 +74,11 @@ tenbox-agent-backup status --agent hermes --vm-id tenbox-agent-backup restore --agent hermes --vm-id ``` -By default backups are written under: +By default backups are written under the first writable shared folder. With the +default TenBox share tag this is usually: ```text -/mnt/shared/tenbox-agent-backups/// +/mnt/shared/shared/tenbox-agent-backups/// ├── agent-data-YYYYMMDDHHMMSS.tar.zst ├── agent-data-YYYYMMDDHHMMSS.tar.zst.manifest.json └── status.json diff --git a/scripts/rootfs-scripts/tenbox-agent-backup b/scripts/rootfs-scripts/tenbox-agent-backup index 1a52fe7..bd3f47a 100755 --- a/scripts/rootfs-scripts/tenbox-agent-backup +++ b/scripts/rootfs-scripts/tenbox-agent-backup @@ -3,7 +3,22 @@ set -eu USER_NAME="${TENBOX_USER:-tenbox}" HOME_DIR="${TENBOX_HOME_DIR:-/home/$USER_NAME}" -SHARED_DIR="${TENBOX_SHARED_DIR:-/mnt/shared}" + +default_shared_dir() { + base="/mnt/shared" + if [ -d "$base/shared" ]; then + echo "$base/shared" + return + fi + first="$(find "$base" -mindepth 1 -maxdepth 1 -type d 2>/dev/null | head -n 1 || true)" + if [ -n "$first" ]; then + echo "$first" + else + echo "$base" + fi +} + +SHARED_DIR="${TENBOX_SHARED_DIR:-$(default_shared_dir)}" RETENTION="${TENBOX_BACKUP_RETENTION:-5}" VM_ID="${TENBOX_VM_ID:-$(hostname 2>/dev/null || echo local-vm)}" BACKUP_ROOT="${TENBOX_BACKUP_ROOT:-$SHARED_DIR/tenbox-agent-backups}" From 96965bbcbe19b774bebfecdaf5b8875aa1e5ee5a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BC=A0=E5=AE=89=E5=93=B2?= Date: Mon, 11 May 2026 01:40:23 +0800 Subject: [PATCH 05/37] fix: use writable shared folder for diagnostics --- docs/agent-profile.md | 2 +- scripts/rootfs-scripts/tenbox-agent-health | 17 ++++++++++++++++- 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/docs/agent-profile.md b/docs/agent-profile.md index 71f8ee0..15927f1 100644 --- a/docs/agent-profile.md +++ b/docs/agent-profile.md @@ -111,5 +111,5 @@ or dotfile paths. Repair actions create an Agent data snapshot through `tenbox-agent-backup` before changing service state or restoring default model configuration. -Diagnostics are exported to `/mnt/shared/tenbox-agent-diagnostics-*.tar.gz` with +Diagnostics are exported to the same writable shared folder as backups with common token patterns redacted. diff --git a/scripts/rootfs-scripts/tenbox-agent-health b/scripts/rootfs-scripts/tenbox-agent-health index 7b19ae0..c0e97c7 100755 --- a/scripts/rootfs-scripts/tenbox-agent-health +++ b/scripts/rootfs-scripts/tenbox-agent-health @@ -3,7 +3,22 @@ set -eu USER_NAME="${TENBOX_USER:-tenbox}" HOME_DIR="${TENBOX_HOME_DIR:-/home/$USER_NAME}" -SHARED_DIR="${TENBOX_SHARED_DIR:-/mnt/shared}" + +default_shared_dir() { + base="/mnt/shared" + if [ -d "$base/shared" ]; then + echo "$base/shared" + return + fi + first="$(find "$base" -mindepth 1 -maxdepth 1 -type d 2>/dev/null | head -n 1 || true)" + if [ -n "$first" ]; then + echo "$first" + else + echo "$base" + fi +} + +SHARED_DIR="${TENBOX_SHARED_DIR:-$(default_shared_dir)}" BACKUP_TOOL="${TENBOX_BACKUP_TOOL:-tenbox-agent-backup}" usage() { From 82720a132caa009825f11c83f5dc8faddee5fc1c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BC=A0=E5=AE=89=E5=93=B2?= Date: Mon, 11 May 2026 01:43:27 +0800 Subject: [PATCH 06/37] fix: stage profile archives before shared copy --- scripts/rootfs-scripts/tenbox-agent-profile | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/scripts/rootfs-scripts/tenbox-agent-profile b/scripts/rootfs-scripts/tenbox-agent-profile index e0cd653..37e1b65 100755 --- a/scripts/rootfs-scripts/tenbox-agent-profile +++ b/scripts/rootfs-scripts/tenbox-agent-profile @@ -138,7 +138,8 @@ export_profile() { (cd "$tmp" && find files -type f | LC_ALL=C sort | while IFS= read -r file; do sha256sum "$file"; done > checksums.txt) mkdir -p "$(dirname "$output")" - (cd "$tmp" && tar --zstd -cf "$output.tmp" manifest.json files checksums.txt) + (cd "$tmp" && tar --zstd -cf "$tmp/package.tar.zst" manifest.json files checksums.txt) + cp "$tmp/package.tar.zst" "$output.tmp" mv "$output.tmp" "$output" chmod 600 "$output" echo "$output" From d029e1c54e823555cabfc0af6a4d1822a8ea20d3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BC=A0=E5=AE=89=E5=93=B2?= Date: Mon, 11 May 2026 01:45:15 +0800 Subject: [PATCH 07/37] fix: avoid virtiofs rename for profile export --- scripts/rootfs-scripts/tenbox-agent-profile | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/scripts/rootfs-scripts/tenbox-agent-profile b/scripts/rootfs-scripts/tenbox-agent-profile index 37e1b65..487c784 100755 --- a/scripts/rootfs-scripts/tenbox-agent-profile +++ b/scripts/rootfs-scripts/tenbox-agent-profile @@ -139,8 +139,8 @@ export_profile() { mkdir -p "$(dirname "$output")" (cd "$tmp" && tar --zstd -cf "$tmp/package.tar.zst" manifest.json files checksums.txt) - cp "$tmp/package.tar.zst" "$output.tmp" - mv "$output.tmp" "$output" + rm -f "$output" + cp "$tmp/package.tar.zst" "$output" chmod 600 "$output" echo "$output" } From 3e54e92db5a505afdeb02193c5ceac34450af264 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BC=A0=E5=AE=89=E5=93=B2?= Date: Mon, 11 May 2026 01:46:28 +0800 Subject: [PATCH 08/37] fix: tolerate shared folder chmod limits --- scripts/rootfs-scripts/tenbox-agent-profile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/rootfs-scripts/tenbox-agent-profile b/scripts/rootfs-scripts/tenbox-agent-profile index 487c784..7fcc51e 100755 --- a/scripts/rootfs-scripts/tenbox-agent-profile +++ b/scripts/rootfs-scripts/tenbox-agent-profile @@ -141,7 +141,7 @@ export_profile() { (cd "$tmp" && tar --zstd -cf "$tmp/package.tar.zst" manifest.json files checksums.txt) rm -f "$output" cp "$tmp/package.tar.zst" "$output" - chmod 600 "$output" + chmod 600 "$output" 2>/dev/null || true echo "$output" } From c266b458ea3abe6fb8ac2dbf59890de539a3495d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BC=A0=E5=AE=89=E5=93=B2?= Date: Mon, 11 May 2026 01:48:27 +0800 Subject: [PATCH 09/37] fix: tolerate shared folder status chmod --- scripts/rootfs-scripts/tenbox-agent-backup | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/rootfs-scripts/tenbox-agent-backup b/scripts/rootfs-scripts/tenbox-agent-backup index bd3f47a..8f17b88 100755 --- a/scripts/rootfs-scripts/tenbox-agent-backup +++ b/scripts/rootfs-scripts/tenbox-agent-backup @@ -95,7 +95,7 @@ write_status() { "updated_at": "$(now_utc)" } EOF - chmod 600 "$dir/status.json" + chmod 600 "$dir/status.json" 2>/dev/null || true } write_backup_manifest() { From 16135bcdd11b03f3c18bdff0fb33fb402970bffe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BC=A0=E5=AE=89=E5=93=B2?= Date: Mon, 11 May 2026 01:50:09 +0800 Subject: [PATCH 10/37] fix: tolerate shared folder diagnostics chmod --- scripts/rootfs-scripts/tenbox-agent-health | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/rootfs-scripts/tenbox-agent-health b/scripts/rootfs-scripts/tenbox-agent-health index c0e97c7..85e4f3e 100755 --- a/scripts/rootfs-scripts/tenbox-agent-health +++ b/scripts/rootfs-scripts/tenbox-agent-health @@ -261,7 +261,7 @@ diagnostics() { df -h > "$tmp/disk.txt" 2>&1 || true sed -Ei 's/(sk-[A-Za-z0-9_-]{8})[A-Za-z0-9_-]+/\1***/g; s/(api[_-]?key[=: ]+)[^ ]+/\1***/Ig' "$tmp"/*.txt "$tmp"/*.json 2>/dev/null || true tar -czf "$out" -C "$tmp" . - chmod 600 "$out" + chmod 600 "$out" 2>/dev/null || true echo "$out" } From cd334cc19e01365d6c27f300f11bfea6752b6b91 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BC=A0=E5=AE=89=E5=93=B2?= Date: Mon, 11 May 2026 01:55:28 +0800 Subject: [PATCH 11/37] fix: accept mounted shared root for backups --- scripts/rootfs-scripts/tenbox-agent-backup | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/rootfs-scripts/tenbox-agent-backup b/scripts/rootfs-scripts/tenbox-agent-backup index 8f17b88..ad92a72 100755 --- a/scripts/rootfs-scripts/tenbox-agent-backup +++ b/scripts/rootfs-scripts/tenbox-agent-backup @@ -127,7 +127,7 @@ rotate_backups() { snapshot() { agent="$1" [ -d "$SHARED_DIR" ] || die "shared folder is not mounted" - if [ -z "${TENBOX_SHARED_DIR:-}" ] && command -v mountpoint >/dev/null 2>&1 && ! mountpoint -q "$SHARED_DIR"; then + if [ -z "${TENBOX_SHARED_DIR:-}" ] && command -v mountpoint >/dev/null 2>&1 && ! mountpoint -q /mnt/shared; then die "shared folder is not mounted" fi [ -d "$(agent_path "$agent")" ] || die "Agent data is not initialized" From ab641d25282d16091d3d3defea42fbb41db695cf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BC=A0=E5=AE=89=E5=93=B2?= Date: Mon, 11 May 2026 02:14:47 +0800 Subject: [PATCH 12/37] feat: add macos agent data controls --- CLAUDE.md | 1 + src/manager-macos/Package.swift | 2 + .../Services/AgentToolsService.swift | 164 ++++++++++++++++++ src/manager-macos/TenBoxApp.swift | 29 ++++ src/manager-macos/Views/AgentToolsView.swift | 125 +++++++++++++ src/manager-macos/Views/ContentView.swift | 11 ++ src/manager-macos/Views/VmDetailView.swift | 71 ++++++++ 7 files changed, 403 insertions(+) create mode 100644 src/manager-macos/Services/AgentToolsService.swift create mode 100644 src/manager-macos/Views/AgentToolsView.swift diff --git a/CLAUDE.md b/CLAUDE.md index 3ce2304..74951eb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -91,6 +91,7 @@ Win/macOS: tenbox-manager ──IPC v1──► tenbox-vm-runtime (WHVP / HVF) - **Agent data profile packages**: Hermes/OpenClaw images include `tenbox-agent-profile export|import` for `/mnt/shared/*.tar.zst` migration packages. Keep the format documented in `docs/agent-profile.md` and reject cross-agent imports. - **Agent data backups**: Hermes/OpenClaw images include `tenbox-agent-backup` and a systemd timer that writes profile packages to the first writable `/mnt/shared/*` folder, retaining the latest 5 packages by default. - **Agent health checks**: Hermes/OpenClaw images include `tenbox-agent-health status|restart|test-model|reset-config|diagnostics`; repair actions must snapshot Agent data first and keep user-facing messages in "Agent/model/browser/disk" terms. +- **macOS Agent data UI**: `TenBox.app` exposes Agent data export/import from the VM toolbar/menu while a VM is running. It uses a temporary shared folder plus the console channel to call `tenbox-agent-profile` inside the guest. - **Static build** (`TENBOX_STATIC_FFMPEG=ON`) requires `/opt/tenbox-deps` (only present inside the CI/packaging container). Dev builds use system shared libs — keep `ON` off by default. - **Release**: `docs/release.md` — VERSION bump → commit → push → tag → push tag. Always push commit before tag. diff --git a/src/manager-macos/Package.swift b/src/manager-macos/Package.swift index fd1a055..0d5c623 100644 --- a/src/manager-macos/Package.swift +++ b/src/manager-macos/Package.swift @@ -45,7 +45,9 @@ let package = Package( "Bridge/VmProcessManager.swift", "Services/ImageSourceService.swift", "Services/LlmProxyService.swift", + "Services/AgentToolsService.swift", "Views/LlmProxyView.swift", + "Views/AgentToolsView.swift", ], resources: [ .copy("Resources/icon.png"), diff --git a/src/manager-macos/Services/AgentToolsService.swift b/src/manager-macos/Services/AgentToolsService.swift new file mode 100644 index 0000000..8f46af0 --- /dev/null +++ b/src/manager-macos/Services/AgentToolsService.swift @@ -0,0 +1,164 @@ +import Foundation + +enum AgentKind: String, CaseIterable, Identifiable { + case hermes + case openclaw + + var id: String { rawValue } + + var displayName: String { + switch self { + case .hermes: return "Hermes" + case .openclaw: return "OpenClaw" + } + } +} + +struct ConsoleCommandResult { + let exitCode: Int32 + let output: String +} + +struct AgentToolResult { + let message: String + let output: String +} + +struct ConsoleCommandError: LocalizedError { + let errorDescription: String? + + init(_ message: String) { + self.errorDescription = message + } +} + +final class AgentToolsService { + private let fileManager = FileManager.default + + func exportProfile(vm: VmInfo, session: VmSession, appState: AppState, agent: AgentKind, + destinationURL: URL, + completion: @escaping (Result) -> Void) { + withOperationShare(vmId: vm.id, appState: appState) { share, cleanup in + let packageName = "tenbox-agent-profile.tar.zst" + let guestDir = "/mnt/shared/\(share.tag)" + let guestPackage = "\(guestDir)/\(packageName)" + let command = "TENBOX_SHARED_DIR=\(Self.shellQuote(guestDir)) tenbox-agent-profile export --agent \(agent.rawValue) --output \(Self.shellQuote(guestPackage))" + + session.runShellCommand(command, timeout: 300) { result in + switch result { + case .success(let commandResult): + guard commandResult.exitCode == 0 else { + cleanup() + completion(.failure(Self.makeError(commandResult.output.isEmpty ? "Agent data export failed" : commandResult.output))) + return + } + let hostPackage = URL(fileURLWithPath: share.hostPath).appendingPathComponent(packageName) + do { + if self.fileManager.fileExists(atPath: destinationURL.path) { + try self.fileManager.removeItem(at: destinationURL) + } + try self.fileManager.copyItem(at: hostPackage, to: destinationURL) + cleanup() + completion(.success(AgentToolResult( + message: "已导出到 \(destinationURL.path)", + output: commandResult.output + ))) + } catch { + cleanup() + completion(.failure(error)) + } + case .failure(let error): + cleanup() + completion(.failure(error)) + } + } + } failure: { error in + completion(.failure(error)) + } + } + + func importProfile(vm: VmInfo, session: VmSession, appState: AppState, agent: AgentKind, + sourceURL: URL, + completion: @escaping (Result) -> Void) { + withOperationShare(vmId: vm.id, appState: appState) { share, cleanup in + let packageName = "tenbox-agent-profile-import.tar.zst" + let hostPackage = URL(fileURLWithPath: share.hostPath).appendingPathComponent(packageName) + do { + if self.fileManager.fileExists(atPath: hostPackage.path) { + try self.fileManager.removeItem(at: hostPackage) + } + try self.fileManager.copyItem(at: sourceURL, to: hostPackage) + } catch { + cleanup() + completion(.failure(error)) + return + } + + let guestDir = "/mnt/shared/\(share.tag)" + let guestPackage = "\(guestDir)/\(packageName)" + let command = "TENBOX_SHARED_DIR=\(Self.shellQuote(guestDir)) tenbox-agent-profile import --agent \(agent.rawValue) --input \(Self.shellQuote(guestPackage))" + + session.runShellCommand(command, timeout: 300) { result in + cleanup() + switch result { + case .success(let commandResult): + guard commandResult.exitCode == 0 else { + completion(.failure(Self.makeError(commandResult.output.isEmpty ? "Agent data import failed" : commandResult.output))) + return + } + completion(.success(AgentToolResult( + message: "已导入 \(agent.displayName) 数据", + output: commandResult.output + ))) + case .failure(let error): + completion(.failure(error)) + } + } + } failure: { error in + completion(.failure(error)) + } + } + + private func withOperationShare(vmId: String, appState: AppState, + perform: (SharedFolder, @escaping () -> Void) -> Void, + failure: (Error) -> Void) { + do { + let base = try operationBaseDirectory() + let tag = "tenbox-agent-ops-\(UUID().uuidString.prefix(8).lowercased())" + let dir = base.appendingPathComponent("\(vmId)-\(tag)", isDirectory: true) + try fileManager.createDirectory(at: dir, withIntermediateDirectories: true) + let share = SharedFolder(tag: tag, hostPath: dir.path, readonly: false) + appState.addSharedFolder(share, toVm: vmId) + + let cleanup: () -> Void = { [weak appState, weak self] in + DispatchQueue.main.async { + appState?.removeSharedFolder(tag: tag, fromVm: vmId) + try? self?.fileManager.removeItem(at: dir) + } + } + perform(share, cleanup) + } catch { + failure(error) + } + } + + private func operationBaseDirectory() throws -> URL { + let appSupport = try fileManager.url( + for: .applicationSupportDirectory, + in: .userDomainMask, + appropriateFor: nil, + create: true + ) + let dir = appSupport.appendingPathComponent("TenBox/AgentOperations", isDirectory: true) + try fileManager.createDirectory(at: dir, withIntermediateDirectories: true) + return dir + } + + private static func shellQuote(_ value: String) -> String { + "'" + value.replacingOccurrences(of: "'", with: "'\\''") + "'" + } + + private static func makeError(_ message: String) -> Error { + ConsoleCommandError(message) + } +} diff --git a/src/manager-macos/TenBoxApp.swift b/src/manager-macos/TenBoxApp.swift index 982e1eb..3d4ade4 100644 --- a/src/manager-macos/TenBoxApp.swift +++ b/src/manager-macos/TenBoxApp.swift @@ -143,12 +143,14 @@ class AppState: ObservableObject { @Published var showForceStopConfirm = false @Published var showSharedFoldersSheet = false @Published var showPortForwardsSheet = false + @Published var showAgentToolsSheet = false @Published var startVmError: String? @Published var hostForwardError: String? @Published var llmMappings: [LlmModelMapping] = [] @Published var llmLoggingEnabled = false let llmProxy = LlmProxyService() + private let agentTools = AgentToolsService() private static let kLlmGuestIp = "10.0.2.3" private static let kLlmGuestPort: UInt16 = 80 @@ -453,6 +455,28 @@ class AppState: ObservableObject { sendNetworkUpdateIfRunning(vmId: vmId) } + func exportAgentProfile(vmId: String, agent: AgentKind, destinationURL: URL, + completion: @escaping (Result) -> Void) { + guard let vm = vms.first(where: { $0.id == vmId }) else { + completion(.failure(ConsoleCommandError("VM not found"))) + return + } + let session = getOrCreateSession(for: vmId) + agentTools.exportProfile(vm: vm, session: session, appState: self, agent: agent, + destinationURL: destinationURL, completion: completion) + } + + func importAgentProfile(vmId: String, agent: AgentKind, sourceURL: URL, + completion: @escaping (Result) -> Void) { + guard let vm = vms.first(where: { $0.id == vmId }) else { + completion(.failure(ConsoleCommandError("VM not found"))) + return + } + let session = getOrCreateSession(for: vmId) + agentTools.importProfile(vm: vm, session: session, appState: self, agent: agent, + sourceURL: sourceURL, completion: completion) + } + // MARK: - LLM Proxy settings private var settingsPath: String { @@ -676,5 +700,10 @@ private struct VmCommandMenuContent: View { appState.showPortForwardsSheet = true } .disabled(vm == nil) + + Button("Agent Data...") { + appState.showAgentToolsSheet = true + } + .disabled(vm == nil || !isRunning) } } diff --git a/src/manager-macos/Views/AgentToolsView.swift b/src/manager-macos/Views/AgentToolsView.swift new file mode 100644 index 0000000..3699570 --- /dev/null +++ b/src/manager-macos/Views/AgentToolsView.swift @@ -0,0 +1,125 @@ +import SwiftUI +import AppKit + +struct AgentToolsSheet: View { + let vmId: String + @EnvironmentObject var appState: AppState + @Environment(\.dismiss) private var dismiss + + @State private var selectedAgent: AgentKind = .hermes + @State private var isRunningOperation = false + @State private var resultText = "" + @State private var errorText = "" + + private var vm: VmInfo? { + appState.vms.first(where: { $0.id == vmId }) + } + + private var canRun: Bool { + vm?.state == .running && !isRunningOperation + } + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + HStack { + Text("Agent Data") + .font(.title3) + .fontWeight(.semibold) + Spacer() + Button("Done") { dismiss() } + .keyboardShortcut(.cancelAction) + } + + Picker("Agent", selection: $selectedAgent) { + ForEach(AgentKind.allCases) { agent in + Text(agent.displayName).tag(agent) + } + } + .pickerStyle(.segmented) + + HStack(spacing: 10) { + Button { + exportProfile() + } label: { + Label("Export", systemImage: "square.and.arrow.up") + } + .disabled(!canRun) + + Button { + importProfile() + } label: { + Label("Import", systemImage: "square.and.arrow.down") + } + .disabled(!canRun) + } + + if isRunningOperation { + ProgressView() + .controlSize(.small) + } + + if let vm = vm, vm.state != .running { + Text("Start the VM before using Agent data tools.") + .foregroundStyle(.secondary) + } + + if !resultText.isEmpty { + Text(resultText) + .foregroundStyle(.secondary) + .textSelection(.enabled) + } + + if !errorText.isEmpty { + Text(errorText) + .foregroundStyle(.red) + .textSelection(.enabled) + } + + Spacer(minLength: 0) + } + .padding() + .frame(width: 460, height: 300) + } + + private func exportProfile() { + guard let vm = vm else { return } + let panel = NSSavePanel() + panel.title = "Export Agent Data" + panel.nameFieldStringValue = "\(vm.name)-\(selectedAgent.rawValue)-profile.tar.zst" + panel.allowedContentTypes = [] + guard panel.runModal() == .OK, let url = panel.url else { return } + runOperation { + appState.exportAgentProfile(vmId: vm.id, agent: selectedAgent, destinationURL: url, completion: $0) + } + } + + private func importProfile() { + guard let vm = vm else { return } + let panel = NSOpenPanel() + panel.title = "Import Agent Data" + panel.canChooseFiles = true + panel.canChooseDirectories = false + panel.allowsMultipleSelection = false + guard panel.runModal() == .OK, let url = panel.url else { return } + runOperation { + appState.importAgentProfile(vmId: vm.id, agent: selectedAgent, sourceURL: url, completion: $0) + } + } + + private func runOperation(_ operation: (@escaping (Result) -> Void) -> Void) { + resultText = "" + errorText = "" + isRunningOperation = true + operation { result in + DispatchQueue.main.async { + isRunningOperation = false + switch result { + case .success(let output): + resultText = output.message + case .failure(let error): + errorText = error.localizedDescription + } + } + } + } +} diff --git a/src/manager-macos/Views/ContentView.swift b/src/manager-macos/Views/ContentView.swift index 5c142ad..436cf18 100644 --- a/src/manager-macos/Views/ContentView.swift +++ b/src/manager-macos/Views/ContentView.swift @@ -112,6 +112,12 @@ struct ContentView: View { } .help("Manage LLM proxy settings") + Button(action: { appState.showAgentToolsSheet = true }) { + Label("Agent Data", systemImage: "externaldrive.badge.person.crop") + } + .disabled(vm.state != .running) + .help("Export or import Agent data") + Picker("", selection: appState.activeTabBinding(for: vm.id)) { Image(systemName: "info.circle").tag(0) Image(systemName: "terminal").tag(1) @@ -144,6 +150,11 @@ struct ContentView: View { .sheet(isPresented: $appState.showLlmProxySheet) { LlmProxySheet() } + .sheet(isPresented: $appState.showAgentToolsSheet) { + if let vm = selectedVm { + AgentToolsSheet(vmId: vm.id) + } + } .alert("Delete VM", isPresented: $appState.showDeleteConfirm) { Button("Cancel", role: .cancel) {} Button("Delete", role: .destructive) { diff --git a/src/manager-macos/Views/VmDetailView.swift b/src/manager-macos/Views/VmDetailView.swift index 9a32088..8587839 100644 --- a/src/manager-macos/Views/VmDetailView.swift +++ b/src/manager-macos/Views/VmDetailView.swift @@ -26,6 +26,14 @@ class VmSession: ObservableObject { private weak var clipboardHandler: ClipboardHandler? private var connecting = false private static let maxConsoleSize = 64 * 1024 + private var pendingConsoleCommands: [String: PendingConsoleCommand] = [:] + + private struct PendingConsoleCommand { + let beginMarker: String + let endPrefix: String + let completion: (Result) -> Void + let timeoutWorkItem: DispatchWorkItem + } init(vmId: String, clipboardHandler: ClipboardHandler) { self.vmId = vmId @@ -213,12 +221,75 @@ class VmSession: ObservableObject { ipcClient.sendConsoleInput(text) } + func runShellCommand(_ command: String, timeout: TimeInterval = 120, + completion: @escaping (Result) -> Void) { + DispatchQueue.main.async { + guard self.connected, self.ipcClient.isConnected else { + completion(.failure(ConsoleCommandError("VM console is not connected"))) + return + } + + let token = UUID().uuidString.replacingOccurrences(of: "-", with: "") + let beginMarker = "__TENBOX_CMD_BEGIN_\(token)__" + let endPrefix = "__TENBOX_CMD_END_\(token)__:" + let quotedCommand = Self.shellQuote(command) + let timeoutWorkItem = DispatchWorkItem { [weak self] in + guard let self = self else { return } + if let pending = self.pendingConsoleCommands.removeValue(forKey: token) { + pending.completion(.failure(ConsoleCommandError("Command timed out"))) + } + } + + self.pendingConsoleCommands[token] = PendingConsoleCommand( + beginMarker: beginMarker, + endPrefix: endPrefix, + completion: completion, + timeoutWorkItem: timeoutWorkItem + ) + + DispatchQueue.main.asyncAfter(deadline: .now() + timeout, execute: timeoutWorkItem) + let wrapped = "stty -echo 2>/dev/null; printf '\\n\(beginMarker)\\n'; /bin/sh -lc \(quotedCommand); rc=$?; printf '\\n\(endPrefix)%s\\n' \"$rc\"; stty echo 2>/dev/null\n" + self.sendConsoleInput(wrapped) + } + } + private func appendConsoleText(_ text: String) { consoleText.append(text) if consoleText.count > Self.maxConsoleSize { let excess = consoleText.count - Self.maxConsoleSize * 3 / 4 consoleText.removeFirst(excess) } + checkPendingConsoleCommands() + } + + private func checkPendingConsoleCommands() { + for token in Array(pendingConsoleCommands.keys) { + guard let pending = pendingConsoleCommands[token], + let endRange = consoleText.range(of: pending.endPrefix, options: .backwards) else { + continue + } + let afterEnd = consoleText[endRange.upperBound...] + guard let lineEnd = afterEnd.firstIndex(where: { $0 == "\n" }) else { continue } + let exitText = afterEnd[.. String { + "'" + value.replacingOccurrences(of: "'", with: "'\\''") + "'" } static func filterAnsi(_ input: String) -> String { From 4289a22edaa9f0b0ef86ff0ed971821600a9c3a8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BC=A0=E5=AE=89=E5=93=B2?= Date: Mon, 11 May 2026 02:16:21 +0800 Subject: [PATCH 13/37] feat: add macos agent backup controls --- CLAUDE.md | 1 + .../Services/AgentToolsService.swift | 79 +++++++++++++++++++ src/manager-macos/TenBoxApp.swift | 33 ++++++++ src/manager-macos/Views/AgentToolsView.swift | 53 ++++++++++++- 4 files changed, 164 insertions(+), 2 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 74951eb..9910ef7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -92,6 +92,7 @@ Win/macOS: tenbox-manager ──IPC v1──► tenbox-vm-runtime (WHVP / HVF) - **Agent data backups**: Hermes/OpenClaw images include `tenbox-agent-backup` and a systemd timer that writes profile packages to the first writable `/mnt/shared/*` folder, retaining the latest 5 packages by default. - **Agent health checks**: Hermes/OpenClaw images include `tenbox-agent-health status|restart|test-model|reset-config|diagnostics`; repair actions must snapshot Agent data first and keep user-facing messages in "Agent/model/browser/disk" terms. - **macOS Agent data UI**: `TenBox.app` exposes Agent data export/import from the VM toolbar/menu while a VM is running. It uses a temporary shared folder plus the console channel to call `tenbox-agent-profile` inside the guest. +- **macOS Agent backup UI**: `TenBox.app` exposes backup status, immediate backup, and restore latest backup actions. Host-triggered backups use `~/Library/Application Support/TenBox/AgentBackups/` as the durable shared folder. - **Static build** (`TENBOX_STATIC_FFMPEG=ON`) requires `/opt/tenbox-deps` (only present inside the CI/packaging container). Dev builds use system shared libs — keep `ON` off by default. - **Release**: `docs/release.md` — VERSION bump → commit → push → tag → push tag. Always push commit before tag. diff --git a/src/manager-macos/Services/AgentToolsService.swift b/src/manager-macos/Services/AgentToolsService.swift index 8f46af0..b1039b9 100644 --- a/src/manager-macos/Services/AgentToolsService.swift +++ b/src/manager-macos/Services/AgentToolsService.swift @@ -119,6 +119,54 @@ final class AgentToolsService { } } + func backupStatus(vm: VmInfo, session: VmSession, appState: AppState, agent: AgentKind, + completion: @escaping (Result) -> Void) { + runBackupCommand(vm: vm, session: session, appState: appState, agent: agent, + command: "status", successMessage: "备份状态已更新", + completion: completion) + } + + func snapshotBackup(vm: VmInfo, session: VmSession, appState: AppState, agent: AgentKind, + completion: @escaping (Result) -> Void) { + runBackupCommand(vm: vm, session: session, appState: appState, agent: agent, + command: "snapshot", successMessage: "已创建 Agent 数据备份", + completion: completion) + } + + func restoreLatestBackup(vm: VmInfo, session: VmSession, appState: AppState, agent: AgentKind, + completion: @escaping (Result) -> Void) { + runBackupCommand(vm: vm, session: session, appState: appState, agent: agent, + command: "restore", successMessage: "已从最近备份恢复 Agent 数据", + completion: completion) + } + + private func runBackupCommand(vm: VmInfo, session: VmSession, appState: AppState, agent: AgentKind, + command: String, successMessage: String, + completion: @escaping (Result) -> Void) { + withBackupShare(vmId: vm.id, appState: appState) { share, cleanup in + let guestDir = "/mnt/shared/\(share.tag)" + let shellCommand = "TENBOX_SHARED_DIR=\(Self.shellQuote(guestDir)) TENBOX_VM_ID=\(Self.shellQuote(vm.id)) tenbox-agent-backup \(command) --agent \(agent.rawValue) --vm-id \(Self.shellQuote(vm.id))" + session.runShellCommand(shellCommand, timeout: 300) { result in + cleanup() + switch result { + case .success(let commandResult): + guard commandResult.exitCode == 0 else { + completion(.failure(Self.makeError(commandResult.output.isEmpty ? "Agent backup command failed" : commandResult.output))) + return + } + completion(.success(AgentToolResult( + message: successMessage, + output: commandResult.output + ))) + case .failure(let error): + completion(.failure(error)) + } + } + } failure: { error in + completion(.failure(error)) + } + } + private func withOperationShare(vmId: String, appState: AppState, perform: (SharedFolder, @escaping () -> Void) -> Void, failure: (Error) -> Void) { @@ -154,6 +202,37 @@ final class AgentToolsService { return dir } + private func withBackupShare(vmId: String, appState: AppState, + perform: (SharedFolder, @escaping () -> Void) -> Void, + failure: (Error) -> Void) { + do { + let dir = try backupDirectory(vmId: vmId) + let tag = "tenbox-agent-backups-\(UUID().uuidString.prefix(8).lowercased())" + let share = SharedFolder(tag: tag, hostPath: dir.path, readonly: false) + appState.addSharedFolder(share, toVm: vmId) + let cleanup: () -> Void = { [weak appState] in + DispatchQueue.main.async { + appState?.removeSharedFolder(tag: tag, fromVm: vmId) + } + } + perform(share, cleanup) + } catch { + failure(error) + } + } + + private func backupDirectory(vmId: String) throws -> URL { + let appSupport = try fileManager.url( + for: .applicationSupportDirectory, + in: .userDomainMask, + appropriateFor: nil, + create: true + ) + let dir = appSupport.appendingPathComponent("TenBox/AgentBackups/\(vmId)", isDirectory: true) + try fileManager.createDirectory(at: dir, withIntermediateDirectories: true) + return dir + } + private static func shellQuote(_ value: String) -> String { "'" + value.replacingOccurrences(of: "'", with: "'\\''") + "'" } diff --git a/src/manager-macos/TenBoxApp.swift b/src/manager-macos/TenBoxApp.swift index 3d4ade4..428eb9e 100644 --- a/src/manager-macos/TenBoxApp.swift +++ b/src/manager-macos/TenBoxApp.swift @@ -477,6 +477,39 @@ class AppState: ObservableObject { sourceURL: sourceURL, completion: completion) } + func agentBackupStatus(vmId: String, agent: AgentKind, + completion: @escaping (Result) -> Void) { + guard let vm = vms.first(where: { $0.id == vmId }) else { + completion(.failure(ConsoleCommandError("VM not found"))) + return + } + let session = getOrCreateSession(for: vmId) + agentTools.backupStatus(vm: vm, session: session, appState: self, agent: agent, + completion: completion) + } + + func snapshotAgentBackup(vmId: String, agent: AgentKind, + completion: @escaping (Result) -> Void) { + guard let vm = vms.first(where: { $0.id == vmId }) else { + completion(.failure(ConsoleCommandError("VM not found"))) + return + } + let session = getOrCreateSession(for: vmId) + agentTools.snapshotBackup(vm: vm, session: session, appState: self, agent: agent, + completion: completion) + } + + func restoreLatestAgentBackup(vmId: String, agent: AgentKind, + completion: @escaping (Result) -> Void) { + guard let vm = vms.first(where: { $0.id == vmId }) else { + completion(.failure(ConsoleCommandError("VM not found"))) + return + } + let session = getOrCreateSession(for: vmId) + agentTools.restoreLatestBackup(vm: vm, session: session, appState: self, agent: agent, + completion: completion) + } + // MARK: - LLM Proxy settings private var settingsPath: String { diff --git a/src/manager-macos/Views/AgentToolsView.swift b/src/manager-macos/Views/AgentToolsView.swift index 3699570..2560e86 100644 --- a/src/manager-macos/Views/AgentToolsView.swift +++ b/src/manager-macos/Views/AgentToolsView.swift @@ -53,6 +53,34 @@ struct AgentToolsSheet: View { .disabled(!canRun) } + Divider() + + Text("Backups") + .font(.headline) + + HStack(spacing: 10) { + Button { + showBackupStatus() + } label: { + Label("Status", systemImage: "checklist") + } + .disabled(!canRun) + + Button { + snapshotBackup() + } label: { + Label("Back Up Now", systemImage: "clock.arrow.circlepath") + } + .disabled(!canRun) + + Button { + restoreLatestBackup() + } label: { + Label("Restore Latest", systemImage: "arrow.uturn.backward") + } + .disabled(!canRun) + } + if isRunningOperation { ProgressView() .controlSize(.small) @@ -78,7 +106,7 @@ struct AgentToolsSheet: View { Spacer(minLength: 0) } .padding() - .frame(width: 460, height: 300) + .frame(width: 520, height: 390) } private func exportProfile() { @@ -106,6 +134,27 @@ struct AgentToolsSheet: View { } } + private func showBackupStatus() { + guard let vm = vm else { return } + runOperation { + appState.agentBackupStatus(vmId: vm.id, agent: selectedAgent, completion: $0) + } + } + + private func snapshotBackup() { + guard let vm = vm else { return } + runOperation { + appState.snapshotAgentBackup(vmId: vm.id, agent: selectedAgent, completion: $0) + } + } + + private func restoreLatestBackup() { + guard let vm = vm else { return } + runOperation { + appState.restoreLatestAgentBackup(vmId: vm.id, agent: selectedAgent, completion: $0) + } + } + private func runOperation(_ operation: (@escaping (Result) -> Void) -> Void) { resultText = "" errorText = "" @@ -115,7 +164,7 @@ struct AgentToolsSheet: View { isRunningOperation = false switch result { case .success(let output): - resultText = output.message + resultText = output.output.isEmpty ? output.message : "\(output.message)\n\(output.output)" case .failure(let error): errorText = error.localizedDescription } From 72e445ac640dbd8730fe94cf1da927c77ca3a209 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BC=A0=E5=AE=89=E5=93=B2?= Date: Mon, 11 May 2026 02:17:59 +0800 Subject: [PATCH 14/37] feat: add macos agent health controls --- CLAUDE.md | 1 + .../Services/AgentToolsService.swift | 62 ++++++++++++++ src/manager-macos/TenBoxApp.swift | 55 +++++++++++++ src/manager-macos/Views/AgentToolsView.swift | 81 ++++++++++++++++++- 4 files changed, 198 insertions(+), 1 deletion(-) diff --git a/CLAUDE.md b/CLAUDE.md index 9910ef7..8a0efcc 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -93,6 +93,7 @@ Win/macOS: tenbox-manager ──IPC v1──► tenbox-vm-runtime (WHVP / HVF) - **Agent health checks**: Hermes/OpenClaw images include `tenbox-agent-health status|restart|test-model|reset-config|diagnostics`; repair actions must snapshot Agent data first and keep user-facing messages in "Agent/model/browser/disk" terms. - **macOS Agent data UI**: `TenBox.app` exposes Agent data export/import from the VM toolbar/menu while a VM is running. It uses a temporary shared folder plus the console channel to call `tenbox-agent-profile` inside the guest. - **macOS Agent backup UI**: `TenBox.app` exposes backup status, immediate backup, and restore latest backup actions. Host-triggered backups use `~/Library/Application Support/TenBox/AgentBackups/` as the durable shared folder. +- **macOS Agent health UI**: `TenBox.app` exposes health check, restart, model test, config reset, and diagnostics actions while a VM is running. Repair actions run through `tenbox-agent-health` so guest-side pre-repair snapshots stay enforced. - **Static build** (`TENBOX_STATIC_FFMPEG=ON`) requires `/opt/tenbox-deps` (only present inside the CI/packaging container). Dev builds use system shared libs — keep `ON` off by default. - **Release**: `docs/release.md` — VERSION bump → commit → push → tag → push tag. Always push commit before tag. diff --git a/src/manager-macos/Services/AgentToolsService.swift b/src/manager-macos/Services/AgentToolsService.swift index b1039b9..d316330 100644 --- a/src/manager-macos/Services/AgentToolsService.swift +++ b/src/manager-macos/Services/AgentToolsService.swift @@ -140,6 +140,41 @@ final class AgentToolsService { completion: completion) } + func healthStatus(vm: VmInfo, session: VmSession, appState: AppState, agent: AgentKind, + completion: @escaping (Result) -> Void) { + runHealthCommand(vm: vm, session: session, appState: appState, agent: agent, + command: "status", successMessage: "健康状态已更新", + completion: completion) + } + + func restartAgent(vm: VmInfo, session: VmSession, appState: AppState, agent: AgentKind, + completion: @escaping (Result) -> Void) { + runHealthCommand(vm: vm, session: session, appState: appState, agent: agent, + command: "restart", successMessage: "已重新启动 Agent", + completion: completion) + } + + func testModel(vm: VmInfo, session: VmSession, appState: AppState, agent: AgentKind, + completion: @escaping (Result) -> Void) { + runHealthCommand(vm: vm, session: session, appState: appState, agent: agent, + command: "test-model", successMessage: "模型连接已测试", + completion: completion) + } + + func resetAgentConfig(vm: VmInfo, session: VmSession, appState: AppState, agent: AgentKind, + completion: @escaping (Result) -> Void) { + runHealthCommand(vm: vm, session: session, appState: appState, agent: agent, + command: "reset-config", successMessage: "已重置 Agent 配置", + completion: completion) + } + + func exportDiagnostics(vm: VmInfo, session: VmSession, appState: AppState, agent: AgentKind, + completion: @escaping (Result) -> Void) { + runHealthCommand(vm: vm, session: session, appState: appState, agent: agent, + command: "diagnostics", successMessage: "已导出诊断包", + completion: completion) + } + private func runBackupCommand(vm: VmInfo, session: VmSession, appState: AppState, agent: AgentKind, command: String, successMessage: String, completion: @escaping (Result) -> Void) { @@ -167,6 +202,33 @@ final class AgentToolsService { } } + private func runHealthCommand(vm: VmInfo, session: VmSession, appState: AppState, agent: AgentKind, + command: String, successMessage: String, + completion: @escaping (Result) -> Void) { + withBackupShare(vmId: vm.id, appState: appState) { share, cleanup in + let guestDir = "/mnt/shared/\(share.tag)" + let shellCommand = "TENBOX_SHARED_DIR=\(Self.shellQuote(guestDir)) TENBOX_VM_ID=\(Self.shellQuote(vm.id)) tenbox-agent-health \(command) --agent \(agent.rawValue)" + session.runShellCommand(shellCommand, timeout: 360) { result in + cleanup() + switch result { + case .success(let commandResult): + guard commandResult.exitCode == 0 else { + completion(.failure(Self.makeError(commandResult.output.isEmpty ? "Agent health command failed" : commandResult.output))) + return + } + completion(.success(AgentToolResult( + message: successMessage, + output: commandResult.output + ))) + case .failure(let error): + completion(.failure(error)) + } + } + } failure: { error in + completion(.failure(error)) + } + } + private func withOperationShare(vmId: String, appState: AppState, perform: (SharedFolder, @escaping () -> Void) -> Void, failure: (Error) -> Void) { diff --git a/src/manager-macos/TenBoxApp.swift b/src/manager-macos/TenBoxApp.swift index 428eb9e..c8cf228 100644 --- a/src/manager-macos/TenBoxApp.swift +++ b/src/manager-macos/TenBoxApp.swift @@ -510,6 +510,61 @@ class AppState: ObservableObject { completion: completion) } + func agentHealthStatus(vmId: String, agent: AgentKind, + completion: @escaping (Result) -> Void) { + guard let vm = vms.first(where: { $0.id == vmId }) else { + completion(.failure(ConsoleCommandError("VM not found"))) + return + } + let session = getOrCreateSession(for: vmId) + agentTools.healthStatus(vm: vm, session: session, appState: self, agent: agent, + completion: completion) + } + + func restartAgent(vmId: String, agent: AgentKind, + completion: @escaping (Result) -> Void) { + guard let vm = vms.first(where: { $0.id == vmId }) else { + completion(.failure(ConsoleCommandError("VM not found"))) + return + } + let session = getOrCreateSession(for: vmId) + agentTools.restartAgent(vm: vm, session: session, appState: self, agent: agent, + completion: completion) + } + + func testAgentModel(vmId: String, agent: AgentKind, + completion: @escaping (Result) -> Void) { + guard let vm = vms.first(where: { $0.id == vmId }) else { + completion(.failure(ConsoleCommandError("VM not found"))) + return + } + let session = getOrCreateSession(for: vmId) + agentTools.testModel(vm: vm, session: session, appState: self, agent: agent, + completion: completion) + } + + func resetAgentConfig(vmId: String, agent: AgentKind, + completion: @escaping (Result) -> Void) { + guard let vm = vms.first(where: { $0.id == vmId }) else { + completion(.failure(ConsoleCommandError("VM not found"))) + return + } + let session = getOrCreateSession(for: vmId) + agentTools.resetAgentConfig(vm: vm, session: session, appState: self, agent: agent, + completion: completion) + } + + func exportAgentDiagnostics(vmId: String, agent: AgentKind, + completion: @escaping (Result) -> Void) { + guard let vm = vms.first(where: { $0.id == vmId }) else { + completion(.failure(ConsoleCommandError("VM not found"))) + return + } + let session = getOrCreateSession(for: vmId) + agentTools.exportDiagnostics(vm: vm, session: session, appState: self, agent: agent, + completion: completion) + } + // MARK: - LLM Proxy settings private var settingsPath: String { diff --git a/src/manager-macos/Views/AgentToolsView.swift b/src/manager-macos/Views/AgentToolsView.swift index 2560e86..77c2a02 100644 --- a/src/manager-macos/Views/AgentToolsView.swift +++ b/src/manager-macos/Views/AgentToolsView.swift @@ -81,6 +81,50 @@ struct AgentToolsSheet: View { .disabled(!canRun) } + Divider() + + Text("Health") + .font(.headline) + + HStack(spacing: 10) { + Button { + checkHealth() + } label: { + Label("Check", systemImage: "stethoscope") + } + .disabled(!canRun) + + Button { + restartAgent() + } label: { + Label("Restart", systemImage: "arrow.clockwise") + } + .disabled(!canRun) + + Button { + testModel() + } label: { + Label("Test Model", systemImage: "bolt.horizontal") + } + .disabled(!canRun) + } + + HStack(spacing: 10) { + Button { + resetConfig() + } label: { + Label("Reset Config", systemImage: "slider.horizontal.2.square") + } + .disabled(!canRun) + + Button { + exportDiagnostics() + } label: { + Label("Diagnostics", systemImage: "doc.zipper") + } + .disabled(!canRun) + } + if isRunningOperation { ProgressView() .controlSize(.small) @@ -106,7 +150,7 @@ struct AgentToolsSheet: View { Spacer(minLength: 0) } .padding() - .frame(width: 520, height: 390) + .frame(width: 560, height: 520) } private func exportProfile() { @@ -155,6 +199,41 @@ struct AgentToolsSheet: View { } } + private func checkHealth() { + guard let vm = vm else { return } + runOperation { + appState.agentHealthStatus(vmId: vm.id, agent: selectedAgent, completion: $0) + } + } + + private func restartAgent() { + guard let vm = vm else { return } + runOperation { + appState.restartAgent(vmId: vm.id, agent: selectedAgent, completion: $0) + } + } + + private func testModel() { + guard let vm = vm else { return } + runOperation { + appState.testAgentModel(vmId: vm.id, agent: selectedAgent, completion: $0) + } + } + + private func resetConfig() { + guard let vm = vm else { return } + runOperation { + appState.resetAgentConfig(vmId: vm.id, agent: selectedAgent, completion: $0) + } + } + + private func exportDiagnostics() { + guard let vm = vm else { return } + runOperation { + appState.exportAgentDiagnostics(vmId: vm.id, agent: selectedAgent, completion: $0) + } + } + private func runOperation(_ operation: (@escaping (Result) -> Void) -> Void) { resultText = "" errorText = "" From 68026e0e5dc1e5eb98a848701c4529fafb6fbc9f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BC=A0=E5=AE=89=E5=93=B2?= Date: Mon, 11 May 2026 02:21:17 +0800 Subject: [PATCH 15/37] fix: stage agent profiles outside tmpfs --- scripts/rootfs-scripts/tenbox-agent-profile | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/scripts/rootfs-scripts/tenbox-agent-profile b/scripts/rootfs-scripts/tenbox-agent-profile index 7fcc51e..abdc8a4 100755 --- a/scripts/rootfs-scripts/tenbox-agent-profile +++ b/scripts/rootfs-scripts/tenbox-agent-profile @@ -25,6 +25,12 @@ need_tool() { command -v "$1" >/dev/null 2>&1 || die "missing required tool: $1" } +make_tmp_dir() { + base="${TENBOX_TMP_DIR:-$HOME_DIR/.cache/tenbox-agent-profile}" + mkdir -p "$base" + mktemp -d "$base/work.XXXXXX" +} + agent_path() { case "$1" in hermes) printf '%s/.hermes\n' "$HOME_DIR" ;; @@ -126,7 +132,7 @@ export_profile() { need_tool zstd need_tool sha256sum - tmp="$(mktemp -d)" + tmp="$(make_tmp_dir)" trap 'rm -rf "$tmp"' EXIT INT TERM mkdir -p "$tmp/files" @@ -162,7 +168,7 @@ import_profile() { need_tool zstd need_tool sha256sum - tmp="$(mktemp -d)" + tmp="$(make_tmp_dir)" trap 'rm -rf "$tmp"' EXIT INT TERM tar --zstd -xf "$input" -C "$tmp" From 2b24cf7c3c8379c8f28a028c9c4d516f900c3da8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BC=A0=E5=AE=89=E5=93=B2?= Date: Mon, 11 May 2026 02:54:47 +0800 Subject: [PATCH 16/37] refactor: move agent health tools into app --- CLAUDE.md | 10 +- docs/agent-profile.md | 127 ++--- scripts/arm64/make-rootfs-hermes.sh | 13 +- scripts/arm64/make-rootfs-openclaw.sh | 13 +- scripts/rootfs-scripts/tenbox-agent-backup | 225 --------- scripts/rootfs-scripts/tenbox-agent-health | 289 ----------- scripts/rootfs-scripts/tenbox-agent-profile | 241 --------- .../tenbox-agent-backup.service | 6 - .../rootfs-services/tenbox-agent-backup.timer | 10 - scripts/x86_64/make-rootfs-hermes.sh | 13 +- scripts/x86_64/make-rootfs-openclaw.sh | 13 +- .../Services/AgentToolsService.swift | 470 +++++++++++++++--- src/manager-macos/Views/AgentToolsView.swift | 2 +- 13 files changed, 455 insertions(+), 977 deletions(-) delete mode 100755 scripts/rootfs-scripts/tenbox-agent-backup delete mode 100755 scripts/rootfs-scripts/tenbox-agent-health delete mode 100755 scripts/rootfs-scripts/tenbox-agent-profile delete mode 100644 scripts/rootfs-services/tenbox-agent-backup.service delete mode 100644 scripts/rootfs-services/tenbox-agent-backup.timer diff --git a/CLAUDE.md b/CLAUDE.md index 8a0efcc..a944661 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -88,12 +88,12 @@ Win/macOS: tenbox-manager ──IPC v1──► tenbox-vm-runtime (WHVP / HVF) - **LLM proxy** exists in two places: `src/daemon/llm_proxy.cpp` (Linux) and `src/manager/llm_proxy.cpp` (Windows); change both when the protocol changes. - **RemoteSession** is single-instance per VM. Read `remote_webrtc.cpp`'s `force` takeover path before adding DataChannels. - **macOS Caps Lock forwarding**: send Caps Lock as a tap (`down` then `up`) on each `flagsChanged` event; AppKit exposes it as a toggle state, but the guest input stack needs a full key press for every switch. -- **Agent data profile packages**: Hermes/OpenClaw images include `tenbox-agent-profile export|import` for `/mnt/shared/*.tar.zst` migration packages. Keep the format documented in `docs/agent-profile.md` and reject cross-agent imports. -- **Agent data backups**: Hermes/OpenClaw images include `tenbox-agent-backup` and a systemd timer that writes profile packages to the first writable `/mnt/shared/*` folder, retaining the latest 5 packages by default. -- **Agent health checks**: Hermes/OpenClaw images include `tenbox-agent-health status|restart|test-model|reset-config|diagnostics`; repair actions must snapshot Agent data first and keep user-facing messages in "Agent/model/browser/disk" terms. -- **macOS Agent data UI**: `TenBox.app` exposes Agent data export/import from the VM toolbar/menu while a VM is running. It uses a temporary shared folder plus the console channel to call `tenbox-agent-profile` inside the guest. +- **Agent data profile packages**: `TenBox.app` exports/imports Hermes/OpenClaw data without image changes by using a temporary shared folder and console-injected standard shell commands. Keep the gzip package format documented in `docs/agent-profile.md` and reject cross-agent imports. +- **Agent data backups**: `TenBox.app` writes host-managed backups to `~/Library/Application Support/TenBox/AgentBackups//` and retains the latest 5 packages. +- **Agent health checks**: `TenBox.app` runs health, restart, model test, config reset, and diagnostics through console-injected commands. Repair actions must create a host-managed Agent data backup first. +- **macOS Agent data UI**: `TenBox.app` exposes Agent data export/import from the VM toolbar/menu while a VM is running. It must not depend on preinstalled guest TenBox scripts. - **macOS Agent backup UI**: `TenBox.app` exposes backup status, immediate backup, and restore latest backup actions. Host-triggered backups use `~/Library/Application Support/TenBox/AgentBackups/` as the durable shared folder. -- **macOS Agent health UI**: `TenBox.app` exposes health check, restart, model test, config reset, and diagnostics actions while a VM is running. Repair actions run through `tenbox-agent-health` so guest-side pre-repair snapshots stay enforced. +- **macOS Agent health UI**: `TenBox.app` exposes health check, restart, model test, config reset, and diagnostics actions while a VM is running. Repair actions run through app-generated shell commands so no image rebuild is required. - **Static build** (`TENBOX_STATIC_FFMPEG=ON`) requires `/opt/tenbox-deps` (only present inside the CI/packaging container). Dev builds use system shared libs — keep `ON` off by default. - **Release**: `docs/release.md` — VERSION bump → commit → push → tag → push tag. Always push commit before tag. diff --git a/docs/agent-profile.md b/docs/agent-profile.md index 15927f1..7cd7ecd 100644 --- a/docs/agent-profile.md +++ b/docs/agent-profile.md @@ -1,115 +1,64 @@ -# Agent Data Profile Packages +# Agent Data Tools -TenBox Agent data export/import uses a versioned archive so migration, -backup, and restore flows can share one artifact shape without exposing -guest dotfile paths to non-technical users. +TenBox.app provides Agent data export/import, backup/restore, and health actions +without requiring Hermes/OpenClaw images to preinstall TenBox-specific scripts. -## Package Format +The macOS manager creates a temporary shared folder, then sends a short shell +command through the existing VM console channel. The command uses standard guest +tools such as `tar`, `gzip`, `systemctl`, `curl`, and `journalctl`. -`tenbox-agent-profile.tar.zst` is a zstd-compressed tar archive: +## Profile package + +The exported package is a gzip tar archive: ```text -tenbox-agent-profile.tar.zst +--profile.tar.gz ├── manifest.json -├── files/ -└── checksums.txt +└── files.tar.gz ``` -`manifest.json` records: +`manifest.json` contains: - `format`: `tenbox-agent-profile` -- `format_version`: currently `1` +- `format_version`: `2` - `agent_type`: `hermes` or `openclaw` -- `tenbox_version` -- `created_at` -- `home` -- `source_path` -- `paths` -- `excluded` -- `checksums` - -`checksums.txt` stores SHA-256 hashes for files under `files/`. It never -contains secret values directly. - -## Supported Agents - -Hermes profile: +- `archive`: `files.tar.gz` -- Includes `/home/tenbox/.hermes` -- Excludes `.hermes/logs`, `.hermes/image_cache`, `.hermes/audio_cache` -- Sensitive files such as API keys remain inside the package payload; logs - and manifest output must not print their values. +`files.tar.gz` contains the Agent data directory relative to the guest home: -OpenClaw profile: +- Hermes: `.hermes` +- OpenClaw: `.openclaw` -- Includes `/home/tenbox/.openclaw` -- Excludes `.openclaw/cache`, `.openclaw/.cache`, `.openclaw/workspace/.cache` -- Sensitive files remain inside the package payload; manifest and logs must - not print token values. +Excluded paths: -QwenPaw is intentionally not included in this version because `.qwenpaw.secret` -needs a separate sensitivity policy. +- Hermes: `.hermes/logs`, `.hermes/image_cache`, `.hermes/audio_cache` +- OpenClaw: `.openclaw/cache`, `.openclaw/.cache`, `.openclaw/workspace/.cache` -## Guest CLI - -Inside Hermes and OpenClaw images: - -```sh -tenbox-agent-profile export --agent hermes --output /mnt/shared/agent-data.tar.zst -tenbox-agent-profile import --agent hermes --input /mnt/shared/agent-data.tar.zst -``` +Import rejects packages whose `agent_type` does not match the selected Agent. +Before replacing existing data, it renames the current directory to +`*.pre-import-YYYYMMDDHHMMSS`. -The import path verifies the archive manifest and checksums, rejects cross-agent -imports, backs up existing data to `*.pre-import-*`, and then restores ownership -and permissions for the `tenbox` user. +## Backups -## Automatic Agent Data Backups - -Hermes and OpenClaw images also include `tenbox-agent-backup`, which reuses the -profile package format instead of creating a second backup format. - -```sh -tenbox-agent-backup snapshot --agent hermes --vm-id -tenbox-agent-backup status --agent hermes --vm-id -tenbox-agent-backup restore --agent hermes --vm-id -``` - -By default backups are written under the first writable shared folder. With the -default TenBox share tag this is usually: +Manual backups are created by TenBox.app in: ```text -/mnt/shared/shared/tenbox-agent-backups/// -├── agent-data-YYYYMMDDHHMMSS.tar.zst -├── agent-data-YYYYMMDDHHMMSS.tar.zst.manifest.json -└── status.json +~/Library/Application Support/TenBox/AgentBackups/// ``` -The default retention is the latest 5 packages per VM and Agent. The snapshot -path estimates source size before export and stops with a clear `space_low` -status when the host-side shared folder does not have enough free space. Restore -uses `tenbox-agent-profile import`, so existing Agent data is still protected by -the `*.pre-import-*` backup before replacement. - -## Agent Health and Repair +Backups use the same profile package format and keep the newest five packages. +Restore uses the newest package for the selected VM and Agent. -`tenbox-agent-health` provides a deterministic health report and a small set of -operator actions: +## Health actions -```sh -tenbox-agent-health status --agent hermes -tenbox-agent-health restart --agent hermes -tenbox-agent-health test-model --agent hermes -tenbox-agent-health reset-config --agent hermes -tenbox-agent-health diagnostics --agent hermes -``` +TenBox.app can run these actions while the VM is running: -The status report separates Agent service state, gateway reachability, TenBox -LLM proxy reachability, browser availability, disk space, and the latest -redacted error summary. User-facing messages use "Agent" and "model -configuration" wording instead of requiring users to understand systemd, QEMU, -or dotfile paths. +- health status +- restart Agent +- test model proxy +- reset Agent config +- export diagnostics -Repair actions create an Agent data snapshot through `tenbox-agent-backup` -before changing service state or restoring default model configuration. -Diagnostics are exported to the same writable shared folder as backups with -common token patterns redacted. +Restart and reset create a backup first, using the same host-managed backup +directory. Diagnostics are exported to the host backup directory through the +temporary shared folder. diff --git a/scripts/arm64/make-rootfs-hermes.sh b/scripts/arm64/make-rootfs-hermes.sh index d6f581b..fdf0fb4 100755 --- a/scripts/arm64/make-rootfs-hermes.sh +++ b/scripts/arm64/make-rootfs-hermes.sh @@ -36,7 +36,7 @@ less,vim,bash-completion,\ openssh-client,gnupg,apt-transport-https,\ lsof,strace,sysstat,\ kmod,pciutils,usbutils,\ -coreutils,findutils,grep,gawk,sed,tar,gzip,bzip2,xz-utils,zstd,\ +coreutils,findutils,grep,gawk,sed,tar,gzip,bzip2,xz-utils,\ linux-image-arm64,iptables,util-linux,util-linux-extra" SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" @@ -891,6 +891,7 @@ OVERRIDE mkdir -p "\$UNIT_DIR/default.target.wants" ln -sf ../hermes-gateway.service "\$UNIT_DIR/default.target.wants/hermes-gateway.service" + chown -R $USER_NAME:$USER_NAME \$USER_HOME/.config mkdir -p /var/lib/systemd/linger @@ -1017,16 +1018,6 @@ EOF do_config_virtiofs() { sudo chroot "$MOUNT_DIR" /bin/bash -e << 'EOF' -cp /tmp/rootfs-scripts/tenbox-agent-profile /usr/local/bin/ -chmod +x /usr/local/bin/tenbox-agent-profile -cp /tmp/rootfs-scripts/tenbox-agent-backup /usr/local/bin/ -chmod +x /usr/local/bin/tenbox-agent-backup -cp /tmp/rootfs-scripts/tenbox-agent-health /usr/local/bin/ -chmod +x /usr/local/bin/tenbox-agent-health -cp /tmp/rootfs-services/tenbox-agent-backup.service /etc/systemd/system/ -cp /tmp/rootfs-services/tenbox-agent-backup.timer /etc/systemd/system/ -systemctl enable tenbox-agent-backup.timer 2>/dev/null || true - if [ -f /etc/systemd/system/virtiofs-automount.service ]; then echo " Virtio-FS already configured" exit 0 diff --git a/scripts/arm64/make-rootfs-openclaw.sh b/scripts/arm64/make-rootfs-openclaw.sh index e5dd910..9a944b7 100755 --- a/scripts/arm64/make-rootfs-openclaw.sh +++ b/scripts/arm64/make-rootfs-openclaw.sh @@ -36,7 +36,7 @@ less,vim,bash-completion,\ openssh-client,gnupg,apt-transport-https,\ lsof,strace,sysstat,\ kmod,pciutils,usbutils,\ -coreutils,findutils,grep,gawk,sed,tar,gzip,bzip2,xz-utils,zstd,\ +coreutils,findutils,grep,gawk,sed,tar,gzip,bzip2,xz-utils,\ linux-image-arm64,iptables,util-linux,util-linux-extra" SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" @@ -631,6 +631,7 @@ OVERRIDE mkdir -p "\$UNIT_DIR/default.target.wants" ln -sf ../openclaw-gateway.service "\$UNIT_DIR/default.target.wants/openclaw-gateway.service" + chown -R $USER_NAME:$USER_NAME \$USER_HOME/.config fi @@ -758,16 +759,6 @@ EOF do_config_virtiofs() { sudo chroot "$MOUNT_DIR" /bin/bash -e << 'EOF' -cp /tmp/rootfs-scripts/tenbox-agent-profile /usr/local/bin/ -chmod +x /usr/local/bin/tenbox-agent-profile -cp /tmp/rootfs-scripts/tenbox-agent-backup /usr/local/bin/ -chmod +x /usr/local/bin/tenbox-agent-backup -cp /tmp/rootfs-scripts/tenbox-agent-health /usr/local/bin/ -chmod +x /usr/local/bin/tenbox-agent-health -cp /tmp/rootfs-services/tenbox-agent-backup.service /etc/systemd/system/ -cp /tmp/rootfs-services/tenbox-agent-backup.timer /etc/systemd/system/ -systemctl enable tenbox-agent-backup.timer 2>/dev/null || true - if [ -f /etc/systemd/system/virtiofs-automount.service ]; then echo " Virtio-FS already configured" exit 0 diff --git a/scripts/rootfs-scripts/tenbox-agent-backup b/scripts/rootfs-scripts/tenbox-agent-backup deleted file mode 100755 index ad92a72..0000000 --- a/scripts/rootfs-scripts/tenbox-agent-backup +++ /dev/null @@ -1,225 +0,0 @@ -#!/bin/sh -set -eu - -USER_NAME="${TENBOX_USER:-tenbox}" -HOME_DIR="${TENBOX_HOME_DIR:-/home/$USER_NAME}" - -default_shared_dir() { - base="/mnt/shared" - if [ -d "$base/shared" ]; then - echo "$base/shared" - return - fi - first="$(find "$base" -mindepth 1 -maxdepth 1 -type d 2>/dev/null | head -n 1 || true)" - if [ -n "$first" ]; then - echo "$first" - else - echo "$base" - fi -} - -SHARED_DIR="${TENBOX_SHARED_DIR:-$(default_shared_dir)}" -RETENTION="${TENBOX_BACKUP_RETENTION:-5}" -VM_ID="${TENBOX_VM_ID:-$(hostname 2>/dev/null || echo local-vm)}" -BACKUP_ROOT="${TENBOX_BACKUP_ROOT:-$SHARED_DIR/tenbox-agent-backups}" -PROFILE_TOOL="${TENBOX_PROFILE_TOOL:-tenbox-agent-profile}" - -usage() { - cat >&2 <&2 - exit 1 -} - -now_utc() { - date -u +"%Y-%m-%dT%H:%M:%SZ" -} - -stamp() { - date -u +"%Y%m%d%H%M%S" -} - -agent_path() { - case "$1" in - hermes) printf '%s/.hermes\n' "$HOME_DIR" ;; - openclaw) printf '%s/.openclaw\n' "$HOME_DIR" ;; - *) die "unsupported agent: $1" ;; - esac -} - -default_agent() { - if [ -d "$HOME_DIR/.hermes" ] && [ ! -d "$HOME_DIR/.openclaw" ]; then - echo hermes - elif [ -d "$HOME_DIR/.openclaw" ] && [ ! -d "$HOME_DIR/.hermes" ]; then - echo openclaw - else - die "use --agent hermes or --agent openclaw" - fi -} - -backup_dir() { - printf '%s/%s/%s\n' "$BACKUP_ROOT" "$VM_ID" "$1" -} - -profile_size_kb() { - path="$(agent_path "$1")" - [ -d "$path" ] || { echo 0; return; } - du -sk "$path" 2>/dev/null | awk '{print $1}' -} - -free_kb() { - df -Pk "$BACKUP_ROOT" 2>/dev/null | awk 'NR==2 {print $4}' -} - -write_status() { - agent="$1" - state="$2" - message="$3" - package="${4:-}" - dir="$(backup_dir "$agent")" - mkdir -p "$dir" - cat > "$dir/status.json" </dev/null || true -} - -write_backup_manifest() { - agent="$1" - package="$2" - manifest="$3" - cat > "$manifest" < keep' | - while IFS= read -r old_pkg; do - rm -f "$old_pkg" "$old_pkg.manifest.json" - done -} - -snapshot() { - agent="$1" - [ -d "$SHARED_DIR" ] || die "shared folder is not mounted" - if [ -z "${TENBOX_SHARED_DIR:-}" ] && command -v mountpoint >/dev/null 2>&1 && ! mountpoint -q /mnt/shared; then - die "shared folder is not mounted" - fi - [ -d "$(agent_path "$agent")" ] || die "Agent data is not initialized" - command -v "$PROFILE_TOOL" >/dev/null 2>&1 || die "missing $PROFILE_TOOL" - - dir="$(backup_dir "$agent")" - mkdir -p "$dir" - chmod 700 "$dir" 2>/dev/null || true - - estimated="$(profile_size_kb "$agent")" - available="$(free_kb || echo 0)" - if [ "${available:-0}" -gt 0 ] && [ "$estimated" -gt 0 ] && [ "$available" -lt $((estimated + 102400)) ]; then - write_status "$agent" "space_low" "Not enough host space for a new Agent data backup" "" - die "not enough host space for a new Agent data backup" - fi - - package="$dir/agent-data-$(stamp).tar.zst" - "$PROFILE_TOOL" export --agent "$agent" --output "$package" >/dev/null - write_backup_manifest "$agent" "$package" "$package.manifest.json" - rotate_backups "$dir" "$RETENTION" - write_status "$agent" "protected" "Agent data is protected" "$package" - echo "$package" -} - -latest_package() { - dir="$(backup_dir "$1")" - find "$dir" -maxdepth 1 -type f -name '*.tar.zst' 2>/dev/null | LC_ALL=C sort -r | head -n 1 -} - -status() { - agent="$1" - if [ -n "$agent" ]; then - dir="$(backup_dir "$agent")" - if [ -f "$dir/status.json" ]; then - cat "$dir/status.json" - else - write_status "$agent" "never_backed_up" "Agent data has not been backed up yet" "" - cat "$dir/status.json" - fi - return - fi - first=1 - printf '[' - for item in hermes openclaw; do - [ -d "$(agent_path "$item")" ] || continue - [ "$first" -eq 1 ] || printf ',' - status "$item" - first=0 - done - printf ']\n' -} - -restore() { - agent="$1" - input="$2" - [ -n "$agent" ] || die "--agent is required" - command -v "$PROFILE_TOOL" >/dev/null 2>&1 || die "missing $PROFILE_TOOL" - if [ -z "$input" ]; then - input="$(latest_package "$agent")" - fi - [ -n "$input" ] || die "no backup package found" - "$PROFILE_TOOL" import --agent "$agent" --input "$input" - write_status "$agent" "protected" "Agent data restored from backup" "$input" -} - -cmd="${1:-}" -[ -n "$cmd" ] || { usage; exit 2; } -shift || true - -agent="" -input="" - -while [ "$#" -gt 0 ]; do - case "$1" in - --agent) agent="${2:-}"; shift 2 ;; - --vm-id) VM_ID="${2:-}"; shift 2 ;; - --input) input="${2:-}"; shift 2 ;; - -h|--help) usage; exit 0 ;; - *) die "unknown option: $1" ;; - esac -done - -case "$cmd" in - snapshot) - [ -n "$agent" ] || agent="$(default_agent)" - snapshot "$agent" - ;; - status) - status "$agent" - ;; - restore) - restore "$agent" "$input" - ;; - *) usage; exit 2 ;; -esac diff --git a/scripts/rootfs-scripts/tenbox-agent-health b/scripts/rootfs-scripts/tenbox-agent-health deleted file mode 100755 index 85e4f3e..0000000 --- a/scripts/rootfs-scripts/tenbox-agent-health +++ /dev/null @@ -1,289 +0,0 @@ -#!/bin/sh -set -eu - -USER_NAME="${TENBOX_USER:-tenbox}" -HOME_DIR="${TENBOX_HOME_DIR:-/home/$USER_NAME}" - -default_shared_dir() { - base="/mnt/shared" - if [ -d "$base/shared" ]; then - echo "$base/shared" - return - fi - first="$(find "$base" -mindepth 1 -maxdepth 1 -type d 2>/dev/null | head -n 1 || true)" - if [ -n "$first" ]; then - echo "$first" - else - echo "$base" - fi -} - -SHARED_DIR="${TENBOX_SHARED_DIR:-$(default_shared_dir)}" -BACKUP_TOOL="${TENBOX_BACKUP_TOOL:-tenbox-agent-backup}" - -usage() { - cat >&2 <&2 - exit 1 -} - -json_quote() { - python3 -c 'import json,sys; print(json.dumps(sys.stdin.read().strip()))' -} - -now_utc() { - date -u +"%Y-%m-%dT%H:%M:%SZ" -} - -agent_path() { - case "$1" in - hermes) printf '%s/.hermes\n' "$HOME_DIR" ;; - openclaw) printf '%s/.openclaw\n' "$HOME_DIR" ;; - *) die "unsupported agent: $1" ;; - esac -} - -service_name() { - case "$1" in - hermes) echo hermes-gateway.service ;; - openclaw) echo openclaw-gateway.service ;; - esac -} - -gateway_port() { - case "$1" in - hermes) echo "" ;; - openclaw) echo 18789 ;; - esac -} - -default_agent() { - if [ -d "$HOME_DIR/.hermes" ] && [ ! -d "$HOME_DIR/.openclaw" ]; then - echo hermes - elif [ -d "$HOME_DIR/.openclaw" ] && [ ! -d "$HOME_DIR/.hermes" ]; then - echo openclaw - else - die "use --agent hermes or --agent openclaw" - fi -} - -systemctl_user() { - XDG_RUNTIME_DIR="${XDG_RUNTIME_DIR:-/run/user/$(id -u)}" systemctl --user "$@" 2>/dev/null -} - -check_service() { - svc="$(service_name "$1")" - if systemctl_user is-active --quiet "$svc"; then - echo ok - elif systemctl_user is-enabled --quiet "$svc"; then - echo starting - else - echo error - fi -} - -check_port() { - port="$(gateway_port "$1")" - if [ -z "$port" ]; then - echo skipped - elif nc -z 127.0.0.1 "$port" >/dev/null 2>&1; then - echo ok - else - echo error - fi -} - -check_model() { - if curl -fsS --max-time 5 http://10.0.2.3/v1/models >/dev/null 2>&1; then - echo ok - else - echo error - fi -} - -check_browser() { - if command -v chromium >/dev/null 2>&1 || command -v chromium-browser >/dev/null 2>&1; then - echo ok - else - echo error - fi -} - -check_disk() { - free_kb="$(df -Pk "$HOME_DIR" 2>/dev/null | awk 'NR==2 {print $4}')" - if [ "${free_kb:-0}" -gt 1048576 ]; then - echo ok - else - echo space_low - fi -} - -last_error() { - svc="$(service_name "$1")" - journalctl --user -u "$svc" -n 80 --no-pager 2>/dev/null | - sed -n '/error\|Error\|ERROR\|failed\|Failed\|FAILED/p' | - tail -n 1 | - sed -E 's/(sk-[A-Za-z0-9_-]{8})[A-Za-z0-9_-]+/\\1***/g; s/(api[_-]?key[=: ]+)[^ ]+/\\1***/Ig' -} - -summary_state() { - service="$1" - port="$2" - model="$3" - browser="$4" - disk="$5" - if [ "$disk" = "space_low" ]; then - echo "error|磁盘空间不足,请先清理空间或调整备份位置" - elif [ "$service" = "error" ]; then - echo "error|Agent 没有正常启动,可以点“重新启动 Agent”" - elif [ "$port" = "error" ]; then - echo "error|Agent 本地入口暂时不可用,可以点“重新启动 Agent”" - elif [ "$model" = "error" ]; then - echo "error|模型连接不可用,请先重新测试模型或检查模型配置" - elif [ "$browser" = "error" ]; then - echo "error|浏览器不可用,请修复镜像或重新下载镜像" - elif [ "$service" = "starting" ]; then - echo "starting|Agent 正在启动,请稍后再试" - else - echo "ok|Agent 正常" - fi -} - -status() { - agent="$1" - service="$(check_service "$agent")" - port="$(check_port "$agent")" - model="$(check_model)" - browser="$(check_browser)" - disk="$(check_disk)" - summary="$(summary_state "$service" "$port" "$model" "$browser" "$disk")" - state="${summary%%|*}" - message="${summary#*|}" - error="$(last_error "$agent")" - svc="$(service_name "$agent")" - port_value="$(gateway_port "$agent")" - - cat </dev/null 2>&1 || die "missing $BACKUP_TOOL" - "$BACKUP_TOOL" snapshot --agent "$agent" >/dev/null -} - -restart_agent() { - agent="$1" - snapshot_before_repair "$agent" - systemctl_user restart "$(service_name "$agent")" || die "Agent restart failed" - status "$agent" -} - -test_model() { - agent="$1" - result="$(check_model)" - if [ "$result" = "ok" ]; then - printf '{"agent_type":"%s","state":"ok","message":"模型连接正常"}\n' "$agent" - else - printf '{"agent_type":"%s","state":"error","message":"模型连接不可用,请检查模型配置"}\n' "$agent" - exit 1 - fi -} - -reset_config() { - agent="$1" - snapshot_before_repair "$agent" - case "$agent" in - hermes) - mkdir -p "$HOME_DIR/.hermes" - cat > "$HOME_DIR/.hermes/config.yaml" <<'EOF' -model: - default: "default" - provider: "custom" - base_url: "http://10.0.2.3/v1" - -terminal: - backend: local - -approvals: - mode: off - timeout: 60 - -display: - streaming: true -EOF - ;; - openclaw) - command -v openclaw >/dev/null 2>&1 || die "OpenClaw command is missing" - openclaw config set models.providers.tenbox '{"baseUrl":"http://10.0.2.3/v1","apiKey":"tenbox","api":"openai-completions","models":[{"id":"default","name":"Default (TenBox Proxy)","reasoning":false,"input":["text","image"],"contextWindow":200000,"maxTokens":65536,"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0}}]}' >/dev/null - openclaw config set models.mode merge >/dev/null - openclaw config set agents.defaults '{"model":{"primary":"tenbox/default"},"compaction":{"mode":"safeguard"},"workspace":"'"$HOME_DIR"'/.openclaw/workspace","models":{"tenbox/default":{}}}' >/dev/null - ;; - esac - status "$agent" -} - -diagnostics() { - agent="$1" - [ -d "$SHARED_DIR" ] || die "shared folder is not mounted" - out="$SHARED_DIR/tenbox-agent-diagnostics-$agent-$(date -u +%Y%m%d%H%M%S).tar.gz" - tmp="$(mktemp -d)" - trap 'rm -rf "$tmp"' EXIT INT TERM - status "$agent" > "$tmp/health.json" - systemctl_user status "$(service_name "$agent")" --no-pager > "$tmp/service.txt" 2>&1 || true - journalctl --user -u "$(service_name "$agent")" -n 200 --no-pager > "$tmp/journal.txt" 2>&1 || true - df -h > "$tmp/disk.txt" 2>&1 || true - sed -Ei 's/(sk-[A-Za-z0-9_-]{8})[A-Za-z0-9_-]+/\1***/g; s/(api[_-]?key[=: ]+)[^ ]+/\1***/Ig' "$tmp"/*.txt "$tmp"/*.json 2>/dev/null || true - tar -czf "$out" -C "$tmp" . - chmod 600 "$out" 2>/dev/null || true - echo "$out" -} - -cmd="${1:-}" -[ -n "$cmd" ] || { usage; exit 2; } -shift || true - -agent="" -while [ "$#" -gt 0 ]; do - case "$1" in - --agent) agent="${2:-}"; shift 2 ;; - -h|--help) usage; exit 0 ;; - *) die "unknown option: $1" ;; - esac -done -[ -n "$agent" ] || agent="$(default_agent)" - -case "$cmd" in - status) status "$agent" ;; - restart) restart_agent "$agent" ;; - test-model) test_model "$agent" ;; - reset-config) reset_config "$agent" ;; - diagnostics) diagnostics "$agent" ;; - *) usage; exit 2 ;; -esac diff --git a/scripts/rootfs-scripts/tenbox-agent-profile b/scripts/rootfs-scripts/tenbox-agent-profile deleted file mode 100755 index abdc8a4..0000000 --- a/scripts/rootfs-scripts/tenbox-agent-profile +++ /dev/null @@ -1,241 +0,0 @@ -#!/bin/sh -set -eu - -USER_NAME="${TENBOX_USER:-tenbox}" -USER_GROUP="${TENBOX_GROUP:-$USER_NAME}" -HOME_DIR="${TENBOX_HOME_DIR:-/home/$USER_NAME}" -FORMAT_NAME="tenbox-agent-profile" -FORMAT_VERSION="1" -SHARED_DIR="${TENBOX_SHARED_DIR:-/mnt/shared}" - -usage() { - cat >&2 <&2 - exit 1 -} - -need_tool() { - command -v "$1" >/dev/null 2>&1 || die "missing required tool: $1" -} - -make_tmp_dir() { - base="${TENBOX_TMP_DIR:-$HOME_DIR/.cache/tenbox-agent-profile}" - mkdir -p "$base" - mktemp -d "$base/work.XXXXXX" -} - -agent_path() { - case "$1" in - hermes) printf '%s/.hermes\n' "$HOME_DIR" ;; - openclaw) printf '%s/.openclaw\n' "$HOME_DIR" ;; - *) die "unsupported agent: $1" ;; - esac -} - -agent_excludes() { - case "$1" in - hermes) - printf '%s\n' \ - ".hermes/logs" \ - ".hermes/image_cache" \ - ".hermes/audio_cache" - ;; - openclaw) - printf '%s\n' \ - ".openclaw/cache" \ - ".openclaw/.cache" \ - ".openclaw/workspace/.cache" - ;; - esac -} - -default_agent() { - if [ -d "$HOME_DIR/.hermes" ] && [ ! -d "$HOME_DIR/.openclaw" ]; then - echo hermes - elif [ -d "$HOME_DIR/.openclaw" ] && [ ! -d "$HOME_DIR/.hermes" ]; then - echo openclaw - else - die "use --agent hermes or --agent openclaw" - fi -} - -created_at() { - date -u +"%Y-%m-%dT%H:%M:%SZ" -} - -safe_output_path() { - case "$1" in - "$SHARED_DIR"/*) printf '%s\n' "$1" ;; - *) die "output/input must be under $SHARED_DIR" ;; - esac -} - -parse_agent_from_manifest() { - sed -n 's/^[[:space:]]*"agent_type":[[:space:]]*"\([^"]*\)".*/\1/p' "$1" | head -n 1 -} - -write_manifest() { - agent="$1" - manifest="$2" - source_path="$(agent_path "$agent")" - excludes_json="" - while IFS= read -r item; do - [ -n "$item" ] || continue - if [ -n "$excludes_json" ]; then - excludes_json="$excludes_json," - fi - excludes_json="$excludes_json\"$item\"" - done < "$manifest" < checksums.txt) - - mkdir -p "$(dirname "$output")" - (cd "$tmp" && tar --zstd -cf "$tmp/package.tar.zst" manifest.json files checksums.txt) - rm -f "$output" - cp "$tmp/package.tar.zst" "$output" - chmod 600 "$output" 2>/dev/null || true - echo "$output" -} - -restore_backup() { - target="$1" - backup="$2" - if [ -d "$backup" ] && [ ! -e "$target" ]; then - mv "$backup" "$target" - fi -} - -import_profile() { - input="$(safe_output_path "$1")" - requested_agent="$2" - [ -f "$input" ] || die "package not found: $input" - - need_tool tar - need_tool zstd - need_tool sha256sum - - tmp="$(make_tmp_dir)" - trap 'rm -rf "$tmp"' EXIT INT TERM - tar --zstd -xf "$input" -C "$tmp" - - [ -f "$tmp/manifest.json" ] || die "manifest.json missing" - [ -f "$tmp/checksums.txt" ] || die "checksums.txt missing" - agent="$(parse_agent_from_manifest "$tmp/manifest.json")" - [ -n "$agent" ] || die "agent_type missing in manifest" - case "$agent" in hermes|openclaw) ;; *) die "unsupported package agent: $agent" ;; esac - if [ -n "$requested_agent" ] && [ "$requested_agent" != "$agent" ]; then - die "package is for $agent, not $requested_agent" - fi - - if [ -s "$tmp/checksums.txt" ]; then - (cd "$tmp" && sha256sum -c checksums.txt >/dev/null) - fi - target="$(agent_path "$agent")" - rel_path=".$agent" - [ -d "$tmp/files/$rel_path" ] || die "package files missing for $agent" - - backup="" - if [ -e "$target" ]; then - backup="$target.pre-import-$(date -u +%Y%m%d%H%M%S)" - mv "$target" "$backup" - fi - - if ! (cd "$tmp/files" && tar -cf - "$rel_path") | (cd "$HOME_DIR" && tar -xf -); then - rm -rf "$target" - restore_backup "$target" "$backup" - die "failed to restore Agent data" - fi - chown -R "$USER_NAME:$USER_GROUP" "$target" - chmod 700 "$target" 2>/dev/null || true - - if [ -n "$backup" ]; then - echo "$backup" - else - echo "imported" - fi -} - -cmd="${1:-}" -[ -n "$cmd" ] || { usage; exit 2; } -shift || true - -agent="" -output="" -input="" - -while [ "$#" -gt 0 ]; do - case "$1" in - --agent) agent="${2:-}"; shift 2 ;; - --output) output="${2:-}"; shift 2 ;; - --input) input="${2:-}"; shift 2 ;; - -h|--help) usage; exit 0 ;; - *) die "unknown option: $1" ;; - esac -done - -case "$cmd" in - export) - [ -n "$agent" ] || agent="$(default_agent)" - [ -n "$output" ] || die "--output is required" - export_profile "$agent" "$output" - ;; - import) - [ -n "$input" ] || die "--input is required" - import_profile "$input" "$agent" - ;; - *) usage; exit 2 ;; -esac diff --git a/scripts/rootfs-services/tenbox-agent-backup.service b/scripts/rootfs-services/tenbox-agent-backup.service deleted file mode 100644 index ac15876..0000000 --- a/scripts/rootfs-services/tenbox-agent-backup.service +++ /dev/null @@ -1,6 +0,0 @@ -[Unit] -Description=Protect TenBox Agent data - -[Service] -Type=oneshot -ExecStart=/usr/local/bin/tenbox-agent-backup snapshot diff --git a/scripts/rootfs-services/tenbox-agent-backup.timer b/scripts/rootfs-services/tenbox-agent-backup.timer deleted file mode 100644 index 2245a7e..0000000 --- a/scripts/rootfs-services/tenbox-agent-backup.timer +++ /dev/null @@ -1,10 +0,0 @@ -[Unit] -Description=Periodic TenBox Agent data protection - -[Timer] -OnBootSec=5min -OnUnitActiveSec=6h -Persistent=true - -[Install] -WantedBy=timers.target diff --git a/scripts/x86_64/make-rootfs-hermes.sh b/scripts/x86_64/make-rootfs-hermes.sh index 26033b1..a5655e6 100755 --- a/scripts/x86_64/make-rootfs-hermes.sh +++ b/scripts/x86_64/make-rootfs-hermes.sh @@ -36,7 +36,7 @@ less,vim,bash-completion,\ openssh-client,gnupg,apt-transport-https,\ lsof,strace,sysstat,\ kmod,pciutils,usbutils,\ -coreutils,findutils,grep,gawk,sed,tar,gzip,bzip2,xz-utils,zstd,\ +coreutils,findutils,grep,gawk,sed,tar,gzip,bzip2,xz-utils,\ linux-image-amd64,iptables,util-linux,util-linux-extra" SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" @@ -891,6 +891,7 @@ OVERRIDE mkdir -p "\$UNIT_DIR/default.target.wants" ln -sf ../hermes-gateway.service "\$UNIT_DIR/default.target.wants/hermes-gateway.service" + chown -R $USER_NAME:$USER_NAME \$USER_HOME/.config mkdir -p /var/lib/systemd/linger @@ -1021,16 +1022,6 @@ EOF do_config_virtiofs() { sudo chroot "$MOUNT_DIR" /bin/bash -e << 'EOF' -cp /tmp/rootfs-scripts/tenbox-agent-profile /usr/local/bin/ -chmod +x /usr/local/bin/tenbox-agent-profile -cp /tmp/rootfs-scripts/tenbox-agent-backup /usr/local/bin/ -chmod +x /usr/local/bin/tenbox-agent-backup -cp /tmp/rootfs-scripts/tenbox-agent-health /usr/local/bin/ -chmod +x /usr/local/bin/tenbox-agent-health -cp /tmp/rootfs-services/tenbox-agent-backup.service /etc/systemd/system/ -cp /tmp/rootfs-services/tenbox-agent-backup.timer /etc/systemd/system/ -systemctl enable tenbox-agent-backup.timer 2>/dev/null || true - if [ -f /etc/systemd/system/virtiofs-automount.service ]; then echo " Virtio-FS already configured" exit 0 diff --git a/scripts/x86_64/make-rootfs-openclaw.sh b/scripts/x86_64/make-rootfs-openclaw.sh index b2e1e31..911d2da 100755 --- a/scripts/x86_64/make-rootfs-openclaw.sh +++ b/scripts/x86_64/make-rootfs-openclaw.sh @@ -32,7 +32,7 @@ less,vim,bash-completion,\ openssh-client,gnupg,apt-transport-https,\ lsof,strace,sysstat,\ kmod,pciutils,usbutils,\ -coreutils,findutils,grep,gawk,sed,tar,gzip,bzip2,xz-utils,zstd,\ +coreutils,findutils,grep,gawk,sed,tar,gzip,bzip2,xz-utils,\ linux-image-amd64,iptables,util-linux,util-linux-extra" SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" @@ -726,6 +726,7 @@ OVERRIDE # Enable the service (create symlink since systemctl --user is unavailable in chroot) mkdir -p "\$UNIT_DIR/default.target.wants" ln -sf ../openclaw-gateway.service "\$UNIT_DIR/default.target.wants/openclaw-gateway.service" + chown -R $USER_NAME:$USER_NAME \$USER_HOME/.config fi @@ -860,16 +861,6 @@ EOF do_config_virtiofs() { sudo chroot "$MOUNT_DIR" /bin/bash -e << 'EOF' -cp /tmp/rootfs-scripts/tenbox-agent-profile /usr/local/bin/ -chmod +x /usr/local/bin/tenbox-agent-profile -cp /tmp/rootfs-scripts/tenbox-agent-backup /usr/local/bin/ -chmod +x /usr/local/bin/tenbox-agent-backup -cp /tmp/rootfs-scripts/tenbox-agent-health /usr/local/bin/ -chmod +x /usr/local/bin/tenbox-agent-health -cp /tmp/rootfs-services/tenbox-agent-backup.service /etc/systemd/system/ -cp /tmp/rootfs-services/tenbox-agent-backup.timer /etc/systemd/system/ -systemctl enable tenbox-agent-backup.timer 2>/dev/null || true - if [ -f /etc/systemd/system/virtiofs-automount.service ]; then echo " Virtio-FS already configured" exit 0 diff --git a/src/manager-macos/Services/AgentToolsService.swift b/src/manager-macos/Services/AgentToolsService.swift index d316330..2d9b090 100644 --- a/src/manager-macos/Services/AgentToolsService.swift +++ b/src/manager-macos/Services/AgentToolsService.swift @@ -39,12 +39,13 @@ final class AgentToolsService { destinationURL: URL, completion: @escaping (Result) -> Void) { withOperationShare(vmId: vm.id, appState: appState) { share, cleanup in - let packageName = "tenbox-agent-profile.tar.zst" - let guestDir = "/mnt/shared/\(share.tag)" - let guestPackage = "\(guestDir)/\(packageName)" - let command = "TENBOX_SHARED_DIR=\(Self.shellQuote(guestDir)) tenbox-agent-profile export --agent \(agent.rawValue) --output \(Self.shellQuote(guestPackage))" + let packageName = destinationURL.lastPathComponent.isEmpty + ? "\(vm.name)-\(agent.rawValue)-profile.tar.gz" + : destinationURL.lastPathComponent + let guestPackage = "/mnt/shared/\(share.tag)/\(packageName)" + let command = Self.profileExportCommand(agent: agent, outputPath: guestPackage) - session.runShellCommand(command, timeout: 300) { result in + session.runShellCommand(command, timeout: 420) { result in switch result { case .success(let commandResult): guard commandResult.exitCode == 0 else { @@ -81,7 +82,7 @@ final class AgentToolsService { sourceURL: URL, completion: @escaping (Result) -> Void) { withOperationShare(vmId: vm.id, appState: appState) { share, cleanup in - let packageName = "tenbox-agent-profile-import.tar.zst" + let packageName = "tenbox-agent-profile-import.tar.gz" let hostPackage = URL(fileURLWithPath: share.hostPath).appendingPathComponent(packageName) do { if self.fileManager.fileExists(atPath: hostPackage.path) { @@ -94,11 +95,9 @@ final class AgentToolsService { return } - let guestDir = "/mnt/shared/\(share.tag)" - let guestPackage = "\(guestDir)/\(packageName)" - let command = "TENBOX_SHARED_DIR=\(Self.shellQuote(guestDir)) tenbox-agent-profile import --agent \(agent.rawValue) --input \(Self.shellQuote(guestPackage))" - - session.runShellCommand(command, timeout: 300) { result in + let guestPackage = "/mnt/shared/\(share.tag)/\(packageName)" + let command = Self.profileImportCommand(agent: agent, inputPath: guestPackage) + session.runShellCommand(command, timeout: 420) { result in cleanup() switch result { case .success(let commandResult): @@ -121,76 +120,138 @@ final class AgentToolsService { func backupStatus(vm: VmInfo, session: VmSession, appState: AppState, agent: AgentKind, completion: @escaping (Result) -> Void) { - runBackupCommand(vm: vm, session: session, appState: appState, agent: agent, - command: "status", successMessage: "备份状态已更新", - completion: completion) + do { + let latest = try latestBackupPackage(vmId: vm.id, agent: agent) + if let latest { + completion(.success(AgentToolResult( + message: "Agent 数据已保护", + output: "最近备份:\(latest.path)" + ))) + } else { + completion(.success(AgentToolResult( + message: "还没有备份", + output: "点击 Back Up Now 创建第一份备份。" + ))) + } + } catch { + completion(.failure(error)) + } } func snapshotBackup(vm: VmInfo, session: VmSession, appState: AppState, agent: AgentKind, completion: @escaping (Result) -> Void) { - runBackupCommand(vm: vm, session: session, appState: appState, agent: agent, - command: "snapshot", successMessage: "已创建 Agent 数据备份", - completion: completion) + do { + let package = try backupPackageURL(vmId: vm.id, agent: agent) + withBackupShare(vmId: vm.id, appState: appState) { share, cleanup in + let guestPackage = "/mnt/shared/\(share.tag)/\(agent.rawValue)/\(package.lastPathComponent)" + let command = "mkdir -p \(Self.shellQuote("/mnt/shared/\(share.tag)/\(agent.rawValue)"))\n" + + Self.profileExportCommand(agent: agent, outputPath: guestPackage) + session.runShellCommand(command, timeout: 420) { result in + cleanup() + switch result { + case .success(let commandResult): + guard commandResult.exitCode == 0 else { + completion(.failure(Self.makeError(commandResult.output.isEmpty ? "Agent backup failed" : commandResult.output))) + return + } + self.rotateBackups(vmId: vm.id, agent: agent, keep: 5) + completion(.success(AgentToolResult( + message: "已创建 Agent 数据备份", + output: package.path + ))) + case .failure(let error): + completion(.failure(error)) + } + } + } failure: { error in + completion(.failure(error)) + } + } catch { + completion(.failure(error)) + } } func restoreLatestBackup(vm: VmInfo, session: VmSession, appState: AppState, agent: AgentKind, completion: @escaping (Result) -> Void) { - runBackupCommand(vm: vm, session: session, appState: appState, agent: agent, - command: "restore", successMessage: "已从最近备份恢复 Agent 数据", - completion: completion) + do { + guard let latest = try latestBackupPackage(vmId: vm.id, agent: agent) else { + completion(.failure(Self.makeError("No backup package found"))) + return + } + withBackupShare(vmId: vm.id, appState: appState) { share, cleanup in + let guestPackage = "/mnt/shared/\(share.tag)/\(agent.rawValue)/\(latest.lastPathComponent)" + let command = Self.profileImportCommand(agent: agent, inputPath: guestPackage) + session.runShellCommand(command, timeout: 420) { result in + cleanup() + switch result { + case .success(let commandResult): + guard commandResult.exitCode == 0 else { + completion(.failure(Self.makeError(commandResult.output.isEmpty ? "Agent backup restore failed" : commandResult.output))) + return + } + completion(.success(AgentToolResult( + message: "已从最近备份恢复 Agent 数据", + output: latest.path + ))) + case .failure(let error): + completion(.failure(error)) + } + } + } failure: { error in + completion(.failure(error)) + } + } catch { + completion(.failure(error)) + } } func healthStatus(vm: VmInfo, session: VmSession, appState: AppState, agent: AgentKind, completion: @escaping (Result) -> Void) { runHealthCommand(vm: vm, session: session, appState: appState, agent: agent, - command: "status", successMessage: "健康状态已更新", + command: Self.healthStatusCommand(agent: agent), + successMessage: "健康状态已更新", completion: completion) } func restartAgent(vm: VmInfo, session: VmSession, appState: AppState, agent: AgentKind, completion: @escaping (Result) -> Void) { - runHealthCommand(vm: vm, session: session, appState: appState, agent: agent, - command: "restart", successMessage: "已重新启动 Agent", + runRepairCommand(vm: vm, session: session, appState: appState, agent: agent, + repairCommand: Self.restartCommand(agent: agent), + successMessage: "已重新启动 Agent", completion: completion) } func testModel(vm: VmInfo, session: VmSession, appState: AppState, agent: AgentKind, completion: @escaping (Result) -> Void) { runHealthCommand(vm: vm, session: session, appState: appState, agent: agent, - command: "test-model", successMessage: "模型连接已测试", + command: Self.testModelCommand(agent: agent), + successMessage: "模型连接已测试", completion: completion) } func resetAgentConfig(vm: VmInfo, session: VmSession, appState: AppState, agent: AgentKind, completion: @escaping (Result) -> Void) { - runHealthCommand(vm: vm, session: session, appState: appState, agent: agent, - command: "reset-config", successMessage: "已重置 Agent 配置", + runRepairCommand(vm: vm, session: session, appState: appState, agent: agent, + repairCommand: Self.resetConfigCommand(agent: agent), + successMessage: "已重置 Agent 配置", completion: completion) } func exportDiagnostics(vm: VmInfo, session: VmSession, appState: AppState, agent: AgentKind, completion: @escaping (Result) -> Void) { - runHealthCommand(vm: vm, session: session, appState: appState, agent: agent, - command: "diagnostics", successMessage: "已导出诊断包", - completion: completion) - } - - private func runBackupCommand(vm: VmInfo, session: VmSession, appState: AppState, agent: AgentKind, - command: String, successMessage: String, - completion: @escaping (Result) -> Void) { withBackupShare(vmId: vm.id, appState: appState) { share, cleanup in let guestDir = "/mnt/shared/\(share.tag)" - let shellCommand = "TENBOX_SHARED_DIR=\(Self.shellQuote(guestDir)) TENBOX_VM_ID=\(Self.shellQuote(vm.id)) tenbox-agent-backup \(command) --agent \(agent.rawValue) --vm-id \(Self.shellQuote(vm.id))" - session.runShellCommand(shellCommand, timeout: 300) { result in + let command = Self.diagnosticsCommand(agent: agent, outputDir: guestDir) + session.runShellCommand(command, timeout: 180) { result in cleanup() switch result { case .success(let commandResult): guard commandResult.exitCode == 0 else { - completion(.failure(Self.makeError(commandResult.output.isEmpty ? "Agent backup command failed" : commandResult.output))) + completion(.failure(Self.makeError(commandResult.output.isEmpty ? "Agent diagnostics failed" : commandResult.output))) return } completion(.success(AgentToolResult( - message: successMessage, + message: "已导出诊断包", output: commandResult.output ))) case .failure(let error): @@ -205,26 +266,54 @@ final class AgentToolsService { private func runHealthCommand(vm: VmInfo, session: VmSession, appState: AppState, agent: AgentKind, command: String, successMessage: String, completion: @escaping (Result) -> Void) { - withBackupShare(vmId: vm.id, appState: appState) { share, cleanup in - let guestDir = "/mnt/shared/\(share.tag)" - let shellCommand = "TENBOX_SHARED_DIR=\(Self.shellQuote(guestDir)) TENBOX_VM_ID=\(Self.shellQuote(vm.id)) tenbox-agent-health \(command) --agent \(agent.rawValue)" - session.runShellCommand(shellCommand, timeout: 360) { result in - cleanup() - switch result { - case .success(let commandResult): - guard commandResult.exitCode == 0 else { - completion(.failure(Self.makeError(commandResult.output.isEmpty ? "Agent health command failed" : commandResult.output))) - return + session.runShellCommand(command, timeout: 180) { result in + switch result { + case .success(let commandResult): + guard commandResult.exitCode == 0 else { + completion(.failure(Self.makeError(commandResult.output.isEmpty ? "Agent health command failed" : commandResult.output))) + return + } + completion(.success(AgentToolResult( + message: successMessage, + output: commandResult.output + ))) + case .failure(let error): + completion(.failure(error)) + } + } + } + + private func runRepairCommand(vm: VmInfo, session: VmSession, appState: AppState, agent: AgentKind, + repairCommand: String, successMessage: String, + completion: @escaping (Result) -> Void) { + do { + let package = try backupPackageURL(vmId: vm.id, agent: agent) + withBackupShare(vmId: vm.id, appState: appState) { share, cleanup in + let guestPackage = "/mnt/shared/\(share.tag)/\(agent.rawValue)/\(package.lastPathComponent)" + let command = "mkdir -p \(Self.shellQuote("/mnt/shared/\(share.tag)/\(agent.rawValue)"))\n" + + Self.profileExportCommand(agent: agent, outputPath: guestPackage) + "\n" + + repairCommand + session.runShellCommand(command, timeout: 420) { result in + cleanup() + switch result { + case .success(let commandResult): + guard commandResult.exitCode == 0 else { + completion(.failure(Self.makeError(commandResult.output.isEmpty ? "Agent repair failed" : commandResult.output))) + return + } + self.rotateBackups(vmId: vm.id, agent: agent, keep: 5) + completion(.success(AgentToolResult( + message: successMessage, + output: "修复前备份:\(package.path)\n\(commandResult.output)" + ))) + case .failure(let error): + completion(.failure(error)) } - completion(.success(AgentToolResult( - message: successMessage, - output: commandResult.output - ))) - case .failure(let error): - completion(.failure(error)) } + } failure: { error in + completion(.failure(error)) } - } failure: { error in + } catch { completion(.failure(error)) } } @@ -252,18 +341,6 @@ final class AgentToolsService { } } - private func operationBaseDirectory() throws -> URL { - let appSupport = try fileManager.url( - for: .applicationSupportDirectory, - in: .userDomainMask, - appropriateFor: nil, - create: true - ) - let dir = appSupport.appendingPathComponent("TenBox/AgentOperations", isDirectory: true) - try fileManager.createDirectory(at: dir, withIntermediateDirectories: true) - return dir - } - private func withBackupShare(vmId: String, appState: AppState, perform: (SharedFolder, @escaping () -> Void) -> Void, failure: (Error) -> Void) { @@ -283,6 +360,18 @@ final class AgentToolsService { } } + private func operationBaseDirectory() throws -> URL { + let appSupport = try fileManager.url( + for: .applicationSupportDirectory, + in: .userDomainMask, + appropriateFor: nil, + create: true + ) + let dir = appSupport.appendingPathComponent("TenBox/AgentOperations", isDirectory: true) + try fileManager.createDirectory(at: dir, withIntermediateDirectories: true) + return dir + } + private func backupDirectory(vmId: String) throws -> URL { let appSupport = try fileManager.url( for: .applicationSupportDirectory, @@ -295,6 +384,253 @@ final class AgentToolsService { return dir } + private func backupPackageDirectory(vmId: String, agent: AgentKind) throws -> URL { + let dir = try backupDirectory(vmId: vmId).appendingPathComponent(agent.rawValue, isDirectory: true) + try fileManager.createDirectory(at: dir, withIntermediateDirectories: true) + return dir + } + + private func backupPackageURL(vmId: String, agent: AgentKind) throws -> URL { + let formatter = DateFormatter() + formatter.calendar = Calendar(identifier: .gregorian) + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.timeZone = TimeZone(secondsFromGMT: 0) + formatter.dateFormat = "yyyyMMddHHmmss" + return try backupPackageDirectory(vmId: vmId, agent: agent) + .appendingPathComponent("agent-data-\(formatter.string(from: Date())).tar.gz") + } + + private func latestBackupPackage(vmId: String, agent: AgentKind) throws -> URL? { + let dir = try backupPackageDirectory(vmId: vmId, agent: agent) + let items = (try? fileManager.contentsOfDirectory( + at: dir, + includingPropertiesForKeys: [.contentModificationDateKey], + options: [.skipsHiddenFiles] + )) ?? [] + return items + .filter { $0.pathExtension == "gz" && $0.lastPathComponent.hasPrefix("agent-data-") } + .sorted { lhs, rhs in + let lm = (try? lhs.resourceValues(forKeys: [.contentModificationDateKey]).contentModificationDate) ?? .distantPast + let rm = (try? rhs.resourceValues(forKeys: [.contentModificationDateKey]).contentModificationDate) ?? .distantPast + return lm > rm + } + .first + } + + private func rotateBackups(vmId: String, agent: AgentKind, keep: Int) { + guard let dir = try? backupPackageDirectory(vmId: vmId, agent: agent), + let items = try? fileManager.contentsOfDirectory( + at: dir, + includingPropertiesForKeys: [.contentModificationDateKey], + options: [.skipsHiddenFiles] + ) else { return } + let packages = items + .filter { $0.pathExtension == "gz" && $0.lastPathComponent.hasPrefix("agent-data-") } + .sorted { lhs, rhs in + let lm = (try? lhs.resourceValues(forKeys: [.contentModificationDateKey]).contentModificationDate) ?? .distantPast + let rm = (try? rhs.resourceValues(forKeys: [.contentModificationDateKey]).contentModificationDate) ?? .distantPast + return lm > rm + } + for old in packages.dropFirst(keep) { + try? fileManager.removeItem(at: old) + } + } + + private static func profileExportCommand(agent: AgentKind, outputPath: String) -> String { + let relPath = agentDataRelativePath(agent) + let excludes = agentExcludeArgs(agent) + let outDir = (outputPath as NSString).deletingLastPathComponent + let workDir = "\(outDir)/.tenbox-profile-work" + return """ + set -eu + home="${HOME:-/home/tenbox}" + rel=\(shellQuote(relPath)) + src="$home/$rel" + out=\(shellQuote(outputPath)) + work=\(shellQuote(workDir)) + [ -d "$src" ] || { echo "Agent data is not initialized: $src" >&2; exit 1; } + rm -rf "$work" + mkdir -p "$work" + cat > "$work/manifest.json" < String { + let relPath = agentDataRelativePath(agent) + return """ + set -eu + home="${HOME:-/home/tenbox}" + input=\(shellQuote(inputPath)) + rel=\(shellQuote(relPath)) + target="$home/$rel" + work=\(shellQuote((inputPath as NSString).deletingLastPathComponent + "/.tenbox-profile-import")) + [ -f "$input" ] || { echo "package not found: $input" >&2; exit 1; } + rm -rf "$work" + mkdir -p "$work" + tar --touch -xzf "$input" -C "$work" + [ -f "$work/manifest.json" ] || { echo "manifest.json missing" >&2; exit 1; } + [ -f "$work/files.tar.gz" ] || { echo "files.tar.gz missing" >&2; exit 1; } + pkg_agent="$(awk -F\\" '/agent_type/ {print $4; exit}' "$work/manifest.json")" + [ "$pkg_agent" = "\(agent.rawValue)" ] || { echo "package is for $pkg_agent, not \(agent.rawValue)" >&2; exit 1; } + backup="" + if [ -e "$target" ]; then + backup="$target.pre-import-$(date -u +%Y%m%d%H%M%S)" + mv "$target" "$backup" + fi + if ! tar -xzf "$work/files.tar.gz" -C "$home"; then + rm -rf "$target" + if [ -n "$backup" ] && [ -d "$backup" ]; then mv "$backup" "$target"; fi + echo "failed to restore Agent data" >&2 + exit 1 + fi + chmod 700 "$target" 2>/dev/null || true + rm -rf "$work" + if [ -n "$backup" ]; then echo "$backup"; else echo "imported"; fi + """ + } + + private static func healthStatusCommand(agent: AgentKind) -> String { + let service = serviceName(agent) + let gatewayPort = agent == .openclaw ? "18789" : "" + return """ + set -u + svc=\(shellQuote(service)) + agent=\(shellQuote(agent.rawValue)) + port=\(shellQuote(gatewayPort)) + if XDG_RUNTIME_DIR="${XDG_RUNTIME_DIR:-/run/user/$(id -u)}" systemctl --user is-active --quiet "$svc" 2>/dev/null; then service_state=ok; else service_state=error; fi + if [ -z "$port" ]; then port_state=skipped; elif nc -z 127.0.0.1 "$port" >/dev/null 2>&1; then port_state=ok; else port_state=error; fi + if curl -fsS --max-time 5 http://10.0.2.3/v1/models >/dev/null 2>&1; then model_state=ok; else model_state=error; fi + if command -v chromium >/dev/null 2>&1 || command -v chromium-browser >/dev/null 2>&1; then browser_state=ok; else browser_state=error; fi + free_kb="$(df -Pk "$HOME" 2>/dev/null | awk 'NR==2 {print $4}')" + if [ "${free_kb:-0}" -gt 1048576 ]; then disk_state=ok; else disk_state=space_low; fi + state=ok + message="Agent normal" + if [ "$disk_state" = space_low ]; then state=error; message="Disk space is low"; fi + if [ "$service_state" = error ]; then state=error; message="Agent service is not running"; fi + if [ "$port_state" = error ]; then state=error; message="Agent gateway is unavailable"; fi + if [ "$model_state" = error ]; then state=error; message="Model proxy is unavailable"; fi + if [ "$browser_state" = error ]; then state=error; message="Browser is unavailable"; fi + printf '{"agent_type":"%s","state":"%s","message":"%s","checks":{"agent_service":"%s","gateway_port":"%s","llm_proxy":"%s","browser":"%s","disk":"%s"}}\\n' "$agent" "$state" "$message" "$service_state" "$port_state" "$model_state" "$browser_state" "$disk_state" + """ + } + + private static func restartCommand(agent: AgentKind) -> String { + """ + XDG_RUNTIME_DIR="${XDG_RUNTIME_DIR:-/run/user/$(id -u)}" systemctl --user restart \(shellQuote(serviceName(agent))) + \(healthStatusCommand(agent: agent)) + """ + } + + private static func testModelCommand(agent: AgentKind) -> String { + """ + set -eu + if curl -fsS --max-time 5 http://10.0.2.3/v1/models >/dev/null 2>&1; then + printf '{"agent_type":"%s","state":"ok","message":"Model proxy is available"}\\n' \(shellQuote(agent.rawValue)) + else + printf '{"agent_type":"%s","state":"error","message":"Model proxy is unavailable"}\\n' \(shellQuote(agent.rawValue)) + exit 1 + fi + """ + } + + private static func resetConfigCommand(agent: AgentKind) -> String { + switch agent { + case .hermes: + return """ + set -eu + mkdir -p "$HOME/.hermes" + cat > "$HOME/.hermes/config.yaml" <<'EOF' + model: + default: "default" + provider: "custom" + base_url: "http://10.0.2.3/v1" + + terminal: + backend: local + + approvals: + mode: off + timeout: 60 + + display: + streaming: true + EOF + \(healthStatusCommand(agent: agent)) + """ + case .openclaw: + return """ + set -eu + command -v openclaw >/dev/null 2>&1 || { echo "OpenClaw command is missing" >&2; exit 1; } + openclaw config set models.providers.tenbox '{"baseUrl":"http://10.0.2.3/v1","apiKey":"tenbox","api":"openai-completions","models":[{"id":"default","name":"Default (TenBox Proxy)","reasoning":false,"input":["text","image"],"contextWindow":200000,"maxTokens":65536,"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0}}]}' >/dev/null + openclaw config set models.mode merge >/dev/null + openclaw config set agents.defaults '{"model":{"primary":"tenbox/default"},"compaction":{"mode":"safeguard"},"workspace":"'"$HOME"'/.openclaw/workspace","models":{"tenbox/default":{}}}' >/dev/null + \(healthStatusCommand(agent: agent)) + """ + } + } + + private static func diagnosticsCommand(agent: AgentKind, outputDir: String) -> String { + let service = serviceName(agent) + return """ + set -eu + out=\(shellQuote(outputDir))/tenbox-agent-diagnostics-\(agent.rawValue)-$(date -u +%Y%m%d%H%M%S).tar.gz + tmp=\(shellQuote(outputDir))/.tenbox-diagnostics-work + rm -rf "$tmp" + mkdir -p "$tmp" + \(healthStatusCommand(agent: agent)) > "$tmp/health.json" 2>&1 || true + XDG_RUNTIME_DIR="${XDG_RUNTIME_DIR:-/run/user/$(id -u)}" systemctl --user status \(shellQuote(service)) --no-pager > "$tmp/service.txt" 2>&1 || true + journalctl --user -u \(shellQuote(service)) -n 200 --no-pager > "$tmp/journal.txt" 2>&1 || true + df -h > "$tmp/disk.txt" 2>&1 || true + sed -Ei 's/(sk-[A-Za-z0-9_-]{8})[A-Za-z0-9_-]+/\\1***/g; s/(api[_-]?key[=: ]+)[^ ]+/\\1***/Ig' "$tmp"/*.txt "$tmp"/*.json 2>/dev/null || true + tar -czf "$out" -C "$tmp" . + rm -rf "$tmp" + echo "$out" + """ + } + + private static func agentDataRelativePath(_ agent: AgentKind) -> String { + switch agent { + case .hermes: return ".hermes" + case .openclaw: return ".openclaw" + } + } + + private static func agentExcludeArgs(_ agent: AgentKind) -> String { + switch agent { + case .hermes: + return [ + "--exclude", ".hermes/logs", + "--exclude", ".hermes/image_cache", + "--exclude", ".hermes/audio_cache", + ].map(shellQuote).joined(separator: " ") + case .openclaw: + return [ + "--exclude", ".openclaw/cache", + "--exclude", ".openclaw/.cache", + "--exclude", ".openclaw/workspace/.cache", + ].map(shellQuote).joined(separator: " ") + } + } + + private static func serviceName(_ agent: AgentKind) -> String { + switch agent { + case .hermes: return "hermes-gateway.service" + case .openclaw: return "openclaw-gateway.service" + } + } + private static func shellQuote(_ value: String) -> String { "'" + value.replacingOccurrences(of: "'", with: "'\\''") + "'" } diff --git a/src/manager-macos/Views/AgentToolsView.swift b/src/manager-macos/Views/AgentToolsView.swift index 77c2a02..48e9e60 100644 --- a/src/manager-macos/Views/AgentToolsView.swift +++ b/src/manager-macos/Views/AgentToolsView.swift @@ -157,7 +157,7 @@ struct AgentToolsSheet: View { guard let vm = vm else { return } let panel = NSSavePanel() panel.title = "Export Agent Data" - panel.nameFieldStringValue = "\(vm.name)-\(selectedAgent.rawValue)-profile.tar.zst" + panel.nameFieldStringValue = "\(vm.name)-\(selectedAgent.rawValue)-profile.tar.gz" panel.allowedContentTypes = [] guard panel.runModal() == .OK, let url = panel.url else { return } runOperation { From 2da2959df643c8dae56725eab8680aa2cc42d419 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BC=A0=E5=AE=89=E5=93=B2?= Date: Mon, 11 May 2026 03:01:49 +0800 Subject: [PATCH 17/37] fix: allow bundled Sparkle under hardened runtime --- CLAUDE.md | 1 + src/manager-macos/Resources/TenBox.entitlements | 2 ++ 2 files changed, 3 insertions(+) diff --git a/CLAUDE.md b/CLAUDE.md index a944661..7635090 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -94,6 +94,7 @@ Win/macOS: tenbox-manager ──IPC v1──► tenbox-vm-runtime (WHVP / HVF) - **macOS Agent data UI**: `TenBox.app` exposes Agent data export/import from the VM toolbar/menu while a VM is running. It must not depend on preinstalled guest TenBox scripts. - **macOS Agent backup UI**: `TenBox.app` exposes backup status, immediate backup, and restore latest backup actions. Host-triggered backups use `~/Library/Application Support/TenBox/AgentBackups/` as the durable shared folder. - **macOS Agent health UI**: `TenBox.app` exposes health check, restart, model test, config reset, and diagnostics actions while a VM is running. Repair actions run through app-generated shell commands so no image rebuild is required. +- **macOS app signing**: the app entitlement includes `com.apple.security.cs.disable-library-validation` so the hardened-runtime app can load the bundled Sparkle framework. - **Static build** (`TENBOX_STATIC_FFMPEG=ON`) requires `/opt/tenbox-deps` (only present inside the CI/packaging container). Dev builds use system shared libs — keep `ON` off by default. - **Release**: `docs/release.md` — VERSION bump → commit → push → tag → push tag. Always push commit before tag. diff --git a/src/manager-macos/Resources/TenBox.entitlements b/src/manager-macos/Resources/TenBox.entitlements index 6f6d171..d267311 100644 --- a/src/manager-macos/Resources/TenBox.entitlements +++ b/src/manager-macos/Resources/TenBox.entitlements @@ -4,6 +4,8 @@ com.apple.security.hypervisor + com.apple.security.cs.disable-library-validation + com.apple.security.app-sandbox From a7b4de2e70cab05aeda7518b10555b4fbf2e6a96 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BC=A0=E5=AE=89=E5=93=B2?= Date: Mon, 11 May 2026 03:32:59 +0800 Subject: [PATCH 18/37] fix: keep agent health tools responsive --- CLAUDE.md | 2 + docs/agent-profile.md | 4 +- .../Services/AgentToolsService.swift | 59 ++++++++++++++++--- src/manager-macos/Views/VmDetailView.swift | 14 +++++ 4 files changed, 69 insertions(+), 10 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 7635090..fcfb328 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -89,11 +89,13 @@ Win/macOS: tenbox-manager ──IPC v1──► tenbox-vm-runtime (WHVP / HVF) - **RemoteSession** is single-instance per VM. Read `remote_webrtc.cpp`'s `force` takeover path before adding DataChannels. - **macOS Caps Lock forwarding**: send Caps Lock as a tap (`down` then `up`) on each `flagsChanged` event; AppKit exposes it as a toggle state, but the guest input stack needs a full key press for every switch. - **Agent data profile packages**: `TenBox.app` exports/imports Hermes/OpenClaw data without image changes by using a temporary shared folder and console-injected standard shell commands. Keep the gzip package format documented in `docs/agent-profile.md` and reject cross-agent imports. +- **Hermes profile scope**: export user/config/state data, not the reinstallable Hermes app checkout, virtualenv, local binaries, logs, or cache directories. - **Agent data backups**: `TenBox.app` writes host-managed backups to `~/Library/Application Support/TenBox/AgentBackups//` and retains the latest 5 packages. - **Agent health checks**: `TenBox.app` runs health, restart, model test, config reset, and diagnostics through console-injected commands. Repair actions must create a host-managed Agent data backup first. - **macOS Agent data UI**: `TenBox.app` exposes Agent data export/import from the VM toolbar/menu while a VM is running. It must not depend on preinstalled guest TenBox scripts. - **macOS Agent backup UI**: `TenBox.app` exposes backup status, immediate backup, and restore latest backup actions. Host-triggered backups use `~/Library/Application Support/TenBox/AgentBackups/` as the durable shared folder. - **macOS Agent health UI**: `TenBox.app` exposes health check, restart, model test, config reset, and diagnostics actions while a VM is running. Repair actions run through app-generated shell commands so no image rebuild is required. +- **macOS console commands**: Agent tool commands fail quickly if the VM shell does not echo the begin marker, and they wait for the temporary shared folder to become writable before reading or writing packages. - **macOS app signing**: the app entitlement includes `com.apple.security.cs.disable-library-validation` so the hardened-runtime app can load the bundled Sparkle framework. - **Static build** (`TENBOX_STATIC_FFMPEG=ON`) requires `/opt/tenbox-deps` (only present inside the CI/packaging container). Dev builds use system shared libs — keep `ON` off by default. - **Release**: `docs/release.md` — VERSION bump → commit → push → tag → push tag. Always push commit before tag. diff --git a/docs/agent-profile.md b/docs/agent-profile.md index 7cd7ecd..6a42540 100644 --- a/docs/agent-profile.md +++ b/docs/agent-profile.md @@ -31,7 +31,9 @@ The exported package is a gzip tar archive: Excluded paths: -- Hermes: `.hermes/logs`, `.hermes/image_cache`, `.hermes/audio_cache` +- Hermes: `.hermes/logs`, `.hermes/image_cache`, `.hermes/audio_cache`, + `.hermes/hermes-agent`, `.hermes/bin`, `.hermes/gateway.pid`, + `.hermes/gateway.lock` - OpenClaw: `.openclaw/cache`, `.openclaw/.cache`, `.openclaw/workspace/.cache` Import rejects packages whose `agent_type` does not match the selected Agent. diff --git a/src/manager-macos/Services/AgentToolsService.swift b/src/manager-macos/Services/AgentToolsService.swift index 2d9b090..8697186 100644 --- a/src/manager-macos/Services/AgentToolsService.swift +++ b/src/manager-macos/Services/AgentToolsService.swift @@ -43,7 +43,10 @@ final class AgentToolsService { ? "\(vm.name)-\(agent.rawValue)-profile.tar.gz" : destinationURL.lastPathComponent let guestPackage = "/mnt/shared/\(share.tag)/\(packageName)" - let command = Self.profileExportCommand(agent: agent, outputPath: guestPackage) + let command = Self.withSharedFolderReady( + tag: share.tag, + body: Self.profileExportCommand(agent: agent, outputPath: guestPackage) + ) session.runShellCommand(command, timeout: 420) { result in switch result { @@ -96,7 +99,10 @@ final class AgentToolsService { } let guestPackage = "/mnt/shared/\(share.tag)/\(packageName)" - let command = Self.profileImportCommand(agent: agent, inputPath: guestPackage) + let command = Self.withSharedFolderReady( + tag: share.tag, + body: Self.profileImportCommand(agent: agent, inputPath: guestPackage) + ) session.runShellCommand(command, timeout: 420) { result in cleanup() switch result { @@ -144,8 +150,11 @@ final class AgentToolsService { let package = try backupPackageURL(vmId: vm.id, agent: agent) withBackupShare(vmId: vm.id, appState: appState) { share, cleanup in let guestPackage = "/mnt/shared/\(share.tag)/\(agent.rawValue)/\(package.lastPathComponent)" - let command = "mkdir -p \(Self.shellQuote("/mnt/shared/\(share.tag)/\(agent.rawValue)"))\n" + - Self.profileExportCommand(agent: agent, outputPath: guestPackage) + let command = Self.withSharedFolderReady( + tag: share.tag, + body: "mkdir -p \(Self.shellQuote("/mnt/shared/\(share.tag)/\(agent.rawValue)"))\n" + + Self.profileExportCommand(agent: agent, outputPath: guestPackage) + ) session.runShellCommand(command, timeout: 420) { result in cleanup() switch result { @@ -180,7 +189,10 @@ final class AgentToolsService { } withBackupShare(vmId: vm.id, appState: appState) { share, cleanup in let guestPackage = "/mnt/shared/\(share.tag)/\(agent.rawValue)/\(latest.lastPathComponent)" - let command = Self.profileImportCommand(agent: agent, inputPath: guestPackage) + let command = Self.withSharedFolderReady( + tag: share.tag, + body: Self.profileImportCommand(agent: agent, inputPath: guestPackage) + ) session.runShellCommand(command, timeout: 420) { result in cleanup() switch result { @@ -241,7 +253,10 @@ final class AgentToolsService { completion: @escaping (Result) -> Void) { withBackupShare(vmId: vm.id, appState: appState) { share, cleanup in let guestDir = "/mnt/shared/\(share.tag)" - let command = Self.diagnosticsCommand(agent: agent, outputDir: guestDir) + let command = Self.withSharedFolderReady( + tag: share.tag, + body: Self.diagnosticsCommand(agent: agent, outputDir: guestDir) + ) session.runShellCommand(command, timeout: 180) { result in cleanup() switch result { @@ -290,9 +305,12 @@ final class AgentToolsService { let package = try backupPackageURL(vmId: vm.id, agent: agent) withBackupShare(vmId: vm.id, appState: appState) { share, cleanup in let guestPackage = "/mnt/shared/\(share.tag)/\(agent.rawValue)/\(package.lastPathComponent)" - let command = "mkdir -p \(Self.shellQuote("/mnt/shared/\(share.tag)/\(agent.rawValue)"))\n" + - Self.profileExportCommand(agent: agent, outputPath: guestPackage) + "\n" + - repairCommand + let command = Self.withSharedFolderReady( + tag: share.tag, + body: "mkdir -p \(Self.shellQuote("/mnt/shared/\(share.tag)/\(agent.rawValue)"))\n" + + Self.profileExportCommand(agent: agent, outputPath: guestPackage) + "\n" + + repairCommand + ) session.runShellCommand(command, timeout: 420) { result in cleanup() switch result { @@ -467,6 +485,25 @@ final class AgentToolsService { """ } + private static func withSharedFolderReady(tag: String, body: String) -> String { + let path = "/mnt/shared/\(tag)" + return """ + set -eu + share_dir=\(shellQuote(path)) + i=0 + while [ "$i" -lt 100 ]; do + if [ -d "$share_dir" ] && [ -w "$share_dir" ]; then + break + fi + i=$((i + 1)) + sleep 0.2 + done + [ -d "$share_dir" ] || { echo "shared folder not mounted: $share_dir" >&2; exit 1; } + [ -w "$share_dir" ] || { echo "shared folder is not writable: $share_dir" >&2; exit 1; } + \(body) + """ + } + private static func profileImportCommand(agent: AgentKind, inputPath: String) -> String { let relPath = agentDataRelativePath(agent) return """ @@ -614,6 +651,10 @@ final class AgentToolsService { "--exclude", ".hermes/logs", "--exclude", ".hermes/image_cache", "--exclude", ".hermes/audio_cache", + "--exclude", ".hermes/hermes-agent", + "--exclude", ".hermes/bin", + "--exclude", ".hermes/gateway.pid", + "--exclude", ".hermes/gateway.lock", ].map(shellQuote).joined(separator: " ") case .openclaw: return [ diff --git a/src/manager-macos/Views/VmDetailView.swift b/src/manager-macos/Views/VmDetailView.swift index 8587839..02f4de9 100644 --- a/src/manager-macos/Views/VmDetailView.swift +++ b/src/manager-macos/Views/VmDetailView.swift @@ -32,6 +32,7 @@ class VmSession: ObservableObject { let beginMarker: String let endPrefix: String let completion: (Result) -> Void + let beginTimeoutWorkItem: DispatchWorkItem let timeoutWorkItem: DispatchWorkItem } @@ -233,9 +234,19 @@ class VmSession: ObservableObject { let beginMarker = "__TENBOX_CMD_BEGIN_\(token)__" let endPrefix = "__TENBOX_CMD_END_\(token)__:" let quotedCommand = Self.shellQuote(command) + let beginTimeoutWorkItem = DispatchWorkItem { [weak self] in + guard let self = self else { return } + guard let pending = self.pendingConsoleCommands[token] else { return } + if self.consoleText.range(of: pending.beginMarker, options: .backwards) == nil { + pending.timeoutWorkItem.cancel() + self.pendingConsoleCommands.removeValue(forKey: token) + pending.completion(.failure(ConsoleCommandError("VM shell did not start the command"))) + } + } let timeoutWorkItem = DispatchWorkItem { [weak self] in guard let self = self else { return } if let pending = self.pendingConsoleCommands.removeValue(forKey: token) { + pending.beginTimeoutWorkItem.cancel() pending.completion(.failure(ConsoleCommandError("Command timed out"))) } } @@ -244,9 +255,11 @@ class VmSession: ObservableObject { beginMarker: beginMarker, endPrefix: endPrefix, completion: completion, + beginTimeoutWorkItem: beginTimeoutWorkItem, timeoutWorkItem: timeoutWorkItem ) + DispatchQueue.main.asyncAfter(deadline: .now() + 12, execute: beginTimeoutWorkItem) DispatchQueue.main.asyncAfter(deadline: .now() + timeout, execute: timeoutWorkItem) let wrapped = "stty -echo 2>/dev/null; printf '\\n\(beginMarker)\\n'; /bin/sh -lc \(quotedCommand); rc=$?; printf '\\n\(endPrefix)%s\\n' \"$rc\"; stty echo 2>/dev/null\n" self.sendConsoleInput(wrapped) @@ -283,6 +296,7 @@ class VmSession: ObservableObject { } pending.timeoutWorkItem.cancel() + pending.beginTimeoutWorkItem.cancel() pendingConsoleCommands.removeValue(forKey: token) pending.completion(.success(ConsoleCommandResult(exitCode: exitCode, output: output))) } From eea9dcd1d49d74da53ab3eba5924c8fc8ec6f9ec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BC=A0=E5=AE=89=E5=93=B2?= Date: Mon, 11 May 2026 03:49:02 +0800 Subject: [PATCH 19/37] fix: prevent agent health UI stalls --- CLAUDE.md | 3 +++ src/manager-macos/Bridge/VmConfigStore.swift | 19 +++++++++++++++ .../Services/AgentToolsService.swift | 8 +++---- src/manager-macos/TenBoxApp.swift | 19 ++++++++++++++- src/manager-macos/Views/AgentToolsView.swift | 24 ++++++++++++++----- 5 files changed, 62 insertions(+), 11 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index fcfb328..0c800c0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -93,6 +93,9 @@ Win/macOS: tenbox-manager ──IPC v1──► tenbox-vm-runtime (WHVP / HVF) - **Agent data backups**: `TenBox.app` writes host-managed backups to `~/Library/Application Support/TenBox/AgentBackups//` and retains the latest 5 packages. - **Agent health checks**: `TenBox.app` runs health, restart, model test, config reset, and diagnostics through console-injected commands. Repair actions must create a host-managed Agent data backup first. - **macOS Agent data UI**: `TenBox.app` exposes Agent data export/import from the VM toolbar/menu while a VM is running. It must not depend on preinstalled guest TenBox scripts. +- **macOS Agent data shares**: Agent tool temporary shared folders are runtime-only; do not persist operation or backup share tags into VM config. +- **macOS Agent share cleanup**: drop persisted `tenbox-agent-ops-*` and `tenbox-agent-backups-*` entries on config load/startup to clean old builds. +- **macOS Agent data panels**: show export/import file panels asynchronously from SwiftUI sheets; do not use blocking `runModal()` from button handlers. - **macOS Agent backup UI**: `TenBox.app` exposes backup status, immediate backup, and restore latest backup actions. Host-triggered backups use `~/Library/Application Support/TenBox/AgentBackups/` as the durable shared folder. - **macOS Agent health UI**: `TenBox.app` exposes health check, restart, model test, config reset, and diagnostics actions while a VM is running. Repair actions run through app-generated shell commands so no image rebuild is required. - **macOS console commands**: Agent tool commands fail quickly if the VM shell does not echo the begin marker, and they wait for the temporary shared folder to become writable before reading or writing packages. diff --git a/src/manager-macos/Bridge/VmConfigStore.swift b/src/manager-macos/Bridge/VmConfigStore.swift index eb7afa7..4b39c4b 100644 --- a/src/manager-macos/Bridge/VmConfigStore.swift +++ b/src/manager-macos/Bridge/VmConfigStore.swift @@ -23,6 +23,10 @@ class VmConfigStore { private let decoder = JSONDecoder() + private static func isAgentToolSharedFolderTag(_ tag: String) -> Bool { + tag.hasPrefix("tenbox-agent-ops-") || tag.hasPrefix("tenbox-agent-backups-") + } + // MARK: - Paths func vmDirectory(for vmId: String) -> URL { @@ -47,9 +51,24 @@ class VmConfigStore { config.kernelPath = resolve(config.kernelPath) config.initrdPath = resolve(config.initrdPath) config.diskPath = resolve(config.diskPath) + config.sharedFolders.removeAll { Self.isAgentToolSharedFolderTag($0.tag) } return config } + func purgeAgentToolSharedFolders() { + let fm = FileManager.default + guard let items = try? fm.contentsOfDirectory(atPath: Self.vmsDirectory.path) else { return } + for item in items { + let url = configURL(for: item) + guard let data = try? Data(contentsOf: url), + var config = try? decoder.decode(VmConfig.self, from: data) else { continue } + let oldCount = config.sharedFolders.count + config.sharedFolders.removeAll { Self.isAgentToolSharedFolderTag($0.tag) } + guard config.sharedFolders.count != oldCount else { continue } + _ = writeConfig(vmId: item, config: config) + } + } + @discardableResult func writeConfig(vmId: String, config: VmConfig) -> Bool { guard let data = try? encoder.encode(config) else { return false } diff --git a/src/manager-macos/Services/AgentToolsService.swift b/src/manager-macos/Services/AgentToolsService.swift index 8697186..2b65afd 100644 --- a/src/manager-macos/Services/AgentToolsService.swift +++ b/src/manager-macos/Services/AgentToolsService.swift @@ -345,11 +345,11 @@ final class AgentToolsService { let dir = base.appendingPathComponent("\(vmId)-\(tag)", isDirectory: true) try fileManager.createDirectory(at: dir, withIntermediateDirectories: true) let share = SharedFolder(tag: tag, hostPath: dir.path, readonly: false) - appState.addSharedFolder(share, toVm: vmId) + appState.addRuntimeSharedFolder(share, toVm: vmId) let cleanup: () -> Void = { [weak appState, weak self] in DispatchQueue.main.async { - appState?.removeSharedFolder(tag: tag, fromVm: vmId) + appState?.removeRuntimeSharedFolder(tag: tag, fromVm: vmId) try? self?.fileManager.removeItem(at: dir) } } @@ -366,10 +366,10 @@ final class AgentToolsService { let dir = try backupDirectory(vmId: vmId) let tag = "tenbox-agent-backups-\(UUID().uuidString.prefix(8).lowercased())" let share = SharedFolder(tag: tag, hostPath: dir.path, readonly: false) - appState.addSharedFolder(share, toVm: vmId) + appState.addRuntimeSharedFolder(share, toVm: vmId) let cleanup: () -> Void = { [weak appState] in DispatchQueue.main.async { - appState?.removeSharedFolder(tag: tag, fromVm: vmId) + appState?.removeRuntimeSharedFolder(tag: tag, fromVm: vmId) } } perform(share, cleanup) diff --git a/src/manager-macos/TenBoxApp.swift b/src/manager-macos/TenBoxApp.swift index c8cf228..93896f3 100644 --- a/src/manager-macos/TenBoxApp.swift +++ b/src/manager-macos/TenBoxApp.swift @@ -157,6 +157,7 @@ class AppState: ObservableObject { private var bridge = TenBoxBridgeWrapper() let clipboardHandler = ClipboardHandler() private var activeSessions: [String: VmSession] = [:] + private var runtimeSharedFolders: [String: [SharedFolder]] = [:] private var sessionCancellables: [String: AnyCancellable] = [:] private var stateObserver: NSObjectProtocol? private var workspaceWakeObserver: NSObjectProtocol? @@ -164,6 +165,7 @@ class AppState: ObservableObject { private var sleepAssertionID: IOPMAssertionID = IOPMAssertionID(0) init() { + bridge.configStore.purgeAgentToolSharedFolders() refreshVmList() NSLog("[TenBoxApp] Loaded %d VM(s):", vms.count) for vm in vms { @@ -431,6 +433,20 @@ class AppState: ObservableObject { sendSharedFoldersUpdateIfRunning(vmId: vmId) } + func addRuntimeSharedFolder(_ folder: SharedFolder, toVm vmId: String) { + runtimeSharedFolders[vmId, default: []].removeAll { $0.tag == folder.tag } + runtimeSharedFolders[vmId, default: []].append(folder) + sendSharedFoldersUpdateIfRunning(vmId: vmId) + } + + func removeRuntimeSharedFolder(tag: String, fromVm vmId: String) { + runtimeSharedFolders[vmId]?.removeAll { $0.tag == tag } + if runtimeSharedFolders[vmId]?.isEmpty == true { + runtimeSharedFolders.removeValue(forKey: vmId) + } + sendSharedFoldersUpdateIfRunning(vmId: vmId) + } + func addHostForward(_ pf: HostForward, toVm vmId: String) { _ = bridge.addHostForward(pf, toVm: vmId) refreshVmList() @@ -670,7 +686,8 @@ class AppState: ObservableObject { private func sendSharedFoldersUpdateIfRunning(vmId: String) { guard let session = activeSessions[vmId], session.ipcClient.isConnected, let vm = vms.first(where: { $0.id == vmId }) else { return } - let entries = vm.sharedFolders.map { f in + let folders = vm.sharedFolders + (runtimeSharedFolders[vmId] ?? []) + let entries = folders.map { f in "\(f.tag)|\(f.hostPath)|\(f.readonly ? "1" : "0")" } session.ipcClient.sendSharedFoldersUpdate(entries: entries) diff --git a/src/manager-macos/Views/AgentToolsView.swift b/src/manager-macos/Views/AgentToolsView.swift index 48e9e60..c9153b2 100644 --- a/src/manager-macos/Views/AgentToolsView.swift +++ b/src/manager-macos/Views/AgentToolsView.swift @@ -159,9 +159,11 @@ struct AgentToolsSheet: View { panel.title = "Export Agent Data" panel.nameFieldStringValue = "\(vm.name)-\(selectedAgent.rawValue)-profile.tar.gz" panel.allowedContentTypes = [] - guard panel.runModal() == .OK, let url = panel.url else { return } - runOperation { - appState.exportAgentProfile(vmId: vm.id, agent: selectedAgent, destinationURL: url, completion: $0) + presentPanel(panel) { response in + guard response == .OK, let url = panel.url else { return } + runOperation { + appState.exportAgentProfile(vmId: vm.id, agent: selectedAgent, destinationURL: url, completion: $0) + } } } @@ -172,9 +174,19 @@ struct AgentToolsSheet: View { panel.canChooseFiles = true panel.canChooseDirectories = false panel.allowsMultipleSelection = false - guard panel.runModal() == .OK, let url = panel.url else { return } - runOperation { - appState.importAgentProfile(vmId: vm.id, agent: selectedAgent, sourceURL: url, completion: $0) + presentPanel(panel) { response in + guard response == .OK, let url = panel.url else { return } + runOperation { + appState.importAgentProfile(vmId: vm.id, agent: selectedAgent, sourceURL: url, completion: $0) + } + } + } + + private func presentPanel(_ panel: NSSavePanel, completion: @escaping (NSApplication.ModalResponse) -> Void) { + if let window = NSApplication.shared.keyWindow { + panel.beginSheetModal(for: window, completionHandler: completion) + } else { + panel.begin(completionHandler: completion) } } From d7bf8a9bf7a4a3ab5b75992b8cb8e04d1e7a20a4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BC=A0=E5=AE=89=E5=93=B2?= Date: Mon, 11 May 2026 04:29:19 +0800 Subject: [PATCH 20/37] fix: avoid echoed console markers --- CLAUDE.md | 1 + src/manager-macos/Views/VmDetailView.swift | 3 ++- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/CLAUDE.md b/CLAUDE.md index 0c800c0..ab357a9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -99,6 +99,7 @@ Win/macOS: tenbox-manager ──IPC v1──► tenbox-vm-runtime (WHVP / HVF) - **macOS Agent backup UI**: `TenBox.app` exposes backup status, immediate backup, and restore latest backup actions. Host-triggered backups use `~/Library/Application Support/TenBox/AgentBackups/` as the durable shared folder. - **macOS Agent health UI**: `TenBox.app` exposes health check, restart, model test, config reset, and diagnostics actions while a VM is running. Repair actions run through app-generated shell commands so no image rebuild is required. - **macOS console commands**: Agent tool commands fail quickly if the VM shell does not echo the begin marker, and they wait for the temporary shared folder to become writable before reading or writing packages. +- **macOS console markers**: do not put the full begin/end marker literal in console input; build it inside the shell so echoed input cannot satisfy marker detection. - **macOS app signing**: the app entitlement includes `com.apple.security.cs.disable-library-validation` so the hardened-runtime app can load the bundled Sparkle framework. - **Static build** (`TENBOX_STATIC_FFMPEG=ON`) requires `/opt/tenbox-deps` (only present inside the CI/packaging container). Dev builds use system shared libs — keep `ON` off by default. - **Release**: `docs/release.md` — VERSION bump → commit → push → tag → push tag. Always push commit before tag. diff --git a/src/manager-macos/Views/VmDetailView.swift b/src/manager-macos/Views/VmDetailView.swift index 02f4de9..afc5fe9 100644 --- a/src/manager-macos/Views/VmDetailView.swift +++ b/src/manager-macos/Views/VmDetailView.swift @@ -261,7 +261,8 @@ class VmSession: ObservableObject { DispatchQueue.main.asyncAfter(deadline: .now() + 12, execute: beginTimeoutWorkItem) DispatchQueue.main.asyncAfter(deadline: .now() + timeout, execute: timeoutWorkItem) - let wrapped = "stty -echo 2>/dev/null; printf '\\n\(beginMarker)\\n'; /bin/sh -lc \(quotedCommand); rc=$?; printf '\\n\(endPrefix)%s\\n' \"$rc\"; stty echo 2>/dev/null\n" + let quotedToken = Self.shellQuote(token) + let wrapped = "stty -echo 2>/dev/null; __tenbox_token=\(quotedToken); __tenbox_begin=\"__TENBOX_CMD_BEGIN_${__tenbox_token}__\"; __tenbox_end=\"__TENBOX_CMD_END_${__tenbox_token}__:\"; printf '\\n%s\\n' \"$__tenbox_begin\"; /bin/sh -lc \(quotedCommand); rc=$?; printf '\\n%s%s\\n' \"$__tenbox_end\" \"$rc\"; stty echo 2>/dev/null\n" self.sendConsoleInput(wrapped) } } From cbcc6b89e1ec40415778c112f73b890e19dafcc3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BC=A0=E5=AE=89=E5=93=B2?= Date: Mon, 11 May 2026 14:14:36 +0800 Subject: [PATCH 21/37] fix: throttle console command input --- CLAUDE.md | 1 + src/runtime/runtime_service.cpp | 9 ++++++++- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/CLAUDE.md b/CLAUDE.md index ab357a9..d892c72 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -100,6 +100,7 @@ Win/macOS: tenbox-manager ──IPC v1──► tenbox-vm-runtime (WHVP / HVF) - **macOS Agent health UI**: `TenBox.app` exposes health check, restart, model test, config reset, and diagnostics actions while a VM is running. Repair actions run through app-generated shell commands so no image rebuild is required. - **macOS console commands**: Agent tool commands fail quickly if the VM shell does not echo the begin marker, and they wait for the temporary shared folder to become writable before reading or writing packages. - **macOS console markers**: do not put the full begin/end marker literal in console input; build it inside the shell so echoed input cannot satisfy marker detection. +- **macOS console input**: throttle manager-to-runtime console input before injecting into the guest UART; bulk shell scripts can overflow the emulated FIFO if delivered as one burst. - **macOS app signing**: the app entitlement includes `com.apple.security.cs.disable-library-validation` so the hardened-runtime app can load the bundled Sparkle framework. - **Static build** (`TENBOX_STATIC_FFMPEG=ON`) requires `/opt/tenbox-deps` (only present inside the CI/packaging container). Dev builds use system shared libs — keep `ON` off by default. - **Release**: `docs/release.md` — VERSION bump → commit → push → tag → push tag. Always push commit before tag. diff --git a/src/runtime/runtime_service.cpp b/src/runtime/runtime_service.cpp index 9847422..dc49045 100644 --- a/src/runtime/runtime_service.cpp +++ b/src/runtime/runtime_service.cpp @@ -505,7 +505,14 @@ void RuntimeControlService::AttachVm(Vm* vm) { if (vm_) { console_port_->SetInputCallback([vm](const uint8_t* data, size_t size) { - vm->InjectConsoleBytes(data, size); + static constexpr size_t kConsoleInputChunk = 64; + for (size_t off = 0; off < size; off += kConsoleInputChunk) { + size_t chunk = std::min(kConsoleInputChunk, size - off); + vm->InjectConsoleBytes(data + off, chunk); + if (off + chunk < size) { + std::this_thread::sleep_for(std::chrono::milliseconds(2)); + } + } }); input_port_->SetKeyEventCallback([vm](const KeyboardEvent& ev) { From 594b1e9979506db4c36fc1d35f31c154def3c4f6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BC=A0=E5=AE=89=E5=93=B2?= Date: Mon, 11 May 2026 23:37:00 +0800 Subject: [PATCH 22/37] feat: run agent tools through guest agent --- CLAUDE.md | 7 +- src/core/guest_agent/guest_agent_handler.cpp | 317 +++++++++++++++++- src/core/guest_agent/guest_agent_handler.h | 36 ++ .../Bridge/IpcClientWrapper.swift | 8 + src/manager-macos/Bridge/Sources/TenBoxIPC.mm | 74 +++- src/manager-macos/Bridge/include/TenBoxIPC.h | 4 + .../Services/AgentToolsService.swift | 14 +- src/manager-macos/Views/VmDetailView.swift | 86 +++++ src/runtime/runtime_service.cpp | 96 +++++- 9 files changed, 611 insertions(+), 31 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index d892c72..1254b58 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -88,17 +88,18 @@ Win/macOS: tenbox-manager ──IPC v1──► tenbox-vm-runtime (WHVP / HVF) - **LLM proxy** exists in two places: `src/daemon/llm_proxy.cpp` (Linux) and `src/manager/llm_proxy.cpp` (Windows); change both when the protocol changes. - **RemoteSession** is single-instance per VM. Read `remote_webrtc.cpp`'s `force` takeover path before adding DataChannels. - **macOS Caps Lock forwarding**: send Caps Lock as a tap (`down` then `up`) on each `flagsChanged` event; AppKit exposes it as a toggle state, but the guest input stack needs a full key press for every switch. -- **Agent data profile packages**: `TenBox.app` exports/imports Hermes/OpenClaw data without image changes by using a temporary shared folder and console-injected standard shell commands. Keep the gzip package format documented in `docs/agent-profile.md` and reject cross-agent imports. +- **Agent data profile packages**: `TenBox.app` exports/imports Hermes/OpenClaw data without image changes by using a temporary shared folder and qemu-guest-agent `guest-exec` shell commands. Keep the gzip package format documented in `docs/agent-profile.md` and reject cross-agent imports. - **Hermes profile scope**: export user/config/state data, not the reinstallable Hermes app checkout, virtualenv, local binaries, logs, or cache directories. - **Agent data backups**: `TenBox.app` writes host-managed backups to `~/Library/Application Support/TenBox/AgentBackups//` and retains the latest 5 packages. -- **Agent health checks**: `TenBox.app` runs health, restart, model test, config reset, and diagnostics through console-injected commands. Repair actions must create a host-managed Agent data backup first. +- **Agent health checks**: `TenBox.app` runs health, restart, model test, config reset, and diagnostics through qemu-guest-agent `guest-exec`. Repair actions must create a host-managed Agent data backup first. - **macOS Agent data UI**: `TenBox.app` exposes Agent data export/import from the VM toolbar/menu while a VM is running. It must not depend on preinstalled guest TenBox scripts. - **macOS Agent data shares**: Agent tool temporary shared folders are runtime-only; do not persist operation or backup share tags into VM config. - **macOS Agent share cleanup**: drop persisted `tenbox-agent-ops-*` and `tenbox-agent-backups-*` entries on config load/startup to clean old builds. - **macOS Agent data panels**: show export/import file panels asynchronously from SwiftUI sheets; do not use blocking `runModal()` from button handlers. - **macOS Agent backup UI**: `TenBox.app` exposes backup status, immediate backup, and restore latest backup actions. Host-triggered backups use `~/Library/Application Support/TenBox/AgentBackups/` as the durable shared folder. - **macOS Agent health UI**: `TenBox.app` exposes health check, restart, model test, config reset, and diagnostics actions while a VM is running. Repair actions run through app-generated shell commands so no image rebuild is required. -- **macOS console commands**: Agent tool commands fail quickly if the VM shell does not echo the begin marker, and they wait for the temporary shared folder to become writable before reading or writing packages. +- **macOS console commands**: keep marker-based console command execution as a fallback path; Agent tools should use qemu-guest-agent `guest-exec` and wait for temporary shared folders before reading or writing packages. +- **macOS Agent tool commands**: prefer qemu-guest-agent `guest-exec` for command execution, pass multiline scripts as `command_hex`, and run them as guest user `tenbox`; keep shared folders as the data plane for profile packages, backups, and diagnostics. - **macOS console markers**: do not put the full begin/end marker literal in console input; build it inside the shell so echoed input cannot satisfy marker detection. - **macOS console input**: throttle manager-to-runtime console input before injecting into the guest UART; bulk shell scripts can overflow the emulated FIFO if delivered as one burst. - **macOS app signing**: the app entitlement includes `com.apple.security.cs.disable-library-validation` so the hardened-runtime app can load the bundled Sparkle framework. diff --git a/src/core/guest_agent/guest_agent_handler.cpp b/src/core/guest_agent/guest_agent_handler.cpp index 3f1073b..3f5e66b 100644 --- a/src/core/guest_agent/guest_agent_handler.cpp +++ b/src/core/guest_agent/guest_agent_handler.cpp @@ -2,7 +2,11 @@ #include "core/device/virtio/virtio_serial.h" #include "core/vmm/types.h" #include +#include #include +#include +#include +#include #include #include @@ -25,6 +29,19 @@ static std::string JsonEscape(const std::string& s) { return out; } +static std::string ShellQuote(const std::string& s) { + std::string out = "'"; + for (char c : s) { + if (c == '\'') { + out += "'\\''"; + } else { + out += c; + } + } + out += "'"; + return out; +} + static int64_t GenerateSyncId() { std::random_device rd; std::mt19937_64 gen(rd()); @@ -47,8 +64,114 @@ static int64_t JsonGetInt(const std::string& json, const std::string& key) { return std::strtoll(json.c_str() + pos, nullptr, 10); } +static std::optional JsonTryGetInt(const std::string& json, const std::string& key) { + std::string needle = "\"" + key + "\":"; + auto pos = json.find(needle); + if (pos == std::string::npos) return std::nullopt; + pos += needle.size(); + while (pos < json.size() && json[pos] == ' ') ++pos; + char* end = nullptr; + int64_t value = std::strtoll(json.c_str() + pos, &end, 10); + if (end == json.c_str() + pos) return std::nullopt; + return value; +} + +static std::optional JsonTryGetBool(const std::string& json, const std::string& key) { + std::string needle = "\"" + key + "\":"; + auto pos = json.find(needle); + if (pos == std::string::npos) return std::nullopt; + pos += needle.size(); + while (pos < json.size() && json[pos] == ' ') ++pos; + if (json.compare(pos, 4, "true") == 0) return true; + if (json.compare(pos, 5, "false") == 0) return false; + return std::nullopt; +} + +static std::string JsonUnescape(const std::string& s) { + std::string out; + out.reserve(s.size()); + bool escaped = false; + for (char c : s) { + if (!escaped) { + if (c == '\\') { + escaped = true; + } else { + out.push_back(c); + } + continue; + } + + switch (c) { + case '"': out.push_back('"'); break; + case '\\': out.push_back('\\'); break; + case '/': out.push_back('/'); break; + case 'b': out.push_back('\b'); break; + case 'f': out.push_back('\f'); break; + case 'n': out.push_back('\n'); break; + case 'r': out.push_back('\r'); break; + case 't': out.push_back('\t'); break; + default: out.push_back(c); break; + } + escaped = false; + } + return out; +} + +static std::optional JsonTryGetString(const std::string& json, const std::string& key) { + std::string needle = "\"" + key + "\":"; + auto pos = json.find(needle); + if (pos == std::string::npos) return std::nullopt; + pos += needle.size(); + while (pos < json.size() && json[pos] == ' ') ++pos; + if (pos >= json.size() || json[pos] != '"') return std::nullopt; + ++pos; + + std::string raw; + bool escaped = false; + for (; pos < json.size(); ++pos) { + char c = json[pos]; + if (!escaped && c == '"') { + return JsonUnescape(raw); + } + if (!escaped && c == '\\') { + escaped = true; + raw.push_back(c); + continue; + } + escaped = false; + raw.push_back(c); + } + return std::nullopt; +} + GuestAgentHandler::GuestAgentHandler() = default; -GuestAgentHandler::~GuestAgentHandler() = default; +GuestAgentHandler::~GuestAgentHandler() { + stopping_ = true; + + std::vector callbacks; + { + std::lock_guard lock(mutex_); + callbacks.reserve(pending_responses_.size()); + for (auto& [_, cb] : pending_responses_) { + callbacks.push_back(std::move(cb)); + } + pending_responses_.clear(); + } + for (auto& cb : callbacks) { + if (cb) cb(R"({"error":{"desc":"guest agent stopped"}})"); + } + + std::vector threads; + { + std::lock_guard lock(exec_threads_mutex_); + threads.swap(exec_threads_); + } + for (auto& thread : threads) { + if (thread.joinable()) { + thread.join(); + } + } +} void GuestAgentHandler::SetSerialDevice(VirtioSerialDevice* device, uint32_t port_id) { serial_device_ = device; @@ -68,11 +191,20 @@ void GuestAgentHandler::OnPortOpen(bool opened) { } else { bool was_connected = connected_.exchange(false); ConnectedCallback cb; + std::vector response_callbacks; { std::lock_guard lock(mutex_); cb = connected_callback_; recv_buffer_.clear(); sync_pending_ = false; + response_callbacks.reserve(pending_responses_.size()); + for (auto& [_, response_cb] : pending_responses_) { + response_callbacks.push_back(std::move(response_cb)); + } + pending_responses_.clear(); + } + for (auto& response_cb : response_callbacks) { + if (response_cb) response_cb(R"({"error":{"desc":"guest agent disconnected"}})"); } if (was_connected && cb) { cb(false); @@ -143,6 +275,7 @@ void GuestAgentHandler::ProcessLine(const std::string& line) { LOG_DEBUG("GuestAgent: recv: %s", line.c_str()); ConnectedCallback cb_to_fire; + ResponseCallback response_cb; { std::lock_guard lock(mutex_); @@ -160,6 +293,15 @@ void GuestAgentHandler::ProcessLine(const std::string& line) { } } + auto id = JsonTryGetInt(line, "id"); + if (id && *id > 0) { + auto it = pending_responses_.find(static_cast(*id)); + if (it != pending_responses_.end()) { + response_cb = std::move(it->second); + pending_responses_.erase(it); + } + } + if (JsonHasKey(line, "error")) { if (sync_pending_) { LOG_DEBUG("GuestAgent: error during sync (expected): %s", line.c_str()); @@ -172,6 +314,9 @@ void GuestAgentHandler::ProcessLine(const std::string& line) { if (cb_to_fire) { cb_to_fire(true); } + if (response_cb) { + response_cb(line); + } } void GuestAgentHandler::SendRaw(const std::string& json_line) { @@ -182,35 +327,179 @@ void GuestAgentHandler::SendRaw(const std::string& json_line) { } void GuestAgentHandler::SendCommand(const std::string& command) { + SendCommandRequest(command, "", nullptr); +} + +void GuestAgentHandler::SendCommand(const std::string& command, + const std::string& arguments_json) { + SendCommandRequest(command, arguments_json, nullptr); +} + +uint64_t GuestAgentHandler::SendCommandRequest(const std::string& command, + const std::string& arguments_json, + ResponseCallback callback) { if (!connected_.load()) { LOG_WARN("GuestAgent: not connected, cannot send %s", command.c_str()); - return; + return 0; + } + + uint64_t id = 0; + { + std::lock_guard lock(mutex_); + id = next_id_++; + if (callback) { + pending_responses_[id] = std::move(callback); + } } - uint64_t id = next_id_++; std::ostringstream oss; oss << R"({"execute":")" << JsonEscape(command) - << R"(","id":)" << id << "}\n"; + << R"(")"; + if (!arguments_json.empty()) { + oss << R"(,"arguments":)" << arguments_json; + } + oss << R"(,"id":)" << id << "}\n"; LOG_INFO("GuestAgent: sending %s (id=%" PRIu64 ")", command.c_str(), id); SendRaw(oss.str()); + return id; } -void GuestAgentHandler::SendCommand(const std::string& command, - const std::string& arguments_json) { +bool GuestAgentHandler::SendCommandSync(const std::string& command, + const std::string& arguments_json, + std::chrono::milliseconds timeout, + std::string* response, + std::string* error) { + struct SyncState { + std::mutex mutex; + std::condition_variable cv; + bool done = false; + std::string response; + }; + + auto state = std::make_shared(); + uint64_t request_id = SendCommandRequest(command, arguments_json, [state](const std::string& line) { + { + std::lock_guard lock(state->mutex); + state->response = line; + state->done = true; + } + state->cv.notify_all(); + }); + if (request_id == 0) { + if (error) *error = "guest agent not connected"; + return false; + } + + auto deadline = std::chrono::steady_clock::now() + timeout; + std::unique_lock lock(state->mutex); + while (!state->done && !stopping_.load()) { + if (state->cv.wait_until(lock, deadline) == std::cv_status::timeout) { + break; + } + } + + if (!state->done) { + std::lock_guard pending_lock(mutex_); + pending_responses_.erase(request_id); + if (error) *error = stopping_.load() ? "guest agent stopped" : "guest agent command timed out"; + return false; + } + + if (response) *response = state->response; + return true; +} + +bool GuestAgentHandler::RunShellCommand(const std::string& command, + const std::string& user, + std::chrono::milliseconds timeout, + ExecCallback callback) { if (!connected_.load()) { - LOG_WARN("GuestAgent: not connected, cannot send %s", command.c_str()); + return false; + } + std::lock_guard lock(exec_threads_mutex_); + exec_threads_.emplace_back( + [this, command, user, timeout, callback = std::move(callback)]() mutable { + RunShellCommandWorker(command, user, timeout, std::move(callback)); + }); + return true; +} + +void GuestAgentHandler::RunShellCommandWorker(const std::string& command, + const std::string& user, + std::chrono::milliseconds timeout, + ExecCallback callback) { + ExecResult result; + auto finish = [&](ExecResult r) { + if (callback) callback(std::move(r)); + }; + + std::string exec_command = command; + if (!user.empty()) { + const std::string quoted_user = ShellQuote(user); + const std::string quoted_command = ShellQuote(command); + exec_command = + "if command -v runuser >/dev/null 2>&1 && id " + quoted_user + " >/dev/null 2>&1; then " + "exec runuser -l " + quoted_user + " -c " + quoted_command + "; " + "else exec /bin/sh -lc " + quoted_command + "; fi"; + } + + const std::string args = + R"({"path":"/bin/sh","arg":["-lc",")" + JsonEscape(exec_command) + + R"("],"capture-output":true})"; + + std::string response; + std::string error; + if (!SendCommandSync("guest-exec", args, std::chrono::seconds(10), &response, &error)) { + result.error = error.empty() ? "failed to start guest command" : error; + finish(std::move(result)); + return; + } + if (auto desc = JsonTryGetString(response, "desc")) { + result.error = *desc; + finish(std::move(result)); + return; + } + auto pid = JsonTryGetInt(response, "pid"); + if (!pid || *pid <= 0) { + result.error = "guest agent did not return a command pid"; + finish(std::move(result)); return; } - uint64_t id = next_id_++; - std::ostringstream oss; - oss << R"({"execute":")" << JsonEscape(command) - << R"(","arguments":)" << arguments_json - << R"(,"id":)" << id << "}\n"; + auto deadline = std::chrono::steady_clock::now() + timeout; + while (!stopping_.load() && std::chrono::steady_clock::now() < deadline) { + std::string status_response; + std::string status_error; + std::string status_args = R"({"pid":)" + std::to_string(*pid) + "}"; + if (!SendCommandSync("guest-exec-status", status_args, + std::chrono::seconds(10), &status_response, &status_error)) { + result.error = status_error.empty() ? "failed to read guest command status" : status_error; + finish(std::move(result)); + return; + } + if (auto desc = JsonTryGetString(status_response, "desc")) { + result.error = *desc; + finish(std::move(result)); + return; + } - LOG_INFO("GuestAgent: sending %s (id=%" PRIu64 ")", command.c_str(), id); - SendRaw(oss.str()); + auto exited = JsonTryGetBool(status_response, "exited"); + if (exited && *exited) { + result.ok = true; + result.exited = true; + result.exit_code = static_cast(JsonTryGetInt(status_response, "exitcode").value_or(0)); + result.out_data = JsonTryGetString(status_response, "out-data").value_or(""); + result.err_data = JsonTryGetString(status_response, "err-data").value_or(""); + finish(std::move(result)); + return; + } + + std::this_thread::sleep_for(std::chrono::milliseconds(250)); + } + + result.error = stopping_.load() ? "guest agent stopped" : "guest command timed out"; + finish(std::move(result)); } void GuestAgentHandler::Shutdown(const std::string& mode) { diff --git a/src/core/guest_agent/guest_agent_handler.h b/src/core/guest_agent/guest_agent_handler.h index b22c644..09e62ca 100644 --- a/src/core/guest_agent/guest_agent_handler.h +++ b/src/core/guest_agent/guest_agent_handler.h @@ -1,10 +1,12 @@ #pragma once #include +#include #include #include #include #include +#include #include #include @@ -13,6 +15,15 @@ class VirtioSerialDevice; class GuestAgentHandler { public: using ConnectedCallback = std::function; + struct ExecResult { + bool ok = false; + bool exited = false; + int exit_code = -1; + std::string out_data; + std::string err_data; + std::string error; + }; + using ExecCallback = std::function; GuestAgentHandler(); ~GuestAgentHandler(); @@ -37,10 +48,30 @@ class GuestAgentHandler { // Sync guest wall clock to host (QGA guest-set-time, nanoseconds since epoch) void SyncTime(); + // Execute a shell command through qemu-guest-agent guest-exec. + bool RunShellCommand(const std::string& command, + const std::string& user, + std::chrono::milliseconds timeout, + ExecCallback callback); + private: + using ResponseCallback = std::function; + void SendCommand(const std::string& command); void SendCommand(const std::string& command, const std::string& arguments_json); + uint64_t SendCommandRequest(const std::string& command, + const std::string& arguments_json, + ResponseCallback callback); + bool SendCommandSync(const std::string& command, + const std::string& arguments_json, + std::chrono::milliseconds timeout, + std::string* response, + std::string* error); + void RunShellCommandWorker(const std::string& command, + const std::string& user, + std::chrono::milliseconds timeout, + ExecCallback callback); void SendRaw(const std::string& json_line); void ProcessLine(const std::string& line); void StartSyncHandshake(); @@ -55,4 +86,9 @@ class GuestAgentHandler { int64_t sync_id_ = 0; uint64_t next_id_ = 1; ConnectedCallback connected_callback_; + std::unordered_map pending_responses_; + + std::atomic stopping_{false}; + std::mutex exec_threads_mutex_; + std::vector exec_threads_; }; diff --git a/src/manager-macos/Bridge/IpcClientWrapper.swift b/src/manager-macos/Bridge/IpcClientWrapper.swift index 64839e0..6a274c7 100644 --- a/src/manager-macos/Bridge/IpcClientWrapper.swift +++ b/src/manager-macos/Bridge/IpcClientWrapper.swift @@ -25,6 +25,7 @@ class IpcClientWrapper: ObservableObject { // VM state var onRuntimeState: ((String) -> Void)? var onGuestAgentState: ((Bool) -> Void)? + var onGuestExecResult: ((UInt64, Bool, Int32, String, String, String?) -> Void)? // Host-forward errors (host ports that failed to bind) var onHostForwardError: (([String]) -> Void)? @@ -73,6 +74,10 @@ class IpcClientWrapper: ObservableObject { _ = client.sendSyncTimeCommand() } + func sendGuestExec(command: String, user: String, requestId: UInt64, timeoutMs: UInt32) -> Bool { + client.sendGuestExecCommand(command, user: user, requestId: requestId, timeoutMs: timeoutMs) + } + func sendKey(code: UInt16, pressed: Bool) { client.sendKeyEvent(code, pressed: pressed) } @@ -152,6 +157,9 @@ class IpcClientWrapper: ObservableObject { guestAgentStateHandler: { [weak self] connected in self?.onGuestAgentState?(connected) }, + guestExecResultHandler: { [weak self] requestId, ok, exitCode, stdoutText, stderrText, error in + self?.onGuestExecResult?(requestId, ok, exitCode, stdoutText, stderrText, error) + }, displayStateHandler: { [weak self] active, w, h in self?.onDisplayState?(active, w, h) }, diff --git a/src/manager-macos/Bridge/Sources/TenBoxIPC.mm b/src/manager-macos/Bridge/Sources/TenBoxIPC.mm index 81aa76c..ff0cb20 100644 --- a/src/manager-macos/Bridge/Sources/TenBoxIPC.mm +++ b/src/manager-macos/Bridge/Sources/TenBoxIPC.mm @@ -50,6 +50,18 @@ return out; } +static NSString* DecodeBase64Utf8(const std::string& value) { + if (value.empty()) return @""; + NSString* b64 = [NSString stringWithUTF8String:value.c_str()]; + NSData* data = [[NSData alloc] initWithBase64EncodedString:b64 options:0]; + if (!data) return @""; + NSString* text = [[NSString alloc] initWithData:data encoding:NSUTF8StringEncoding]; + if (!text) { + text = [[NSString alloc] initWithData:data encoding:NSISOLatin1StringEncoding]; + } + return text ?: @""; +} + @implementation TBIpcClient { std::unique_ptr _connection; std::mutex _sendLock; @@ -132,7 +144,8 @@ - (BOOL)sendControlCommand:(NSString *)command { msg.channel = ipc::Channel::kControl; msg.kind = ipc::Kind::kRequest; msg.type = "runtime.command"; - msg.fields["command"] = command.UTF8String; + std::string raw = command.UTF8String; + msg.fields["command_hex"] = HexEncode(raw); std::lock_guard lock(_sendLock); std::string encoded = ipc::Encode(msg); @@ -144,6 +157,25 @@ - (BOOL)sendSyncTimeCommand { return [self sendControlCommand:@"sync-time"]; } +- (BOOL)sendGuestExecCommand:(NSString *)command user:(NSString *)user requestId:(uint64_t)requestId timeoutMs:(uint32_t)timeoutMs { + if (!_connection || !_connection->IsValid()) return NO; + + ipc::Message msg; + msg.channel = ipc::Channel::kControl; + msg.kind = ipc::Kind::kRequest; + msg.type = "runtime.guest_exec"; + msg.request_id = requestId; + std::string raw = command.UTF8String; + msg.fields["command_hex"] = HexEncode(raw); + if (user.length > 0) { + msg.fields["user"] = user.UTF8String; + } + msg.fields["timeout_ms"] = std::to_string(timeoutMs); + + std::lock_guard lock(_sendLock); + return _connection->Send(ipc::Encode(msg)); +} + #pragma mark - Send: Input - (BOOL)sendKeyEvent:(uint16_t)code pressed:(BOOL)pressed { @@ -354,6 +386,7 @@ - (void)startReceiveLoopWithFrameHandler:(void (^)(const void *, size_t, uint32_ clipboardRequestHandler:(void (^)(uint32_t))clipboardRequestHandler runtimeStateHandler:(void (^)(NSString *))runtimeStateHandler guestAgentStateHandler:(void (^)(BOOL))guestAgentStateHandler + guestExecResultHandler:(void (^)(uint64_t, BOOL, int32_t, NSString *, NSString *, NSString * _Nullable))guestExecResultHandler displayStateHandler:(void (^)(BOOL, uint32_t, uint32_t))displayStateHandler disconnectHandler:(void (^)(void))disconnectHandler { if (_recvThread.joinable()) { @@ -373,6 +406,7 @@ - (void)startReceiveLoopWithFrameHandler:(void (^)(const void *, size_t, uint32_ typedef void (^ClipReqBlock)(uint32_t); typedef void (^StateBlock)(NSString *); typedef void (^BoolBlock)(BOOL); + typedef void (^GuestExecBlock)(uint64_t, BOOL, int32_t, NSString *, NSString *, NSString * _Nullable); typedef void (^DispStateBlock)(BOOL, uint32_t, uint32_t); typedef void (^VoidBlock)(void); @@ -385,10 +419,11 @@ - (void)startReceiveLoopWithFrameHandler:(void (^)(const void *, size_t, uint32_ ClipReqBlock crH = [clipboardRequestHandler copy]; StateBlock rsH = [runtimeStateHandler copy]; BoolBlock gaH = [guestAgentStateHandler copy]; + GuestExecBlock geH = [guestExecResultHandler copy]; DispStateBlock dsH = [displayStateHandler copy]; VoidBlock dh = [disconnectHandler copy]; - _recvThread = std::thread([self, fh, cuH, ah, coh, cgH, cdH, crH, rsH, gaH, dsH, dh] { + _recvThread = std::thread([self, fh, cuH, ah, coh, cgH, cdH, crH, rsH, gaH, geH, dsH, dh] { // Streaming parser — mirrors the Windows DispatchPipeData approach. // One large read buffer, parse header lines + payloads in-place. std::string pending; @@ -409,7 +444,7 @@ - (void)startReceiveLoopWithFrameHandler:(void (^)(const void *, size_t, uint32_ payload_needed = 0; auto& msg = pending_msg; - [self dispatchMsg:msg fh:fh cuH:cuH ah:ah coh:coh cgH:cgH cdH:cdH crH:crH rsH:rsH gaH:gaH dsH:dsH]; + [self dispatchMsg:msg fh:fh cuH:cuH ah:ah coh:coh cgH:cgH cdH:cdH crH:crH rsH:rsH gaH:gaH geH:geH dsH:dsH]; continue; } @@ -432,7 +467,7 @@ - (void)startReceiveLoopWithFrameHandler:(void (^)(const void *, size_t, uint32_ } auto& msg = *decoded; - [self dispatchMsg:msg fh:fh cuH:cuH ah:ah coh:coh cgH:cgH cdH:cdH crH:crH rsH:rsH gaH:gaH dsH:dsH]; + [self dispatchMsg:msg fh:fh cuH:cuH ah:ah coh:coh cgH:cgH cdH:cdH crH:crH rsH:rsH gaH:gaH geH:geH dsH:dsH]; } }; @@ -466,6 +501,7 @@ - (void)dispatchMsg:(ipc::Message&)msg crH:(void (^)(uint32_t))crH rsH:(void (^)(NSString *))rsH gaH:(void (^)(BOOL))gaH + geH:(void (^)(uint64_t, BOOL, int32_t, NSString *, NSString *, NSString * _Nullable))geH dsH:(void (^)(BOOL, uint32_t, uint32_t))dsH { auto getU32 = [&](const char* key) -> uint32_t { @@ -635,6 +671,36 @@ - (void)dispatchMsg:(ipc::Message&)msg IPC_DEBUG_LOG(@"[IPC] << %s guest_agent.state connected=%d", GetTimestamp().c_str(), connected); dispatch_async(dispatch_get_main_queue(), ^{ gaH(connected); }); } + else if (msg.type == "runtime.guest_exec.result") { + BOOL ok = false; + int32_t exitCode = -1; + NSString* outText = @""; + NSString* errText = @""; + NSString* errorText = nil; + + auto okIt = msg.fields.find("ok"); + ok = (okIt != msg.fields.end() && okIt->second == "true"); + auto codeIt = msg.fields.find("exit_code"); + if (codeIt != msg.fields.end()) { + exitCode = static_cast(std::strtol(codeIt->second.c_str(), nullptr, 10)); + } + auto outIt = msg.fields.find("out_b64"); + if (outIt != msg.fields.end()) { + outText = DecodeBase64Utf8(outIt->second); + } + auto errIt = msg.fields.find("err_b64"); + if (errIt != msg.fields.end()) { + errText = DecodeBase64Utf8(errIt->second); + } + auto errorIt = msg.fields.find("error"); + if (errorIt != msg.fields.end()) { + errorText = [NSString stringWithUTF8String:errorIt->second.c_str()]; + } + uint64_t reqId = msg.request_id; + dispatch_async(dispatch_get_main_queue(), ^{ + geH(reqId, ok, exitCode, outText, errText, errorText); + }); + } else if (msg.type == "display.state") { auto ai = msg.fields.find("active"); auto wi = msg.fields.find("width"); diff --git a/src/manager-macos/Bridge/include/TenBoxIPC.h b/src/manager-macos/Bridge/include/TenBoxIPC.h index 8efd703..c9f56f4 100644 --- a/src/manager-macos/Bridge/include/TenBoxIPC.h +++ b/src/manager-macos/Bridge/include/TenBoxIPC.h @@ -21,6 +21,9 @@ NS_ASSUME_NONNULL_BEGIN /// Push host wall time to guest (qemu-ga guest-set-time) when guest agent is connected. - (BOOL)sendSyncTimeCommand; +/// Execute a shell command through qemu-guest-agent guest-exec. +- (BOOL)sendGuestExecCommand:(NSString *)command user:(NSString *)user requestId:(uint64_t)requestId timeoutMs:(uint32_t)timeoutMs; + // Input events (forwarded to virtio-input) - (BOOL)sendKeyEvent:(uint16_t)code pressed:(BOOL)pressed; - (BOOL)sendPointerAbsolute:(int32_t)x y:(int32_t)y buttons:(uint32_t)buttons; @@ -62,6 +65,7 @@ NS_ASSUME_NONNULL_BEGIN clipboardRequestHandler:(void (^)(uint32_t dataType))clipboardRequestHandler runtimeStateHandler:(void (^)(NSString *state))runtimeStateHandler guestAgentStateHandler:(void (^)(BOOL connected))guestAgentStateHandler + guestExecResultHandler:(void (^)(uint64_t requestId, BOOL ok, int32_t exitCode, NSString *stdoutText, NSString *stderrText, NSString * _Nullable error))guestExecResultHandler displayStateHandler:(void (^)(BOOL active, uint32_t width, uint32_t height))displayStateHandler disconnectHandler:(void (^)(void))disconnectHandler; - (void)stopReceiveLoop; diff --git a/src/manager-macos/Services/AgentToolsService.swift b/src/manager-macos/Services/AgentToolsService.swift index 2b65afd..06157ab 100644 --- a/src/manager-macos/Services/AgentToolsService.swift +++ b/src/manager-macos/Services/AgentToolsService.swift @@ -48,7 +48,7 @@ final class AgentToolsService { body: Self.profileExportCommand(agent: agent, outputPath: guestPackage) ) - session.runShellCommand(command, timeout: 420) { result in + session.runGuestAgentCommand(command, timeout: 420) { result in switch result { case .success(let commandResult): guard commandResult.exitCode == 0 else { @@ -103,7 +103,7 @@ final class AgentToolsService { tag: share.tag, body: Self.profileImportCommand(agent: agent, inputPath: guestPackage) ) - session.runShellCommand(command, timeout: 420) { result in + session.runGuestAgentCommand(command, timeout: 420) { result in cleanup() switch result { case .success(let commandResult): @@ -155,7 +155,7 @@ final class AgentToolsService { body: "mkdir -p \(Self.shellQuote("/mnt/shared/\(share.tag)/\(agent.rawValue)"))\n" + Self.profileExportCommand(agent: agent, outputPath: guestPackage) ) - session.runShellCommand(command, timeout: 420) { result in + session.runGuestAgentCommand(command, timeout: 420) { result in cleanup() switch result { case .success(let commandResult): @@ -193,7 +193,7 @@ final class AgentToolsService { tag: share.tag, body: Self.profileImportCommand(agent: agent, inputPath: guestPackage) ) - session.runShellCommand(command, timeout: 420) { result in + session.runGuestAgentCommand(command, timeout: 420) { result in cleanup() switch result { case .success(let commandResult): @@ -257,7 +257,7 @@ final class AgentToolsService { tag: share.tag, body: Self.diagnosticsCommand(agent: agent, outputDir: guestDir) ) - session.runShellCommand(command, timeout: 180) { result in + session.runGuestAgentCommand(command, timeout: 180) { result in cleanup() switch result { case .success(let commandResult): @@ -281,7 +281,7 @@ final class AgentToolsService { private func runHealthCommand(vm: VmInfo, session: VmSession, appState: AppState, agent: AgentKind, command: String, successMessage: String, completion: @escaping (Result) -> Void) { - session.runShellCommand(command, timeout: 180) { result in + session.runGuestAgentCommand(command, timeout: 180) { result in switch result { case .success(let commandResult): guard commandResult.exitCode == 0 else { @@ -311,7 +311,7 @@ final class AgentToolsService { Self.profileExportCommand(agent: agent, outputPath: guestPackage) + "\n" + repairCommand ) - session.runShellCommand(command, timeout: 420) { result in + session.runGuestAgentCommand(command, timeout: 420) { result in cleanup() switch result { case .success(let commandResult): diff --git a/src/manager-macos/Views/VmDetailView.swift b/src/manager-macos/Views/VmDetailView.swift index afc5fe9..bdb6dab 100644 --- a/src/manager-macos/Views/VmDetailView.swift +++ b/src/manager-macos/Views/VmDetailView.swift @@ -27,6 +27,8 @@ class VmSession: ObservableObject { private var connecting = false private static let maxConsoleSize = 64 * 1024 private var pendingConsoleCommands: [String: PendingConsoleCommand] = [:] + private var nextGuestExecRequestId: UInt64 = 1 + private var pendingGuestExecCommands: [UInt64: PendingGuestExecCommand] = [:] private struct PendingConsoleCommand { let beginMarker: String @@ -36,6 +38,11 @@ class VmSession: ObservableObject { let timeoutWorkItem: DispatchWorkItem } + private struct PendingGuestExecCommand { + let completion: (Result) -> Void + let timeoutWorkItem: DispatchWorkItem + } + init(vmId: String, clipboardHandler: ClipboardHandler) { self.vmId = vmId self.clipboardHandler = clipboardHandler @@ -56,6 +63,16 @@ class VmSession: ObservableObject { ipcClient.onGuestAgentState = { [weak self] conn in self?.guestAgentConnected = conn } + ipcClient.onGuestExecResult = { [weak self] requestId, ok, exitCode, stdoutText, stderrText, error in + self?.finishGuestExecCommand( + requestId: requestId, + ok: ok, + exitCode: exitCode, + stdoutText: stdoutText, + stderrText: stderrText, + error: error + ) + } ipcClient.onFrame = { [weak self] pixelBytes, pixelLength, w, h, stride, resW, resH, dirtyX, dirtyY in guard let self = self, let renderer = self.renderer else { return } @@ -109,6 +126,7 @@ class VmSession: ObservableObject { self.connected = false self.connecting = false self.displayInitialized = false + self.failPendingGuestExecCommands(ConsoleCommandError("VM runtime disconnected")) } setupClipboardCallbacks() @@ -222,6 +240,42 @@ class VmSession: ObservableObject { ipcClient.sendConsoleInput(text) } + func runGuestAgentCommand(_ command: String, timeout: TimeInterval = 120, + completion: @escaping (Result) -> Void) { + DispatchQueue.main.async { + guard self.connected, self.ipcClient.isConnected else { + completion(.failure(ConsoleCommandError("VM runtime is not connected"))) + return + } + guard self.guestAgentConnected else { + completion(.failure(ConsoleCommandError("Guest agent is not connected"))) + return + } + + let requestId = self.nextGuestExecRequestId + self.nextGuestExecRequestId += 1 + let timeoutMs = UInt32(min(max(timeout * 1000, 1000), 600000)) + let timeoutWorkItem = DispatchWorkItem { [weak self] in + guard let self = self else { return } + if let pending = self.pendingGuestExecCommands.removeValue(forKey: requestId) { + pending.completion(.failure(ConsoleCommandError("Command timed out"))) + } + } + + self.pendingGuestExecCommands[requestId] = PendingGuestExecCommand( + completion: completion, + timeoutWorkItem: timeoutWorkItem + ) + DispatchQueue.main.asyncAfter(deadline: .now() + timeout, execute: timeoutWorkItem) + + if !self.ipcClient.sendGuestExec(command: command, user: "tenbox", requestId: requestId, timeoutMs: timeoutMs) { + timeoutWorkItem.cancel() + self.pendingGuestExecCommands.removeValue(forKey: requestId) + completion(.failure(ConsoleCommandError("Failed to send guest agent command"))) + } + } + } + func runShellCommand(_ command: String, timeout: TimeInterval = 120, completion: @escaping (Result) -> Void) { DispatchQueue.main.async { @@ -267,6 +321,38 @@ class VmSession: ObservableObject { } } + private func finishGuestExecCommand(requestId: UInt64, ok: Bool, exitCode: Int32, + stdoutText: String, stderrText: String, + error: String?) { + guard let pending = pendingGuestExecCommands.removeValue(forKey: requestId) else { + return + } + pending.timeoutWorkItem.cancel() + + let output: String + if !stdoutText.isEmpty && !stderrText.isEmpty { + output = stdoutText + "\n" + stderrText + } else { + output = stdoutText + stderrText + } + + if ok { + pending.completion(.success(ConsoleCommandResult(exitCode: exitCode, output: output))) + } else { + let message = error ?? (output.isEmpty ? "Guest agent command failed" : output) + pending.completion(.failure(ConsoleCommandError(message))) + } + } + + private func failPendingGuestExecCommands(_ error: Error) { + let pending = pendingGuestExecCommands + pendingGuestExecCommands.removeAll() + for (_, command) in pending { + command.timeoutWorkItem.cancel() + command.completion(.failure(error)) + } + } + private func appendConsoleText(_ text: String) { consoleText.append(text) if consoleText.count > Self.maxConsoleSize { diff --git a/src/runtime/runtime_service.cpp b/src/runtime/runtime_service.cpp index dc49045..9332284 100644 --- a/src/runtime/runtime_service.cpp +++ b/src/runtime/runtime_service.cpp @@ -592,6 +592,87 @@ bool RuntimeControlService::SendWithPayload(const ipc::Message& message) { } void RuntimeControlService::HandleMessage(const ipc::Message& message) { + if (message.channel == ipc::Channel::kControl && + message.kind == ipc::Kind::kRequest && + message.type == "runtime.guest_exec") { + ipc::Message resp; + resp.kind = ipc::Kind::kResponse; + resp.channel = ipc::Channel::kControl; + resp.type = "runtime.guest_exec.result"; + resp.vm_id = vm_id_; + resp.request_id = message.request_id; + + std::string command; + auto it_hex = message.fields.find("command_hex"); + if (it_hex != message.fields.end()) { + auto command_bytes = DecodeHex(it_hex->second); + command.assign(command_bytes.begin(), command_bytes.end()); + } else { + auto it = message.fields.find("command"); + if (it != message.fields.end()) { + command = it->second; + } + } + if (command.empty()) { + resp.fields["ok"] = "false"; + resp.fields["error"] = "missing command"; + Send(resp); + return; + } + if (!vm_ || !vm_->GetGuestAgentHandler() || !vm_->IsGuestAgentConnected()) { + resp.fields["ok"] = "false"; + resp.fields["error"] = "guest agent not connected"; + Send(resp); + return; + } + + int timeout_ms = 120000; + auto it_timeout = message.fields.find("timeout_ms"); + if (it_timeout != message.fields.end()) { + auto [p, ec] = std::from_chars( + it_timeout->second.data(), + it_timeout->second.data() + it_timeout->second.size(), + timeout_ms); + if (ec != std::errc{} || timeout_ms <= 0) { + timeout_ms = 120000; + } + } + timeout_ms = std::clamp(timeout_ms, 1000, 600000); + + const uint64_t req_id = message.request_id; + std::string user; + auto it_user = message.fields.find("user"); + if (it_user != message.fields.end()) { + user = it_user->second; + } + bool started = vm_->GetGuestAgentHandler()->RunShellCommand( + command, + user, + std::chrono::milliseconds(timeout_ms), + [this, req_id](GuestAgentHandler::ExecResult result) { + ipc::Message exec_resp; + exec_resp.kind = ipc::Kind::kResponse; + exec_resp.channel = ipc::Channel::kControl; + exec_resp.type = "runtime.guest_exec.result"; + exec_resp.vm_id = vm_id_; + exec_resp.request_id = req_id; + exec_resp.fields["ok"] = result.ok ? "true" : "false"; + exec_resp.fields["exit_code"] = std::to_string(result.exit_code); + exec_resp.fields["out_b64"] = result.out_data; + exec_resp.fields["err_b64"] = result.err_data; + if (!result.error.empty()) { + exec_resp.fields["error"] = result.error; + } + Send(exec_resp); + }); + if (!started) { + resp.fields["ok"] = "false"; + resp.fields["error"] = "failed to start guest command"; + Send(resp); + } + return; + } + if (message.channel == ipc::Channel::kControl && message.kind == ipc::Kind::kRequest && message.type == "runtime.command") { @@ -603,14 +684,23 @@ void RuntimeControlService::HandleMessage(const ipc::Message& message) { resp.request_id = message.request_id; resp.fields["ok"] = "true"; - auto it = message.fields.find("command"); - if (it == message.fields.end()) { + std::string cmd; + auto it_hex = message.fields.find("command_hex"); + if (it_hex != message.fields.end()) { + auto command_bytes = DecodeHex(it_hex->second); + cmd.assign(command_bytes.begin(), command_bytes.end()); + } else { + auto it = message.fields.find("command"); + if (it != message.fields.end()) { + cmd = it->second; + } + } + if (cmd.empty()) { resp.fields["ok"] = "false"; resp.fields["error"] = "missing command"; Send(resp); return; } - const std::string& cmd = it->second; if (cmd == "stop") { if (vm_) vm_->RequestStop(); } else if (cmd == "shutdown") { From 440e98d88e8ee81d62c738af0ce838900ae944ef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BC=A0=E5=AE=89=E5=93=B2?= Date: Mon, 11 May 2026 23:47:01 +0800 Subject: [PATCH 23/37] fix: tolerate live agent backup changes --- CLAUDE.md | 2 +- src/manager-macos/Services/AgentToolsService.swift | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 1254b58..7253082 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -90,7 +90,7 @@ Win/macOS: tenbox-manager ──IPC v1──► tenbox-vm-runtime (WHVP / HVF) - **macOS Caps Lock forwarding**: send Caps Lock as a tap (`down` then `up`) on each `flagsChanged` event; AppKit exposes it as a toggle state, but the guest input stack needs a full key press for every switch. - **Agent data profile packages**: `TenBox.app` exports/imports Hermes/OpenClaw data without image changes by using a temporary shared folder and qemu-guest-agent `guest-exec` shell commands. Keep the gzip package format documented in `docs/agent-profile.md` and reject cross-agent imports. - **Hermes profile scope**: export user/config/state data, not the reinstallable Hermes app checkout, virtualenv, local binaries, logs, or cache directories. -- **Agent data backups**: `TenBox.app` writes host-managed backups to `~/Library/Application Support/TenBox/AgentBackups//` and retains the latest 5 packages. +- **Agent data backups**: `TenBox.app` writes host-managed backups to `~/Library/Application Support/TenBox/AgentBackups//` and retains the latest 5 packages. Profile export runs against live Agent data, so GNU tar exit 1 from changed/skipped files is treated as non-fatal; fatal tar errors must still fail the backup. - **Agent health checks**: `TenBox.app` runs health, restart, model test, config reset, and diagnostics through qemu-guest-agent `guest-exec`. Repair actions must create a host-managed Agent data backup first. - **macOS Agent data UI**: `TenBox.app` exposes Agent data export/import from the VM toolbar/menu while a VM is running. It must not depend on preinstalled guest TenBox scripts. - **macOS Agent data shares**: Agent tool temporary shared folders are runtime-only; do not persist operation or backup share tags into VM config. diff --git a/src/manager-macos/Services/AgentToolsService.swift b/src/manager-macos/Services/AgentToolsService.swift index 06157ab..6ba31ad 100644 --- a/src/manager-macos/Services/AgentToolsService.swift +++ b/src/manager-macos/Services/AgentToolsService.swift @@ -477,7 +477,9 @@ final class AgentToolsService { "archive": "files.tar.gz" } EOF - (cd "$home" && tar \(excludes) -czf "$work/files.tar.gz" "$rel") + tar_status=0 + (cd "$home" && tar --warning=no-file-changed --ignore-failed-read \(excludes) -czf "$work/files.tar.gz" "$rel") || tar_status=$? + [ "$tar_status" -le 1 ] || exit "$tar_status" rm -f "$out" tar -czf "$out" -C "$work" manifest.json files.tar.gz rm -rf "$work" From 96b185fd2b62d0d8079d8c175a158884d23bf776 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BC=A0=E5=AE=89=E5=93=B2?= Date: Tue, 12 May 2026 00:06:16 +0800 Subject: [PATCH 24/37] feat: improve agent tools experience --- CLAUDE.md | 2 +- .../Services/AgentToolsService.swift | 54 +- src/manager-macos/TenBoxApp.swift | 22 +- src/manager-macos/Views/AgentToolsView.swift | 877 +++++++++++++++--- src/manager-macos/Views/ContentView.swift | 6 +- 5 files changed, 776 insertions(+), 185 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 7253082..f3a7601 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -92,7 +92,7 @@ Win/macOS: tenbox-manager ──IPC v1──► tenbox-vm-runtime (WHVP / HVF) - **Hermes profile scope**: export user/config/state data, not the reinstallable Hermes app checkout, virtualenv, local binaries, logs, or cache directories. - **Agent data backups**: `TenBox.app` writes host-managed backups to `~/Library/Application Support/TenBox/AgentBackups//` and retains the latest 5 packages. Profile export runs against live Agent data, so GNU tar exit 1 from changed/skipped files is treated as non-fatal; fatal tar errors must still fail the backup. - **Agent health checks**: `TenBox.app` runs health, restart, model test, config reset, and diagnostics through qemu-guest-agent `guest-exec`. Repair actions must create a host-managed Agent data backup first. -- **macOS Agent data UI**: `TenBox.app` exposes Agent data export/import from the VM toolbar/menu while a VM is running. It must not depend on preinstalled guest TenBox scripts. +- **macOS Agent data UI**: `TenBox.app` exposes Agent data export/import from the VM toolbar/menu while a VM is running. Keep user-facing Agent tool copy in Chinese, show operation status/results in the sheet, and confirm destructive import/restore/reset actions. It must not depend on preinstalled guest TenBox scripts. - **macOS Agent data shares**: Agent tool temporary shared folders are runtime-only; do not persist operation or backup share tags into VM config. - **macOS Agent share cleanup**: drop persisted `tenbox-agent-ops-*` and `tenbox-agent-backups-*` entries on config load/startup to clean old builds. - **macOS Agent data panels**: show export/import file panels asynchronously from SwiftUI sheets; do not use blocking `runModal()` from button handlers. diff --git a/src/manager-macos/Services/AgentToolsService.swift b/src/manager-macos/Services/AgentToolsService.swift index 6ba31ad..b6d0069 100644 --- a/src/manager-macos/Services/AgentToolsService.swift +++ b/src/manager-macos/Services/AgentToolsService.swift @@ -53,7 +53,7 @@ final class AgentToolsService { case .success(let commandResult): guard commandResult.exitCode == 0 else { cleanup() - completion(.failure(Self.makeError(commandResult.output.isEmpty ? "Agent data export failed" : commandResult.output))) + completion(.failure(Self.makeError(commandResult.output.isEmpty ? "Agent 数据导出失败" : commandResult.output))) return } let hostPackage = URL(fileURLWithPath: share.hostPath).appendingPathComponent(packageName) @@ -108,7 +108,7 @@ final class AgentToolsService { switch result { case .success(let commandResult): guard commandResult.exitCode == 0 else { - completion(.failure(Self.makeError(commandResult.output.isEmpty ? "Agent data import failed" : commandResult.output))) + completion(.failure(Self.makeError(commandResult.output.isEmpty ? "Agent 数据导入失败" : commandResult.output))) return } completion(.success(AgentToolResult( @@ -136,7 +136,7 @@ final class AgentToolsService { } else { completion(.success(AgentToolResult( message: "还没有备份", - output: "点击 Back Up Now 创建第一份备份。" + output: "点击“立即备份”创建第一份备份。" ))) } } catch { @@ -160,7 +160,7 @@ final class AgentToolsService { switch result { case .success(let commandResult): guard commandResult.exitCode == 0 else { - completion(.failure(Self.makeError(commandResult.output.isEmpty ? "Agent backup failed" : commandResult.output))) + completion(.failure(Self.makeError(commandResult.output.isEmpty ? "Agent 备份失败" : commandResult.output))) return } self.rotateBackups(vmId: vm.id, agent: agent, keep: 5) @@ -184,7 +184,7 @@ final class AgentToolsService { completion: @escaping (Result) -> Void) { do { guard let latest = try latestBackupPackage(vmId: vm.id, agent: agent) else { - completion(.failure(Self.makeError("No backup package found"))) + completion(.failure(Self.makeError("没有找到备份包"))) return } withBackupShare(vmId: vm.id, appState: appState) { share, cleanup in @@ -198,7 +198,7 @@ final class AgentToolsService { switch result { case .success(let commandResult): guard commandResult.exitCode == 0 else { - completion(.failure(Self.makeError(commandResult.output.isEmpty ? "Agent backup restore failed" : commandResult.output))) + completion(.failure(Self.makeError(commandResult.output.isEmpty ? "Agent 备份恢复失败" : commandResult.output))) return } completion(.success(AgentToolResult( @@ -262,7 +262,7 @@ final class AgentToolsService { switch result { case .success(let commandResult): guard commandResult.exitCode == 0 else { - completion(.failure(Self.makeError(commandResult.output.isEmpty ? "Agent diagnostics failed" : commandResult.output))) + completion(.failure(Self.makeError(commandResult.output.isEmpty ? "Agent 诊断导出失败" : commandResult.output))) return } completion(.success(AgentToolResult( @@ -285,7 +285,7 @@ final class AgentToolsService { switch result { case .success(let commandResult): guard commandResult.exitCode == 0 else { - completion(.failure(Self.makeError(commandResult.output.isEmpty ? "Agent health command failed" : commandResult.output))) + completion(.failure(Self.makeError(commandResult.output.isEmpty ? "Agent 健康检查失败" : commandResult.output))) return } completion(.success(AgentToolResult( @@ -316,7 +316,7 @@ final class AgentToolsService { switch result { case .success(let commandResult): guard commandResult.exitCode == 0 else { - completion(.failure(Self.makeError(commandResult.output.isEmpty ? "Agent repair failed" : commandResult.output))) + completion(.failure(Self.makeError(commandResult.output.isEmpty ? "Agent 修复操作失败" : commandResult.output))) return } self.rotateBackups(vmId: vm.id, agent: agent, keep: 5) @@ -466,7 +466,7 @@ final class AgentToolsService { src="$home/$rel" out=\(shellQuote(outputPath)) work=\(shellQuote(workDir)) - [ -d "$src" ] || { echo "Agent data is not initialized: $src" >&2; exit 1; } + [ -d "$src" ] || { echo "Agent 数据尚未初始化:$src" >&2; exit 1; } rm -rf "$work" mkdir -p "$work" cat > "$work/manifest.json" <&2; exit 1; } - [ -w "$share_dir" ] || { echo "shared folder is not writable: $share_dir" >&2; exit 1; } + [ -d "$share_dir" ] || { echo "共享文件夹未挂载:$share_dir" >&2; exit 1; } + [ -w "$share_dir" ] || { echo "共享文件夹不可写:$share_dir" >&2; exit 1; } \(body) """ } @@ -515,14 +515,14 @@ final class AgentToolsService { rel=\(shellQuote(relPath)) target="$home/$rel" work=\(shellQuote((inputPath as NSString).deletingLastPathComponent + "/.tenbox-profile-import")) - [ -f "$input" ] || { echo "package not found: $input" >&2; exit 1; } + [ -f "$input" ] || { echo "找不到导入包:$input" >&2; exit 1; } rm -rf "$work" mkdir -p "$work" tar --touch -xzf "$input" -C "$work" - [ -f "$work/manifest.json" ] || { echo "manifest.json missing" >&2; exit 1; } - [ -f "$work/files.tar.gz" ] || { echo "files.tar.gz missing" >&2; exit 1; } + [ -f "$work/manifest.json" ] || { echo "导入包缺少 manifest.json" >&2; exit 1; } + [ -f "$work/files.tar.gz" ] || { echo "导入包缺少 files.tar.gz" >&2; exit 1; } pkg_agent="$(awk -F\\" '/agent_type/ {print $4; exit}' "$work/manifest.json")" - [ "$pkg_agent" = "\(agent.rawValue)" ] || { echo "package is for $pkg_agent, not \(agent.rawValue)" >&2; exit 1; } + [ "$pkg_agent" = "\(agent.rawValue)" ] || { echo "导入包属于 $pkg_agent,不是 \(agent.rawValue)" >&2; exit 1; } backup="" if [ -e "$target" ]; then backup="$target.pre-import-$(date -u +%Y%m%d%H%M%S)" @@ -531,12 +531,12 @@ final class AgentToolsService { if ! tar -xzf "$work/files.tar.gz" -C "$home"; then rm -rf "$target" if [ -n "$backup" ] && [ -d "$backup" ]; then mv "$backup" "$target"; fi - echo "failed to restore Agent data" >&2 + echo "恢复 Agent 数据失败" >&2 exit 1 fi chmod 700 "$target" 2>/dev/null || true rm -rf "$work" - if [ -n "$backup" ]; then echo "$backup"; else echo "imported"; fi + if [ -n "$backup" ]; then echo "$backup"; else echo "已导入"; fi """ } @@ -555,12 +555,12 @@ final class AgentToolsService { free_kb="$(df -Pk "$HOME" 2>/dev/null | awk 'NR==2 {print $4}')" if [ "${free_kb:-0}" -gt 1048576 ]; then disk_state=ok; else disk_state=space_low; fi state=ok - message="Agent normal" - if [ "$disk_state" = space_low ]; then state=error; message="Disk space is low"; fi - if [ "$service_state" = error ]; then state=error; message="Agent service is not running"; fi - if [ "$port_state" = error ]; then state=error; message="Agent gateway is unavailable"; fi - if [ "$model_state" = error ]; then state=error; message="Model proxy is unavailable"; fi - if [ "$browser_state" = error ]; then state=error; message="Browser is unavailable"; fi + message="Agent 正常" + if [ "$disk_state" = space_low ]; then state=error; message="磁盘空间不足"; fi + if [ "$service_state" = error ]; then state=error; message="Agent 服务未运行"; fi + if [ "$port_state" = error ]; then state=error; message="Agent 网关不可用"; fi + if [ "$model_state" = error ]; then state=error; message="模型代理不可用"; fi + if [ "$browser_state" = error ]; then state=error; message="浏览器不可用"; fi printf '{"agent_type":"%s","state":"%s","message":"%s","checks":{"agent_service":"%s","gateway_port":"%s","llm_proxy":"%s","browser":"%s","disk":"%s"}}\\n' "$agent" "$state" "$message" "$service_state" "$port_state" "$model_state" "$browser_state" "$disk_state" """ } @@ -576,9 +576,9 @@ final class AgentToolsService { """ set -eu if curl -fsS --max-time 5 http://10.0.2.3/v1/models >/dev/null 2>&1; then - printf '{"agent_type":"%s","state":"ok","message":"Model proxy is available"}\\n' \(shellQuote(agent.rawValue)) + printf '{"agent_type":"%s","state":"ok","message":"模型代理可用"}\\n' \(shellQuote(agent.rawValue)) else - printf '{"agent_type":"%s","state":"error","message":"Model proxy is unavailable"}\\n' \(shellQuote(agent.rawValue)) + printf '{"agent_type":"%s","state":"error","message":"模型代理不可用"}\\n' \(shellQuote(agent.rawValue)) exit 1 fi """ @@ -611,7 +611,7 @@ final class AgentToolsService { case .openclaw: return """ set -eu - command -v openclaw >/dev/null 2>&1 || { echo "OpenClaw command is missing" >&2; exit 1; } + command -v openclaw >/dev/null 2>&1 || { echo "缺少 OpenClaw 命令" >&2; exit 1; } openclaw config set models.providers.tenbox '{"baseUrl":"http://10.0.2.3/v1","apiKey":"tenbox","api":"openai-completions","models":[{"id":"default","name":"Default (TenBox Proxy)","reasoning":false,"input":["text","image"],"contextWindow":200000,"maxTokens":65536,"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0}}]}' >/dev/null openclaw config set models.mode merge >/dev/null openclaw config set agents.defaults '{"model":{"primary":"tenbox/default"},"compaction":{"mode":"safeguard"},"workspace":"'"$HOME"'/.openclaw/workspace","models":{"tenbox/default":{}}}' >/dev/null diff --git a/src/manager-macos/TenBoxApp.swift b/src/manager-macos/TenBoxApp.swift index 93896f3..f0a8e1d 100644 --- a/src/manager-macos/TenBoxApp.swift +++ b/src/manager-macos/TenBoxApp.swift @@ -474,7 +474,7 @@ class AppState: ObservableObject { func exportAgentProfile(vmId: String, agent: AgentKind, destinationURL: URL, completion: @escaping (Result) -> Void) { guard let vm = vms.first(where: { $0.id == vmId }) else { - completion(.failure(ConsoleCommandError("VM not found"))) + completion(.failure(ConsoleCommandError("找不到 VM"))) return } let session = getOrCreateSession(for: vmId) @@ -485,7 +485,7 @@ class AppState: ObservableObject { func importAgentProfile(vmId: String, agent: AgentKind, sourceURL: URL, completion: @escaping (Result) -> Void) { guard let vm = vms.first(where: { $0.id == vmId }) else { - completion(.failure(ConsoleCommandError("VM not found"))) + completion(.failure(ConsoleCommandError("找不到 VM"))) return } let session = getOrCreateSession(for: vmId) @@ -496,7 +496,7 @@ class AppState: ObservableObject { func agentBackupStatus(vmId: String, agent: AgentKind, completion: @escaping (Result) -> Void) { guard let vm = vms.first(where: { $0.id == vmId }) else { - completion(.failure(ConsoleCommandError("VM not found"))) + completion(.failure(ConsoleCommandError("找不到 VM"))) return } let session = getOrCreateSession(for: vmId) @@ -507,7 +507,7 @@ class AppState: ObservableObject { func snapshotAgentBackup(vmId: String, agent: AgentKind, completion: @escaping (Result) -> Void) { guard let vm = vms.first(where: { $0.id == vmId }) else { - completion(.failure(ConsoleCommandError("VM not found"))) + completion(.failure(ConsoleCommandError("找不到 VM"))) return } let session = getOrCreateSession(for: vmId) @@ -518,7 +518,7 @@ class AppState: ObservableObject { func restoreLatestAgentBackup(vmId: String, agent: AgentKind, completion: @escaping (Result) -> Void) { guard let vm = vms.first(where: { $0.id == vmId }) else { - completion(.failure(ConsoleCommandError("VM not found"))) + completion(.failure(ConsoleCommandError("找不到 VM"))) return } let session = getOrCreateSession(for: vmId) @@ -529,7 +529,7 @@ class AppState: ObservableObject { func agentHealthStatus(vmId: String, agent: AgentKind, completion: @escaping (Result) -> Void) { guard let vm = vms.first(where: { $0.id == vmId }) else { - completion(.failure(ConsoleCommandError("VM not found"))) + completion(.failure(ConsoleCommandError("找不到 VM"))) return } let session = getOrCreateSession(for: vmId) @@ -540,7 +540,7 @@ class AppState: ObservableObject { func restartAgent(vmId: String, agent: AgentKind, completion: @escaping (Result) -> Void) { guard let vm = vms.first(where: { $0.id == vmId }) else { - completion(.failure(ConsoleCommandError("VM not found"))) + completion(.failure(ConsoleCommandError("找不到 VM"))) return } let session = getOrCreateSession(for: vmId) @@ -551,7 +551,7 @@ class AppState: ObservableObject { func testAgentModel(vmId: String, agent: AgentKind, completion: @escaping (Result) -> Void) { guard let vm = vms.first(where: { $0.id == vmId }) else { - completion(.failure(ConsoleCommandError("VM not found"))) + completion(.failure(ConsoleCommandError("找不到 VM"))) return } let session = getOrCreateSession(for: vmId) @@ -562,7 +562,7 @@ class AppState: ObservableObject { func resetAgentConfig(vmId: String, agent: AgentKind, completion: @escaping (Result) -> Void) { guard let vm = vms.first(where: { $0.id == vmId }) else { - completion(.failure(ConsoleCommandError("VM not found"))) + completion(.failure(ConsoleCommandError("找不到 VM"))) return } let session = getOrCreateSession(for: vmId) @@ -573,7 +573,7 @@ class AppState: ObservableObject { func exportAgentDiagnostics(vmId: String, agent: AgentKind, completion: @escaping (Result) -> Void) { guard let vm = vms.first(where: { $0.id == vmId }) else { - completion(.failure(ConsoleCommandError("VM not found"))) + completion(.failure(ConsoleCommandError("找不到 VM"))) return } let session = getOrCreateSession(for: vmId) @@ -806,7 +806,7 @@ private struct VmCommandMenuContent: View { } .disabled(vm == nil) - Button("Agent Data...") { + Button("Agent 数据...") { appState.showAgentToolsSheet = true } .disabled(vm == nil || !isRunning) diff --git a/src/manager-macos/Views/AgentToolsView.swift b/src/manager-macos/Views/AgentToolsView.swift index c9153b2..7a57130 100644 --- a/src/manager-macos/Views/AgentToolsView.swift +++ b/src/manager-macos/Views/AgentToolsView.swift @@ -1,184 +1,251 @@ import SwiftUI import AppKit +import UniformTypeIdentifiers struct AgentToolsSheet: View { let vmId: String + @ObservedObject private var session: VmSession @EnvironmentObject var appState: AppState @Environment(\.dismiss) private var dismiss @State private var selectedAgent: AgentKind = .hermes - @State private var isRunningOperation = false - @State private var resultText = "" - @State private var errorText = "" + @State private var runningOperation: AgentToolOperation? + @State private var operationResult: AgentOperationDisplay? + @State private var pendingConfirmation: PendingAgentConfirmation? + @State private var latestBackupText = "正在读取..." + @State private var latestBackupPath: String? + + init(vmId: String, session: VmSession) { + self.vmId = vmId + self.session = session + } private var vm: VmInfo? { appState.vms.first(where: { $0.id == vmId }) } private var canRun: Bool { - vm?.state == .running && !isRunningOperation + vm?.state == .running && session.guestAgentConnected && runningOperation == nil } - var body: some View { - VStack(alignment: .leading, spacing: 16) { - HStack { - Text("Agent Data") - .font(.title3) - .fontWeight(.semibold) - Spacer() - Button("Done") { dismiss() } - .keyboardShortcut(.cancelAction) - } - - Picker("Agent", selection: $selectedAgent) { - ForEach(AgentKind.allCases) { agent in - Text(agent.displayName).tag(agent) - } - } - .pickerStyle(.segmented) - - HStack(spacing: 10) { - Button { - exportProfile() - } label: { - Label("Export", systemImage: "square.and.arrow.up") - } - .disabled(!canRun) + private var confirmationPresented: Binding { + Binding( + get: { pendingConfirmation != nil }, + set: { if !$0 { pendingConfirmation = nil } } + ) + } - Button { - importProfile() - } label: { - Label("Import", systemImage: "square.and.arrow.down") - } - .disabled(!canRun) - } + var body: some View { + VStack(spacing: 0) { + header Divider() - Text("Backups") - .font(.headline) - - HStack(spacing: 10) { - Button { - showBackupStatus() - } label: { - Label("Status", systemImage: "checklist") - } - .disabled(!canRun) - - Button { - snapshotBackup() - } label: { - Label("Back Up Now", systemImage: "clock.arrow.circlepath") - } - .disabled(!canRun) - - Button { - restoreLatestBackup() - } label: { - Label("Restore Latest", systemImage: "arrow.uturn.backward") + ScrollView { + VStack(alignment: .leading, spacing: 16) { + statusPanel + + Picker("Agent", selection: $selectedAgent) { + ForEach(AgentKind.allCases) { agent in + Text(agent.displayName).tag(agent) + } + } + .pickerStyle(.segmented) + + operationSection( + title: "常用操作", + operations: [.snapshotBackup, .exportProfile, .healthCheck] + ) + + operationSection( + title: "维护操作", + operations: [.importProfile, .restoreLatest, .restartAgent, .testModel, .resetConfig, .diagnostics] + ) + + if let runningOperation { + HStack(spacing: 8) { + ProgressView() + .controlSize(.small) + Text(runningOperation.runningText(agent: selectedAgent)) + .foregroundStyle(.secondary) + } + } + + if let operationResult { + AgentOperationResultView(result: operationResult) + } } - .disabled(!canRun) + .padding() } - - Divider() - - Text("Health") - .font(.headline) - - HStack(spacing: 10) { - Button { - checkHealth() - } label: { - Label("Check", systemImage: "stethoscope") - } - .disabled(!canRun) - - Button { - restartAgent() - } label: { - Label("Restart", systemImage: "arrow.clockwise") - } - .disabled(!canRun) - - Button { - testModel() - } label: { - Label("Test Model", systemImage: "bolt.horizontal") + } + .frame(width: 640, height: 600) + .onAppear { + refreshBackupSummary() + } + .onChange(of: selectedAgent, perform: { _ in + operationResult = nil + refreshBackupSummary() + }) + .alert(pendingConfirmation?.title ?? "", isPresented: confirmationPresented) { + Button("取消", role: .cancel) { + pendingConfirmation = nil + } + if let pendingConfirmation { + Button(pendingConfirmation.confirmTitle, role: .destructive) { + confirmPendingAction(pendingConfirmation) } - .disabled(!canRun) } + } message: { + Text(pendingConfirmation?.message ?? "") + } + } - HStack(spacing: 10) { - Button { - resetConfig() - } label: { - Label("Reset Config", systemImage: "slider.horizontal.2.square") - } - .disabled(!canRun) + private var header: some View { + HStack(spacing: 12) { + Text("Agent 数据") + .font(.title3) + .fontWeight(.semibold) - Button { - exportDiagnostics() - } label: { - Label("Diagnostics", systemImage: "doc.zipper") - } - .disabled(!canRun) - } + Spacer() - if isRunningOperation { - ProgressView() - .controlSize(.small) - } + Button("完成") { dismiss() } + .keyboardShortcut(.cancelAction) + } + .padding() + } - if let vm = vm, vm.state != .running { - Text("Start the VM before using Agent data tools.") - .foregroundStyle(.secondary) + private var statusPanel: some View { + VStack(alignment: .leading, spacing: 10) { + HStack(spacing: 8) { + StatusPill( + title: "虚拟机", + value: vmStateText, + systemImage: "desktopcomputer", + tone: vm?.state == .running ? .ok : .muted + ) + StatusPill( + title: "执行通道", + value: session.guestAgentConnected ? "已连接" : "未连接", + systemImage: "checkmark.seal", + tone: session.guestAgentConnected ? .ok : .warning + ) + StatusPill( + title: "最近备份", + value: latestBackupText, + systemImage: "clock.arrow.circlepath", + tone: latestBackupPath == nil ? .muted : .ok + ) } - if !resultText.isEmpty { - Text(resultText) + if vm?.state != .running { + Text("请先启动 VM,再使用 Agent 数据工具。") + .font(.caption) + .foregroundStyle(.secondary) + } else if !session.guestAgentConnected { + Text("执行通道连接后才能执行导入、备份和健康检查。") + .font(.caption) .foregroundStyle(.secondary) - .textSelection(.enabled) } + } + .padding(12) + .background(.quaternary.opacity(0.7)) + .clipShape(RoundedRectangle(cornerRadius: 8)) + } + + private var vmStateText: String { + switch vm?.state { + case .running: return "运行中" + case .starting: return "启动中" + case .rebooting: return "重启中" + case .crashed: return "异常退出" + case .stopped: return "已停止" + case .none: return "未知" + } + } - if !errorText.isEmpty { - Text(errorText) - .foregroundStyle(.red) - .textSelection(.enabled) + private func operationSection(title: String, operations: [AgentToolOperation]) -> some View { + VStack(alignment: .leading, spacing: 8) { + Text(title) + .font(.headline) + + LazyVGrid(columns: [ + GridItem(.flexible(), spacing: 10), + GridItem(.flexible(), spacing: 10), + GridItem(.flexible(), spacing: 10) + ], spacing: 10) { + ForEach(operations) { operation in + Button { + run(operation) + } label: { + Label(operation.title, systemImage: operation.systemImage) + .frame(maxWidth: .infinity, alignment: .center) + } + .disabled(!canRun) + .help(operation.help) + } } + } + } - Spacer(minLength: 0) + private func run(_ operation: AgentToolOperation) { + switch operation { + case .exportProfile: + exportProfile() + case .importProfile: + importProfile() + case .snapshotBackup: + snapshotBackup() + case .restoreLatest: + pendingConfirmation = .restoreLatest + case .healthCheck: + checkHealth() + case .restartAgent: + restartAgent() + case .testModel: + testModel() + case .resetConfig: + pendingConfirmation = .resetConfig + case .diagnostics: + exportDiagnostics() } - .padding() - .frame(width: 560, height: 520) } private func exportProfile() { guard let vm = vm else { return } let panel = NSSavePanel() - panel.title = "Export Agent Data" + panel.title = "导出 Agent 数据" panel.nameFieldStringValue = "\(vm.name)-\(selectedAgent.rawValue)-profile.tar.gz" - panel.allowedContentTypes = [] + applyGzipTypeLimit(to: panel) presentPanel(panel) { response in guard response == .OK, let url = panel.url else { return } - runOperation { - appState.exportAgentProfile(vmId: vm.id, agent: selectedAgent, destinationURL: url, completion: $0) + let destinationURL = Self.normalizedPackageURL(url) + runOperation(.exportProfile, revealPath: destinationURL.path) { + appState.exportAgentProfile(vmId: vm.id, agent: selectedAgent, destinationURL: destinationURL, completion: $0) } } } private func importProfile() { - guard let vm = vm else { return } let panel = NSOpenPanel() - panel.title = "Import Agent Data" + panel.title = "导入 Agent 数据" panel.canChooseFiles = true panel.canChooseDirectories = false panel.allowsMultipleSelection = false + applyGzipTypeLimit(to: panel) presentPanel(panel) { response in guard response == .OK, let url = panel.url else { return } - runOperation { - appState.importAgentProfile(vmId: vm.id, agent: selectedAgent, sourceURL: url, completion: $0) + guard Self.isAgentPackageURL(url) else { + operationResult = AgentOperationDisplay( + isSuccess: false, + title: "导入失败", + summary: "请选择 .tar.gz 或 .tgz 文件", + details: url.path, + revealPath: nil, + healthReport: nil + ) + return } + pendingConfirmation = .importProfile(url) } } @@ -190,74 +257,598 @@ struct AgentToolsSheet: View { } } - private func showBackupStatus() { - guard let vm = vm else { return } - runOperation { - appState.agentBackupStatus(vmId: vm.id, agent: selectedAgent, completion: $0) + private func applyGzipTypeLimit(to panel: NSSavePanel) { + if let gzipType = UTType(filenameExtension: "gz") { + panel.allowedContentTypes = [gzipType] + } + } + + private func confirmPendingAction(_ pending: PendingAgentConfirmation) { + pendingConfirmation = nil + switch pending { + case .importProfile(let url): + guard let vm = vm else { return } + runOperation(.importProfile) { + appState.importAgentProfile(vmId: vm.id, agent: selectedAgent, sourceURL: url, completion: $0) + } + case .restoreLatest: + restoreLatestBackup() + case .resetConfig: + resetConfig() } } private func snapshotBackup() { guard let vm = vm else { return } - runOperation { + runOperation(.snapshotBackup) { appState.snapshotAgentBackup(vmId: vm.id, agent: selectedAgent, completion: $0) } } private func restoreLatestBackup() { guard let vm = vm else { return } - runOperation { + runOperation(.restoreLatest) { appState.restoreLatestAgentBackup(vmId: vm.id, agent: selectedAgent, completion: $0) } } private func checkHealth() { guard let vm = vm else { return } - runOperation { + runOperation(.healthCheck) { appState.agentHealthStatus(vmId: vm.id, agent: selectedAgent, completion: $0) } } private func restartAgent() { guard let vm = vm else { return } - runOperation { + runOperation(.restartAgent) { appState.restartAgent(vmId: vm.id, agent: selectedAgent, completion: $0) } } private func testModel() { guard let vm = vm else { return } - runOperation { + runOperation(.testModel) { appState.testAgentModel(vmId: vm.id, agent: selectedAgent, completion: $0) } } private func resetConfig() { guard let vm = vm else { return } - runOperation { + runOperation(.resetConfig) { appState.resetAgentConfig(vmId: vm.id, agent: selectedAgent, completion: $0) } } private func exportDiagnostics() { guard let vm = vm else { return } - runOperation { + runOperation(.diagnostics) { appState.exportAgentDiagnostics(vmId: vm.id, agent: selectedAgent, completion: $0) } } - private func runOperation(_ operation: (@escaping (Result) -> Void) -> Void) { - resultText = "" - errorText = "" - isRunningOperation = true - operation { result in + private func refreshBackupSummary() { + guard let vm = vm else { + latestBackupText = "未知" + latestBackupPath = nil + return + } + appState.agentBackupStatus(vmId: vm.id, agent: selectedAgent) { result in + DispatchQueue.main.async { + switch result { + case .success(let status): + latestBackupPath = Self.extractBackupPath(from: status.output) + if let latestBackupPath { + latestBackupText = Self.compactBackupText(path: latestBackupPath) + } else { + latestBackupText = "暂无" + } + case .failure: + latestBackupText = "读取失败" + latestBackupPath = nil + } + } + } + } + + private func runOperation(_ operation: AgentToolOperation, + revealPath: String? = nil, + _ action: (@escaping (Result) -> Void) -> Void) { + operationResult = nil + runningOperation = operation + action { result in DispatchQueue.main.async { - isRunningOperation = false + runningOperation = nil switch result { case .success(let output): - resultText = output.output.isEmpty ? output.message : "\(output.message)\n\(output.output)" + operationResult = Self.makeSuccessDisplay( + operation: operation, + result: output, + revealPath: revealPath + ) + refreshBackupSummary() case .failure(let error): - errorText = error.localizedDescription + operationResult = Self.makeFailureDisplay(operation: operation, error: error) + refreshBackupSummary() + } + } + } + } + + private static func makeSuccessDisplay(operation: AgentToolOperation, + result: AgentToolResult, + revealPath: String?) -> AgentOperationDisplay { + let raw = result.output.trimmingCharacters(in: .whitespacesAndNewlines) + let detectedPath = revealPath ?? operation.revealPath(from: result) + let health = operation.showsHealth ? HealthReport.parse(from: raw) : nil + let summary = result.message.trimmingCharacters(in: .whitespacesAndNewlines) + return AgentOperationDisplay( + isSuccess: true, + title: operation.successTitle, + summary: summary.isEmpty ? "操作已完成" : summary, + details: raw, + revealPath: detectedPath, + healthReport: health + ) + } + + private static func makeFailureDisplay(operation: AgentToolOperation, error: Error) -> AgentOperationDisplay { + let raw = error.localizedDescription.trimmingCharacters(in: .whitespacesAndNewlines) + return AgentOperationDisplay( + isSuccess: false, + title: operation.failureTitle, + summary: friendlyErrorMessage(raw), + details: raw, + revealPath: nil, + healthReport: nil + ) + } + + private static func extractBackupPath(from output: String) -> String? { + let prefix = "最近备份:" + for line in output.split(whereSeparator: { $0.isNewline }) { + let text = String(line).trimmingCharacters(in: .whitespaces) + if text.hasPrefix(prefix) { + return String(text.dropFirst(prefix.count)) + } + } + return nil + } + + private static func isAgentPackageURL(_ url: URL) -> Bool { + let name = url.lastPathComponent.lowercased() + return name.hasSuffix(".tar.gz") || name.hasSuffix(".tgz") + } + + private static func normalizedPackageURL(_ url: URL) -> URL { + if isAgentPackageURL(url) { + return url + } + if url.lastPathComponent.lowercased().hasSuffix(".gz") { + return url.deletingPathExtension().appendingPathExtension("tar.gz") + } + return url.appendingPathExtension("tar.gz") + } + + private static func compactBackupText(path: String) -> String { + let url = URL(fileURLWithPath: path) + if let attrs = try? FileManager.default.attributesOfItem(atPath: path), + let date = attrs[.modificationDate] as? Date { + let formatter = DateFormatter() + formatter.dateFormat = "MM-dd HH:mm" + return formatter.string(from: date) + } + return url.lastPathComponent + } + + private static func friendlyErrorMessage(_ raw: String) -> String { + if raw.isEmpty { return "操作失败" } + let checks: [(String, String)] = [ + ("VM not found", "找不到 VM"), + ("VM runtime is not connected", "VM 运行时未连接"), + ("Guest agent is not connected", "Guest Agent 未连接"), + ("Command timed out", "操作超时"), + ("Failed to send guest agent command", "发送 Guest Agent 命令失败"), + ("Agent data is not initialized", "Agent 数据尚未初始化"), + ("No backup package found", "没有找到可恢复的备份"), + ("package not found", "找不到导入包"), + ("manifest.json missing", "导入包缺少 manifest.json"), + ("files.tar.gz missing", "导入包缺少 files.tar.gz"), + ("Model proxy is unavailable", "模型代理不可用"), + ("Browser is unavailable", "浏览器不可用"), + ("Disk space is low", "磁盘空间不足"), + ("Agent service is not running", "Agent 服务未运行"), + ("Agent gateway is unavailable", "Agent 网关不可用") + ] + for (needle, message) in checks where raw.contains(needle) { + return message + } + return raw + } +} + +private enum AgentToolOperation: String, CaseIterable, Identifiable { + case exportProfile + case importProfile + case snapshotBackup + case restoreLatest + case healthCheck + case restartAgent + case testModel + case resetConfig + case diagnostics + + var id: String { rawValue } + + var title: String { + switch self { + case .exportProfile: return "导出" + case .importProfile: return "导入" + case .snapshotBackup: return "立即备份" + case .restoreLatest: return "恢复最近备份" + case .healthCheck: return "健康检查" + case .restartAgent: return "重启服务" + case .testModel: return "测试模型" + case .resetConfig: return "重置配置" + case .diagnostics: return "导出诊断" + } + } + + var systemImage: String { + switch self { + case .exportProfile: return "square.and.arrow.up" + case .importProfile: return "square.and.arrow.down" + case .snapshotBackup: return "clock.arrow.circlepath" + case .restoreLatest: return "arrow.uturn.backward" + case .healthCheck: return "stethoscope" + case .restartAgent: return "arrow.clockwise" + case .testModel: return "bolt.horizontal" + case .resetConfig: return "slider.horizontal.2.square" + case .diagnostics: return "doc.zipper" + } + } + + var help: String { + switch self { + case .exportProfile: return "导出当前 Agent 数据" + case .importProfile: return "从归档包导入 Agent 数据" + case .snapshotBackup: return "创建一份主机侧备份" + case .restoreLatest: return "用最近备份恢复 Agent 数据" + case .healthCheck: return "检查 Agent 运行状态" + case .restartAgent: return "重启 Agent 服务" + case .testModel: return "测试模型代理连接" + case .resetConfig: return "重置 Agent 模型配置" + case .diagnostics: return "导出诊断包" + } + } + + var successTitle: String { + switch self { + case .exportProfile: return "导出完成" + case .importProfile: return "导入完成" + case .snapshotBackup: return "备份完成" + case .restoreLatest: return "恢复完成" + case .healthCheck: return "健康检查完成" + case .restartAgent: return "重启完成" + case .testModel: return "模型测试完成" + case .resetConfig: return "配置已重置" + case .diagnostics: return "诊断包已导出" + } + } + + var failureTitle: String { + switch self { + case .exportProfile: return "导出失败" + case .importProfile: return "导入失败" + case .snapshotBackup: return "备份失败" + case .restoreLatest: return "恢复失败" + case .healthCheck: return "健康检查失败" + case .restartAgent: return "重启失败" + case .testModel: return "模型测试失败" + case .resetConfig: return "重置失败" + case .diagnostics: return "诊断导出失败" + } + } + + var showsHealth: Bool { + switch self { + case .healthCheck, .restartAgent, .testModel, .resetConfig: return true + default: return false + } + } + + func runningText(agent: AgentKind) -> String { + switch self { + case .exportProfile: return "正在导出 \(agent.displayName) 数据..." + case .importProfile: return "正在导入 \(agent.displayName) 数据..." + case .snapshotBackup: return "正在备份 \(agent.displayName) 数据..." + case .restoreLatest: return "正在恢复 \(agent.displayName) 最近备份..." + case .healthCheck: return "正在检查 \(agent.displayName) 状态..." + case .restartAgent: return "正在重启 \(agent.displayName) 服务..." + case .testModel: return "正在测试模型代理..." + case .resetConfig: return "正在重置 \(agent.displayName) 配置..." + case .diagnostics: return "正在导出 \(agent.displayName) 诊断包..." + } + } + + func revealPath(from result: AgentToolResult) -> String? { + let raw = result.output.trimmingCharacters(in: .whitespacesAndNewlines) + switch self { + case .snapshotBackup, .restoreLatest, .diagnostics: + return raw.split(whereSeparator: { $0.isNewline }).map(String.init).last + default: + return nil + } + } +} + +private enum PendingAgentConfirmation: Identifiable { + case importProfile(URL) + case restoreLatest + case resetConfig + + var id: String { + switch self { + case .importProfile(let url): return "import-\(url.path)" + case .restoreLatest: return "restore" + case .resetConfig: return "reset" + } + } + + var title: String { + switch self { + case .importProfile: return "确认导入 Agent 数据?" + case .restoreLatest: return "确认恢复最近备份?" + case .resetConfig: return "确认重置配置?" + } + } + + var message: String { + switch self { + case .importProfile(let url): + return "导入会替换当前 Agent 数据。文件:\(url.lastPathComponent)" + case .restoreLatest: + return "恢复会用最近一份备份覆盖当前 Agent 数据。" + case .resetConfig: + return "重置会覆盖当前 Agent 模型配置。" + } + } + + var confirmTitle: String { + switch self { + case .importProfile: return "导入" + case .restoreLatest: return "恢复" + case .resetConfig: return "重置" + } + } +} + +private struct AgentOperationDisplay: Identifiable { + let id = UUID() + let isSuccess: Bool + let title: String + let summary: String + let details: String + let revealPath: String? + let healthReport: HealthReport? +} + +private struct AgentOperationResultView: View { + let result: AgentOperationDisplay + @State private var showsDetails = false + + var body: some View { + VStack(alignment: .leading, spacing: 10) { + HStack(spacing: 8) { + Image(systemName: result.isSuccess ? "checkmark.circle.fill" : "xmark.octagon.fill") + .foregroundStyle(result.isSuccess ? .green : .red) + VStack(alignment: .leading, spacing: 2) { + Text(result.title) + .fontWeight(.semibold) + Text(result.summary) + .font(.caption) + .foregroundStyle(.secondary) + .textSelection(.enabled) + } + Spacer() + } + + if let report = result.healthReport { + HealthReportView(report: report) + } + + HStack(spacing: 12) { + if let path = result.revealPath, !path.isEmpty { + Button { + NSWorkspace.shared.activateFileViewerSelecting([URL(fileURLWithPath: path)]) + } label: { + Label("在 Finder 中显示", systemImage: "folder") + } + .buttonStyle(.link) + } + + if !result.details.isEmpty { + Button { + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(result.details, forType: .string) + } label: { + Label("复制详情", systemImage: "doc.on.doc") + } + .buttonStyle(.link) + } + + Spacer() + } + + if !result.details.isEmpty { + DisclosureGroup(isExpanded: $showsDetails) { + ScrollView { + Text(result.details) + .font(.system(.caption, design: .monospaced)) + .textSelection(.enabled) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.top, 4) + } + .frame(maxHeight: 140) + } label: { + Text("详情") + .font(.caption) + } + } + } + .padding(12) + .background(result.isSuccess ? Color.green.opacity(0.08) : Color.red.opacity(0.08)) + .clipShape(RoundedRectangle(cornerRadius: 8)) + } +} + +private struct StatusPill: View { + enum Tone { + case ok + case warning + case muted + } + + let title: String + let value: String + let systemImage: String + let tone: Tone + + private var color: Color { + switch tone { + case .ok: return .green + case .warning: return .orange + case .muted: return .secondary + } + } + + var body: some View { + HStack(spacing: 6) { + Image(systemName: systemImage) + .foregroundStyle(color) + VStack(alignment: .leading, spacing: 1) { + Text(title) + .font(.caption2) + .foregroundStyle(.secondary) + Text(value) + .font(.caption) + .fontWeight(.medium) + .lineLimit(1) + .truncationMode(.middle) + } + } + .padding(.horizontal, 8) + .padding(.vertical, 6) + .frame(maxWidth: .infinity, alignment: .leading) + .background(.background.opacity(0.6)) + .clipShape(RoundedRectangle(cornerRadius: 6)) + } +} + +private struct HealthReport { + let state: String + let message: String + let checks: [HealthCheckItem] + + static func parse(from raw: String) -> HealthReport? { + let jsonLine = raw + .split(whereSeparator: { $0.isNewline }) + .map { String($0).trimmingCharacters(in: .whitespacesAndNewlines) } + .first { $0.hasPrefix("{") && $0.hasSuffix("}") } + guard let jsonLine, + let data = jsonLine.data(using: .utf8), + let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { + return nil + } + let checks = object["checks"] as? [String: Any] ?? [:] + return HealthReport( + state: object["state"] as? String ?? "unknown", + message: translateMessage(object["message"] as? String ?? ""), + checks: [ + HealthCheckItem(title: "Agent 服务", value: checks["agent_service"] as? String ?? "unknown"), + HealthCheckItem(title: "网关端口", value: checks["gateway_port"] as? String ?? "unknown"), + HealthCheckItem(title: "模型代理", value: checks["llm_proxy"] as? String ?? "unknown"), + HealthCheckItem(title: "浏览器", value: checks["browser"] as? String ?? "unknown"), + HealthCheckItem(title: "磁盘空间", value: checks["disk"] as? String ?? "unknown") + ] + ) + } + + private static func translateMessage(_ message: String) -> String { + switch message { + case "Agent normal": return "Agent 正常" + case "Disk space is low": return "磁盘空间不足" + case "Agent service is not running": return "Agent 服务未运行" + case "Agent gateway is unavailable": return "Agent 网关不可用" + case "Model proxy is unavailable": return "模型代理不可用" + case "Browser is unavailable": return "浏览器不可用" + case "Model proxy is available": return "模型代理可用" + default: return message.isEmpty ? "状态未知" : message + } + } +} + +private struct HealthCheckItem: Identifiable { + let id = UUID() + let title: String + let value: String + + var displayValue: String { + switch value { + case "ok": return "正常" + case "error": return "异常" + case "skipped": return "跳过" + case "space_low": return "空间不足" + default: return "未知" + } + } + + var color: Color { + switch value { + case "ok", "skipped": return .green + case "space_low": return .orange + case "error": return .red + default: return .secondary + } + } + + var icon: String { + switch value { + case "ok", "skipped": return "checkmark.circle.fill" + case "space_low": return "exclamationmark.triangle.fill" + case "error": return "xmark.octagon.fill" + default: return "questionmark.circle" + } + } +} + +private struct HealthReportView: View { + let report: HealthReport + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + Text(report.message) + .font(.caption) + .foregroundStyle(report.state == "ok" ? Color.secondary : Color.red) + + LazyVGrid(columns: [ + GridItem(.flexible(), spacing: 8), + GridItem(.flexible(), spacing: 8) + ], spacing: 8) { + ForEach(report.checks) { item in + HStack(spacing: 6) { + Image(systemName: item.icon) + .foregroundStyle(item.color) + Text(item.title) + Spacer() + Text(item.displayValue) + .foregroundStyle(.secondary) + } + .font(.caption) + .padding(.horizontal, 8) + .padding(.vertical, 6) + .background(.background.opacity(0.65)) + .clipShape(RoundedRectangle(cornerRadius: 6)) } } } diff --git a/src/manager-macos/Views/ContentView.swift b/src/manager-macos/Views/ContentView.swift index 436cf18..d25ca48 100644 --- a/src/manager-macos/Views/ContentView.swift +++ b/src/manager-macos/Views/ContentView.swift @@ -113,10 +113,10 @@ struct ContentView: View { .help("Manage LLM proxy settings") Button(action: { appState.showAgentToolsSheet = true }) { - Label("Agent Data", systemImage: "externaldrive.badge.person.crop") + Label("Agent 数据", systemImage: "externaldrive.badge.person.crop") } .disabled(vm.state != .running) - .help("Export or import Agent data") + .help("管理 Agent 数据") Picker("", selection: appState.activeTabBinding(for: vm.id)) { Image(systemName: "info.circle").tag(0) @@ -152,7 +152,7 @@ struct ContentView: View { } .sheet(isPresented: $appState.showAgentToolsSheet) { if let vm = selectedVm { - AgentToolsSheet(vmId: vm.id) + AgentToolsSheet(vmId: vm.id, session: appState.getOrCreateSession(for: vm.id)) } } .alert("Delete VM", isPresented: $appState.showDeleteConfirm) { From ab9cd029c7f33f6149b25d799b99fd8d09b6c4cd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BC=A0=E5=AE=89=E5=93=B2?= Date: Tue, 12 May 2026 00:29:36 +0800 Subject: [PATCH 25/37] feat: add scheduled agent backups --- CLAUDE.md | 3 +- .../Services/AgentToolsService.swift | 142 ++++++---- src/manager-macos/TenBoxApp.swift | 177 ++++++++++-- src/manager-macos/Views/AgentToolsView.swift | 252 ++++++++++++++++-- 4 files changed, 480 insertions(+), 94 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index f3a7601..132036f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -90,7 +90,8 @@ Win/macOS: tenbox-manager ──IPC v1──► tenbox-vm-runtime (WHVP / HVF) - **macOS Caps Lock forwarding**: send Caps Lock as a tap (`down` then `up`) on each `flagsChanged` event; AppKit exposes it as a toggle state, but the guest input stack needs a full key press for every switch. - **Agent data profile packages**: `TenBox.app` exports/imports Hermes/OpenClaw data without image changes by using a temporary shared folder and qemu-guest-agent `guest-exec` shell commands. Keep the gzip package format documented in `docs/agent-profile.md` and reject cross-agent imports. - **Hermes profile scope**: export user/config/state data, not the reinstallable Hermes app checkout, virtualenv, local binaries, logs, or cache directories. -- **Agent data backups**: `TenBox.app` writes host-managed backups to `~/Library/Application Support/TenBox/AgentBackups//` and retains the latest 5 packages. Profile export runs against live Agent data, so GNU tar exit 1 from changed/skipped files is treated as non-fatal; fatal tar errors must still fail the backup. +- **Agent data backups**: `TenBox.app` writes host-managed backups to `~/Library/Application Support/TenBox/AgentBackups//` and retains packages according to the configured per-Agent retention count (default 7). Profile export runs against live Agent data, so GNU tar exit 1 from changed/skipped files is treated as non-fatal; fatal tar errors must still fail the backup. +- **Agent scheduled backups**: `TenBox.app` supports per-VM/per-Agent scheduled backups stored in `settings.json`; default time is 03:00 and default retention is 7 packages. Backup filenames must be time-based (`agent-data-yyyy-MM-dd-HHmmss.tar.gz`), restore should let the user choose a package, and scheduled backups only run when the VM is running and the guest execution channel is connected. - **Agent health checks**: `TenBox.app` runs health, restart, model test, config reset, and diagnostics through qemu-guest-agent `guest-exec`. Repair actions must create a host-managed Agent data backup first. - **macOS Agent data UI**: `TenBox.app` exposes Agent data export/import from the VM toolbar/menu while a VM is running. Keep user-facing Agent tool copy in Chinese, show operation status/results in the sheet, and confirm destructive import/restore/reset actions. It must not depend on preinstalled guest TenBox scripts. - **macOS Agent data shares**: Agent tool temporary shared folders are runtime-only; do not persist operation or backup share tags into VM config. diff --git a/src/manager-macos/Services/AgentToolsService.swift b/src/manager-macos/Services/AgentToolsService.swift index b6d0069..8202f55 100644 --- a/src/manager-macos/Services/AgentToolsService.swift +++ b/src/manager-macos/Services/AgentToolsService.swift @@ -24,6 +24,43 @@ struct AgentToolResult { let output: String } +struct AgentBackupPackage: Identifiable, Equatable { + let url: URL + let modifiedAt: Date + let sizeBytes: Int64 + + var id: String { url.path } + var filename: String { url.lastPathComponent } +} + +struct AgentBackupSchedule: Codable, Equatable { + static let defaultHour = 3 + static let defaultMinute = 0 + static let defaultKeepCount = 7 + + var enabled: Bool + var hour: Int + var minute: Int + var keepCount: Int + var lastRunDate: String? + + init(enabled: Bool = false, + hour: Int = Self.defaultHour, + minute: Int = Self.defaultMinute, + keepCount: Int = Self.defaultKeepCount, + lastRunDate: String? = nil) { + self.enabled = enabled + self.hour = min(max(hour, 0), 23) + self.minute = min(max(minute, 0), 59) + self.keepCount = min(max(keepCount, 1), 99) + self.lastRunDate = lastRunDate + } + + var timeText: String { + String(format: "%02d:%02d", hour, minute) + } +} + struct ConsoleCommandError: LocalizedError { let errorDescription: String? @@ -145,6 +182,7 @@ final class AgentToolsService { } func snapshotBackup(vm: VmInfo, session: VmSession, appState: AppState, agent: AgentKind, + keepCount: Int = AgentBackupSchedule.defaultKeepCount, completion: @escaping (Result) -> Void) { do { let package = try backupPackageURL(vmId: vm.id, agent: agent) @@ -163,7 +201,7 @@ final class AgentToolsService { completion(.failure(Self.makeError(commandResult.output.isEmpty ? "Agent 备份失败" : commandResult.output))) return } - self.rotateBackups(vmId: vm.id, agent: agent, keep: 5) + self.rotateBackups(vmId: vm.id, agent: agent, keep: keepCount) completion(.success(AgentToolResult( message: "已创建 Agent 数据备份", output: package.path @@ -180,39 +218,32 @@ final class AgentToolsService { } } - func restoreLatestBackup(vm: VmInfo, session: VmSession, appState: AppState, agent: AgentKind, - completion: @escaping (Result) -> Void) { - do { - guard let latest = try latestBackupPackage(vmId: vm.id, agent: agent) else { - completion(.failure(Self.makeError("没有找到备份包"))) - return - } - withBackupShare(vmId: vm.id, appState: appState) { share, cleanup in - let guestPackage = "/mnt/shared/\(share.tag)/\(agent.rawValue)/\(latest.lastPathComponent)" - let command = Self.withSharedFolderReady( - tag: share.tag, - body: Self.profileImportCommand(agent: agent, inputPath: guestPackage) - ) - session.runGuestAgentCommand(command, timeout: 420) { result in - cleanup() - switch result { - case .success(let commandResult): - guard commandResult.exitCode == 0 else { - completion(.failure(Self.makeError(commandResult.output.isEmpty ? "Agent 备份恢复失败" : commandResult.output))) - return - } - completion(.success(AgentToolResult( - message: "已从最近备份恢复 Agent 数据", - output: latest.path - ))) - case .failure(let error): - completion(.failure(error)) + func restoreBackup(vm: VmInfo, session: VmSession, appState: AppState, agent: AgentKind, + packageURL: URL, + completion: @escaping (Result) -> Void) { + withBackupShare(vmId: vm.id, appState: appState) { share, cleanup in + let guestPackage = "/mnt/shared/\(share.tag)/\(agent.rawValue)/\(packageURL.lastPathComponent)" + let command = Self.withSharedFolderReady( + tag: share.tag, + body: Self.profileImportCommand(agent: agent, inputPath: guestPackage) + ) + session.runGuestAgentCommand(command, timeout: 420) { result in + cleanup() + switch result { + case .success(let commandResult): + guard commandResult.exitCode == 0 else { + completion(.failure(Self.makeError(commandResult.output.isEmpty ? "Agent 备份恢复失败" : commandResult.output))) + return } + completion(.success(AgentToolResult( + message: "已恢复 Agent 数据备份", + output: packageURL.path + ))) + case .failure(let error): + completion(.failure(error)) } - } failure: { error in - completion(.failure(error)) } - } catch { + } failure: { error in completion(.failure(error)) } } @@ -226,10 +257,12 @@ final class AgentToolsService { } func restartAgent(vm: VmInfo, session: VmSession, appState: AppState, agent: AgentKind, + keepCount: Int = AgentBackupSchedule.defaultKeepCount, completion: @escaping (Result) -> Void) { runRepairCommand(vm: vm, session: session, appState: appState, agent: agent, repairCommand: Self.restartCommand(agent: agent), successMessage: "已重新启动 Agent", + keepCount: keepCount, completion: completion) } @@ -242,10 +275,12 @@ final class AgentToolsService { } func resetAgentConfig(vm: VmInfo, session: VmSession, appState: AppState, agent: AgentKind, + keepCount: Int = AgentBackupSchedule.defaultKeepCount, completion: @escaping (Result) -> Void) { runRepairCommand(vm: vm, session: session, appState: appState, agent: agent, repairCommand: Self.resetConfigCommand(agent: agent), successMessage: "已重置 Agent 配置", + keepCount: keepCount, completion: completion) } @@ -300,6 +335,7 @@ final class AgentToolsService { private func runRepairCommand(vm: VmInfo, session: VmSession, appState: AppState, agent: AgentKind, repairCommand: String, successMessage: String, + keepCount: Int = AgentBackupSchedule.defaultKeepCount, completion: @escaping (Result) -> Void) { do { let package = try backupPackageURL(vmId: vm.id, agent: agent) @@ -319,7 +355,7 @@ final class AgentToolsService { completion(.failure(Self.makeError(commandResult.output.isEmpty ? "Agent 修复操作失败" : commandResult.output))) return } - self.rotateBackups(vmId: vm.id, agent: agent, keep: 5) + self.rotateBackups(vmId: vm.id, agent: agent, keep: keepCount) completion(.success(AgentToolResult( message: successMessage, output: "修复前备份:\(package.path)\n\(commandResult.output)" @@ -412,45 +448,39 @@ final class AgentToolsService { let formatter = DateFormatter() formatter.calendar = Calendar(identifier: .gregorian) formatter.locale = Locale(identifier: "en_US_POSIX") - formatter.timeZone = TimeZone(secondsFromGMT: 0) - formatter.dateFormat = "yyyyMMddHHmmss" + formatter.dateFormat = "yyyy-MM-dd-HHmmss" return try backupPackageDirectory(vmId: vmId, agent: agent) .appendingPathComponent("agent-data-\(formatter.string(from: Date())).tar.gz") } - private func latestBackupPackage(vmId: String, agent: AgentKind) throws -> URL? { + func listBackupPackages(vmId: String, agent: AgentKind) throws -> [AgentBackupPackage] { let dir = try backupPackageDirectory(vmId: vmId, agent: agent) let items = (try? fileManager.contentsOfDirectory( at: dir, - includingPropertiesForKeys: [.contentModificationDateKey], + includingPropertiesForKeys: [.contentModificationDateKey, .fileSizeKey], options: [.skipsHiddenFiles] )) ?? [] return items .filter { $0.pathExtension == "gz" && $0.lastPathComponent.hasPrefix("agent-data-") } - .sorted { lhs, rhs in - let lm = (try? lhs.resourceValues(forKeys: [.contentModificationDateKey]).contentModificationDate) ?? .distantPast - let rm = (try? rhs.resourceValues(forKeys: [.contentModificationDateKey]).contentModificationDate) ?? .distantPast - return lm > rm + .map { url in + let values = try? url.resourceValues(forKeys: [.contentModificationDateKey, .fileSizeKey]) + return AgentBackupPackage( + url: url, + modifiedAt: values?.contentModificationDate ?? .distantPast, + sizeBytes: Int64(values?.fileSize ?? 0) + ) } - .first + .sorted { $0.modifiedAt > $1.modifiedAt } } - private func rotateBackups(vmId: String, agent: AgentKind, keep: Int) { - guard let dir = try? backupPackageDirectory(vmId: vmId, agent: agent), - let items = try? fileManager.contentsOfDirectory( - at: dir, - includingPropertiesForKeys: [.contentModificationDateKey], - options: [.skipsHiddenFiles] - ) else { return } - let packages = items - .filter { $0.pathExtension == "gz" && $0.lastPathComponent.hasPrefix("agent-data-") } - .sorted { lhs, rhs in - let lm = (try? lhs.resourceValues(forKeys: [.contentModificationDateKey]).contentModificationDate) ?? .distantPast - let rm = (try? rhs.resourceValues(forKeys: [.contentModificationDateKey]).contentModificationDate) ?? .distantPast - return lm > rm - } + private func latestBackupPackage(vmId: String, agent: AgentKind) throws -> URL? { + try listBackupPackages(vmId: vmId, agent: agent).first?.url + } + + func rotateBackups(vmId: String, agent: AgentKind, keep: Int) { + guard let packages = try? listBackupPackages(vmId: vmId, agent: agent) else { return } for old in packages.dropFirst(keep) { - try? fileManager.removeItem(at: old) + try? fileManager.removeItem(at: old.url) } } diff --git a/src/manager-macos/TenBoxApp.swift b/src/manager-macos/TenBoxApp.swift index f0a8e1d..db2de37 100644 --- a/src/manager-macos/TenBoxApp.swift +++ b/src/manager-macos/TenBoxApp.swift @@ -148,6 +148,7 @@ class AppState: ObservableObject { @Published var hostForwardError: String? @Published var llmMappings: [LlmModelMapping] = [] @Published var llmLoggingEnabled = false + @Published var agentBackupSchedules: [String: AgentBackupSchedule] = [:] let llmProxy = LlmProxyService() private let agentTools = AgentToolsService() @@ -161,6 +162,8 @@ class AppState: ObservableObject { private var sessionCancellables: [String: AnyCancellable] = [:] private var stateObserver: NSObjectProtocol? private var workspaceWakeObserver: NSObjectProtocol? + private var agentBackupTimer: Timer? + private var scheduledBackupsRunning: Set = [] private var pendingVmStartId: String? private var sleepAssertionID: IOPMAssertionID = IOPMAssertionID(0) @@ -173,6 +176,8 @@ class AppState: ObservableObject { } loadLlmMappings() startLlmProxyIfNeeded() + loadAgentBackupSchedules() + startAgentBackupScheduler() setupClipboard() stateObserver = NotificationCenter.default.addObserver( forName: NSNotification.Name("TenBoxVmStateChanged"), @@ -228,6 +233,7 @@ class AppState: ObservableObject { deinit { clipboardHandler.stopMonitoring() + agentBackupTimer?.invalidate() releaseSleepAssertion() if let obs = stateObserver { NotificationCenter.default.removeObserver(obs) @@ -504,6 +510,10 @@ class AppState: ObservableObject { completion: completion) } + func listAgentBackups(vmId: String, agent: AgentKind) -> [AgentBackupPackage] { + (try? agentTools.listBackupPackages(vmId: vmId, agent: agent)) ?? [] + } + func snapshotAgentBackup(vmId: String, agent: AgentKind, completion: @escaping (Result) -> Void) { guard let vm = vms.first(where: { $0.id == vmId }) else { @@ -512,18 +522,19 @@ class AppState: ObservableObject { } let session = getOrCreateSession(for: vmId) agentTools.snapshotBackup(vm: vm, session: session, appState: self, agent: agent, + keepCount: agentBackupSchedule(vmId: vmId, agent: agent).keepCount, completion: completion) } - func restoreLatestAgentBackup(vmId: String, agent: AgentKind, - completion: @escaping (Result) -> Void) { + func restoreAgentBackup(vmId: String, agent: AgentKind, packageURL: URL, + completion: @escaping (Result) -> Void) { guard let vm = vms.first(where: { $0.id == vmId }) else { completion(.failure(ConsoleCommandError("找不到 VM"))) return } let session = getOrCreateSession(for: vmId) - agentTools.restoreLatestBackup(vm: vm, session: session, appState: self, agent: agent, - completion: completion) + agentTools.restoreBackup(vm: vm, session: session, appState: self, agent: agent, + packageURL: packageURL, completion: completion) } func agentHealthStatus(vmId: String, agent: AgentKind, @@ -545,6 +556,7 @@ class AppState: ObservableObject { } let session = getOrCreateSession(for: vmId) agentTools.restartAgent(vm: vm, session: session, appState: self, agent: agent, + keepCount: agentBackupSchedule(vmId: vmId, agent: agent).keepCount, completion: completion) } @@ -567,6 +579,7 @@ class AppState: ObservableObject { } let session = getOrCreateSession(for: vmId) agentTools.resetAgentConfig(vm: vm, session: session, appState: self, agent: agent, + keepCount: agentBackupSchedule(vmId: vmId, agent: agent).keepCount, completion: completion) } @@ -581,6 +594,134 @@ class AppState: ObservableObject { completion: completion) } + func agentBackupSchedule(vmId: String, agent: AgentKind) -> AgentBackupSchedule { + agentBackupSchedules[Self.agentBackupScheduleKey(vmId: vmId, agent: agent)] ?? AgentBackupSchedule() + } + + func setAgentBackupSchedule(_ schedule: AgentBackupSchedule, vmId: String, agent: AgentKind) { + let previous = agentBackupSchedule(vmId: vmId, agent: agent) + var normalized = AgentBackupSchedule( + enabled: schedule.enabled, + hour: schedule.hour, + minute: schedule.minute, + keepCount: schedule.keepCount, + lastRunDate: schedule.lastRunDate + ) + let now = Date() + let calendar = Calendar.current + let nowMinutes = calendar.component(.hour, from: now) * 60 + calendar.component(.minute, from: now) + let scheduledMinutes = normalized.hour * 60 + normalized.minute + if !previous.enabled && normalized.enabled && nowMinutes >= scheduledMinutes && normalized.lastRunDate == nil { + normalized.lastRunDate = Self.agentBackupDateKey(now) + } + agentBackupSchedules[Self.agentBackupScheduleKey(vmId: vmId, agent: agent)] = normalized + saveAgentBackupSchedules() + agentTools.rotateBackups(vmId: vmId, agent: agent, keep: normalized.keepCount) + } + + private static func agentBackupScheduleKey(vmId: String, agent: AgentKind) -> String { + "\(vmId)|\(agent.rawValue)" + } + + private func loadAgentBackupSchedules() { + guard let agentBackups = readSettingsJSON()["agent_backups"] as? [String: Any], + let schedules = agentBackups["schedules"] as? [String: Any] else { + agentBackupSchedules = [:] + return + } + var loaded: [String: AgentBackupSchedule] = [:] + for (key, value) in schedules { + guard let dict = value as? [String: Any] else { continue } + loaded[key] = AgentBackupSchedule( + enabled: dict["enabled"] as? Bool ?? false, + hour: dict["hour"] as? Int ?? AgentBackupSchedule.defaultHour, + minute: dict["minute"] as? Int ?? AgentBackupSchedule.defaultMinute, + keepCount: dict["keep_count"] as? Int ?? AgentBackupSchedule.defaultKeepCount, + lastRunDate: dict["last_run_date"] as? String + ) + } + agentBackupSchedules = loaded + } + + private func saveAgentBackupSchedules() { + var json = readSettingsJSON() + let schedules: [String: [String: Any]] = agentBackupSchedules.mapValues { schedule in + var value: [String: Any] = [ + "enabled": schedule.enabled, + "hour": schedule.hour, + "minute": schedule.minute, + "keep_count": schedule.keepCount, + ] + if let lastRunDate = schedule.lastRunDate { + value["last_run_date"] = lastRunDate + } + return value + } + json["agent_backups"] = ["schedules": schedules] as [String: Any] + writeSettingsJSON(json) + } + + private func startAgentBackupScheduler() { + agentBackupTimer?.invalidate() + agentBackupTimer = Timer.scheduledTimer(withTimeInterval: 60, repeats: true) { [weak self] _ in + self?.runDueAgentBackups() + } + runDueAgentBackups() + } + + private func runDueAgentBackups(now: Date = Date()) { + let calendar = Calendar.current + let today = Self.agentBackupDateKey(now) + let nowMinutes = calendar.component(.hour, from: now) * 60 + calendar.component(.minute, from: now) + + for (key, schedule) in agentBackupSchedules { + guard schedule.enabled, schedule.lastRunDate != today else { continue } + let scheduledMinutes = schedule.hour * 60 + schedule.minute + guard nowMinutes >= scheduledMinutes else { continue } + guard !scheduledBackupsRunning.contains(key) else { continue } + + let parts = key.split(separator: "|", maxSplits: 1).map(String.init) + guard parts.count == 2, + let agent = AgentKind(rawValue: parts[1]), + let vm = vms.first(where: { $0.id == parts[0] && $0.state == .running }) else { + continue + } + + let session = getOrCreateSession(for: vm.id) + if !session.connected || !session.ipcClient.isConnected { + session.connectIfNeeded() + continue + } + guard session.guestAgentConnected else { continue } + + scheduledBackupsRunning.insert(key) + agentTools.snapshotBackup(vm: vm, session: session, appState: self, agent: agent, keepCount: schedule.keepCount) { [weak self] result in + DispatchQueue.main.async { + guard let self = self else { return } + self.scheduledBackupsRunning.remove(key) + switch result { + case .success: + var updated = self.agentBackupSchedules[key] ?? schedule + updated.lastRunDate = today + self.agentBackupSchedules[key] = updated + self.saveAgentBackupSchedules() + NSLog("[AgentBackup] Scheduled backup completed: %@ %@", vm.id, agent.rawValue) + case .failure(let error): + NSLog("[AgentBackup] Scheduled backup failed: %@ %@ %@", vm.id, agent.rawValue, error.localizedDescription) + } + } + } + } + } + + private static func agentBackupDateKey(_ date: Date) -> String { + let formatter = DateFormatter() + formatter.calendar = Calendar(identifier: .gregorian) + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.dateFormat = "yyyy-MM-dd" + return formatter.string(from: date) + } + // MARK: - LLM Proxy settings private var settingsPath: String { @@ -590,10 +731,22 @@ class AppState: ObservableObject { return dir + "/settings.json" } - func loadLlmMappings() { + private func readSettingsJSON() -> [String: Any] { guard let data = FileManager.default.contents(atPath: settingsPath), - let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], - let llmProxy = json["llm_proxy"] as? [String: Any], + let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { + return [:] + } + return json + } + + private func writeSettingsJSON(_ json: [String: Any]) { + if let data = try? JSONSerialization.data(withJSONObject: json, options: .prettyPrinted) { + try? data.write(to: URL(fileURLWithPath: settingsPath)) + } + } + + func loadLlmMappings() { + guard let llmProxy = readSettingsJSON()["llm_proxy"] as? [String: Any], let mappingsArray = llmProxy["mappings"] as? [[String: Any]] else { llmMappings = [] return @@ -612,11 +765,7 @@ class AppState: ObservableObject { } private func saveLlmMappings() { - var json: [String: Any] = [:] - if let data = FileManager.default.contents(atPath: settingsPath), - let existing = try? JSONSerialization.jsonObject(with: data) as? [String: Any] { - json = existing - } + var json = readSettingsJSON() let mappingsArray: [[String: Any]] = llmMappings.map { m in [ "alias": m.alias, @@ -630,9 +779,7 @@ class AppState: ObservableObject { "mappings": mappingsArray, "enable_logging": llmLoggingEnabled, ] as [String: Any] - if let data = try? JSONSerialization.data(withJSONObject: json, options: .prettyPrinted) { - try? data.write(to: URL(fileURLWithPath: settingsPath)) - } + writeSettingsJSON(json) } func addLlmMapping(_ mapping: LlmModelMapping) { diff --git a/src/manager-macos/Views/AgentToolsView.swift b/src/manager-macos/Views/AgentToolsView.swift index 7a57130..3b80a28 100644 --- a/src/manager-macos/Views/AgentToolsView.swift +++ b/src/manager-macos/Views/AgentToolsView.swift @@ -14,6 +14,9 @@ struct AgentToolsSheet: View { @State private var pendingConfirmation: PendingAgentConfirmation? @State private var latestBackupText = "正在读取..." @State private var latestBackupPath: String? + @State private var backupSchedule = AgentBackupSchedule() + @State private var backupPackages: [AgentBackupPackage] = [] + @State private var selectedBackupId: String? init(vmId: String, session: VmSession) { self.vmId = vmId @@ -57,9 +60,13 @@ struct AgentToolsSheet: View { operations: [.snapshotBackup, .exportProfile, .healthCheck] ) + schedulePanel + + backupPickerPanel + operationSection( title: "维护操作", - operations: [.importProfile, .restoreLatest, .restartAgent, .testModel, .resetConfig, .diagnostics] + operations: [.importProfile, .restartAgent, .testModel, .resetConfig, .diagnostics] ) if let runningOperation { @@ -80,10 +87,15 @@ struct AgentToolsSheet: View { } .frame(width: 640, height: 600) .onAppear { + loadSchedule() + refreshBackupList() refreshBackupSummary() } .onChange(of: selectedAgent, perform: { _ in operationResult = nil + selectedBackupId = nil + loadSchedule() + refreshBackupList() refreshBackupSummary() }) .alert(pendingConfirmation?.title ?? "", isPresented: confirmationPresented) { @@ -152,6 +164,136 @@ struct AgentToolsSheet: View { .clipShape(RoundedRectangle(cornerRadius: 8)) } + private var schedulePanel: some View { + VStack(alignment: .leading, spacing: 10) { + HStack { + Text("定时备份") + .font(.headline) + Spacer() + Toggle("启用", isOn: scheduleEnabledBinding) + .toggleStyle(.checkbox) + } + + HStack(spacing: 12) { + Picker("时间", selection: scheduleHourBinding) { + ForEach(0..<24, id: \.self) { hour in + Text(String(format: "%02d", hour)).tag(hour) + } + } + .frame(width: 112) + + Picker("分钟", selection: scheduleMinuteBinding) { + ForEach(0..<60, id: \.self) { minute in + Text(String(format: "%02d", minute)).tag(minute) + } + } + .frame(width: 112) + + Stepper(value: scheduleKeepCountBinding, in: 1...99) { + Text("最多保留 \(backupSchedule.keepCount) 条") + } + + Spacer() + } + + Text("每天 \(backupSchedule.timeText) 自动备份;只有 VM 运行且执行通道已连接时才会执行。") + .font(.caption) + .foregroundStyle(.secondary) + } + .padding(12) + .background(.quaternary.opacity(0.45)) + .clipShape(RoundedRectangle(cornerRadius: 8)) + } + + private var backupPickerPanel: some View { + VStack(alignment: .leading, spacing: 10) { + HStack { + Text("备份列表") + .font(.headline) + Spacer() + Button { + refreshBackupList() + refreshBackupSummary() + } label: { + Label("刷新", systemImage: "arrow.clockwise") + } + .buttonStyle(.borderless) + } + + if backupPackages.isEmpty { + Text("还没有备份。") + .font(.caption) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.vertical, 4) + } else { + VStack(spacing: 6) { + ForEach(backupPackages) { package in + BackupPackageRow( + package: package, + isSelected: selectedBackupId == package.id + ) { + selectedBackupId = package.id + } + } + } + + HStack { + Button { + guard let package = selectedBackupPackage else { return } + pendingConfirmation = .restoreBackup(package) + } label: { + Label("恢复选中备份", systemImage: "arrow.uturn.backward") + } + .disabled(!canRun || selectedBackupPackage == nil) + + Spacer() + } + } + } + .padding(12) + .background(.quaternary.opacity(0.45)) + .clipShape(RoundedRectangle(cornerRadius: 8)) + } + + private var selectedBackupPackage: AgentBackupPackage? { + guard let selectedBackupId else { return nil } + return backupPackages.first { $0.id == selectedBackupId } + } + + private var scheduleEnabledBinding: Binding { + Binding( + get: { backupSchedule.enabled }, + set: { backupSchedule.enabled = $0; saveSchedule() } + ) + } + + private var scheduleHourBinding: Binding { + Binding( + get: { backupSchedule.hour }, + set: { backupSchedule.hour = $0; saveSchedule() } + ) + } + + private var scheduleMinuteBinding: Binding { + Binding( + get: { backupSchedule.minute }, + set: { backupSchedule.minute = $0; saveSchedule() } + ) + } + + private var scheduleKeepCountBinding: Binding { + Binding( + get: { backupSchedule.keepCount }, + set: { + backupSchedule.keepCount = $0 + saveSchedule() + refreshBackupList() + refreshBackupSummary() + } + ) + } + private var vmStateText: String { switch vm?.state { case .running: return "运行中" @@ -195,8 +337,10 @@ struct AgentToolsSheet: View { importProfile() case .snapshotBackup: snapshotBackup() - case .restoreLatest: - pendingConfirmation = .restoreLatest + case .restoreBackup: + if let package = selectedBackupPackage { + pendingConfirmation = .restoreBackup(package) + } case .healthCheck: checkHealth() case .restartAgent: @@ -271,8 +415,8 @@ struct AgentToolsSheet: View { runOperation(.importProfile) { appState.importAgentProfile(vmId: vm.id, agent: selectedAgent, sourceURL: url, completion: $0) } - case .restoreLatest: - restoreLatestBackup() + case .restoreBackup(let package): + restoreBackup(package) case .resetConfig: resetConfig() } @@ -285,10 +429,10 @@ struct AgentToolsSheet: View { } } - private func restoreLatestBackup() { + private func restoreBackup(_ package: AgentBackupPackage) { guard let vm = vm else { return } - runOperation(.restoreLatest) { - appState.restoreLatestAgentBackup(vmId: vm.id, agent: selectedAgent, completion: $0) + runOperation(.restoreBackup) { + appState.restoreAgentBackup(vmId: vm.id, agent: selectedAgent, packageURL: package.url, completion: $0) } } @@ -351,6 +495,27 @@ struct AgentToolsSheet: View { } } + private func refreshBackupList() { + guard let vm = vm else { + backupPackages = [] + selectedBackupId = nil + return + } + backupPackages = appState.listAgentBackups(vmId: vm.id, agent: selectedAgent) + if let selectedBackupId, backupPackages.contains(where: { $0.id == selectedBackupId }) { + return + } + selectedBackupId = backupPackages.first?.id + } + + private func loadSchedule() { + backupSchedule = appState.agentBackupSchedule(vmId: vmId, agent: selectedAgent) + } + + private func saveSchedule() { + appState.setAgentBackupSchedule(backupSchedule, vmId: vmId, agent: selectedAgent) + } + private func runOperation(_ operation: AgentToolOperation, revealPath: String? = nil, _ action: (@escaping (Result) -> Void) -> Void) { @@ -367,9 +532,11 @@ struct AgentToolsSheet: View { revealPath: revealPath ) refreshBackupSummary() + refreshBackupList() case .failure(let error): operationResult = Self.makeFailureDisplay(operation: operation, error: error) refreshBackupSummary() + refreshBackupList() } } } @@ -471,7 +638,7 @@ private enum AgentToolOperation: String, CaseIterable, Identifiable { case exportProfile case importProfile case snapshotBackup - case restoreLatest + case restoreBackup case healthCheck case restartAgent case testModel @@ -485,7 +652,7 @@ private enum AgentToolOperation: String, CaseIterable, Identifiable { case .exportProfile: return "导出" case .importProfile: return "导入" case .snapshotBackup: return "立即备份" - case .restoreLatest: return "恢复最近备份" + case .restoreBackup: return "恢复备份" case .healthCheck: return "健康检查" case .restartAgent: return "重启服务" case .testModel: return "测试模型" @@ -499,7 +666,7 @@ private enum AgentToolOperation: String, CaseIterable, Identifiable { case .exportProfile: return "square.and.arrow.up" case .importProfile: return "square.and.arrow.down" case .snapshotBackup: return "clock.arrow.circlepath" - case .restoreLatest: return "arrow.uturn.backward" + case .restoreBackup: return "arrow.uturn.backward" case .healthCheck: return "stethoscope" case .restartAgent: return "arrow.clockwise" case .testModel: return "bolt.horizontal" @@ -513,7 +680,7 @@ private enum AgentToolOperation: String, CaseIterable, Identifiable { case .exportProfile: return "导出当前 Agent 数据" case .importProfile: return "从归档包导入 Agent 数据" case .snapshotBackup: return "创建一份主机侧备份" - case .restoreLatest: return "用最近备份恢复 Agent 数据" + case .restoreBackup: return "用选中的备份恢复 Agent 数据" case .healthCheck: return "检查 Agent 运行状态" case .restartAgent: return "重启 Agent 服务" case .testModel: return "测试模型代理连接" @@ -527,7 +694,7 @@ private enum AgentToolOperation: String, CaseIterable, Identifiable { case .exportProfile: return "导出完成" case .importProfile: return "导入完成" case .snapshotBackup: return "备份完成" - case .restoreLatest: return "恢复完成" + case .restoreBackup: return "恢复完成" case .healthCheck: return "健康检查完成" case .restartAgent: return "重启完成" case .testModel: return "模型测试完成" @@ -541,7 +708,7 @@ private enum AgentToolOperation: String, CaseIterable, Identifiable { case .exportProfile: return "导出失败" case .importProfile: return "导入失败" case .snapshotBackup: return "备份失败" - case .restoreLatest: return "恢复失败" + case .restoreBackup: return "恢复失败" case .healthCheck: return "健康检查失败" case .restartAgent: return "重启失败" case .testModel: return "模型测试失败" @@ -562,7 +729,7 @@ private enum AgentToolOperation: String, CaseIterable, Identifiable { case .exportProfile: return "正在导出 \(agent.displayName) 数据..." case .importProfile: return "正在导入 \(agent.displayName) 数据..." case .snapshotBackup: return "正在备份 \(agent.displayName) 数据..." - case .restoreLatest: return "正在恢复 \(agent.displayName) 最近备份..." + case .restoreBackup: return "正在恢复 \(agent.displayName) 备份..." case .healthCheck: return "正在检查 \(agent.displayName) 状态..." case .restartAgent: return "正在重启 \(agent.displayName) 服务..." case .testModel: return "正在测试模型代理..." @@ -574,7 +741,7 @@ private enum AgentToolOperation: String, CaseIterable, Identifiable { func revealPath(from result: AgentToolResult) -> String? { let raw = result.output.trimmingCharacters(in: .whitespacesAndNewlines) switch self { - case .snapshotBackup, .restoreLatest, .diagnostics: + case .snapshotBackup, .restoreBackup, .diagnostics: return raw.split(whereSeparator: { $0.isNewline }).map(String.init).last default: return nil @@ -584,13 +751,13 @@ private enum AgentToolOperation: String, CaseIterable, Identifiable { private enum PendingAgentConfirmation: Identifiable { case importProfile(URL) - case restoreLatest + case restoreBackup(AgentBackupPackage) case resetConfig var id: String { switch self { case .importProfile(let url): return "import-\(url.path)" - case .restoreLatest: return "restore" + case .restoreBackup(let package): return "restore-\(package.id)" case .resetConfig: return "reset" } } @@ -598,7 +765,7 @@ private enum PendingAgentConfirmation: Identifiable { var title: String { switch self { case .importProfile: return "确认导入 Agent 数据?" - case .restoreLatest: return "确认恢复最近备份?" + case .restoreBackup: return "确认恢复这个备份?" case .resetConfig: return "确认重置配置?" } } @@ -607,8 +774,8 @@ private enum PendingAgentConfirmation: Identifiable { switch self { case .importProfile(let url): return "导入会替换当前 Agent 数据。文件:\(url.lastPathComponent)" - case .restoreLatest: - return "恢复会用最近一份备份覆盖当前 Agent 数据。" + case .restoreBackup(let package): + return "恢复会用选中的备份覆盖当前 Agent 数据。文件:\(package.filename)" case .resetConfig: return "重置会覆盖当前 Agent 模型配置。" } @@ -617,7 +784,7 @@ private enum PendingAgentConfirmation: Identifiable { var confirmTitle: String { switch self { case .importProfile: return "导入" - case .restoreLatest: return "恢复" + case .restoreBackup: return "恢复" case .resetConfig: return "重置" } } @@ -633,6 +800,47 @@ private struct AgentOperationDisplay: Identifiable { let healthReport: HealthReport? } +private struct BackupPackageRow: View { + let package: AgentBackupPackage + let isSelected: Bool + let select: () -> Void + + var body: some View { + Button(action: select) { + HStack(spacing: 8) { + Image(systemName: isSelected ? "checkmark.circle.fill" : "circle") + .foregroundStyle(isSelected ? Color.accentColor : Color.secondary) + VStack(alignment: .leading, spacing: 2) { + Text(package.filename) + .fontWeight(.medium) + .lineLimit(1) + .truncationMode(.middle) + Text("\(Self.dateText(package.modifiedAt)) · \(Self.sizeText(package.sizeBytes))") + .font(.caption) + .foregroundStyle(.secondary) + } + Spacer() + } + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .padding(.horizontal, 8) + .padding(.vertical, 6) + .background(isSelected ? Color.accentColor.opacity(0.12) : Color.clear) + .clipShape(RoundedRectangle(cornerRadius: 6)) + } + + private static func dateText(_ date: Date) -> String { + let formatter = DateFormatter() + formatter.dateFormat = "yyyy-MM-dd HH:mm:ss" + return formatter.string(from: date) + } + + private static func sizeText(_ bytes: Int64) -> String { + ByteCountFormatter.string(fromByteCount: bytes, countStyle: .file) + } +} + private struct AgentOperationResultView: View { let result: AgentOperationDisplay @State private var showsDetails = false From 2b593e4396425a8771ea6a122822431017f6d9a0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BC=A0=E5=AE=89=E5=93=B2?= Date: Tue, 12 May 2026 00:35:18 +0800 Subject: [PATCH 26/37] chore: rename agent tools entry --- CLAUDE.md | 2 +- src/manager-macos/TenBoxApp.swift | 2 +- src/manager-macos/Views/AgentToolsView.swift | 2 +- src/manager-macos/Views/ContentView.swift | 12 ++++++------ 4 files changed, 9 insertions(+), 9 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 132036f..eda2604 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -93,7 +93,7 @@ Win/macOS: tenbox-manager ──IPC v1──► tenbox-vm-runtime (WHVP / HVF) - **Agent data backups**: `TenBox.app` writes host-managed backups to `~/Library/Application Support/TenBox/AgentBackups//` and retains packages according to the configured per-Agent retention count (default 7). Profile export runs against live Agent data, so GNU tar exit 1 from changed/skipped files is treated as non-fatal; fatal tar errors must still fail the backup. - **Agent scheduled backups**: `TenBox.app` supports per-VM/per-Agent scheduled backups stored in `settings.json`; default time is 03:00 and default retention is 7 packages. Backup filenames must be time-based (`agent-data-yyyy-MM-dd-HHmmss.tar.gz`), restore should let the user choose a package, and scheduled backups only run when the VM is running and the guest execution channel is connected. - **Agent health checks**: `TenBox.app` runs health, restart, model test, config reset, and diagnostics through qemu-guest-agent `guest-exec`. Repair actions must create a host-managed Agent data backup first. -- **macOS Agent data UI**: `TenBox.app` exposes Agent data export/import from the VM toolbar/menu while a VM is running. Keep user-facing Agent tool copy in Chinese, show operation status/results in the sheet, and confirm destructive import/restore/reset actions. It must not depend on preinstalled guest TenBox scripts. +- **macOS Agent data UI**: `TenBox.app` exposes Agent急救箱 from the front of the VM toolbar and from the VM menu while a VM is running. Keep user-facing Agent tool copy in Chinese, show operation status/results in the sheet, and confirm destructive import/restore/reset actions. It must not depend on preinstalled guest TenBox scripts. - **macOS Agent data shares**: Agent tool temporary shared folders are runtime-only; do not persist operation or backup share tags into VM config. - **macOS Agent share cleanup**: drop persisted `tenbox-agent-ops-*` and `tenbox-agent-backups-*` entries on config load/startup to clean old builds. - **macOS Agent data panels**: show export/import file panels asynchronously from SwiftUI sheets; do not use blocking `runModal()` from button handlers. diff --git a/src/manager-macos/TenBoxApp.swift b/src/manager-macos/TenBoxApp.swift index db2de37..ec962d9 100644 --- a/src/manager-macos/TenBoxApp.swift +++ b/src/manager-macos/TenBoxApp.swift @@ -953,7 +953,7 @@ private struct VmCommandMenuContent: View { } .disabled(vm == nil) - Button("Agent 数据...") { + Button("Agent急救箱...") { appState.showAgentToolsSheet = true } .disabled(vm == nil || !isRunning) diff --git a/src/manager-macos/Views/AgentToolsView.swift b/src/manager-macos/Views/AgentToolsView.swift index 3b80a28..64f9b6a 100644 --- a/src/manager-macos/Views/AgentToolsView.swift +++ b/src/manager-macos/Views/AgentToolsView.swift @@ -114,7 +114,7 @@ struct AgentToolsSheet: View { private var header: some View { HStack(spacing: 12) { - Text("Agent 数据") + Text("Agent急救箱") .font(.title3) .fontWeight(.semibold) diff --git a/src/manager-macos/Views/ContentView.swift b/src/manager-macos/Views/ContentView.swift index d25ca48..960b354 100644 --- a/src/manager-macos/Views/ContentView.swift +++ b/src/manager-macos/Views/ContentView.swift @@ -85,6 +85,12 @@ struct ContentView: View { Divider() + Button(action: { appState.showAgentToolsSheet = true }) { + Label("Agent急救箱", systemImage: "cross.case") + } + .disabled(vm.state != .running) + .help("打开 Agent 急救箱") + Button(action: { appState.showSharedFoldersSheet = true }) { ToolbarBadgeLabel( title: "Shared Folders", @@ -112,12 +118,6 @@ struct ContentView: View { } .help("Manage LLM proxy settings") - Button(action: { appState.showAgentToolsSheet = true }) { - Label("Agent 数据", systemImage: "externaldrive.badge.person.crop") - } - .disabled(vm.state != .running) - .help("管理 Agent 数据") - Picker("", selection: appState.activeTabBinding(for: vm.id)) { Image(systemName: "info.circle").tag(0) Image(systemName: "terminal").tag(1) From 44a89250f7165ad1b394e584dde9c6f7afdf5fc7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BC=A0=E5=AE=89=E5=93=B2?= Date: Tue, 12 May 2026 00:42:20 +0800 Subject: [PATCH 27/37] feat: streamline agent rescue flow --- CLAUDE.md | 2 +- src/manager-macos/Views/AgentToolsView.swift | 200 ++++++++++++++++--- 2 files changed, 169 insertions(+), 33 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index eda2604..1c1071c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -93,7 +93,7 @@ Win/macOS: tenbox-manager ──IPC v1──► tenbox-vm-runtime (WHVP / HVF) - **Agent data backups**: `TenBox.app` writes host-managed backups to `~/Library/Application Support/TenBox/AgentBackups//` and retains packages according to the configured per-Agent retention count (default 7). Profile export runs against live Agent data, so GNU tar exit 1 from changed/skipped files is treated as non-fatal; fatal tar errors must still fail the backup. - **Agent scheduled backups**: `TenBox.app` supports per-VM/per-Agent scheduled backups stored in `settings.json`; default time is 03:00 and default retention is 7 packages. Backup filenames must be time-based (`agent-data-yyyy-MM-dd-HHmmss.tar.gz`), restore should let the user choose a package, and scheduled backups only run when the VM is running and the guest execution channel is connected. - **Agent health checks**: `TenBox.app` runs health, restart, model test, config reset, and diagnostics through qemu-guest-agent `guest-exec`. Repair actions must create a host-managed Agent data backup first. -- **macOS Agent data UI**: `TenBox.app` exposes Agent急救箱 from the front of the VM toolbar and from the VM menu while a VM is running. Keep user-facing Agent tool copy in Chinese, show operation status/results in the sheet, and confirm destructive import/restore/reset actions. It must not depend on preinstalled guest TenBox scripts. +- **macOS Agent data UI**: `TenBox.app` exposes Agent急救箱 from the front of the VM toolbar and from the VM menu while a VM is running. Keep user-facing Agent tool copy in Chinese, lead with one-click diagnosis, show repair suggestions for failed checks, keep destructive/low-frequency actions under advanced operations, and confirm destructive import/restore/reset actions. It must not depend on preinstalled guest TenBox scripts. - **macOS Agent data shares**: Agent tool temporary shared folders are runtime-only; do not persist operation or backup share tags into VM config. - **macOS Agent share cleanup**: drop persisted `tenbox-agent-ops-*` and `tenbox-agent-backups-*` entries on config load/startup to clean old builds. - **macOS Agent data panels**: show export/import file panels asynchronously from SwiftUI sheets; do not use blocking `runModal()` from button handlers. diff --git a/src/manager-macos/Views/AgentToolsView.swift b/src/manager-macos/Views/AgentToolsView.swift index 64f9b6a..d5a338f 100644 --- a/src/manager-macos/Views/AgentToolsView.swift +++ b/src/manager-macos/Views/AgentToolsView.swift @@ -17,6 +17,7 @@ struct AgentToolsSheet: View { @State private var backupSchedule = AgentBackupSchedule() @State private var backupPackages: [AgentBackupPackage] = [] @State private var selectedBackupId: String? + @State private var showsAdvancedActions = false init(vmId: String, session: VmSession) { self.vmId = vmId @@ -55,19 +56,13 @@ struct AgentToolsSheet: View { } .pickerStyle(.segmented) - operationSection( - title: "常用操作", - operations: [.snapshotBackup, .exportProfile, .healthCheck] - ) + triagePanel schedulePanel backupPickerPanel - operationSection( - title: "维护操作", - operations: [.importProfile, .restartAgent, .testModel, .resetConfig, .diagnostics] - ) + advancedActionsPanel if let runningOperation { HStack(spacing: 8) { @@ -80,6 +75,9 @@ struct AgentToolsSheet: View { if let operationResult { AgentOperationResultView(result: operationResult) + if let report = operationResult.healthReport, report.state != "ok" { + repairSuggestionPanel(report: report) + } } } .padding() @@ -164,6 +162,48 @@ struct AgentToolsSheet: View { .clipShape(RoundedRectangle(cornerRadius: 8)) } + private var triagePanel: some View { + VStack(alignment: .leading, spacing: 10) { + Text("急救") + .font(.headline) + + Button { + checkHealth() + } label: { + Label("一键诊断", systemImage: "stethoscope") + .frame(maxWidth: .infinity, alignment: .center) + } + .controlSize(.large) + .disabled(!canRun) + .help("检查 Agent 服务、模型代理、浏览器和磁盘状态") + + HStack(spacing: 10) { + Button { + snapshotBackup() + } label: { + Label("立即备份", systemImage: "clock.arrow.circlepath") + .frame(maxWidth: .infinity) + } + .disabled(!canRun) + + Button { + exportProfile() + } label: { + Label("导出迁移包", systemImage: "square.and.arrow.up") + .frame(maxWidth: .infinity) + } + .disabled(!canRun) + } + + Text("建议先点“一键诊断”。只有需要迁移或人工处理时,再导入、重置配置或导出诊断包。") + .font(.caption) + .foregroundStyle(.secondary) + } + .padding(12) + .background(.quaternary.opacity(0.45)) + .clipShape(RoundedRectangle(cornerRadius: 8)) + } + private var schedulePanel: some View { VStack(alignment: .leading, spacing: 10) { HStack { @@ -196,7 +236,7 @@ struct AgentToolsSheet: View { Spacer() } - Text("每天 \(backupSchedule.timeText) 自动备份;只有 VM 运行且执行通道已连接时才会执行。") + Text(scheduleDescription) .font(.caption) .foregroundStyle(.secondary) } @@ -205,6 +245,29 @@ struct AgentToolsSheet: View { .clipShape(RoundedRectangle(cornerRadius: 8)) } + private var scheduleDescription: String { + guard backupSchedule.enabled else { + return "定时备份未启用。默认时间为 03:00,只有 VM 运行且执行通道已连接时才会执行。" + } + return "每天 \(backupSchedule.timeText) 自动备份;\(nextBackupText)。" + } + + private var nextBackupText: String { + let calendar = Calendar.current + let now = Date() + var components = calendar.dateComponents([.year, .month, .day], from: now) + components.hour = backupSchedule.hour + components.minute = backupSchedule.minute + components.second = 0 + var next = calendar.date(from: components) ?? now + if next <= now { + next = calendar.date(byAdding: .day, value: 1, to: next) ?? next + } + let formatter = DateFormatter() + formatter.dateFormat = calendar.isDateInToday(next) ? "今天 HH:mm" : "明天 HH:mm" + return "下次预计 \(formatter.string(from: next))" + } + private var backupPickerPanel: some View { VStack(alignment: .leading, spacing: 10) { HStack { @@ -261,6 +324,78 @@ struct AgentToolsSheet: View { return backupPackages.first { $0.id == selectedBackupId } } + private var advancedActionsPanel: some View { + DisclosureGroup(isExpanded: $showsAdvancedActions) { + VStack(alignment: .leading, spacing: 10) { + operationSection( + title: "高级操作", + operations: [.importProfile, .restartAgent, .resetConfig, .diagnostics] + ) + Text("这些操作会改动配置、覆盖数据或生成排障包,建议在诊断结果提示后再使用。") + .font(.caption) + .foregroundStyle(.secondary) + } + .padding(.top, 8) + } label: { + Text("高级操作") + .font(.headline) + } + .padding(12) + .background(.quaternary.opacity(0.45)) + .clipShape(RoundedRectangle(cornerRadius: 8)) + } + + private func repairSuggestionPanel(report: HealthReport) -> some View { + VStack(alignment: .leading, spacing: 10) { + Text("建议修复") + .font(.headline) + + LazyVGrid(columns: [ + GridItem(.flexible(), spacing: 10), + GridItem(.flexible(), spacing: 10) + ], spacing: 10) { + if report.isError("agent_service") || report.isError("gateway_port") { + Button { + restartAgent() + } label: { + Label("重启服务", systemImage: "arrow.clockwise") + .frame(maxWidth: .infinity) + } + .disabled(!canRun) + } + + if report.isError("llm_proxy") { + Button { + openLlmProxySettings() + } label: { + Label("检查 LLM Proxy", systemImage: "key.viewfinder") + .frame(maxWidth: .infinity) + } + .disabled(runningOperation != nil) + + Button { + pendingConfirmation = .resetConfig + } label: { + Label("重置模型配置", systemImage: "slider.horizontal.2.square") + .frame(maxWidth: .infinity) + } + .disabled(!canRun) + } + + Button { + exportDiagnostics() + } label: { + Label("导出诊断包", systemImage: "doc.zipper") + .frame(maxWidth: .infinity) + } + .disabled(!canRun) + } + } + .padding(12) + .background(Color.orange.opacity(0.08)) + .clipShape(RoundedRectangle(cornerRadius: 8)) + } + private var scheduleEnabledBinding: Binding { Binding( get: { backupSchedule.enabled }, @@ -345,8 +480,6 @@ struct AgentToolsSheet: View { checkHealth() case .restartAgent: restartAgent() - case .testModel: - testModel() case .resetConfig: pendingConfirmation = .resetConfig case .diagnostics: @@ -450,10 +583,10 @@ struct AgentToolsSheet: View { } } - private func testModel() { - guard let vm = vm else { return } - runOperation(.testModel) { - appState.testAgentModel(vmId: vm.id, agent: selectedAgent, completion: $0) + private func openLlmProxySettings() { + dismiss() + DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { + appState.showLlmProxySheet = true } } @@ -641,7 +774,6 @@ private enum AgentToolOperation: String, CaseIterable, Identifiable { case restoreBackup case healthCheck case restartAgent - case testModel case resetConfig case diagnostics @@ -653,9 +785,8 @@ private enum AgentToolOperation: String, CaseIterable, Identifiable { case .importProfile: return "导入" case .snapshotBackup: return "立即备份" case .restoreBackup: return "恢复备份" - case .healthCheck: return "健康检查" + case .healthCheck: return "一键诊断" case .restartAgent: return "重启服务" - case .testModel: return "测试模型" case .resetConfig: return "重置配置" case .diagnostics: return "导出诊断" } @@ -669,7 +800,6 @@ private enum AgentToolOperation: String, CaseIterable, Identifiable { case .restoreBackup: return "arrow.uturn.backward" case .healthCheck: return "stethoscope" case .restartAgent: return "arrow.clockwise" - case .testModel: return "bolt.horizontal" case .resetConfig: return "slider.horizontal.2.square" case .diagnostics: return "doc.zipper" } @@ -683,7 +813,6 @@ private enum AgentToolOperation: String, CaseIterable, Identifiable { case .restoreBackup: return "用选中的备份恢复 Agent 数据" case .healthCheck: return "检查 Agent 运行状态" case .restartAgent: return "重启 Agent 服务" - case .testModel: return "测试模型代理连接" case .resetConfig: return "重置 Agent 模型配置" case .diagnostics: return "导出诊断包" } @@ -695,9 +824,8 @@ private enum AgentToolOperation: String, CaseIterable, Identifiable { case .importProfile: return "导入完成" case .snapshotBackup: return "备份完成" case .restoreBackup: return "恢复完成" - case .healthCheck: return "健康检查完成" + case .healthCheck: return "诊断完成" case .restartAgent: return "重启完成" - case .testModel: return "模型测试完成" case .resetConfig: return "配置已重置" case .diagnostics: return "诊断包已导出" } @@ -709,9 +837,8 @@ private enum AgentToolOperation: String, CaseIterable, Identifiable { case .importProfile: return "导入失败" case .snapshotBackup: return "备份失败" case .restoreBackup: return "恢复失败" - case .healthCheck: return "健康检查失败" + case .healthCheck: return "诊断失败" case .restartAgent: return "重启失败" - case .testModel: return "模型测试失败" case .resetConfig: return "重置失败" case .diagnostics: return "诊断导出失败" } @@ -719,7 +846,7 @@ private enum AgentToolOperation: String, CaseIterable, Identifiable { var showsHealth: Bool { switch self { - case .healthCheck, .restartAgent, .testModel, .resetConfig: return true + case .healthCheck, .restartAgent, .resetConfig: return true default: return false } } @@ -730,9 +857,8 @@ private enum AgentToolOperation: String, CaseIterable, Identifiable { case .importProfile: return "正在导入 \(agent.displayName) 数据..." case .snapshotBackup: return "正在备份 \(agent.displayName) 数据..." case .restoreBackup: return "正在恢复 \(agent.displayName) 备份..." - case .healthCheck: return "正在检查 \(agent.displayName) 状态..." + case .healthCheck: return "正在诊断 \(agent.displayName) 状态..." case .restartAgent: return "正在重启 \(agent.displayName) 服务..." - case .testModel: return "正在测试模型代理..." case .resetConfig: return "正在重置 \(agent.displayName) 配置..." case .diagnostics: return "正在导出 \(agent.displayName) 诊断包..." } @@ -958,6 +1084,15 @@ private struct HealthReport { let message: String let checks: [HealthCheckItem] + func value(_ key: String) -> String { + checks.first { $0.key == key }?.value ?? "unknown" + } + + func isError(_ key: String) -> Bool { + let state = value(key) + return state == "error" || state == "space_low" + } + static func parse(from raw: String) -> HealthReport? { let jsonLine = raw .split(whereSeparator: { $0.isNewline }) @@ -973,11 +1108,11 @@ private struct HealthReport { state: object["state"] as? String ?? "unknown", message: translateMessage(object["message"] as? String ?? ""), checks: [ - HealthCheckItem(title: "Agent 服务", value: checks["agent_service"] as? String ?? "unknown"), - HealthCheckItem(title: "网关端口", value: checks["gateway_port"] as? String ?? "unknown"), - HealthCheckItem(title: "模型代理", value: checks["llm_proxy"] as? String ?? "unknown"), - HealthCheckItem(title: "浏览器", value: checks["browser"] as? String ?? "unknown"), - HealthCheckItem(title: "磁盘空间", value: checks["disk"] as? String ?? "unknown") + HealthCheckItem(key: "agent_service", title: "Agent 服务", value: checks["agent_service"] as? String ?? "unknown"), + HealthCheckItem(key: "gateway_port", title: "网关端口", value: checks["gateway_port"] as? String ?? "unknown"), + HealthCheckItem(key: "llm_proxy", title: "模型代理", value: checks["llm_proxy"] as? String ?? "unknown"), + HealthCheckItem(key: "browser", title: "浏览器", value: checks["browser"] as? String ?? "unknown"), + HealthCheckItem(key: "disk", title: "磁盘空间", value: checks["disk"] as? String ?? "unknown") ] ) } @@ -998,6 +1133,7 @@ private struct HealthReport { private struct HealthCheckItem: Identifiable { let id = UUID() + let key: String let title: String let value: String From 11da5a2070a8485dfb905915c0d86a18810c5574 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BC=A0=E5=AE=89=E5=93=B2?= Date: Tue, 12 May 2026 00:45:17 +0800 Subject: [PATCH 28/37] chore: move backup settings lower --- src/manager-macos/Views/AgentToolsView.swift | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/manager-macos/Views/AgentToolsView.swift b/src/manager-macos/Views/AgentToolsView.swift index d5a338f..8c991c1 100644 --- a/src/manager-macos/Views/AgentToolsView.swift +++ b/src/manager-macos/Views/AgentToolsView.swift @@ -58,12 +58,6 @@ struct AgentToolsSheet: View { triagePanel - schedulePanel - - backupPickerPanel - - advancedActionsPanel - if let runningOperation { HStack(spacing: 8) { ProgressView() @@ -79,6 +73,12 @@ struct AgentToolsSheet: View { repairSuggestionPanel(report: report) } } + + advancedActionsPanel + + schedulePanel + + backupPickerPanel } .padding() } From 75c3f148d048729348acc30604824cec675cd0e3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BC=A0=E5=AE=89=E5=93=B2?= Date: Tue, 12 May 2026 01:03:56 +0800 Subject: [PATCH 29/37] feat: refine agent rescue workflow --- CLAUDE.md | 6 +- .../Services/AgentToolsService.swift | 11 +- src/manager-macos/TenBoxApp.swift | 66 +++++++-- src/manager-macos/Views/AgentToolsView.swift | 126 ++++++++++++------ 4 files changed, 156 insertions(+), 53 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 1c1071c..9bb11fc 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -91,13 +91,13 @@ Win/macOS: tenbox-manager ──IPC v1──► tenbox-vm-runtime (WHVP / HVF) - **Agent data profile packages**: `TenBox.app` exports/imports Hermes/OpenClaw data without image changes by using a temporary shared folder and qemu-guest-agent `guest-exec` shell commands. Keep the gzip package format documented in `docs/agent-profile.md` and reject cross-agent imports. - **Hermes profile scope**: export user/config/state data, not the reinstallable Hermes app checkout, virtualenv, local binaries, logs, or cache directories. - **Agent data backups**: `TenBox.app` writes host-managed backups to `~/Library/Application Support/TenBox/AgentBackups//` and retains packages according to the configured per-Agent retention count (default 7). Profile export runs against live Agent data, so GNU tar exit 1 from changed/skipped files is treated as non-fatal; fatal tar errors must still fail the backup. -- **Agent scheduled backups**: `TenBox.app` supports per-VM/per-Agent scheduled backups stored in `settings.json`; default time is 03:00 and default retention is 7 packages. Backup filenames must be time-based (`agent-data-yyyy-MM-dd-HHmmss.tar.gz`), restore should let the user choose a package, and scheduled backups only run when the VM is running and the guest execution channel is connected. +- **Agent scheduled backups**: `TenBox.app` supports per-VM/per-Agent scheduled backups stored in `settings.json`; default time is 03:00 and default retention is 7 packages. Backup filenames must be time-based (`agent-data-yyyy-MM-dd-HHmmss.tar.gz`), restore should let the user choose a package, scheduled backups only run when the VM is running and the guest execution channel is connected, and the UI should surface the last automatic backup attempt result. - **Agent health checks**: `TenBox.app` runs health, restart, model test, config reset, and diagnostics through qemu-guest-agent `guest-exec`. Repair actions must create a host-managed Agent data backup first. -- **macOS Agent data UI**: `TenBox.app` exposes Agent急救箱 from the front of the VM toolbar and from the VM menu while a VM is running. Keep user-facing Agent tool copy in Chinese, lead with one-click diagnosis, show repair suggestions for failed checks, keep destructive/low-frequency actions under advanced operations, and confirm destructive import/restore/reset actions. It must not depend on preinstalled guest TenBox scripts. +- **macOS Agent data UI**: `TenBox.app` exposes Agent急救箱 from the front of the VM toolbar and from the VM menu while a VM is running. Keep user-facing Agent tool copy in Chinese, lead with one-click diagnosis, show repair suggestions for failed checks, auto-expand advanced operations after diagnosis failure, keep destructive/low-frequency actions under advanced operations, and confirm destructive import/restore/reset actions. It must not depend on preinstalled guest TenBox scripts. - **macOS Agent data shares**: Agent tool temporary shared folders are runtime-only; do not persist operation or backup share tags into VM config. - **macOS Agent share cleanup**: drop persisted `tenbox-agent-ops-*` and `tenbox-agent-backups-*` entries on config load/startup to clean old builds. - **macOS Agent data panels**: show export/import file panels asynchronously from SwiftUI sheets; do not use blocking `runModal()` from button handlers. -- **macOS Agent backup UI**: `TenBox.app` exposes backup status, immediate backup, and restore latest backup actions. Host-triggered backups use `~/Library/Application Support/TenBox/AgentBackups/` as the durable shared folder. +- **macOS Agent backup UI**: `TenBox.app` exposes backup status, immediate backup, and restore latest backup actions. Host-triggered backups use `~/Library/Application Support/TenBox/AgentBackups/` as the durable shared folder. Backup lists should default to the latest three packages, allow showing all packages, and provide Finder reveal per package. - **macOS Agent health UI**: `TenBox.app` exposes health check, restart, model test, config reset, and diagnostics actions while a VM is running. Repair actions run through app-generated shell commands so no image rebuild is required. - **macOS console commands**: keep marker-based console command execution as a fallback path; Agent tools should use qemu-guest-agent `guest-exec` and wait for temporary shared folders before reading or writing packages. - **macOS Agent tool commands**: prefer qemu-guest-agent `guest-exec` for command execution, pass multiline scripts as `command_hex`, and run them as guest user `tenbox`; keep shared folders as the data plane for profile packages, backups, and diagnostics. diff --git a/src/manager-macos/Services/AgentToolsService.swift b/src/manager-macos/Services/AgentToolsService.swift index 8202f55..ad657f9 100644 --- a/src/manager-macos/Services/AgentToolsService.swift +++ b/src/manager-macos/Services/AgentToolsService.swift @@ -43,17 +43,26 @@ struct AgentBackupSchedule: Codable, Equatable { var minute: Int var keepCount: Int var lastRunDate: String? + var lastAttemptAt: String? + var lastAttemptStatus: String? + var lastAttemptMessage: String? init(enabled: Bool = false, hour: Int = Self.defaultHour, minute: Int = Self.defaultMinute, keepCount: Int = Self.defaultKeepCount, - lastRunDate: String? = nil) { + lastRunDate: String? = nil, + lastAttemptAt: String? = nil, + lastAttemptStatus: String? = nil, + lastAttemptMessage: String? = nil) { self.enabled = enabled self.hour = min(max(hour, 0), 23) self.minute = min(max(minute, 0), 59) self.keepCount = min(max(keepCount, 1), 99) self.lastRunDate = lastRunDate + self.lastAttemptAt = lastAttemptAt + self.lastAttemptStatus = lastAttemptStatus + self.lastAttemptMessage = lastAttemptMessage } var timeText: String { diff --git a/src/manager-macos/TenBoxApp.swift b/src/manager-macos/TenBoxApp.swift index ec962d9..2349c54 100644 --- a/src/manager-macos/TenBoxApp.swift +++ b/src/manager-macos/TenBoxApp.swift @@ -605,13 +605,16 @@ class AppState: ObservableObject { hour: schedule.hour, minute: schedule.minute, keepCount: schedule.keepCount, - lastRunDate: schedule.lastRunDate + lastRunDate: schedule.lastRunDate, + lastAttemptAt: schedule.lastAttemptAt, + lastAttemptStatus: schedule.lastAttemptStatus, + lastAttemptMessage: schedule.lastAttemptMessage ) let now = Date() let calendar = Calendar.current let nowMinutes = calendar.component(.hour, from: now) * 60 + calendar.component(.minute, from: now) let scheduledMinutes = normalized.hour * 60 + normalized.minute - if !previous.enabled && normalized.enabled && nowMinutes >= scheduledMinutes && normalized.lastRunDate == nil { + if !previous.enabled && normalized.enabled && nowMinutes >= scheduledMinutes { normalized.lastRunDate = Self.agentBackupDateKey(now) } agentBackupSchedules[Self.agentBackupScheduleKey(vmId: vmId, agent: agent)] = normalized @@ -637,7 +640,10 @@ class AppState: ObservableObject { hour: dict["hour"] as? Int ?? AgentBackupSchedule.defaultHour, minute: dict["minute"] as? Int ?? AgentBackupSchedule.defaultMinute, keepCount: dict["keep_count"] as? Int ?? AgentBackupSchedule.defaultKeepCount, - lastRunDate: dict["last_run_date"] as? String + lastRunDate: dict["last_run_date"] as? String, + lastAttemptAt: dict["last_attempt_at"] as? String, + lastAttemptStatus: dict["last_attempt_status"] as? String, + lastAttemptMessage: dict["last_attempt_message"] as? String ) } agentBackupSchedules = loaded @@ -655,6 +661,15 @@ class AppState: ObservableObject { if let lastRunDate = schedule.lastRunDate { value["last_run_date"] = lastRunDate } + if let lastAttemptAt = schedule.lastAttemptAt { + value["last_attempt_at"] = lastAttemptAt + } + if let lastAttemptStatus = schedule.lastAttemptStatus { + value["last_attempt_status"] = lastAttemptStatus + } + if let lastAttemptMessage = schedule.lastAttemptMessage { + value["last_attempt_message"] = lastAttemptMessage + } return value } json["agent_backups"] = ["schedules": schedules] as [String: Any] @@ -682,17 +697,25 @@ class AppState: ObservableObject { let parts = key.split(separator: "|", maxSplits: 1).map(String.init) guard parts.count == 2, - let agent = AgentKind(rawValue: parts[1]), - let vm = vms.first(where: { $0.id == parts[0] && $0.state == .running }) else { + let agent = AgentKind(rawValue: parts[1]) else { + continue + } + guard let vm = vms.first(where: { $0.id == parts[0] }) else { continue } + guard vm.state == .running else { + updateAgentBackupAttempt(key: key, base: schedule, status: "failed", message: "VM 未运行", at: now, lastRunDate: today) continue } let session = getOrCreateSession(for: vm.id) if !session.connected || !session.ipcClient.isConnected { session.connectIfNeeded() + updateAgentBackupAttempt(key: key, base: schedule, status: "failed", message: "执行通道未连接", at: now, lastRunDate: today) + continue + } + guard session.guestAgentConnected else { + updateAgentBackupAttempt(key: key, base: schedule, status: "failed", message: "执行通道未连接", at: now, lastRunDate: today) continue } - guard session.guestAgentConnected else { continue } scheduledBackupsRunning.insert(key) agentTools.snapshotBackup(vm: vm, session: session, appState: self, agent: agent, keepCount: schedule.keepCount) { [weak self] result in @@ -701,12 +724,10 @@ class AppState: ObservableObject { self.scheduledBackupsRunning.remove(key) switch result { case .success: - var updated = self.agentBackupSchedules[key] ?? schedule - updated.lastRunDate = today - self.agentBackupSchedules[key] = updated - self.saveAgentBackupSchedules() + self.updateAgentBackupAttempt(key: key, base: schedule, status: "success", message: "成功", at: now, lastRunDate: today) NSLog("[AgentBackup] Scheduled backup completed: %@ %@", vm.id, agent.rawValue) case .failure(let error): + self.updateAgentBackupAttempt(key: key, base: schedule, status: "failed", message: error.localizedDescription, at: now, lastRunDate: today) NSLog("[AgentBackup] Scheduled backup failed: %@ %@ %@", vm.id, agent.rawValue, error.localizedDescription) } } @@ -714,6 +735,23 @@ class AppState: ObservableObject { } } + private func updateAgentBackupAttempt(key: String, + base: AgentBackupSchedule, + status: String, + message: String, + at date: Date, + lastRunDate: String? = nil) { + var updated = agentBackupSchedules[key] ?? base + updated.lastAttemptAt = Self.agentBackupAttemptTimeText(date) + updated.lastAttemptStatus = status + updated.lastAttemptMessage = message + if let lastRunDate { + updated.lastRunDate = lastRunDate + } + agentBackupSchedules[key] = updated + saveAgentBackupSchedules() + } + private static func agentBackupDateKey(_ date: Date) -> String { let formatter = DateFormatter() formatter.calendar = Calendar(identifier: .gregorian) @@ -722,6 +760,14 @@ class AppState: ObservableObject { return formatter.string(from: date) } + private static func agentBackupAttemptTimeText(_ date: Date) -> String { + let formatter = DateFormatter() + formatter.calendar = Calendar(identifier: .gregorian) + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.dateFormat = "MM-dd HH:mm" + return formatter.string(from: date) + } + // MARK: - LLM Proxy settings private var settingsPath: String { diff --git a/src/manager-macos/Views/AgentToolsView.swift b/src/manager-macos/Views/AgentToolsView.swift index 8c991c1..6190cde 100644 --- a/src/manager-macos/Views/AgentToolsView.swift +++ b/src/manager-macos/Views/AgentToolsView.swift @@ -18,6 +18,7 @@ struct AgentToolsSheet: View { @State private var backupPackages: [AgentBackupPackage] = [] @State private var selectedBackupId: String? @State private var showsAdvancedActions = false + @State private var showsAllBackups = false init(vmId: String, session: VmSession) { self.vmId = vmId @@ -92,6 +93,7 @@ struct AgentToolsSheet: View { .onChange(of: selectedAgent, perform: { _ in operationResult = nil selectedBackupId = nil + showsAllBackups = false loadSchedule() refreshBackupList() refreshBackupSummary() @@ -177,23 +179,13 @@ struct AgentToolsSheet: View { .disabled(!canRun) .help("检查 Agent 服务、模型代理、浏览器和磁盘状态") - HStack(spacing: 10) { - Button { - snapshotBackup() - } label: { - Label("立即备份", systemImage: "clock.arrow.circlepath") - .frame(maxWidth: .infinity) - } - .disabled(!canRun) - - Button { - exportProfile() - } label: { - Label("导出迁移包", systemImage: "square.and.arrow.up") - .frame(maxWidth: .infinity) - } - .disabled(!canRun) + Button { + snapshotBackup() + } label: { + Label("立即备份", systemImage: "clock.arrow.circlepath") + .frame(maxWidth: .infinity, alignment: .center) } + .disabled(!canRun) Text("建议先点“一键诊断”。只有需要迁移或人工处理时,再导入、重置配置或导出诊断包。") .font(.caption) @@ -239,6 +231,12 @@ struct AgentToolsSheet: View { Text(scheduleDescription) .font(.caption) .foregroundStyle(.secondary) + + if let scheduleStatusText { + Text(scheduleStatusText) + .font(.caption) + .foregroundStyle(backupSchedule.lastAttemptStatus == "failed" ? Color.orange : Color.secondary) + } } .padding(12) .background(.quaternary.opacity(0.45)) @@ -252,6 +250,18 @@ struct AgentToolsSheet: View { return "每天 \(backupSchedule.timeText) 自动备份;\(nextBackupText)。" } + private var scheduleStatusText: String? { + guard let at = backupSchedule.lastAttemptAt, + let status = backupSchedule.lastAttemptStatus else { + return nil + } + if status == "success" { + return "上次自动备份:\(at) 成功" + } + let message = backupSchedule.lastAttemptMessage ?? "失败" + return "上次自动备份失败:\(message)(\(at))" + } + private var nextBackupText: String { let calendar = Calendar.current let now = Date() @@ -291,10 +301,11 @@ struct AgentToolsSheet: View { .padding(.vertical, 4) } else { VStack(spacing: 6) { - ForEach(backupPackages) { package in + ForEach(displayedBackupPackages) { package in BackupPackageRow( package: package, - isSelected: selectedBackupId == package.id + isSelected: selectedBackupId == package.id, + reveal: { revealBackup(package) } ) { selectedBackupId = package.id } @@ -310,6 +321,13 @@ struct AgentToolsSheet: View { } .disabled(!canRun || selectedBackupPackage == nil) + if backupPackages.count > 3 { + Button(showsAllBackups ? "收起" : "显示全部 \(backupPackages.count) 条") { + showsAllBackups.toggle() + } + .buttonStyle(.link) + } + Spacer() } } @@ -324,12 +342,19 @@ struct AgentToolsSheet: View { return backupPackages.first { $0.id == selectedBackupId } } + private var displayedBackupPackages: [AgentBackupPackage] { + if showsAllBackups { + return backupPackages + } + return Array(backupPackages.prefix(3)) + } + private var advancedActionsPanel: some View { DisclosureGroup(isExpanded: $showsAdvancedActions) { VStack(alignment: .leading, spacing: 10) { operationSection( title: "高级操作", - operations: [.importProfile, .restartAgent, .resetConfig, .diagnostics] + operations: [.exportProfile, .importProfile, .restartAgent, .resetConfig, .diagnostics] ) Text("这些操作会改动配置、覆盖数据或生成排障包,建议在诊断结果提示后再使用。") .font(.caption) @@ -641,6 +666,10 @@ struct AgentToolsSheet: View { selectedBackupId = backupPackages.first?.id } + private func revealBackup(_ package: AgentBackupPackage) { + NSWorkspace.shared.activateFileViewerSelecting([package.url]) + } + private func loadSchedule() { backupSchedule = appState.agentBackupSchedule(vmId: vmId, agent: selectedAgent) } @@ -659,15 +688,22 @@ struct AgentToolsSheet: View { runningOperation = nil switch result { case .success(let output): - operationResult = Self.makeSuccessDisplay( + let display = Self.makeSuccessDisplay( operation: operation, result: output, revealPath: revealPath ) + operationResult = display + if operation == .healthCheck { + showsAdvancedActions = display.healthReport?.state != "ok" + } refreshBackupSummary() refreshBackupList() case .failure(let error): operationResult = Self.makeFailureDisplay(operation: operation, error: error) + if operation == .healthCheck { + showsAdvancedActions = true + } refreshBackupSummary() refreshBackupList() } @@ -681,12 +717,14 @@ struct AgentToolsSheet: View { let raw = result.output.trimmingCharacters(in: .whitespacesAndNewlines) let detectedPath = revealPath ?? operation.revealPath(from: result) let health = operation.showsHealth ? HealthReport.parse(from: raw) : nil - let summary = result.message.trimmingCharacters(in: .whitespacesAndNewlines) + let rawSummary = result.message.trimmingCharacters(in: .whitespacesAndNewlines) + let summary = health?.state == "ok" ? (health?.message ?? rawSummary) : rawSummary + let details = operation == .healthCheck && health?.state == "ok" ? "" : raw return AgentOperationDisplay( isSuccess: true, title: operation.successTitle, summary: summary.isEmpty ? "操作已完成" : summary, - details: raw, + details: details, revealPath: detectedPath, healthReport: health ) @@ -781,7 +819,7 @@ private enum AgentToolOperation: String, CaseIterable, Identifiable { var title: String { switch self { - case .exportProfile: return "导出" + case .exportProfile: return "导出迁移包" case .importProfile: return "导入" case .snapshotBackup: return "立即备份" case .restoreBackup: return "恢复备份" @@ -929,27 +967,37 @@ private struct AgentOperationDisplay: Identifiable { private struct BackupPackageRow: View { let package: AgentBackupPackage let isSelected: Bool + let reveal: () -> Void let select: () -> Void var body: some View { - Button(action: select) { - HStack(spacing: 8) { - Image(systemName: isSelected ? "checkmark.circle.fill" : "circle") - .foregroundStyle(isSelected ? Color.accentColor : Color.secondary) - VStack(alignment: .leading, spacing: 2) { - Text(package.filename) - .fontWeight(.medium) - .lineLimit(1) - .truncationMode(.middle) - Text("\(Self.dateText(package.modifiedAt)) · \(Self.sizeText(package.sizeBytes))") - .font(.caption) - .foregroundStyle(.secondary) + HStack(spacing: 8) { + Button(action: select) { + HStack(spacing: 8) { + Image(systemName: isSelected ? "checkmark.circle.fill" : "circle") + .foregroundStyle(isSelected ? Color.accentColor : Color.secondary) + VStack(alignment: .leading, spacing: 2) { + Text(package.filename) + .fontWeight(.medium) + .lineLimit(1) + .truncationMode(.middle) + Text("\(Self.dateText(package.modifiedAt)) · \(Self.sizeText(package.sizeBytes))") + .font(.caption) + .foregroundStyle(.secondary) + } + Spacer() } - Spacer() + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .frame(maxWidth: .infinity, alignment: .leading) + + Button(action: reveal) { + Image(systemName: "folder") } - .contentShape(Rectangle()) + .buttonStyle(.borderless) + .help("在 Finder 中显示") } - .buttonStyle(.plain) .padding(.horizontal, 8) .padding(.vertical, 6) .background(isSelected ? Color.accentColor.opacity(0.12) : Color.clear) @@ -987,7 +1035,7 @@ private struct AgentOperationResultView: View { Spacer() } - if let report = result.healthReport { + if let report = result.healthReport, report.state != "ok" { HealthReportView(report: report) } From 7b5f34ab82e6974163eab346e148167eaf184132 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BC=A0=E5=AE=89=E5=93=B2?= Date: Tue, 12 May 2026 01:12:42 +0800 Subject: [PATCH 30/37] fix: close agent tools review gaps --- CLAUDE.md | 4 ++-- docs/agent-profile.md | 8 ++++---- src/manager-macos/Views/VmDetailView.swift | 1 + 3 files changed, 7 insertions(+), 6 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 9bb11fc..9d51b9e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -92,13 +92,13 @@ Win/macOS: tenbox-manager ──IPC v1──► tenbox-vm-runtime (WHVP / HVF) - **Hermes profile scope**: export user/config/state data, not the reinstallable Hermes app checkout, virtualenv, local binaries, logs, or cache directories. - **Agent data backups**: `TenBox.app` writes host-managed backups to `~/Library/Application Support/TenBox/AgentBackups//` and retains packages according to the configured per-Agent retention count (default 7). Profile export runs against live Agent data, so GNU tar exit 1 from changed/skipped files is treated as non-fatal; fatal tar errors must still fail the backup. - **Agent scheduled backups**: `TenBox.app` supports per-VM/per-Agent scheduled backups stored in `settings.json`; default time is 03:00 and default retention is 7 packages. Backup filenames must be time-based (`agent-data-yyyy-MM-dd-HHmmss.tar.gz`), restore should let the user choose a package, scheduled backups only run when the VM is running and the guest execution channel is connected, and the UI should surface the last automatic backup attempt result. -- **Agent health checks**: `TenBox.app` runs health, restart, model test, config reset, and diagnostics through qemu-guest-agent `guest-exec`. Repair actions must create a host-managed Agent data backup first. +- **Agent health checks**: `TenBox.app` runs health, restart, config reset, and diagnostics through qemu-guest-agent `guest-exec`. Repair actions must create a host-managed Agent data backup first. - **macOS Agent data UI**: `TenBox.app` exposes Agent急救箱 from the front of the VM toolbar and from the VM menu while a VM is running. Keep user-facing Agent tool copy in Chinese, lead with one-click diagnosis, show repair suggestions for failed checks, auto-expand advanced operations after diagnosis failure, keep destructive/low-frequency actions under advanced operations, and confirm destructive import/restore/reset actions. It must not depend on preinstalled guest TenBox scripts. - **macOS Agent data shares**: Agent tool temporary shared folders are runtime-only; do not persist operation or backup share tags into VM config. - **macOS Agent share cleanup**: drop persisted `tenbox-agent-ops-*` and `tenbox-agent-backups-*` entries on config load/startup to clean old builds. - **macOS Agent data panels**: show export/import file panels asynchronously from SwiftUI sheets; do not use blocking `runModal()` from button handlers. - **macOS Agent backup UI**: `TenBox.app` exposes backup status, immediate backup, and restore latest backup actions. Host-triggered backups use `~/Library/Application Support/TenBox/AgentBackups/` as the durable shared folder. Backup lists should default to the latest three packages, allow showing all packages, and provide Finder reveal per package. -- **macOS Agent health UI**: `TenBox.app` exposes health check, restart, model test, config reset, and diagnostics actions while a VM is running. Repair actions run through app-generated shell commands so no image rebuild is required. +- **macOS Agent health UI**: `TenBox.app` exposes health check, restart, config reset, and diagnostics actions while a VM is running. Repair actions run through app-generated shell commands so no image rebuild is required. - **macOS console commands**: keep marker-based console command execution as a fallback path; Agent tools should use qemu-guest-agent `guest-exec` and wait for temporary shared folders before reading or writing packages. - **macOS Agent tool commands**: prefer qemu-guest-agent `guest-exec` for command execution, pass multiline scripts as `command_hex`, and run them as guest user `tenbox`; keep shared folders as the data plane for profile packages, backups, and diagnostics. - **macOS console markers**: do not put the full begin/end marker literal in console input; build it inside the shell so echoed input cannot satisfy marker detection. diff --git a/docs/agent-profile.md b/docs/agent-profile.md index 6a42540..70a2660 100644 --- a/docs/agent-profile.md +++ b/docs/agent-profile.md @@ -4,7 +4,7 @@ TenBox.app provides Agent data export/import, backup/restore, and health actions without requiring Hermes/OpenClaw images to preinstall TenBox-specific scripts. The macOS manager creates a temporary shared folder, then sends a short shell -command through the existing VM console channel. The command uses standard guest +command through qemu-guest-agent `guest-exec`. The command uses standard guest tools such as `tar`, `gzip`, `systemctl`, `curl`, and `journalctl`. ## Profile package @@ -48,8 +48,9 @@ Manual backups are created by TenBox.app in: ~/Library/Application Support/TenBox/AgentBackups/// ``` -Backups use the same profile package format and keep the newest five packages. -Restore uses the newest package for the selected VM and Agent. +Backups use the same profile package format. Retention is configurable per VM +and Agent; the default keeps the newest seven packages. Restore uses the package +selected in the backup list for the selected VM and Agent. ## Health actions @@ -57,7 +58,6 @@ TenBox.app can run these actions while the VM is running: - health status - restart Agent -- test model proxy - reset Agent config - export diagnostics diff --git a/src/manager-macos/Views/VmDetailView.swift b/src/manager-macos/Views/VmDetailView.swift index bdb6dab..468a5c4 100644 --- a/src/manager-macos/Views/VmDetailView.swift +++ b/src/manager-macos/Views/VmDetailView.swift @@ -231,6 +231,7 @@ class VmSession: ObservableObject { func disconnect() { audioPlayer.stop() + failPendingGuestExecCommands(ConsoleCommandError("VM runtime disconnected")) ipcClient.disconnect() connected = false connecting = false From 8cdc848b10de2b2467a616c4320c1e527bf70663 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BC=A0=E5=AE=89=E5=93=B2?= Date: Tue, 12 May 2026 02:04:49 +0800 Subject: [PATCH 31/37] fix: align agent migration and windows build --- CLAUDE.md | 6 +- docs/agent-profile.md | 36 ++- .../Services/AgentToolsService.swift | 253 +++++++++++++++--- src/manager-macos/TenBoxApp.swift | 53 ++++ src/manager-macos/Views/AgentToolsView.swift | 89 ++++++ src/runtime/runtime_service.cpp | 3 +- 6 files changed, 396 insertions(+), 44 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 9d51b9e..b6509bb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -88,11 +88,13 @@ Win/macOS: tenbox-manager ──IPC v1──► tenbox-vm-runtime (WHVP / HVF) - **LLM proxy** exists in two places: `src/daemon/llm_proxy.cpp` (Linux) and `src/manager/llm_proxy.cpp` (Windows); change both when the protocol changes. - **RemoteSession** is single-instance per VM. Read `remote_webrtc.cpp`'s `force` takeover path before adding DataChannels. - **macOS Caps Lock forwarding**: send Caps Lock as a tap (`down` then `up`) on each `flagsChanged` event; AppKit exposes it as a toggle state, but the guest input stack needs a full key press for every switch. -- **Agent data profile packages**: `TenBox.app` exports/imports Hermes/OpenClaw data without image changes by using a temporary shared folder and qemu-guest-agent `guest-exec` shell commands. Keep the gzip package format documented in `docs/agent-profile.md` and reject cross-agent imports. +- **Agent data profile packages**: `TenBox.app` exports/imports Hermes/OpenClaw data without image changes by using a temporary shared folder and qemu-guest-agent `guest-exec` shell commands. Keep the gzip package format documented in `docs/agent-profile.md`, include `export_scope`, and reject cross-agent imports. +- **Agent migration exports**: user-triggered migration packages should carry the user's full Agent state, including secrets, credentials, identity/device state, sessions, browser profiles, and config files. Exclude only volatile logs, caches, runtime lock files, and reinstallable binaries by default. +- **Agent OpenClaw to Hermes migration**: the macOS Agent急救箱 can migrate a running OpenClaw VM into a running Hermes VM by first creating a host-managed Hermes backup, sharing one runtime-only folder, exporting `~/.openclaw` with full user state, then running official `hermes claw migrate --source ... --preset full --migrate-secrets --skill-conflict skip --yes` in the Hermes VM after a dry run. Do not reimplement the migration mapping in TenBox. - **Hermes profile scope**: export user/config/state data, not the reinstallable Hermes app checkout, virtualenv, local binaries, logs, or cache directories. - **Agent data backups**: `TenBox.app` writes host-managed backups to `~/Library/Application Support/TenBox/AgentBackups//` and retains packages according to the configured per-Agent retention count (default 7). Profile export runs against live Agent data, so GNU tar exit 1 from changed/skipped files is treated as non-fatal; fatal tar errors must still fail the backup. - **Agent scheduled backups**: `TenBox.app` supports per-VM/per-Agent scheduled backups stored in `settings.json`; default time is 03:00 and default retention is 7 packages. Backup filenames must be time-based (`agent-data-yyyy-MM-dd-HHmmss.tar.gz`), restore should let the user choose a package, scheduled backups only run when the VM is running and the guest execution channel is connected, and the UI should surface the last automatic backup attempt result. -- **Agent health checks**: `TenBox.app` runs health, restart, config reset, and diagnostics through qemu-guest-agent `guest-exec`. Repair actions must create a host-managed Agent data backup first. +- **Agent health checks**: `TenBox.app` runs health, restart, config reset, and diagnostics through qemu-guest-agent `guest-exec`. Repair actions must create a host-managed Agent data backup first, and config reset should patch only the needed Agent settings instead of replacing full user config files. - **macOS Agent data UI**: `TenBox.app` exposes Agent急救箱 from the front of the VM toolbar and from the VM menu while a VM is running. Keep user-facing Agent tool copy in Chinese, lead with one-click diagnosis, show repair suggestions for failed checks, auto-expand advanced operations after diagnosis failure, keep destructive/low-frequency actions under advanced operations, and confirm destructive import/restore/reset actions. It must not depend on preinstalled guest TenBox scripts. - **macOS Agent data shares**: Agent tool temporary shared folders are runtime-only; do not persist operation or backup share tags into VM config. - **macOS Agent share cleanup**: drop persisted `tenbox-agent-ops-*` and `tenbox-agent-backups-*` entries on config load/startup to clean old builds. diff --git a/docs/agent-profile.md b/docs/agent-profile.md index 70a2660..9897373 100644 --- a/docs/agent-profile.md +++ b/docs/agent-profile.md @@ -22,6 +22,7 @@ The exported package is a gzip tar archive: - `format`: `tenbox-agent-profile` - `format_version`: `2` - `agent_type`: `hermes` or `openclaw` +- `export_scope`: `migration` or `backup` - `archive`: `files.tar.gz` `files.tar.gz` contains the Agent data directory relative to the guest home: @@ -29,12 +30,17 @@ The exported package is a gzip tar archive: - Hermes: `.hermes` - OpenClaw: `.openclaw` -Excluded paths: +Always excluded paths: - Hermes: `.hermes/logs`, `.hermes/image_cache`, `.hermes/audio_cache`, - `.hermes/hermes-agent`, `.hermes/bin`, `.hermes/gateway.pid`, + `.hermes/cache`, `.hermes/hermes-agent`, `.hermes/bin`, `.hermes/gateway.pid`, `.hermes/gateway.lock` -- OpenClaw: `.openclaw/cache`, `.openclaw/.cache`, `.openclaw/workspace/.cache` +- OpenClaw: `.openclaw/cache`, `.openclaw/.cache`, `.openclaw/workspace/.cache`, + `.openclaw/logs` + +Migration exports keep secrets, identity, session state, and config files so a +profile can move with the user's full Agent state. Only volatile logs, caches, +runtime lock files, and reinstallable binaries are skipped. Import rejects packages whose `agent_type` does not match the selected Agent. Before replacing existing data, it renames the current directory to @@ -52,6 +58,10 @@ Backups use the same profile package format. Retention is configurable per VM and Agent; the default keeps the newest seven packages. Restore uses the package selected in the backup list for the selected VM and Agent. +Host-managed backups use `export_scope: backup` and keep restorable user state +except volatile logs, caches, runtime lock files, and reinstallable binaries. +They are intended for recovery on the same host, not for sharing. + ## Health actions TenBox.app can run these actions while the VM is running: @@ -64,3 +74,23 @@ TenBox.app can run these actions while the VM is running: Restart and reset create a backup first, using the same host-managed backup directory. Diagnostics are exported to the host backup directory through the temporary shared folder. + +## OpenClaw to Hermes migration + +When both source and target VMs are running, TenBox.app can migrate OpenClaw +data into a Hermes VM without image-specific helper scripts: + +1. Create a host-managed Hermes backup for the target VM. +2. Mount one runtime-only host shared folder into both VMs. +3. Export the source VM's `~/.openclaw` into that shared folder with full user + state, including secrets, identity, browser profile, and OpenClaw config. +4. Extract it inside the Hermes VM and run the official Hermes CLI: + + ```sh + hermes claw migrate --dry-run --source /.openclaw --preset full --migrate-secrets + hermes claw migrate --source /.openclaw --preset full --migrate-secrets --skill-conflict skip --yes + ``` + +The migration deliberately uses the `full` preset with `--migrate-secrets` so +Hermes can import every compatible secret and file category its official +OpenClaw migration flow supports. diff --git a/src/manager-macos/Services/AgentToolsService.swift b/src/manager-macos/Services/AgentToolsService.swift index ad657f9..4ae1fa0 100644 --- a/src/manager-macos/Services/AgentToolsService.swift +++ b/src/manager-macos/Services/AgentToolsService.swift @@ -33,6 +33,11 @@ struct AgentBackupPackage: Identifiable, Equatable { var filename: String { url.lastPathComponent } } +private enum AgentProfileExportScope: String { + case migration + case backup +} + struct AgentBackupSchedule: Codable, Equatable { static let defaultHour = 3 static let defaultMinute = 0 @@ -91,7 +96,7 @@ final class AgentToolsService { let guestPackage = "/mnt/shared/\(share.tag)/\(packageName)" let command = Self.withSharedFolderReady( tag: share.tag, - body: Self.profileExportCommand(agent: agent, outputPath: guestPackage) + body: Self.profileExportCommand(agent: agent, outputPath: guestPackage, scope: .migration) ) session.runGuestAgentCommand(command, timeout: 420) { result in @@ -322,6 +327,92 @@ final class AgentToolsService { } } + func migrateOpenClawToHermes(sourceVm: VmInfo, sourceSession: VmSession, + targetVm: VmInfo, targetSession: VmSession, + appState: AppState, + keepCount: Int = AgentBackupSchedule.defaultKeepCount, + completion: @escaping (Result) -> Void) { + do { + let backupPackage = try backupPackageURL(vmId: targetVm.id, agent: .hermes) + withBackupShare(vmId: targetVm.id, appState: appState) { backupShare, backupCleanup in + withOperationShare(vmIds: [sourceVm.id, targetVm.id], appState: appState) { share, cleanup in + let cleanupAll = { + cleanup() + backupCleanup() + } + let guestBackup = "/mnt/shared/\(backupShare.tag)/hermes/\(backupPackage.lastPathComponent)" + let backupCommand = Self.withSharedFolderReady( + tag: backupShare.tag, + body: "mkdir -p \(Self.shellQuote("/mnt/shared/\(backupShare.tag)/hermes"))\n" + + Self.profileExportCommand(agent: .hermes, outputPath: guestBackup, scope: .backup) + ) + + targetSession.runGuestAgentCommand(backupCommand, timeout: 420) { backupResult in + switch backupResult { + case .success(let backupCommandResult): + guard backupCommandResult.exitCode == 0 else { + cleanupAll() + completion(.failure(Self.makeError(backupCommandResult.output.isEmpty ? "Hermes 迁移前备份失败" : backupCommandResult.output))) + return + } + + let archivePath = "/mnt/shared/\(share.tag)/openclaw-source.tar.gz" + let exportCommand = Self.withSharedFolderReady( + tag: share.tag, + body: Self.openClawMigrationSourceExportCommand(outputPath: archivePath) + ) + sourceSession.runGuestAgentCommand(exportCommand, timeout: 420) { sourceResult in + switch sourceResult { + case .success(let sourceCommandResult): + guard sourceCommandResult.exitCode == 0 else { + cleanupAll() + completion(.failure(Self.makeError(sourceCommandResult.output.isEmpty ? "OpenClaw 数据导出失败" : sourceCommandResult.output))) + return + } + + let migrateCommand = Self.withSharedFolderReady( + tag: share.tag, + body: Self.openClawToHermesMigrationCommand(inputPath: archivePath) + ) + targetSession.runGuestAgentCommand(migrateCommand, timeout: 600) { targetResult in + cleanupAll() + switch targetResult { + case .success(let targetCommandResult): + guard targetCommandResult.exitCode == 0 else { + completion(.failure(Self.makeError(targetCommandResult.output.isEmpty ? "OpenClaw 到 Hermes 迁移失败" : targetCommandResult.output))) + return + } + self.rotateBackups(vmId: targetVm.id, agent: .hermes, keep: keepCount) + completion(.success(AgentToolResult( + message: "已完成 OpenClaw 到 Hermes 迁移", + output: "迁移前备份:\(backupPackage.path)\n来源 VM:\(sourceVm.name)\n目标 VM:\(targetVm.name)\n\(targetCommandResult.output)" + ))) + case .failure(let error): + completion(.failure(error)) + } + } + case .failure(let error): + cleanupAll() + completion(.failure(error)) + } + } + case .failure(let error): + cleanupAll() + completion(.failure(error)) + } + } + } failure: { error in + backupCleanup() + completion(.failure(error)) + } + } failure: { error in + completion(.failure(error)) + } + } catch { + completion(.failure(error)) + } + } + private func runHealthCommand(vm: VmInfo, session: VmSession, appState: AppState, agent: AgentKind, command: String, successMessage: String, completion: @escaping (Result) -> Void) { @@ -384,17 +475,28 @@ final class AgentToolsService { private func withOperationShare(vmId: String, appState: AppState, perform: (SharedFolder, @escaping () -> Void) -> Void, failure: (Error) -> Void) { + withOperationShare(vmIds: [vmId], appState: appState, perform: perform, failure: failure) + } + + private func withOperationShare(vmIds: [String], appState: AppState, + perform: (SharedFolder, @escaping () -> Void) -> Void, + failure: (Error) -> Void) { do { let base = try operationBaseDirectory() let tag = "tenbox-agent-ops-\(UUID().uuidString.prefix(8).lowercased())" - let dir = base.appendingPathComponent("\(vmId)-\(tag)", isDirectory: true) + let dirName = "\(vmIds.joined(separator: "-"))-\(tag)" + let dir = base.appendingPathComponent(dirName, isDirectory: true) try fileManager.createDirectory(at: dir, withIntermediateDirectories: true) let share = SharedFolder(tag: tag, hostPath: dir.path, readonly: false) - appState.addRuntimeSharedFolder(share, toVm: vmId) + for vmId in vmIds { + appState.addRuntimeSharedFolder(share, toVm: vmId) + } let cleanup: () -> Void = { [weak appState, weak self] in DispatchQueue.main.async { - appState?.removeRuntimeSharedFolder(tag: tag, fromVm: vmId) + for vmId in vmIds { + appState?.removeRuntimeSharedFolder(tag: tag, fromVm: vmId) + } try? self?.fileManager.removeItem(at: dir) } } @@ -493,9 +595,10 @@ final class AgentToolsService { } } - private static func profileExportCommand(agent: AgentKind, outputPath: String) -> String { + private static func profileExportCommand(agent: AgentKind, outputPath: String, + scope: AgentProfileExportScope = .backup) -> String { let relPath = agentDataRelativePath(agent) - let excludes = agentExcludeArgs(agent) + let excludes = agentExcludeArgs(agent, scope: scope) let outDir = (outputPath as NSString).deletingLastPathComponent let workDir = "\(outDir)/.tenbox-profile-work" return """ @@ -513,6 +616,7 @@ final class AgentToolsService { "format": "tenbox-agent-profile", "format_version": 2, "agent_type": "\(agent.rawValue)", + "export_scope": "\(scope.rawValue)", "archive": "files.tar.gz" } EOF @@ -560,7 +664,19 @@ final class AgentToolsService { tar --touch -xzf "$input" -C "$work" [ -f "$work/manifest.json" ] || { echo "导入包缺少 manifest.json" >&2; exit 1; } [ -f "$work/files.tar.gz" ] || { echo "导入包缺少 files.tar.gz" >&2; exit 1; } - pkg_agent="$(awk -F\\" '/agent_type/ {print $4; exit}' "$work/manifest.json")" + pkg_agent="" + if command -v python3 >/dev/null 2>&1; then + pkg_agent="$(python3 - "$work/manifest.json" <<'PY' + import json + import sys + with open(sys.argv[1], "r", encoding="utf-8") as f: + print(json.load(f).get("agent_type", "")) + PY + )" || pkg_agent="" + fi + if [ -z "$pkg_agent" ]; then + pkg_agent="$(awk -F\\" '/agent_type/ {print $4; exit}' "$work/manifest.json")" + fi [ "$pkg_agent" = "\(agent.rawValue)" ] || { echo "导入包属于 $pkg_agent,不是 \(agent.rawValue)" >&2; exit 1; } backup="" if [ -e "$target" ]; then @@ -580,11 +696,10 @@ final class AgentToolsService { } private static func healthStatusCommand(agent: AgentKind) -> String { - let service = serviceName(agent) let gatewayPort = agent == .openclaw ? "18789" : "" return """ set -u - svc=\(shellQuote(service)) + svc="$(\(serviceResolverCommand(agent: agent)))" agent=\(shellQuote(agent.rawValue)) port=\(shellQuote(gatewayPort)) if XDG_RUNTIME_DIR="${XDG_RUNTIME_DIR:-/run/user/$(id -u)}" systemctl --user is-active --quiet "$svc" 2>/dev/null; then service_state=ok; else service_state=error; fi @@ -606,7 +721,9 @@ final class AgentToolsService { private static func restartCommand(agent: AgentKind) -> String { """ - XDG_RUNTIME_DIR="${XDG_RUNTIME_DIR:-/run/user/$(id -u)}" systemctl --user restart \(shellQuote(serviceName(agent))) + svc="$(\(serviceResolverCommand(agent: agent)))" + [ -n "$svc" ] || { echo "Agent 服务未安装" >&2; exit 1; } + XDG_RUNTIME_DIR="${XDG_RUNTIME_DIR:-/run/user/$(id -u)}" systemctl --user restart "$svc" \(healthStatusCommand(agent: agent)) """ } @@ -628,39 +745,30 @@ final class AgentToolsService { case .hermes: return """ set -eu - mkdir -p "$HOME/.hermes" - cat > "$HOME/.hermes/config.yaml" <<'EOF' - model: - default: "default" - provider: "custom" - base_url: "http://10.0.2.3/v1" - - terminal: - backend: local - - approvals: - mode: off - timeout: 60 - - display: - streaming: true - EOF + command -v hermes >/dev/null 2>&1 || { echo "缺少 Hermes 命令" >&2; exit 1; } + hermes config set model.default default >/dev/null + hermes config set model.provider custom >/dev/null + hermes config set model.base_url http://10.0.2.3/v1 >/dev/null + hermes config set terminal.backend local >/dev/null \(healthStatusCommand(agent: agent)) """ case .openclaw: return """ set -eu command -v openclaw >/dev/null 2>&1 || { echo "缺少 OpenClaw 命令" >&2; exit 1; } - openclaw config set models.providers.tenbox '{"baseUrl":"http://10.0.2.3/v1","apiKey":"tenbox","api":"openai-completions","models":[{"id":"default","name":"Default (TenBox Proxy)","reasoning":false,"input":["text","image"],"contextWindow":200000,"maxTokens":65536,"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0}}]}' >/dev/null + tenbox_provider='{"baseUrl":"http://10.0.2.3/v1","apiKey":"tenbox","api":"openai-completions","models":[{"id":"default","name":"Default (TenBox Proxy)","reasoning":false,"input":["text","image"],"contextWindow":200000,"maxTokens":65536,"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0}}]}' + openclaw config set models.providers.tenbox "$tenbox_provider" --strict-json --merge >/dev/null 2>&1 || openclaw config set models.providers.tenbox "$tenbox_provider" >/dev/null openclaw config set models.mode merge >/dev/null - openclaw config set agents.defaults '{"model":{"primary":"tenbox/default"},"compaction":{"mode":"safeguard"},"workspace":"'"$HOME"'/.openclaw/workspace","models":{"tenbox/default":{}}}' >/dev/null + openclaw config set agents.defaults.model.primary tenbox/default >/dev/null + openclaw config set agents.defaults.compaction.mode safeguard >/dev/null + openclaw config set agents.defaults.workspace "$HOME/.openclaw/workspace" >/dev/null + openclaw config set agents.defaults.models.tenbox/default '{"alias":"TenBox Proxy"}' --strict-json --merge >/dev/null 2>&1 || openclaw config set agents.defaults.models.tenbox/default '{"alias":"TenBox Proxy"}' >/dev/null \(healthStatusCommand(agent: agent)) """ } } private static func diagnosticsCommand(agent: AgentKind, outputDir: String) -> String { - let service = serviceName(agent) return """ set -eu out=\(shellQuote(outputDir))/tenbox-agent-diagnostics-\(agent.rawValue)-$(date -u +%Y%m%d%H%M%S).tar.gz @@ -668,10 +776,16 @@ final class AgentToolsService { rm -rf "$tmp" mkdir -p "$tmp" \(healthStatusCommand(agent: agent)) > "$tmp/health.json" 2>&1 || true - XDG_RUNTIME_DIR="${XDG_RUNTIME_DIR:-/run/user/$(id -u)}" systemctl --user status \(shellQuote(service)) --no-pager > "$tmp/service.txt" 2>&1 || true - journalctl --user -u \(shellQuote(service)) -n 200 --no-pager > "$tmp/journal.txt" 2>&1 || true + svc="$(\(serviceResolverCommand(agent: agent)))" + if [ -n "$svc" ]; then + XDG_RUNTIME_DIR="${XDG_RUNTIME_DIR:-/run/user/$(id -u)}" systemctl --user status "$svc" --no-pager > "$tmp/service.txt" 2>&1 || true + journalctl --user -u "$svc" -n 200 --no-pager > "$tmp/journal.txt" 2>&1 || true + else + echo "Agent 服务未安装" > "$tmp/service.txt" + echo "Agent 服务未安装" > "$tmp/journal.txt" + fi df -h > "$tmp/disk.txt" 2>&1 || true - sed -Ei 's/(sk-[A-Za-z0-9_-]{8})[A-Za-z0-9_-]+/\\1***/g; s/(api[_-]?key[=: ]+)[^ ]+/\\1***/Ig' "$tmp"/*.txt "$tmp"/*.json 2>/dev/null || true + sed -Ei 's/(sk-[A-Za-z0-9_-]{8})[A-Za-z0-9_-]+/\\1***/g; s/(authorization:[[:space:]]*bearer[[:space:]]+)[^[:space:]]+/\\1***/Ig; s/((api[_-]?key|token|secret|password)[=: ]+)[^ ]+/\\1***/Ig' "$tmp"/*.txt "$tmp"/*.json 2>/dev/null || true tar -czf "$out" -C "$tmp" . rm -rf "$tmp" echo "$out" @@ -685,24 +799,73 @@ final class AgentToolsService { } } - private static func agentExcludeArgs(_ agent: AgentKind) -> String { + private static func openClawMigrationSourceExportCommand(outputPath: String) -> String { + let outDir = (outputPath as NSString).deletingLastPathComponent + let workDir = "\(outDir)/.tenbox-openclaw-migrate-source" + let excludes = agentExcludeArgs(.openclaw, scope: .migration) + return """ + set -eu + home="${HOME:-/home/tenbox}" + src="$home/.openclaw" + out=\(shellQuote(outputPath)) + work=\(shellQuote(workDir)) + [ -d "$src" ] || { echo "OpenClaw 数据尚未初始化:$src" >&2; exit 1; } + rm -rf "$work" "$out" + mkdir -p "$work" + tar_status=0 + (cd "$home" && tar --warning=no-file-changed --ignore-failed-read \(excludes) -czf "$out" ".openclaw") || tar_status=$? + [ "$tar_status" -le 1 ] || exit "$tar_status" + rm -rf "$work" + echo "$out" + """ + } + + private static func openClawToHermesMigrationCommand(inputPath: String) -> String { + let workDir = (inputPath as NSString).deletingLastPathComponent + "/.tenbox-openclaw-to-hermes" + return """ + set -eu + command -v hermes >/dev/null 2>&1 || { echo "缺少 Hermes 命令" >&2; exit 1; } + input=\(shellQuote(inputPath)) + work=\(shellQuote(workDir)) + source_dir="$work/source" + [ -f "$input" ] || { echo "找不到 OpenClaw 迁移包:$input" >&2; exit 1; } + rm -rf "$work" + mkdir -p "$source_dir" + tar -xzf "$input" -C "$source_dir" + [ -d "$source_dir/.openclaw" ] || { echo "迁移包缺少 .openclaw 目录" >&2; exit 1; } + hermes claw migrate --dry-run --source "$source_dir/.openclaw" --preset full --migrate-secrets + hermes claw migrate --source "$source_dir/.openclaw" --preset full --migrate-secrets --skill-conflict skip --yes + svc="$(\(serviceResolverCommand(agent: .hermes)))" + if [ -n "$svc" ]; then + XDG_RUNTIME_DIR="${XDG_RUNTIME_DIR:-/run/user/$(id -u)}" systemctl --user restart "$svc" >/dev/null 2>&1 || true + fi + rm -rf "$work" + \(healthStatusCommand(agent: .hermes)) + """ + } + + private static func agentExcludeArgs(_ agent: AgentKind, scope: AgentProfileExportScope) -> String { switch agent { case .hermes: - return [ + let excludes = [ "--exclude", ".hermes/logs", + "--exclude", ".hermes/cache", "--exclude", ".hermes/image_cache", "--exclude", ".hermes/audio_cache", "--exclude", ".hermes/hermes-agent", "--exclude", ".hermes/bin", "--exclude", ".hermes/gateway.pid", "--exclude", ".hermes/gateway.lock", - ].map(shellQuote).joined(separator: " ") + ] + return excludes.map(shellQuote).joined(separator: " ") case .openclaw: - return [ + let excludes = [ "--exclude", ".openclaw/cache", "--exclude", ".openclaw/.cache", "--exclude", ".openclaw/workspace/.cache", - ].map(shellQuote).joined(separator: " ") + "--exclude", ".openclaw/logs", + ] + return excludes.map(shellQuote).joined(separator: " ") } } @@ -713,6 +876,20 @@ final class AgentToolsService { } } + private static func serviceResolverCommand(agent: AgentKind) -> String { + let pattern: String + switch agent { + case .hermes: + pattern = "hermes-gateway*.service" + case .openclaw: + pattern = "openclaw-gateway*.service" + } + let preferred = serviceName(agent) + return """ + { preferred=\(shellQuote(preferred)); pattern=\(shellQuote(pattern)); if XDG_RUNTIME_DIR="${XDG_RUNTIME_DIR:-/run/user/$(id -u)}" systemctl --user status "$preferred" >/dev/null 2>&1; then printf '%s' "$preferred"; else XDG_RUNTIME_DIR="${XDG_RUNTIME_DIR:-/run/user/$(id -u)}" systemctl --user list-units --all "$pattern" --no-legend 2>/dev/null | awk 'NR==1 {print $1; exit}'; fi; } + """ + } + private static func shellQuote(_ value: String) -> String { "'" + value.replacingOccurrences(of: "'", with: "'\\''") + "'" } diff --git a/src/manager-macos/TenBoxApp.swift b/src/manager-macos/TenBoxApp.swift index 2349c54..f6066f4 100644 --- a/src/manager-macos/TenBoxApp.swift +++ b/src/manager-macos/TenBoxApp.swift @@ -499,6 +499,59 @@ class AppState: ObservableObject { sourceURL: sourceURL, completion: completion) } + func migrateOpenClawToHermes(sourceVmId: String, targetVmId: String, + completion: @escaping (Result) -> Void) { + guard sourceVmId != targetVmId else { + completion(.failure(ConsoleCommandError("来源 VM 和目标 VM 不能相同"))) + return + } + guard let sourceVm = vms.first(where: { $0.id == sourceVmId }) else { + completion(.failure(ConsoleCommandError("找不到 OpenClaw 来源 VM"))) + return + } + guard let targetVm = vms.first(where: { $0.id == targetVmId }) else { + completion(.failure(ConsoleCommandError("找不到 Hermes 目标 VM"))) + return + } + guard sourceVm.state == .running else { + completion(.failure(ConsoleCommandError("OpenClaw 来源 VM 未运行"))) + return + } + guard targetVm.state == .running else { + completion(.failure(ConsoleCommandError("Hermes 目标 VM 未运行"))) + return + } + + let sourceSession = getOrCreateSession(for: sourceVmId) + let targetSession = getOrCreateSession(for: targetVmId) + if !sourceSession.connected || !sourceSession.ipcClient.isConnected { + sourceSession.connectIfNeeded() + completion(.failure(ConsoleCommandError("OpenClaw 来源 VM 执行通道未连接,请稍后重试"))) + return + } + guard sourceSession.guestAgentConnected else { + completion(.failure(ConsoleCommandError("OpenClaw 来源 VM Guest Agent 未连接"))) + return + } + if !targetSession.connected || !targetSession.ipcClient.isConnected { + targetSession.connectIfNeeded() + completion(.failure(ConsoleCommandError("Hermes 目标 VM 执行通道未连接,请稍后重试"))) + return + } + guard targetSession.guestAgentConnected else { + completion(.failure(ConsoleCommandError("Hermes 目标 VM Guest Agent 未连接"))) + return + } + + agentTools.migrateOpenClawToHermes(sourceVm: sourceVm, + sourceSession: sourceSession, + targetVm: targetVm, + targetSession: targetSession, + appState: self, + keepCount: agentBackupSchedule(vmId: targetVmId, agent: .hermes).keepCount, + completion: completion) + } + func agentBackupStatus(vmId: String, agent: AgentKind, completion: @escaping (Result) -> Void) { guard let vm = vms.first(where: { $0.id == vmId }) else { diff --git a/src/manager-macos/Views/AgentToolsView.swift b/src/manager-macos/Views/AgentToolsView.swift index 6190cde..bdca512 100644 --- a/src/manager-macos/Views/AgentToolsView.swift +++ b/src/manager-macos/Views/AgentToolsView.swift @@ -19,6 +19,7 @@ struct AgentToolsSheet: View { @State private var selectedBackupId: String? @State private var showsAdvancedActions = false @State private var showsAllBackups = false + @State private var selectedOpenClawSourceVmId: String? init(vmId: String, session: VmSession) { self.vmId = vmId @@ -89,6 +90,7 @@ struct AgentToolsSheet: View { loadSchedule() refreshBackupList() refreshBackupSummary() + refreshMigrationSourceSelection() } .onChange(of: selectedAgent, perform: { _ in operationResult = nil @@ -97,6 +99,7 @@ struct AgentToolsSheet: View { loadSchedule() refreshBackupList() refreshBackupSummary() + refreshMigrationSourceSelection() }) .alert(pendingConfirmation?.title ?? "", isPresented: confirmationPresented) { Button("取消", role: .cancel) { @@ -349,9 +352,28 @@ struct AgentToolsSheet: View { return Array(backupPackages.prefix(3)) } + private var openClawMigrationCandidates: [VmInfo] { + appState.vms + .filter { $0.id != vmId && $0.state == .running } + .sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending } + } + + private var selectedOpenClawSourceVm: VmInfo? { + guard let selectedOpenClawSourceVmId else { return nil } + return openClawMigrationCandidates.first { $0.id == selectedOpenClawSourceVmId } + } + + private var canMigrateOpenClaw: Bool { + selectedAgent == .hermes && canRun && selectedOpenClawSourceVm != nil + } + private var advancedActionsPanel: some View { DisclosureGroup(isExpanded: $showsAdvancedActions) { VStack(alignment: .leading, spacing: 10) { + if selectedAgent == .hermes { + openClawMigrationPanel + Divider() + } operationSection( title: "高级操作", operations: [.exportProfile, .importProfile, .restartAgent, .resetConfig, .diagnostics] @@ -370,6 +392,35 @@ struct AgentToolsSheet: View { .clipShape(RoundedRectangle(cornerRadius: 8)) } + private var openClawMigrationPanel: some View { + VStack(alignment: .leading, spacing: 8) { + Text("从 OpenClaw 迁移") + .font(.headline) + + HStack(spacing: 10) { + Picker("来源 VM", selection: $selectedOpenClawSourceVmId) { + Text("选择运行中的 OpenClaw VM").tag(String?.none) + ForEach(openClawMigrationCandidates) { vm in + Text(vm.name).tag(Optional(vm.id)) + } + } + .frame(maxWidth: .infinity) + + Button { + guard let sourceVm = selectedOpenClawSourceVm else { return } + pendingConfirmation = .migrateOpenClaw(sourceVm.id) + } label: { + Label("自动迁移", systemImage: "arrow.triangle.2.circlepath") + } + .disabled(!canMigrateOpenClaw) + } + + Text("会先从来源 VM 导出完整 OpenClaw 数据,再在当前 Hermes VM 中调用官方迁移命令。两个 VM 都需要运行且 Guest Agent 已连接。") + .font(.caption) + .foregroundStyle(.secondary) + } + } + private func repairSuggestionPanel(report: HealthReport) -> some View { VStack(alignment: .leading, spacing: 10) { Text("建议修复") @@ -495,6 +546,8 @@ struct AgentToolsSheet: View { exportProfile() case .importProfile: importProfile() + case .migrateOpenClaw: + migrateOpenClawToHermes() case .snapshotBackup: snapshotBackup() case .restoreBackup: @@ -573,6 +626,8 @@ struct AgentToolsSheet: View { runOperation(.importProfile) { appState.importAgentProfile(vmId: vm.id, agent: selectedAgent, sourceURL: url, completion: $0) } + case .migrateOpenClaw(let sourceVmId): + migrateOpenClawToHermes(sourceVmId: sourceVmId) case .restoreBackup(let package): restoreBackup(package) case .resetConfig: @@ -580,6 +635,15 @@ struct AgentToolsSheet: View { } } + private func migrateOpenClawToHermes(sourceVmId: String? = nil) { + guard let vm = vm else { return } + let resolvedSourceVmId = sourceVmId ?? selectedOpenClawSourceVm?.id + guard let resolvedSourceVmId else { return } + runOperation(.migrateOpenClaw) { + appState.migrateOpenClawToHermes(sourceVmId: resolvedSourceVmId, targetVmId: vm.id, completion: $0) + } + } + private func snapshotBackup() { guard let vm = vm else { return } runOperation(.snapshotBackup) { @@ -666,6 +730,15 @@ struct AgentToolsSheet: View { selectedBackupId = backupPackages.first?.id } + private func refreshMigrationSourceSelection() { + let candidates = openClawMigrationCandidates + if let selectedOpenClawSourceVmId, + candidates.contains(where: { $0.id == selectedOpenClawSourceVmId }) { + return + } + selectedOpenClawSourceVmId = candidates.first?.id + } + private func revealBackup(_ package: AgentBackupPackage) { NSWorkspace.shared.activateFileViewerSelecting([package.url]) } @@ -788,6 +861,9 @@ struct AgentToolsSheet: View { ("Command timed out", "操作超时"), ("Failed to send guest agent command", "发送 Guest Agent 命令失败"), ("Agent data is not initialized", "Agent 数据尚未初始化"), + ("OpenClaw 数据尚未初始化", "OpenClaw 数据尚未初始化"), + ("缺少 Hermes 命令", "目标 VM 缺少 Hermes 命令"), + ("缺少 OpenClaw 命令", "VM 缺少 OpenClaw 命令"), ("No backup package found", "没有找到可恢复的备份"), ("package not found", "找不到导入包"), ("manifest.json missing", "导入包缺少 manifest.json"), @@ -808,6 +884,7 @@ struct AgentToolsSheet: View { private enum AgentToolOperation: String, CaseIterable, Identifiable { case exportProfile case importProfile + case migrateOpenClaw case snapshotBackup case restoreBackup case healthCheck @@ -821,6 +898,7 @@ private enum AgentToolOperation: String, CaseIterable, Identifiable { switch self { case .exportProfile: return "导出迁移包" case .importProfile: return "导入" + case .migrateOpenClaw: return "OpenClaw 迁移" case .snapshotBackup: return "立即备份" case .restoreBackup: return "恢复备份" case .healthCheck: return "一键诊断" @@ -834,6 +912,7 @@ private enum AgentToolOperation: String, CaseIterable, Identifiable { switch self { case .exportProfile: return "square.and.arrow.up" case .importProfile: return "square.and.arrow.down" + case .migrateOpenClaw: return "arrow.triangle.2.circlepath" case .snapshotBackup: return "clock.arrow.circlepath" case .restoreBackup: return "arrow.uturn.backward" case .healthCheck: return "stethoscope" @@ -847,6 +926,7 @@ private enum AgentToolOperation: String, CaseIterable, Identifiable { switch self { case .exportProfile: return "导出当前 Agent 数据" case .importProfile: return "从归档包导入 Agent 数据" + case .migrateOpenClaw: return "从运行中的 OpenClaw VM 迁移到当前 Hermes VM" case .snapshotBackup: return "创建一份主机侧备份" case .restoreBackup: return "用选中的备份恢复 Agent 数据" case .healthCheck: return "检查 Agent 运行状态" @@ -860,6 +940,7 @@ private enum AgentToolOperation: String, CaseIterable, Identifiable { switch self { case .exportProfile: return "导出完成" case .importProfile: return "导入完成" + case .migrateOpenClaw: return "迁移完成" case .snapshotBackup: return "备份完成" case .restoreBackup: return "恢复完成" case .healthCheck: return "诊断完成" @@ -873,6 +954,7 @@ private enum AgentToolOperation: String, CaseIterable, Identifiable { switch self { case .exportProfile: return "导出失败" case .importProfile: return "导入失败" + case .migrateOpenClaw: return "迁移失败" case .snapshotBackup: return "备份失败" case .restoreBackup: return "恢复失败" case .healthCheck: return "诊断失败" @@ -893,6 +975,7 @@ private enum AgentToolOperation: String, CaseIterable, Identifiable { switch self { case .exportProfile: return "正在导出 \(agent.displayName) 数据..." case .importProfile: return "正在导入 \(agent.displayName) 数据..." + case .migrateOpenClaw: return "正在从 OpenClaw VM 迁移到 Hermes..." case .snapshotBackup: return "正在备份 \(agent.displayName) 数据..." case .restoreBackup: return "正在恢复 \(agent.displayName) 备份..." case .healthCheck: return "正在诊断 \(agent.displayName) 状态..." @@ -915,12 +998,14 @@ private enum AgentToolOperation: String, CaseIterable, Identifiable { private enum PendingAgentConfirmation: Identifiable { case importProfile(URL) + case migrateOpenClaw(String) case restoreBackup(AgentBackupPackage) case resetConfig var id: String { switch self { case .importProfile(let url): return "import-\(url.path)" + case .migrateOpenClaw(let sourceVmId): return "migrate-openclaw-\(sourceVmId)" case .restoreBackup(let package): return "restore-\(package.id)" case .resetConfig: return "reset" } @@ -929,6 +1014,7 @@ private enum PendingAgentConfirmation: Identifiable { var title: String { switch self { case .importProfile: return "确认导入 Agent 数据?" + case .migrateOpenClaw: return "确认从 OpenClaw VM 自动迁移?" case .restoreBackup: return "确认恢复这个备份?" case .resetConfig: return "确认重置配置?" } @@ -938,6 +1024,8 @@ private enum PendingAgentConfirmation: Identifiable { switch self { case .importProfile(let url): return "导入会替换当前 Agent 数据。文件:\(url.lastPathComponent)" + case .migrateOpenClaw: + return "迁移会先备份目标 Hermes 数据,再导入来源 OpenClaw 的用户数据、密钥、记忆、技能和兼容配置。" case .restoreBackup(let package): return "恢复会用选中的备份覆盖当前 Agent 数据。文件:\(package.filename)" case .resetConfig: @@ -948,6 +1036,7 @@ private enum PendingAgentConfirmation: Identifiable { var confirmTitle: String { switch self { case .importProfile: return "导入" + case .migrateOpenClaw: return "迁移" case .restoreBackup: return "恢复" case .resetConfig: return "重置" } diff --git a/src/runtime/runtime_service.cpp b/src/runtime/runtime_service.cpp index 9332284..15f7c3b 100644 --- a/src/runtime/runtime_service.cpp +++ b/src/runtime/runtime_service.cpp @@ -507,7 +507,8 @@ void RuntimeControlService::AttachVm(Vm* vm) { console_port_->SetInputCallback([vm](const uint8_t* data, size_t size) { static constexpr size_t kConsoleInputChunk = 64; for (size_t off = 0; off < size; off += kConsoleInputChunk) { - size_t chunk = std::min(kConsoleInputChunk, size - off); + size_t remaining = size - off; + size_t chunk = remaining < kConsoleInputChunk ? remaining : kConsoleInputChunk; vm->InjectConsoleBytes(data + off, chunk); if (off + chunk < size) { std::this_thread::sleep_for(std::chrono::milliseconds(2)); From 31a8c389b54ac937d76af711233fe89f4fb7595e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BC=A0=E5=AE=89=E5=93=B2?= Date: Tue, 12 May 2026 02:41:12 +0800 Subject: [PATCH 32/37] fix: keep agent migration responsive --- CLAUDE.md | 3 +- .../Bridge/IpcClientWrapper.swift | 15 +- .../Services/AgentToolsService.swift | 253 ++++++++++++++++-- src/manager-macos/TenBoxApp.swift | 4 + src/manager-macos/Views/AgentToolsView.swift | 111 +++++++- src/manager-macos/Views/VmDetailView.swift | 9 +- 6 files changed, 370 insertions(+), 25 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index b6509bb..186b992 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -90,7 +90,7 @@ Win/macOS: tenbox-manager ──IPC v1──► tenbox-vm-runtime (WHVP / HVF) - **macOS Caps Lock forwarding**: send Caps Lock as a tap (`down` then `up`) on each `flagsChanged` event; AppKit exposes it as a toggle state, but the guest input stack needs a full key press for every switch. - **Agent data profile packages**: `TenBox.app` exports/imports Hermes/OpenClaw data without image changes by using a temporary shared folder and qemu-guest-agent `guest-exec` shell commands. Keep the gzip package format documented in `docs/agent-profile.md`, include `export_scope`, and reject cross-agent imports. - **Agent migration exports**: user-triggered migration packages should carry the user's full Agent state, including secrets, credentials, identity/device state, sessions, browser profiles, and config files. Exclude only volatile logs, caches, runtime lock files, and reinstallable binaries by default. -- **Agent OpenClaw to Hermes migration**: the macOS Agent急救箱 can migrate a running OpenClaw VM into a running Hermes VM by first creating a host-managed Hermes backup, sharing one runtime-only folder, exporting `~/.openclaw` with full user state, then running official `hermes claw migrate --source ... --preset full --migrate-secrets --skill-conflict skip --yes` in the Hermes VM after a dry run. Do not reimplement the migration mapping in TenBox. +- **Agent OpenClaw to Hermes migration**: the macOS Agent急救箱 can migrate a running OpenClaw VM into a running Hermes VM by first creating a host-managed Hermes backup, sharing one runtime-only folder, exporting `~/.openclaw` with full user state, then running official `hermes claw migrate` in the Hermes VM after a separate dry run. Keep the UI non-blocking with step progress, expose the skill conflict strategy, pass `--migrate-secrets`, support `--workspace-target`, and save the dry-run/final migration report beside the host-managed Hermes backups. Do not reimplement the migration mapping in TenBox. - **Hermes profile scope**: export user/config/state data, not the reinstallable Hermes app checkout, virtualenv, local binaries, logs, or cache directories. - **Agent data backups**: `TenBox.app` writes host-managed backups to `~/Library/Application Support/TenBox/AgentBackups//` and retains packages according to the configured per-Agent retention count (default 7). Profile export runs against live Agent data, so GNU tar exit 1 from changed/skipped files is treated as non-fatal; fatal tar errors must still fail the backup. - **Agent scheduled backups**: `TenBox.app` supports per-VM/per-Agent scheduled backups stored in `settings.json`; default time is 03:00 and default retention is 7 packages. Backup filenames must be time-based (`agent-data-yyyy-MM-dd-HHmmss.tar.gz`), restore should let the user choose a package, scheduled backups only run when the VM is running and the guest execution channel is connected, and the UI should surface the last automatic backup attempt result. @@ -103,6 +103,7 @@ Win/macOS: tenbox-manager ──IPC v1──► tenbox-vm-runtime (WHVP / HVF) - **macOS Agent health UI**: `TenBox.app` exposes health check, restart, config reset, and diagnostics actions while a VM is running. Repair actions run through app-generated shell commands so no image rebuild is required. - **macOS console commands**: keep marker-based console command execution as a fallback path; Agent tools should use qemu-guest-agent `guest-exec` and wait for temporary shared folders before reading or writing packages. - **macOS Agent tool commands**: prefer qemu-guest-agent `guest-exec` for command execution, pass multiline scripts as `command_hex`, and run them as guest user `tenbox`; keep shared folders as the data plane for profile packages, backups, and diagnostics. +- **macOS Agent tool responsiveness**: do not perform guest-exec or runtime shared-folder IPC sends on the SwiftUI main thread. Queue those sends off-main and keep only request bookkeeping / UI state updates on the main thread. - **macOS console markers**: do not put the full begin/end marker literal in console input; build it inside the shell so echoed input cannot satisfy marker detection. - **macOS console input**: throttle manager-to-runtime console input before injecting into the guest UART; bulk shell scripts can overflow the emulated FIFO if delivered as one burst. - **macOS app signing**: the app entitlement includes `com.apple.security.cs.disable-library-validation` so the hardened-runtime app can load the bundled Sparkle framework. diff --git a/src/manager-macos/Bridge/IpcClientWrapper.swift b/src/manager-macos/Bridge/IpcClientWrapper.swift index 6a274c7..ab69b55 100644 --- a/src/manager-macos/Bridge/IpcClientWrapper.swift +++ b/src/manager-macos/Bridge/IpcClientWrapper.swift @@ -3,6 +3,7 @@ import TenBoxBridge class IpcClientWrapper: ObservableObject { private let client = TBIpcClient() + private let sendQueue = DispatchQueue(label: "tenbox.ipc.send", qos: .userInitiated) @Published var isConnected = false // Display: (pixelBytes, pixelLength, dirtyW, dirtyH, stride, resourceW, resourceH, dirtyX, dirtyY) @@ -78,6 +79,16 @@ class IpcClientWrapper: ObservableObject { client.sendGuestExecCommand(command, user: user, requestId: requestId, timeoutMs: timeoutMs) } + func sendGuestExecAsync(command: String, user: String, requestId: UInt64, timeoutMs: UInt32, + completion: @escaping (Bool) -> Void) { + sendQueue.async { [client] in + let sent = client.sendGuestExecCommand(command, user: user, requestId: requestId, timeoutMs: timeoutMs) + DispatchQueue.main.async { + completion(sent) + } + } + } + func sendKey(code: UInt16, pressed: Bool) { client.sendKeyEvent(code, pressed: pressed) } @@ -115,7 +126,9 @@ class IpcClientWrapper: ObservableObject { } func sendSharedFoldersUpdate(entries: [String]) { - client.sendSharedFoldersUpdate(entries) + sendQueue.async { [client, entries] in + client.sendSharedFoldersUpdate(entries) + } } func sendNetworkUpdate(hostfwdEntries: [String], guestfwdEntries: [String], netEnabled: Bool) { diff --git a/src/manager-macos/Services/AgentToolsService.swift b/src/manager-macos/Services/AgentToolsService.swift index 4ae1fa0..d02c867 100644 --- a/src/manager-macos/Services/AgentToolsService.swift +++ b/src/manager-macos/Services/AgentToolsService.swift @@ -24,6 +24,72 @@ struct AgentToolResult { let output: String } +enum AgentSkillConflictStrategy: String, CaseIterable, Identifiable { + case skip + case overwrite + case rename + + var id: String { rawValue } + + var displayName: String { + switch self { + case .skip: return "保留 Hermes" + case .overwrite: return "覆盖 Hermes" + case .rename: return "重命名导入" + } + } + + var help: String { + switch self { + case .skip: return "遇到同名技能时保留目标 Hermes 版本" + case .overwrite: return "遇到同名技能时使用 OpenClaw 版本覆盖" + case .rename: return "遇到同名技能时将 OpenClaw 版本重命名导入" + } + } +} + +struct AgentMigrationOptions: Equatable { + var skillConflictStrategy: AgentSkillConflictStrategy = .skip + var workspaceTarget: String = "/home/tenbox/.hermes/workspace/openclaw-migrated" +} + +enum AgentMigrationStep: String { + case backup + case exportSource + case dryRun + case migrate + case restart + case health + case complete + + var title: String { + switch self { + case .backup: return "备份 Hermes" + case .exportSource: return "导出 OpenClaw" + case .dryRun: return "检查迁移计划" + case .migrate: return "执行迁移" + case .restart: return "重启 Hermes" + case .health: return "健康检查" + case .complete: return "完成" + } + } +} + +struct AgentMigrationProgress: Identifiable, Equatable { + let id = UUID() + let step: AgentMigrationStep + let message: String + let detail: String? + let date: Date + + init(step: AgentMigrationStep, message: String, detail: String? = nil, date: Date = Date()) { + self.step = step + self.message = message + self.detail = detail + self.date = date + } +} + struct AgentBackupPackage: Identifiable, Equatable { let url: URL let modifiedAt: Date @@ -330,10 +396,19 @@ final class AgentToolsService { func migrateOpenClawToHermes(sourceVm: VmInfo, sourceSession: VmSession, targetVm: VmInfo, targetSession: VmSession, appState: AppState, + options: AgentMigrationOptions = AgentMigrationOptions(), keepCount: Int = AgentBackupSchedule.defaultKeepCount, + progress: @escaping (AgentMigrationProgress) -> Void = { _ in }, completion: @escaping (Result) -> Void) { + let emit: (AgentMigrationStep, String, String?) -> Void = { step, message, detail in + DispatchQueue.main.async { + progress(AgentMigrationProgress(step: step, message: message, detail: detail)) + } + } + do { let backupPackage = try backupPackageURL(vmId: targetVm.id, agent: .hermes) + let reportURL = try migrationReportURL(vmId: targetVm.id, agent: .hermes) withBackupShare(vmId: targetVm.id, appState: appState) { backupShare, backupCleanup in withOperationShare(vmIds: [sourceVm.id, targetVm.id], appState: appState) { share, cleanup in let cleanupAll = { @@ -341,12 +416,14 @@ final class AgentToolsService { backupCleanup() } let guestBackup = "/mnt/shared/\(backupShare.tag)/hermes/\(backupPackage.lastPathComponent)" + let guestReport = "/mnt/shared/\(backupShare.tag)/hermes/\(reportURL.lastPathComponent)" let backupCommand = Self.withSharedFolderReady( tag: backupShare.tag, body: "mkdir -p \(Self.shellQuote("/mnt/shared/\(backupShare.tag)/hermes"))\n" + Self.profileExportCommand(agent: .hermes, outputPath: guestBackup, scope: .backup) ) + emit(.backup, "正在创建目标 Hermes 迁移前备份", backupPackage.path) targetSession.runGuestAgentCommand(backupCommand, timeout: 420) { backupResult in switch backupResult { case .success(let backupCommandResult): @@ -361,6 +438,7 @@ final class AgentToolsService { tag: share.tag, body: Self.openClawMigrationSourceExportCommand(outputPath: archivePath) ) + emit(.exportSource, "正在从来源 VM 导出 OpenClaw 用户数据", sourceVm.name) sourceSession.runGuestAgentCommand(exportCommand, timeout: 420) { sourceResult in switch sourceResult { case .success(let sourceCommandResult): @@ -370,24 +448,65 @@ final class AgentToolsService { return } - let migrateCommand = Self.withSharedFolderReady( + let dryRunCommand = Self.withSharedFolderReady( tag: share.tag, - body: Self.openClawToHermesMigrationCommand(inputPath: archivePath) + body: Self.openClawToHermesDryRunCommand( + inputPath: archivePath, + reportPath: guestReport, + options: options + ) ) - targetSession.runGuestAgentCommand(migrateCommand, timeout: 600) { targetResult in - cleanupAll() - switch targetResult { - case .success(let targetCommandResult): - guard targetCommandResult.exitCode == 0 else { - completion(.failure(Self.makeError(targetCommandResult.output.isEmpty ? "OpenClaw 到 Hermes 迁移失败" : targetCommandResult.output))) + emit(.dryRun, "正在生成官方 dry-run 迁移计划", "冲突策略:\(options.skillConflictStrategy.displayName)") + targetSession.runGuestAgentCommand(dryRunCommand, timeout: 420) { dryRunResult in + switch dryRunResult { + case .success(let dryRunCommandResult): + guard dryRunCommandResult.exitCode == 0 else { + cleanupAll() + completion(.failure(Self.makeError(dryRunCommandResult.output.isEmpty ? "OpenClaw 到 Hermes 迁移预检失败" : dryRunCommandResult.output))) return } - self.rotateBackups(vmId: targetVm.id, agent: .hermes, keep: keepCount) - completion(.success(AgentToolResult( - message: "已完成 OpenClaw 到 Hermes 迁移", - output: "迁移前备份:\(backupPackage.path)\n来源 VM:\(sourceVm.name)\n目标 VM:\(targetVm.name)\n\(targetCommandResult.output)" - ))) + emit(.migrate, "dry-run 已通过,正在执行正式迁移", Self.compactMigrationOutput(dryRunCommandResult.output)) + let migrateCommand = Self.withSharedFolderReady( + tag: share.tag, + body: Self.openClawToHermesMigrationCommand( + inputPath: archivePath, + reportPath: guestReport, + options: options + ) + ) + targetSession.runGuestAgentCommand(migrateCommand, timeout: 600) { targetResult in + cleanupAll() + switch targetResult { + case .success(let targetCommandResult): + guard targetCommandResult.exitCode == 0 else { + completion(.failure(Self.makeError(targetCommandResult.output.isEmpty ? "OpenClaw 到 Hermes 迁移失败" : targetCommandResult.output))) + return + } + self.rotateBackups(vmId: targetVm.id, agent: .hermes, keep: keepCount) + emit(.complete, "迁移完成,报告已保存", reportURL.path) + completion(.success(AgentToolResult( + message: "已完成 OpenClaw 到 Hermes 迁移", + output: """ + 迁移前备份:\(backupPackage.path) + 迁移报告:\(reportURL.path) + 来源 VM:\(sourceVm.name) + 目标 VM:\(targetVm.name) + 冲突策略:\(options.skillConflictStrategy.displayName) + Workspace 目标:\(options.workspaceTarget.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? "默认" : options.workspaceTarget) + + [dry-run] + \(dryRunCommandResult.output) + + [migrate] + \(targetCommandResult.output) + """ + ))) + case .failure(let error): + completion(.failure(error)) + } + } case .failure(let error): + cleanupAll() completion(.failure(error)) } } @@ -564,6 +683,15 @@ final class AgentToolsService { .appendingPathComponent("agent-data-\(formatter.string(from: Date())).tar.gz") } + private func migrationReportURL(vmId: String, agent: AgentKind) throws -> URL { + let formatter = DateFormatter() + formatter.calendar = Calendar(identifier: .gregorian) + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.dateFormat = "yyyy-MM-dd-HHmmss" + return try backupPackageDirectory(vmId: vmId, agent: agent) + .appendingPathComponent("openclaw-migration-\(formatter.string(from: Date())).txt") + } + func listBackupPackages(vmId: String, agent: AgentKind) throws -> [AgentBackupPackage] { let dir = try backupPackageDirectory(vmId: vmId, agent: agent) let items = (try? fileManager.contentsOfDirectory( @@ -820,12 +948,47 @@ final class AgentToolsService { """ } - private static func openClawToHermesMigrationCommand(inputPath: String) -> String { + private static func openClawToHermesDryRunCommand(inputPath: String, + reportPath: String, + options: AgentMigrationOptions) -> String { + let workDir = (inputPath as NSString).deletingLastPathComponent + "/.tenbox-openclaw-to-hermes" + let flags = openClawMigrationFlags(options: options, includeYes: false) + return """ + set -eu + command -v hermes >/dev/null 2>&1 || { echo "缺少 Hermes 命令" >&2; exit 1; } + input=\(shellQuote(inputPath)) + report=\(shellQuote(reportPath)) + work=\(shellQuote(workDir)) + source_dir="$work/source" + [ -f "$input" ] || { echo "找不到 OpenClaw 迁移包:$input" >&2; exit 1; } + rm -rf "$work" + mkdir -p "$source_dir" + tar -xzf "$input" -C "$source_dir" + [ -d "$source_dir/.openclaw" ] || { echo "迁移包缺少 .openclaw 目录" >&2; exit 1; } + dry_log="$work/dry-run.txt" + dry_status=0 + hermes claw migrate --dry-run --source "$source_dir/.openclaw" \(flags) > "$dry_log" 2>&1 || dry_status=$? + { + echo "===== OpenClaw -> Hermes dry-run $(date -u +%Y-%m-%dT%H:%M:%SZ) =====" + cat "$dry_log" + echo + } >> "$report" + \(limitedLogCommand(logVariable: "dry_log")) + [ "$dry_status" -eq 0 ] || exit "$dry_status" + rm -rf "$work" + """ + } + + private static func openClawToHermesMigrationCommand(inputPath: String, + reportPath: String, + options: AgentMigrationOptions) -> String { let workDir = (inputPath as NSString).deletingLastPathComponent + "/.tenbox-openclaw-to-hermes" + let flags = openClawMigrationFlags(options: options, includeYes: true) return """ set -eu command -v hermes >/dev/null 2>&1 || { echo "缺少 Hermes 命令" >&2; exit 1; } input=\(shellQuote(inputPath)) + report=\(shellQuote(reportPath)) work=\(shellQuote(workDir)) source_dir="$work/source" [ -f "$input" ] || { echo "找不到 OpenClaw 迁移包:$input" >&2; exit 1; } @@ -833,15 +996,73 @@ final class AgentToolsService { mkdir -p "$source_dir" tar -xzf "$input" -C "$source_dir" [ -d "$source_dir/.openclaw" ] || { echo "迁移包缺少 .openclaw 目录" >&2; exit 1; } - hermes claw migrate --dry-run --source "$source_dir/.openclaw" --preset full --migrate-secrets - hermes claw migrate --source "$source_dir/.openclaw" --preset full --migrate-secrets --skill-conflict skip --yes + migrate_log="$work/migrate.txt" + migrate_status=0 + hermes claw migrate --source "$source_dir/.openclaw" \(flags) > "$migrate_log" 2>&1 || migrate_status=$? + { + echo "===== OpenClaw -> Hermes migrate $(date -u +%Y-%m-%dT%H:%M:%SZ) =====" + cat "$migrate_log" + echo + } >> "$report" + \(limitedLogCommand(logVariable: "migrate_log")) + [ "$migrate_status" -eq 0 ] || exit "$migrate_status" svc="$(\(serviceResolverCommand(agent: .hermes)))" if [ -n "$svc" ]; then XDG_RUNTIME_DIR="${XDG_RUNTIME_DIR:-/run/user/$(id -u)}" systemctl --user restart "$svc" >/dev/null 2>&1 || true + echo "重启服务:$svc" >> "$report" fi rm -rf "$work" + health_log="$(mktemp)" + ( \(healthStatusCommand(agent: .hermes)) + ) > "$health_log" 2>&1 || true + cat "$health_log" + { + echo "===== Hermes health =====" + cat "$health_log" + echo + } >> "$report" + rm -f "$health_log" + """ + } + + private static func openClawMigrationFlags(options: AgentMigrationOptions, includeYes: Bool) -> String { + var flags = [ + "--preset", "full", + "--migrate-secrets", + "--skill-conflict", options.skillConflictStrategy.rawValue + ].map(shellQuote).joined(separator: " ") + + let workspaceTarget = options.workspaceTarget.trimmingCharacters(in: .whitespacesAndNewlines) + if !workspaceTarget.isEmpty { + flags += " --workspace-target \(shellQuote(workspaceTarget))" + } + if includeYes { + flags += " --yes" + } + return flags + } + + private static func limitedLogCommand(logVariable: String) -> String { """ + line_count="$(wc -l < "$\(logVariable)" | tr -d ' ')" + if [ "${line_count:-0}" -gt 160 ]; then + sed -n '1,80p' "$\(logVariable)" + echo "... 输出已截断,完整内容见迁移报告 ..." + tail -n 80 "$\(logVariable)" + else + cat "$\(logVariable)" + fi + """ + } + + private static func compactMigrationOutput(_ output: String) -> String? { + let lines = output + .split(whereSeparator: { $0.isNewline }) + .map { String($0).trimmingCharacters(in: .whitespaces) } + .filter { !$0.isEmpty } + guard !lines.isEmpty else { return nil } + return lines.prefix(8).joined(separator: "\n") } private static func agentExcludeArgs(_ agent: AgentKind, scope: AgentProfileExportScope) -> String { diff --git a/src/manager-macos/TenBoxApp.swift b/src/manager-macos/TenBoxApp.swift index f6066f4..feab93f 100644 --- a/src/manager-macos/TenBoxApp.swift +++ b/src/manager-macos/TenBoxApp.swift @@ -500,6 +500,8 @@ class AppState: ObservableObject { } func migrateOpenClawToHermes(sourceVmId: String, targetVmId: String, + options: AgentMigrationOptions = AgentMigrationOptions(), + progress: @escaping (AgentMigrationProgress) -> Void = { _ in }, completion: @escaping (Result) -> Void) { guard sourceVmId != targetVmId else { completion(.failure(ConsoleCommandError("来源 VM 和目标 VM 不能相同"))) @@ -548,7 +550,9 @@ class AppState: ObservableObject { targetVm: targetVm, targetSession: targetSession, appState: self, + options: options, keepCount: agentBackupSchedule(vmId: targetVmId, agent: .hermes).keepCount, + progress: progress, completion: completion) } diff --git a/src/manager-macos/Views/AgentToolsView.swift b/src/manager-macos/Views/AgentToolsView.swift index bdca512..35bbba1 100644 --- a/src/manager-macos/Views/AgentToolsView.swift +++ b/src/manager-macos/Views/AgentToolsView.swift @@ -20,6 +20,9 @@ struct AgentToolsSheet: View { @State private var showsAdvancedActions = false @State private var showsAllBackups = false @State private var selectedOpenClawSourceVmId: String? + @State private var migrationSkillConflictStrategy: AgentSkillConflictStrategy = .skip + @State private var migrationWorkspaceTarget = AgentMigrationOptions().workspaceTarget + @State private var migrationProgress: [AgentMigrationProgress] = [] init(vmId: String, session: VmSession) { self.vmId = vmId @@ -69,6 +72,10 @@ struct AgentToolsSheet: View { } } + if runningOperation == .migrateOpenClaw || !migrationProgress.isEmpty { + MigrationProgressView(items: migrationProgress) + } + if let operationResult { AgentOperationResultView(result: operationResult) if let report = operationResult.healthReport, report.state != "ok" { @@ -94,6 +101,7 @@ struct AgentToolsSheet: View { } .onChange(of: selectedAgent, perform: { _ in operationResult = nil + migrationProgress = [] selectedBackupId = nil showsAllBackups = false loadSchedule() @@ -415,7 +423,20 @@ struct AgentToolsSheet: View { .disabled(!canMigrateOpenClaw) } - Text("会先从来源 VM 导出完整 OpenClaw 数据,再在当前 Hermes VM 中调用官方迁移命令。两个 VM 都需要运行且 Guest Agent 已连接。") + HStack(spacing: 10) { + Picker("技能冲突", selection: $migrationSkillConflictStrategy) { + ForEach(AgentSkillConflictStrategy.allCases) { strategy in + Text(strategy.displayName).tag(strategy) + } + } + .help(migrationSkillConflictStrategy.help) + + TextField("Workspace 目标", text: $migrationWorkspaceTarget) + .textFieldStyle(.roundedBorder) + .help("OpenClaw workspace 指令迁移到 Hermes 的目标目录;留空则交给 hermes 默认处理") + } + + Text("会先备份目标 Hermes,导出完整 OpenClaw 用户数据,执行官方 dry-run 并把迁移报告保存到宿主机。两个 VM 都需要运行且 Guest Agent 已连接。") .font(.caption) .foregroundStyle(.secondary) } @@ -639,8 +660,35 @@ struct AgentToolsSheet: View { guard let vm = vm else { return } let resolvedSourceVmId = sourceVmId ?? selectedOpenClawSourceVm?.id guard let resolvedSourceVmId else { return } + let workspaceTarget = migrationWorkspaceTarget.trimmingCharacters(in: .whitespacesAndNewlines) + guard workspaceTarget.isEmpty || workspaceTarget.hasPrefix("/") else { + operationResult = AgentOperationDisplay( + isSuccess: false, + title: "迁移失败", + summary: "Workspace 目标必须是绝对路径", + details: workspaceTarget, + revealPath: nil, + healthReport: nil + ) + return + } + let options = AgentMigrationOptions( + skillConflictStrategy: migrationSkillConflictStrategy, + workspaceTarget: workspaceTarget + ) + migrationProgress = [] runOperation(.migrateOpenClaw) { - appState.migrateOpenClawToHermes(sourceVmId: resolvedSourceVmId, targetVmId: vm.id, completion: $0) + appState.migrateOpenClawToHermes( + sourceVmId: resolvedSourceVmId, + targetVmId: vm.id, + options: options, + progress: { item in + DispatchQueue.main.async { + migrationProgress.append(item) + } + }, + completion: $0 + ) } } @@ -755,6 +803,9 @@ struct AgentToolsSheet: View { revealPath: String? = nil, _ action: (@escaping (Result) -> Void) -> Void) { operationResult = nil + if operation != .migrateOpenClaw { + migrationProgress = [] + } runningOperation = operation action { result in DispatchQueue.main.async { @@ -990,6 +1041,13 @@ private enum AgentToolOperation: String, CaseIterable, Identifiable { switch self { case .snapshotBackup, .restoreBackup, .diagnostics: return raw.split(whereSeparator: { $0.isNewline }).map(String.init).last + case .migrateOpenClaw: + let prefix = "迁移报告:" + return raw + .split(whereSeparator: { $0.isNewline }) + .map { String($0).trimmingCharacters(in: .whitespaces) } + .first { $0.hasPrefix(prefix) } + .map { String($0.dropFirst(prefix.count)) } default: return nil } @@ -1025,7 +1083,7 @@ private enum PendingAgentConfirmation: Identifiable { case .importProfile(let url): return "导入会替换当前 Agent 数据。文件:\(url.lastPathComponent)" case .migrateOpenClaw: - return "迁移会先备份目标 Hermes 数据,再导入来源 OpenClaw 的用户数据、密钥、记忆、技能和兼容配置。" + return "迁移会先备份目标 Hermes 数据,执行 dry-run 预检,再导入来源 OpenClaw 的用户数据、密钥、记忆、技能和兼容配置。" case .restoreBackup(let package): return "恢复会用选中的备份覆盖当前 Agent 数据。文件:\(package.filename)" case .resetConfig: @@ -1104,6 +1162,53 @@ private struct BackupPackageRow: View { } } +private struct MigrationProgressView: View { + let items: [AgentMigrationProgress] + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + Text("迁移进度") + .font(.headline) + + if items.isEmpty { + HStack(spacing: 8) { + ProgressView() + .controlSize(.small) + Text("准备迁移...") + .foregroundStyle(.secondary) + } + } else { + VStack(alignment: .leading, spacing: 8) { + ForEach(items) { item in + HStack(alignment: .top, spacing: 8) { + Image(systemName: item.step == .complete ? "checkmark.circle.fill" : "circle.fill") + .font(.system(size: 8)) + .foregroundStyle(item.step == .complete ? Color.green : Color.accentColor) + .padding(.top, 5) + VStack(alignment: .leading, spacing: 2) { + Text("\(item.step.title):\(item.message)") + .font(.caption) + .textSelection(.enabled) + if let detail = item.detail, !detail.isEmpty { + Text(detail) + .font(.system(.caption2, design: .monospaced)) + .foregroundStyle(.secondary) + .lineLimit(4) + .textSelection(.enabled) + } + } + Spacer() + } + } + } + } + } + .padding(12) + .background(Color.accentColor.opacity(0.08)) + .clipShape(RoundedRectangle(cornerRadius: 8)) + } +} + private struct AgentOperationResultView: View { let result: AgentOperationDisplay @State private var showsDetails = false diff --git a/src/manager-macos/Views/VmDetailView.swift b/src/manager-macos/Views/VmDetailView.swift index 468a5c4..979e22d 100644 --- a/src/manager-macos/Views/VmDetailView.swift +++ b/src/manager-macos/Views/VmDetailView.swift @@ -269,10 +269,11 @@ class VmSession: ObservableObject { ) DispatchQueue.main.asyncAfter(deadline: .now() + timeout, execute: timeoutWorkItem) - if !self.ipcClient.sendGuestExec(command: command, user: "tenbox", requestId: requestId, timeoutMs: timeoutMs) { - timeoutWorkItem.cancel() - self.pendingGuestExecCommands.removeValue(forKey: requestId) - completion(.failure(ConsoleCommandError("Failed to send guest agent command"))) + self.ipcClient.sendGuestExecAsync(command: command, user: "tenbox", requestId: requestId, timeoutMs: timeoutMs) { [weak self] sent in + guard let self = self, !sent else { return } + guard let pending = self.pendingGuestExecCommands.removeValue(forKey: requestId) else { return } + pending.timeoutWorkItem.cancel() + pending.completion(.failure(ConsoleCommandError("Failed to send guest agent command"))) } } } From 4581d1309f8868d1b7c384f9dd66860f627b1afd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BC=A0=E5=AE=89=E5=93=B2?= Date: Tue, 12 May 2026 04:46:23 +0800 Subject: [PATCH 33/37] fix: harden macos agent toolbox flows --- CLAUDE.md | 6 + .../Services/AgentToolsService.swift | 191 ++++++++++++++---- src/manager-macos/Views/AgentToolsView.swift | 71 +++++-- 3 files changed, 209 insertions(+), 59 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 186b992..a83ded7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -104,6 +104,12 @@ Win/macOS: tenbox-manager ──IPC v1──► tenbox-vm-runtime (WHVP / HVF) - **macOS console commands**: keep marker-based console command execution as a fallback path; Agent tools should use qemu-guest-agent `guest-exec` and wait for temporary shared folders before reading or writing packages. - **macOS Agent tool commands**: prefer qemu-guest-agent `guest-exec` for command execution, pass multiline scripts as `command_hex`, and run them as guest user `tenbox`; keep shared folders as the data plane for profile packages, backups, and diagnostics. - **macOS Agent tool responsiveness**: do not perform guest-exec or runtime shared-folder IPC sends on the SwiftUI main thread. Queue those sends off-main and keep only request bookkeeping / UI state updates on the main thread. +- **macOS Agent tool text rendering**: do not render full command output, migration reports, or long host paths inside SwiftUI sheets. Show compact labels in progress/result panels and keep full logs in files or copy-only details. +- **macOS Agent tool archive extraction**: shared-folder mounts may not support preserving file metadata such as mtimes/owners. Extract Agent import and migration archives in the guest `/tmp` and use tar options that avoid restoring unsupported metadata. +- **macOS Agent service resolution**: parse `systemctl --user list-units` with `--plain`, ignore failed-unit markers, and restart the resolved user service after Agent import/restore/reset operations. +- **macOS Hermes repairs**: support Hermes images where `hermes` is not on `PATH` by resolving the command from the service/venv when needed, and fall back to patching `~/.hermes/config.yaml` / `.env` for config reset. +- **macOS OpenClaw repairs**: support OpenClaw images where `openclaw` is not on guest-exec `PATH` by resolving `~/.npm-global/bin/openclaw` before running config reset commands. +- **macOS Agent data import**: never replace the whole Agent home directory with an exported profile package. Export packages intentionally exclude reinstallable binaries such as Hermes `hermes-agent`, so import/restore must merge package contents into the existing directory and preserve excluded install assets. - **macOS console markers**: do not put the full begin/end marker literal in console input; build it inside the shell so echoed input cannot satisfy marker detection. - **macOS console input**: throttle manager-to-runtime console input before injecting into the guest UART; bulk shell scripts can overflow the emulated FIFO if delivered as one burst. - **macOS app signing**: the app entitlement includes `com.apple.security.cs.disable-library-validation` so the hardened-runtime app can load the bundled Sparkle framework. diff --git a/src/manager-macos/Services/AgentToolsService.swift b/src/manager-macos/Services/AgentToolsService.swift index d02c867..b3e7bbc 100644 --- a/src/manager-macos/Services/AgentToolsService.swift +++ b/src/manager-macos/Services/AgentToolsService.swift @@ -423,7 +423,7 @@ final class AgentToolsService { Self.profileExportCommand(agent: .hermes, outputPath: guestBackup, scope: .backup) ) - emit(.backup, "正在创建目标 Hermes 迁移前备份", backupPackage.path) + emit(.backup, "正在创建目标 Hermes 迁移前备份", backupPackage.lastPathComponent) targetSession.runGuestAgentCommand(backupCommand, timeout: 420) { backupResult in switch backupResult { case .success(let backupCommandResult): @@ -465,7 +465,7 @@ final class AgentToolsService { completion(.failure(Self.makeError(dryRunCommandResult.output.isEmpty ? "OpenClaw 到 Hermes 迁移预检失败" : dryRunCommandResult.output))) return } - emit(.migrate, "dry-run 已通过,正在执行正式迁移", Self.compactMigrationOutput(dryRunCommandResult.output)) + emit(.migrate, "dry-run 已通过,正在执行正式迁移", "完整计划已写入 \(reportURL.lastPathComponent)") let migrateCommand = Self.withSharedFolderReady( tag: share.tag, body: Self.openClawToHermesMigrationCommand( @@ -483,7 +483,7 @@ final class AgentToolsService { return } self.rotateBackups(vmId: targetVm.id, agent: .hermes, keep: keepCount) - emit(.complete, "迁移完成,报告已保存", reportURL.path) + emit(.complete, "迁移完成,报告已保存", reportURL.lastPathComponent) completion(.success(AgentToolResult( message: "已完成 OpenClaw 到 Hermes 迁移", output: """ @@ -493,12 +493,10 @@ final class AgentToolsService { 目标 VM:\(targetVm.name) 冲突策略:\(options.skillConflictStrategy.displayName) Workspace 目标:\(options.workspaceTarget.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? "默认" : options.workspaceTarget) + 完整 dry-run / migrate 输出见迁移报告。 - [dry-run] - \(dryRunCommandResult.output) - - [migrate] - \(targetCommandResult.output) + [health] + \(Self.compactMigrationOutput(targetCommandResult.output) ?? "迁移命令已完成") """ ))) case .failure(let error): @@ -785,11 +783,10 @@ final class AgentToolsService { input=\(shellQuote(inputPath)) rel=\(shellQuote(relPath)) target="$home/$rel" - work=\(shellQuote((inputPath as NSString).deletingLastPathComponent + "/.tenbox-profile-import")) [ -f "$input" ] || { echo "找不到导入包:$input" >&2; exit 1; } - rm -rf "$work" - mkdir -p "$work" - tar --touch -xzf "$input" -C "$work" + work="$(mktemp -d /tmp/tenbox-profile-import.XXXXXX)" + trap 'rm -rf "$work"' EXIT + tar --touch --no-same-owner -xzf "$input" -C "$work" [ -f "$work/manifest.json" ] || { echo "导入包缺少 manifest.json" >&2; exit 1; } [ -f "$work/files.tar.gz" ] || { echo "导入包缺少 files.tar.gz" >&2; exit 1; } pkg_agent="" @@ -806,19 +803,38 @@ final class AgentToolsService { pkg_agent="$(awk -F\\" '/agent_type/ {print $4; exit}' "$work/manifest.json")" fi [ "$pkg_agent" = "\(agent.rawValue)" ] || { echo "导入包属于 $pkg_agent,不是 \(agent.rawValue)" >&2; exit 1; } + extract_root="$work/files" + mkdir -p "$extract_root" + if ! tar --touch --no-same-owner -xzf "$work/files.tar.gz" -C "$extract_root"; then + echo "解包 Agent 数据失败" >&2 + exit 1 + fi + extract_target="$extract_root/$rel" + [ -d "$extract_target" ] || { echo "导入包缺少 $rel 目录" >&2; exit 1; } backup="" if [ -e "$target" ]; then backup="$target.pre-import-$(date -u +%Y%m%d%H%M%S)" - mv "$target" "$backup" + cp -a "$target" "$backup" fi - if ! tar -xzf "$work/files.tar.gz" -C "$home"; then + mkdir -p "$target" + if ! ( + cd "$extract_target" + for item in .[!.]* ..?* *; do + [ -e "$item" ] || continue + rm -rf "$target/$item" + cp -a "$item" "$target/$item" + done + ); then rm -rf "$target" if [ -n "$backup" ] && [ -d "$backup" ]; then mv "$backup" "$target"; fi echo "恢复 Agent 数据失败" >&2 exit 1 fi chmod 700 "$target" 2>/dev/null || true - rm -rf "$work" + svc="$(\(serviceResolverCommand(agent: agent)))" + if [ -n "$svc" ]; then + XDG_RUNTIME_DIR="${XDG_RUNTIME_DIR:-/run/user/$(id -u)}" systemctl --user restart "$svc" >/dev/null 2>&1 || true + fi if [ -n "$backup" ]; then echo "$backup"; else echo "已导入"; fi """ } @@ -873,24 +889,105 @@ final class AgentToolsService { case .hermes: return """ set -eu - command -v hermes >/dev/null 2>&1 || { echo "缺少 Hermes 命令" >&2; exit 1; } - hermes config set model.default default >/dev/null - hermes config set model.provider custom >/dev/null - hermes config set model.base_url http://10.0.2.3/v1 >/dev/null - hermes config set terminal.backend local >/dev/null + home="${HOME:-/home/tenbox}" + hermes_cmd="$(\(hermesCommandResolver()))" + if [ -n "$hermes_cmd" ]; then + "$hermes_cmd" config set model.default default >/dev/null + "$hermes_cmd" config set model.provider custom >/dev/null + "$hermes_cmd" config set model.base_url http://10.0.2.3/v1 >/dev/null + "$hermes_cmd" config set terminal.backend local >/dev/null + else + mkdir -p "$home/.hermes" + cfg="$home/.hermes/config.yaml" + env_file="$home/.hermes/.env" + if command -v python3 >/dev/null 2>&1; then + python3 - "$cfg" "$env_file" <<'PY' + from pathlib import Path + import sys + + cfg = Path(sys.argv[1]) + env = Path(sys.argv[2]) + lines = cfg.read_text(encoding="utf-8").splitlines() if cfg.exists() else [] + out = [] + i = 0 + while i < len(lines): + line = lines[i] + if line.startswith("model:") or line.startswith("terminal:"): + i += 1 + while i < len(lines) and (lines[i].startswith(" ") or not lines[i].strip()): + i += 1 + continue + out.append(line) + i += 1 + block = [ + 'model:', + ' default: "default"', + ' provider: "custom"', + ' base_url: "http://10.0.2.3/v1"', + '', + 'terminal:', + ' backend: local', + '' + ] + cfg.write_text("\\n".join(block + [line for line in out if line.strip()]) + "\\n", encoding="utf-8") + + env_lines = env.read_text(encoding="utf-8").splitlines() if env.exists() else [] + values = { + "OPENAI_BASE_URL": "http://10.0.2.3/v1", + "OPENAI_API_KEY": "tenbox", + "AGENT_BROWSER_HEADED": "true", + "AGENT_BROWSER_EXECUTABLE_PATH": "/usr/bin/chromium", + } + seen = set() + patched = [] + for line in env_lines: + key = line.split("=", 1)[0] if "=" in line and not line.startswith("#") else None + if key in values: + patched.append(f"{key}={values[key]}") + seen.add(key) + else: + patched.append(line) + for key, value in values.items(): + if key not in seen: + patched.append(f"{key}={value}") + env.write_text("\\n".join(patched) + "\\n", encoding="utf-8") + PY + else + cat > "$cfg" <<'EOF' + model: + default: "default" + provider: "custom" + base_url: "http://10.0.2.3/v1" + + terminal: + backend: local + EOF + { + echo "OPENAI_BASE_URL=http://10.0.2.3/v1" + echo "OPENAI_API_KEY=tenbox" + echo "AGENT_BROWSER_HEADED=true" + echo "AGENT_BROWSER_EXECUTABLE_PATH=/usr/bin/chromium" + } >> "$env_file" + fi + fi + svc="$(\(serviceResolverCommand(agent: agent)))" + if [ -n "$svc" ]; then + XDG_RUNTIME_DIR="${XDG_RUNTIME_DIR:-/run/user/$(id -u)}" systemctl --user restart "$svc" >/dev/null 2>&1 || true + fi \(healthStatusCommand(agent: agent)) """ case .openclaw: return """ set -eu - command -v openclaw >/dev/null 2>&1 || { echo "缺少 OpenClaw 命令" >&2; exit 1; } + openclaw_cmd="$(\(openClawCommandResolver()))" + [ -n "$openclaw_cmd" ] || { echo "缺少 OpenClaw 命令" >&2; exit 1; } tenbox_provider='{"baseUrl":"http://10.0.2.3/v1","apiKey":"tenbox","api":"openai-completions","models":[{"id":"default","name":"Default (TenBox Proxy)","reasoning":false,"input":["text","image"],"contextWindow":200000,"maxTokens":65536,"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0}}]}' - openclaw config set models.providers.tenbox "$tenbox_provider" --strict-json --merge >/dev/null 2>&1 || openclaw config set models.providers.tenbox "$tenbox_provider" >/dev/null - openclaw config set models.mode merge >/dev/null - openclaw config set agents.defaults.model.primary tenbox/default >/dev/null - openclaw config set agents.defaults.compaction.mode safeguard >/dev/null - openclaw config set agents.defaults.workspace "$HOME/.openclaw/workspace" >/dev/null - openclaw config set agents.defaults.models.tenbox/default '{"alias":"TenBox Proxy"}' --strict-json --merge >/dev/null 2>&1 || openclaw config set agents.defaults.models.tenbox/default '{"alias":"TenBox Proxy"}' >/dev/null + "$openclaw_cmd" config set models.providers.tenbox "$tenbox_provider" --strict-json --merge >/dev/null 2>&1 || "$openclaw_cmd" config set models.providers.tenbox "$tenbox_provider" >/dev/null + "$openclaw_cmd" config set models.mode merge >/dev/null + "$openclaw_cmd" config set agents.defaults.model.primary tenbox/default >/dev/null + "$openclaw_cmd" config set agents.defaults.compaction.mode safeguard >/dev/null + "$openclaw_cmd" config set agents.defaults.workspace "$HOME/.openclaw/workspace" >/dev/null + "$openclaw_cmd" config set agents.defaults.models.tenbox/default '{"alias":"TenBox Proxy"}' --strict-json --merge >/dev/null 2>&1 || "$openclaw_cmd" config set agents.defaults.models.tenbox/default '{"alias":"TenBox Proxy"}' >/dev/null \(healthStatusCommand(agent: agent)) """ } @@ -951,23 +1048,23 @@ final class AgentToolsService { private static func openClawToHermesDryRunCommand(inputPath: String, reportPath: String, options: AgentMigrationOptions) -> String { - let workDir = (inputPath as NSString).deletingLastPathComponent + "/.tenbox-openclaw-to-hermes" let flags = openClawMigrationFlags(options: options, includeYes: false) return """ set -eu - command -v hermes >/dev/null 2>&1 || { echo "缺少 Hermes 命令" >&2; exit 1; } + hermes_cmd="$(\(hermesCommandResolver()))" + [ -n "$hermes_cmd" ] || { echo "目标 VM 缺少 Hermes 命令" >&2; exit 1; } input=\(shellQuote(inputPath)) report=\(shellQuote(reportPath)) - work=\(shellQuote(workDir)) + work="$(mktemp -d /tmp/tenbox-openclaw-to-hermes.XXXXXX)" + trap 'rm -rf "$work"' EXIT source_dir="$work/source" [ -f "$input" ] || { echo "找不到 OpenClaw 迁移包:$input" >&2; exit 1; } - rm -rf "$work" mkdir -p "$source_dir" - tar -xzf "$input" -C "$source_dir" + tar --touch --no-same-owner -xzf "$input" -C "$source_dir" [ -d "$source_dir/.openclaw" ] || { echo "迁移包缺少 .openclaw 目录" >&2; exit 1; } dry_log="$work/dry-run.txt" dry_status=0 - hermes claw migrate --dry-run --source "$source_dir/.openclaw" \(flags) > "$dry_log" 2>&1 || dry_status=$? + "$hermes_cmd" claw migrate --dry-run --source "$source_dir/.openclaw" \(flags) > "$dry_log" 2>&1 || dry_status=$? { echo "===== OpenClaw -> Hermes dry-run $(date -u +%Y-%m-%dT%H:%M:%SZ) =====" cat "$dry_log" @@ -975,30 +1072,29 @@ final class AgentToolsService { } >> "$report" \(limitedLogCommand(logVariable: "dry_log")) [ "$dry_status" -eq 0 ] || exit "$dry_status" - rm -rf "$work" """ } private static func openClawToHermesMigrationCommand(inputPath: String, reportPath: String, options: AgentMigrationOptions) -> String { - let workDir = (inputPath as NSString).deletingLastPathComponent + "/.tenbox-openclaw-to-hermes" let flags = openClawMigrationFlags(options: options, includeYes: true) return """ set -eu - command -v hermes >/dev/null 2>&1 || { echo "缺少 Hermes 命令" >&2; exit 1; } + hermes_cmd="$(\(hermesCommandResolver()))" + [ -n "$hermes_cmd" ] || { echo "目标 VM 缺少 Hermes 命令" >&2; exit 1; } input=\(shellQuote(inputPath)) report=\(shellQuote(reportPath)) - work=\(shellQuote(workDir)) + work="$(mktemp -d /tmp/tenbox-openclaw-to-hermes.XXXXXX)" + trap 'rm -rf "$work"' EXIT source_dir="$work/source" [ -f "$input" ] || { echo "找不到 OpenClaw 迁移包:$input" >&2; exit 1; } - rm -rf "$work" mkdir -p "$source_dir" - tar -xzf "$input" -C "$source_dir" + tar --touch --no-same-owner -xzf "$input" -C "$source_dir" [ -d "$source_dir/.openclaw" ] || { echo "迁移包缺少 .openclaw 目录" >&2; exit 1; } migrate_log="$work/migrate.txt" migrate_status=0 - hermes claw migrate --source "$source_dir/.openclaw" \(flags) > "$migrate_log" 2>&1 || migrate_status=$? + "$hermes_cmd" claw migrate --source "$source_dir/.openclaw" \(flags) > "$migrate_log" 2>&1 || migrate_status=$? { echo "===== OpenClaw -> Hermes migrate $(date -u +%Y-%m-%dT%H:%M:%SZ) =====" cat "$migrate_log" @@ -1011,7 +1107,6 @@ final class AgentToolsService { XDG_RUNTIME_DIR="${XDG_RUNTIME_DIR:-/run/user/$(id -u)}" systemctl --user restart "$svc" >/dev/null 2>&1 || true echo "重启服务:$svc" >> "$report" fi - rm -rf "$work" health_log="$(mktemp)" ( \(healthStatusCommand(agent: .hermes)) @@ -1107,7 +1202,19 @@ final class AgentToolsService { } let preferred = serviceName(agent) return """ - { preferred=\(shellQuote(preferred)); pattern=\(shellQuote(pattern)); if XDG_RUNTIME_DIR="${XDG_RUNTIME_DIR:-/run/user/$(id -u)}" systemctl --user status "$preferred" >/dev/null 2>&1; then printf '%s' "$preferred"; else XDG_RUNTIME_DIR="${XDG_RUNTIME_DIR:-/run/user/$(id -u)}" systemctl --user list-units --all "$pattern" --no-legend 2>/dev/null | awk 'NR==1 {print $1; exit}'; fi; } + { preferred=\(shellQuote(preferred)); pattern=\(shellQuote(pattern)); if XDG_RUNTIME_DIR="${XDG_RUNTIME_DIR:-/run/user/$(id -u)}" systemctl --user status "$preferred" >/dev/null 2>&1; then printf '%s' "$preferred"; else XDG_RUNTIME_DIR="${XDG_RUNTIME_DIR:-/run/user/$(id -u)}" systemctl --user list-units --all "$pattern" --no-legend --plain 2>/dev/null | awk 'NR==1 {if ($1=="●") print $2; else print $1; exit}'; fi; } + """ + } + + private static func hermesCommandResolver() -> String { + """ + { hermes_cmd="$(command -v hermes 2>/dev/null || true)"; if [ -z "$hermes_cmd" ]; then svc="$(\(serviceResolverCommand(agent: .hermes)))"; if [ -n "$svc" ]; then exec_path="$(XDG_RUNTIME_DIR="${XDG_RUNTIME_DIR:-/run/user/$(id -u)}" systemctl --user show "$svc" -p ExecStart --value 2>/dev/null | sed -n 's/.*path=\\([^ ;]*\\).*/\\1/p' | head -n 1)"; if [ -n "$exec_path" ]; then exec_dir="$(dirname "$exec_path")"; for candidate in "$exec_dir/hermes" "$exec_dir/../bin/hermes" "$exec_dir/../../bin/hermes"; do [ -x "$candidate" ] && hermes_cmd="$candidate" && break; done; fi; fi; fi; if [ -z "$hermes_cmd" ]; then for candidate in "$HOME/.hermes/hermes-agent/.venv/bin/hermes" "$HOME/.hermes/hermes-agent/venv/bin/hermes" "$HOME/.local/bin/hermes"; do [ -x "$candidate" ] && hermes_cmd="$candidate" && break; done; fi; printf '%s' "$hermes_cmd"; } + """ + } + + private static func openClawCommandResolver() -> String { + """ + { openclaw_cmd="$(command -v openclaw 2>/dev/null || true)"; if [ -z "$openclaw_cmd" ]; then for candidate in "$HOME/.npm-global/bin/openclaw" "$HOME/.local/bin/openclaw"; do [ -x "$candidate" ] && openclaw_cmd="$candidate" && break; done; fi; printf '%s' "$openclaw_cmd"; } """ } diff --git a/src/manager-macos/Views/AgentToolsView.swift b/src/manager-macos/Views/AgentToolsView.swift index 35bbba1..ab7317c 100644 --- a/src/manager-macos/Views/AgentToolsView.swift +++ b/src/manager-macos/Views/AgentToolsView.swift @@ -847,7 +847,7 @@ struct AgentToolsSheet: View { return AgentOperationDisplay( isSuccess: true, title: operation.successTitle, - summary: summary.isEmpty ? "操作已完成" : summary, + summary: compactSummary(summary, fallback: "操作已完成"), details: details, revealPath: detectedPath, healthReport: health @@ -859,7 +859,7 @@ struct AgentToolsSheet: View { return AgentOperationDisplay( isSuccess: false, title: operation.failureTitle, - summary: friendlyErrorMessage(raw), + summary: compactSummary(friendlyErrorMessage(raw), fallback: "操作失败"), details: raw, revealPath: nil, healthReport: nil @@ -930,6 +930,17 @@ struct AgentToolsSheet: View { } return raw } + + private static func compactSummary(_ text: String, fallback: String) -> String { + let lines = text + .split(whereSeparator: { $0.isNewline }) + .map { String($0).trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty } + guard let first = lines.first else { return fallback } + let limit = 180 + guard first.count > limit else { return first } + return "\(first.prefix(limit)) ... 完整输出请复制详情" + } } private enum AgentToolOperation: String, CaseIterable, Identifiable { @@ -1164,6 +1175,8 @@ private struct BackupPackageRow: View { private struct MigrationProgressView: View { let items: [AgentMigrationProgress] + private static let maxLineCharacters = 96 + private static let maxDetailCharacters = 96 var body: some View { VStack(alignment: .leading, spacing: 8) { @@ -1186,15 +1199,16 @@ private struct MigrationProgressView: View { .foregroundStyle(item.step == .complete ? Color.green : Color.accentColor) .padding(.top, 5) VStack(alignment: .leading, spacing: 2) { - Text("\(item.step.title):\(item.message)") + Text(Self.compact("\(item.step.title):\(item.message)", limit: Self.maxLineCharacters)) .font(.caption) - .textSelection(.enabled) + .lineLimit(1) + .truncationMode(.middle) if let detail = item.detail, !detail.isEmpty { - Text(detail) - .font(.system(.caption2, design: .monospaced)) + Text(Self.compact(detail, limit: Self.maxDetailCharacters)) + .font(.caption2) .foregroundStyle(.secondary) - .lineLimit(4) - .textSelection(.enabled) + .lineLimit(1) + .truncationMode(.middle) } } Spacer() @@ -1207,11 +1221,19 @@ private struct MigrationProgressView: View { .background(Color.accentColor.opacity(0.08)) .clipShape(RoundedRectangle(cornerRadius: 8)) } + + private static func compact(_ text: String, limit: Int) -> String { + guard text.count > limit else { return text } + let headCount = max(1, limit * 2 / 3) + let tailCount = max(1, limit - headCount) + return "\(text.prefix(headCount)) ... \(text.suffix(tailCount))" + } } private struct AgentOperationResultView: View { let result: AgentOperationDisplay @State private var showsDetails = false + private static let maxRenderedDetailCharacters = 12_000 var body: some View { VStack(alignment: .leading, spacing: 10) { @@ -1224,7 +1246,7 @@ private struct AgentOperationResultView: View { Text(result.summary) .font(.caption) .foregroundStyle(.secondary) - .textSelection(.enabled) + .lineLimit(2) } Spacer() } @@ -1258,16 +1280,18 @@ private struct AgentOperationResultView: View { if !result.details.isEmpty { DisclosureGroup(isExpanded: $showsDetails) { - ScrollView { - Text(result.details) - .font(.system(.caption, design: .monospaced)) - .textSelection(.enabled) - .frame(maxWidth: .infinity, alignment: .leading) - .padding(.top, 4) + if showsDetails { + ScrollView { + Text(Self.renderedDetails(result.details)) + .font(.system(.caption, design: .monospaced)) + .textSelection(.enabled) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.top, 4) + } + .frame(maxHeight: 140) } - .frame(maxHeight: 140) } label: { - Text("详情") + Text(result.details.count > Self.maxRenderedDetailCharacters ? "详情(已截断显示,可复制完整内容)" : "详情") .font(.caption) } } @@ -1276,6 +1300,19 @@ private struct AgentOperationResultView: View { .background(result.isSuccess ? Color.green.opacity(0.08) : Color.red.opacity(0.08)) .clipShape(RoundedRectangle(cornerRadius: 8)) } + + private static func renderedDetails(_ details: String) -> String { + guard details.count > maxRenderedDetailCharacters else { return details } + let headCount = maxRenderedDetailCharacters / 2 + let tailCount = maxRenderedDetailCharacters - headCount + return """ + \(String(details.prefix(headCount))) + + ... 详情过长,界面只显示前后片段;完整内容可复制,迁移完整日志请查看报告文件 ... + + \(String(details.suffix(tailCount))) + """ + } } private struct StatusPill: View { From bda9259f9872ab4b6d7746aab69956323560b175 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BC=A0=E5=AE=89=E5=93=B2?= Date: Tue, 12 May 2026 09:21:22 +0800 Subject: [PATCH 34/37] fix: make agent profile recovery stream-safe --- CLAUDE.md | 6 +- .../Services/AgentToolsService.swift | 69 +++++++++++++------ 2 files changed, 53 insertions(+), 22 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index a83ded7..f3ba610 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -92,9 +92,11 @@ Win/macOS: tenbox-manager ──IPC v1──► tenbox-vm-runtime (WHVP / HVF) - **Agent migration exports**: user-triggered migration packages should carry the user's full Agent state, including secrets, credentials, identity/device state, sessions, browser profiles, and config files. Exclude only volatile logs, caches, runtime lock files, and reinstallable binaries by default. - **Agent OpenClaw to Hermes migration**: the macOS Agent急救箱 can migrate a running OpenClaw VM into a running Hermes VM by first creating a host-managed Hermes backup, sharing one runtime-only folder, exporting `~/.openclaw` with full user state, then running official `hermes claw migrate` in the Hermes VM after a separate dry run. Keep the UI non-blocking with step progress, expose the skill conflict strategy, pass `--migrate-secrets`, support `--workspace-target`, and save the dry-run/final migration report beside the host-managed Hermes backups. Do not reimplement the migration mapping in TenBox. - **Hermes profile scope**: export user/config/state data, not the reinstallable Hermes app checkout, virtualenv, local binaries, logs, or cache directories. +- **OpenClaw profile scope**: export user/config/state data, not volatile caches/logs or generated backup archives such as `.openclaw/backup` and `.openclaw/openclaw-backup*.tar.gz`; including those archives makes Agent急救箱 backup/import/migration packages unnecessarily huge and can exhaust guest disk during restore. - **Agent data backups**: `TenBox.app` writes host-managed backups to `~/Library/Application Support/TenBox/AgentBackups//` and retains packages according to the configured per-Agent retention count (default 7). Profile export runs against live Agent data, so GNU tar exit 1 from changed/skipped files is treated as non-fatal; fatal tar errors must still fail the backup. - **Agent scheduled backups**: `TenBox.app` supports per-VM/per-Agent scheduled backups stored in `settings.json`; default time is 03:00 and default retention is 7 packages. Backup filenames must be time-based (`agent-data-yyyy-MM-dd-HHmmss.tar.gz`), restore should let the user choose a package, scheduled backups only run when the VM is running and the guest execution channel is connected, and the UI should surface the last automatic backup attempt result. - **Agent health checks**: `TenBox.app` runs health, restart, config reset, and diagnostics through qemu-guest-agent `guest-exec`. Repair actions must create a host-managed Agent data backup first, and config reset should patch only the needed Agent settings instead of replacing full user config files. +- **macOS OpenClaw restart checks**: after restarting `openclaw-gateway.service`, wait briefly for port `18789` before running the health JSON check. The user service can become `active` before the gateway listener is ready. - **macOS Agent data UI**: `TenBox.app` exposes Agent急救箱 from the front of the VM toolbar and from the VM menu while a VM is running. Keep user-facing Agent tool copy in Chinese, lead with one-click diagnosis, show repair suggestions for failed checks, auto-expand advanced operations after diagnosis failure, keep destructive/low-frequency actions under advanced operations, and confirm destructive import/restore/reset actions. It must not depend on preinstalled guest TenBox scripts. - **macOS Agent data shares**: Agent tool temporary shared folders are runtime-only; do not persist operation or backup share tags into VM config. - **macOS Agent share cleanup**: drop persisted `tenbox-agent-ops-*` and `tenbox-agent-backups-*` entries on config load/startup to clean old builds. @@ -105,11 +107,13 @@ Win/macOS: tenbox-manager ──IPC v1──► tenbox-vm-runtime (WHVP / HVF) - **macOS Agent tool commands**: prefer qemu-guest-agent `guest-exec` for command execution, pass multiline scripts as `command_hex`, and run them as guest user `tenbox`; keep shared folders as the data plane for profile packages, backups, and diagnostics. - **macOS Agent tool responsiveness**: do not perform guest-exec or runtime shared-folder IPC sends on the SwiftUI main thread. Queue those sends off-main and keep only request bookkeeping / UI state updates on the main thread. - **macOS Agent tool text rendering**: do not render full command output, migration reports, or long host paths inside SwiftUI sheets. Show compact labels in progress/result panels and keep full logs in files or copy-only details. -- **macOS Agent tool archive extraction**: shared-folder mounts may not support preserving file metadata such as mtimes/owners. Extract Agent import and migration archives in the guest `/tmp` and use tar options that avoid restoring unsupported metadata. +- **macOS Agent tool archive extraction**: shared-folder mounts may not support preserving file metadata such as mtimes/owners. Extract Agent import and migration archives in a guest-local temp directory such as `$HOME/.tenbox-tmp` rather than inside the shared folder, and use tar options that avoid restoring unsupported metadata. - **macOS Agent service resolution**: parse `systemctl --user list-units` with `--plain`, ignore failed-unit markers, and restart the resolved user service after Agent import/restore/reset operations. - **macOS Hermes repairs**: support Hermes images where `hermes` is not on `PATH` by resolving the command from the service/venv when needed, and fall back to patching `~/.hermes/config.yaml` / `.env` for config reset. - **macOS OpenClaw repairs**: support OpenClaw images where `openclaw` is not on guest-exec `PATH` by resolving `~/.npm-global/bin/openclaw` before running config reset commands. - **macOS Agent data import**: never replace the whole Agent home directory with an exported profile package. Export packages intentionally exclude reinstallable binaries such as Hermes `hermes-agent`, so import/restore must merge package contents into the existing directory and preserve excluded install assets. +- **macOS Agent data import space usage**: for import/restore, keep the outer profile work files and compressed rollback backup beside the shared-folder input package, then stream `files.tar.gz` into the Agent home. Avoid fully extracting the source tree or rollback snapshot onto the guest disk; large OpenClaw profiles can exhaust the VM. +- **macOS Agent pre-import backups**: create import/restore rollback snapshots with tar and tolerate live-file churn (`--ignore-failed-read` / tar status 1), because running Agents may rotate SQLite WAL/SHM files while the backup is being captured. Do not write a full intermediate rollback archive into guest `/tmp`; small images can run out of space. - **macOS console markers**: do not put the full begin/end marker literal in console input; build it inside the shell so echoed input cannot satisfy marker detection. - **macOS console input**: throttle manager-to-runtime console input before injecting into the guest UART; bulk shell scripts can overflow the emulated FIFO if delivered as one burst. - **macOS app signing**: the app entitlement includes `com.apple.security.cs.disable-library-validation` so the hardened-runtime app can load the bundled Sparkle framework. diff --git a/src/manager-macos/Services/AgentToolsService.swift b/src/manager-macos/Services/AgentToolsService.swift index b3e7bbc..15d9932 100644 --- a/src/manager-macos/Services/AgentToolsService.swift +++ b/src/manager-macos/Services/AgentToolsService.swift @@ -784,7 +784,10 @@ final class AgentToolsService { rel=\(shellQuote(relPath)) target="$home/$rel" [ -f "$input" ] || { echo "找不到导入包:$input" >&2; exit 1; } - work="$(mktemp -d /tmp/tenbox-profile-import.XXXXXX)" + input_dir="$(dirname "$input")" + work="$input_dir/.tenbox-profile-import-$(date -u +%Y%m%d%H%M%S)-$$" + rm -rf "$work" + mkdir -p "$work" trap 'rm -rf "$work"' EXIT tar --touch --no-same-owner -xzf "$input" -C "$work" [ -f "$work/manifest.json" ] || { echo "导入包缺少 manifest.json" >&2; exit 1; } @@ -803,30 +806,29 @@ final class AgentToolsService { pkg_agent="$(awk -F\\" '/agent_type/ {print $4; exit}' "$work/manifest.json")" fi [ "$pkg_agent" = "\(agent.rawValue)" ] || { echo "导入包属于 $pkg_agent,不是 \(agent.rawValue)" >&2; exit 1; } - extract_root="$work/files" - mkdir -p "$extract_root" - if ! tar --touch --no-same-owner -xzf "$work/files.tar.gz" -C "$extract_root"; then - echo "解包 Agent 数据失败" >&2 + if ! tar -tzf "$work/files.tar.gz" "$rel" >/dev/null 2>&1; then + echo "导入包缺少 $rel 目录" >&2 exit 1 fi - extract_target="$extract_root/$rel" - [ -d "$extract_target" ] || { echo "导入包缺少 $rel 目录" >&2; exit 1; } backup="" if [ -e "$target" ]; then - backup="$target.pre-import-$(date -u +%Y%m%d%H%M%S)" - cp -a "$target" "$backup" + backup="$input_dir/pre-import-\(agent.rawValue)-$(date -u +%Y%m%d%H%M%S).tar.gz" + backup_status=0 + (cd "$home" && tar --warning=no-file-changed --ignore-failed-read -czf "$backup" "$rel") || backup_status=$? + if [ "$backup_status" -gt 1 ]; then + rm -f "$backup" + echo "创建导入前备份失败" >&2 + exit "$backup_status" + fi fi mkdir -p "$target" - if ! ( - cd "$extract_target" - for item in .[!.]* ..?* *; do - [ -e "$item" ] || continue - rm -rf "$target/$item" - cp -a "$item" "$target/$item" - done - ); then + tar -tzf "$work/files.tar.gz" | awk -v rel="$rel/" 'index($0, rel) == 1 { rest=substr($0, length(rel)+1); split(rest, a, "/"); if (a[1] != "") print a[1] }' | sort -u | while IFS= read -r item; do + [ -n "$item" ] || continue + rm -rf "$target/$item" + done + if ! tar --touch --no-same-owner -xzf "$work/files.tar.gz" -C "$home"; then rm -rf "$target" - if [ -n "$backup" ] && [ -d "$backup" ]; then mv "$backup" "$target"; fi + if [ -n "$backup" ] && [ -f "$backup" ]; then tar --touch --no-same-owner -xzf "$backup" -C "$home"; fi echo "恢复 Agent 数据失败" >&2 exit 1 fi @@ -864,10 +866,27 @@ final class AgentToolsService { } private static func restartCommand(agent: AgentKind) -> String { - """ + let waitForGateway: String + switch agent { + case .hermes: + waitForGateway = "" + case .openclaw: + waitForGateway = """ + i=0 + while [ "$i" -lt 60 ]; do + if nc -z 127.0.0.1 18789 >/dev/null 2>&1; then + break + fi + i=$((i + 1)) + sleep 1 + done + """ + } + return """ svc="$(\(serviceResolverCommand(agent: agent)))" [ -n "$svc" ] || { echo "Agent 服务未安装" >&2; exit 1; } XDG_RUNTIME_DIR="${XDG_RUNTIME_DIR:-/run/user/$(id -u)}" systemctl --user restart "$svc" + \(waitForGateway) \(healthStatusCommand(agent: agent)) """ } @@ -1055,7 +1074,10 @@ final class AgentToolsService { [ -n "$hermes_cmd" ] || { echo "目标 VM 缺少 Hermes 命令" >&2; exit 1; } input=\(shellQuote(inputPath)) report=\(shellQuote(reportPath)) - work="$(mktemp -d /tmp/tenbox-openclaw-to-hermes.XXXXXX)" + tmp_parent="${HOME:-/home/tenbox}/.tenbox-tmp" + mkdir -p "$tmp_parent" + chmod 700 "$tmp_parent" 2>/dev/null || true + work="$(mktemp -d "$tmp_parent/openclaw-to-hermes.XXXXXX")" trap 'rm -rf "$work"' EXIT source_dir="$work/source" [ -f "$input" ] || { echo "找不到 OpenClaw 迁移包:$input" >&2; exit 1; } @@ -1085,7 +1107,10 @@ final class AgentToolsService { [ -n "$hermes_cmd" ] || { echo "目标 VM 缺少 Hermes 命令" >&2; exit 1; } input=\(shellQuote(inputPath)) report=\(shellQuote(reportPath)) - work="$(mktemp -d /tmp/tenbox-openclaw-to-hermes.XXXXXX)" + tmp_parent="${HOME:-/home/tenbox}/.tenbox-tmp" + mkdir -p "$tmp_parent" + chmod 700 "$tmp_parent" 2>/dev/null || true + work="$(mktemp -d "$tmp_parent/openclaw-to-hermes.XXXXXX")" trap 'rm -rf "$work"' EXIT source_dir="$work/source" [ -f "$input" ] || { echo "找不到 OpenClaw 迁移包:$input" >&2; exit 1; } @@ -1180,6 +1205,8 @@ final class AgentToolsService { "--exclude", ".openclaw/.cache", "--exclude", ".openclaw/workspace/.cache", "--exclude", ".openclaw/logs", + "--exclude", ".openclaw/backup", + "--exclude", ".openclaw/openclaw-backup*.tar.gz", ] return excludes.map(shellQuote).joined(separator: " ") } From aad183e3c5982d1285f4f92df8d44583dcf7b08d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BC=A0=E5=AE=89=E5=93=B2?= Date: Tue, 12 May 2026 11:52:52 +0800 Subject: [PATCH 35/37] Add Windows agent recovery tools --- CLAUDE.md | 6 +- docs/agent-profile.md | 49 +- .../Services/AgentToolsService.swift | 150 ++- src/manager/CMakeLists.txt | 3 + src/manager/agent_tools_service.cpp | 1030 +++++++++++++++++ src/manager/agent_tools_service.h | 158 +++ src/manager/app_settings.cpp | 41 + src/manager/app_settings.h | 13 + src/manager/manager_service.cpp | 225 +++- src/manager/manager_service.h | 32 + src/manager/ui/agent_tools_dialog.cpp | 362 ++++++ src/manager/ui/agent_tools_dialog.h | 11 + src/manager/ui/win32_ui_shell.cpp | 87 ++ src/manager/ui/win32_ui_shell.h | 1 + 14 files changed, 2128 insertions(+), 40 deletions(-) create mode 100644 src/manager/agent_tools_service.cpp create mode 100644 src/manager/agent_tools_service.h create mode 100644 src/manager/ui/agent_tools_dialog.cpp create mode 100644 src/manager/ui/agent_tools_dialog.h diff --git a/CLAUDE.md b/CLAUDE.md index f3ba610..93bde0c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -90,7 +90,10 @@ Win/macOS: tenbox-manager ──IPC v1──► tenbox-vm-runtime (WHVP / HVF) - **macOS Caps Lock forwarding**: send Caps Lock as a tap (`down` then `up`) on each `flagsChanged` event; AppKit exposes it as a toggle state, but the guest input stack needs a full key press for every switch. - **Agent data profile packages**: `TenBox.app` exports/imports Hermes/OpenClaw data without image changes by using a temporary shared folder and qemu-guest-agent `guest-exec` shell commands. Keep the gzip package format documented in `docs/agent-profile.md`, include `export_scope`, and reject cross-agent imports. - **Agent migration exports**: user-triggered migration packages should carry the user's full Agent state, including secrets, credentials, identity/device state, sessions, browser profiles, and config files. Exclude only volatile logs, caches, runtime lock files, and reinstallable binaries by default. -- **Agent OpenClaw to Hermes migration**: the macOS Agent急救箱 can migrate a running OpenClaw VM into a running Hermes VM by first creating a host-managed Hermes backup, sharing one runtime-only folder, exporting `~/.openclaw` with full user state, then running official `hermes claw migrate` in the Hermes VM after a separate dry run. Keep the UI non-blocking with step progress, expose the skill conflict strategy, pass `--migrate-secrets`, support `--workspace-target`, and save the dry-run/final migration report beside the host-managed Hermes backups. Do not reimplement the migration mapping in TenBox. +- **Agent OpenClaw to Hermes migration**: the macOS Agent急救箱 can migrate a running OpenClaw VM into a running Hermes VM by first creating a host-managed Hermes backup, sharing one runtime-only folder, exporting `~/.openclaw` with full user state, then running official `hermes claw migrate` in the Hermes VM after a separate dry run. Keep the UI non-blocking with step progress, expose the skill conflict strategy, pass `--migrate-secrets`, support `--workspace-target`, and save the dry-run/final migration report beside the host-managed Hermes backups. Do not reimplement the broad migration mapping in TenBox; keep only targeted compatibility patches documented below. +- **OpenClaw migration conflict handling**: always pass Hermes CLI's global `--overwrite` for OpenClaw-to-Hermes migration; target-level conflicts such as `soul` or `model-config` otherwise make Hermes print `Refusing to apply` without importing anything. The UI conflict strategy maps only to `--skill-conflict` for imported skills. Treat refusal text as a migration failure even if the CLI exits successfully. +- **OpenClaw migration model config**: after Hermes CLI applies OpenClaw `model-config`, restore TenBox's local model proxy settings (`model.default=default`, `model.provider=custom`, `model.base_url=http://10.0.2.3/v1`, `OPENAI_API_KEY=tenbox`) and the auxiliary compression/vision/session_search model settings. The imported providers can remain in `custom_providers`, but the running TenBox image must keep using the host model proxy. +- **OpenClaw migration channel config**: after official Hermes migration succeeds, TenBox may copy compatible Feishu/WeCom settings from `.openclaw/openclaw.json` into Hermes `.env` (`FEISHU_*`, `WECOM_*`) and enable `platforms.feishu` / `platforms.wecom` best-effort. Do not copy plugin install state, pairing/device runtime state, or channel adapter internals; report those limits to the user. - **Hermes profile scope**: export user/config/state data, not the reinstallable Hermes app checkout, virtualenv, local binaries, logs, or cache directories. - **OpenClaw profile scope**: export user/config/state data, not volatile caches/logs or generated backup archives such as `.openclaw/backup` and `.openclaw/openclaw-backup*.tar.gz`; including those archives makes Agent急救箱 backup/import/migration packages unnecessarily huge and can exhaust guest disk during restore. - **Agent data backups**: `TenBox.app` writes host-managed backups to `~/Library/Application Support/TenBox/AgentBackups//` and retains packages according to the configured per-Agent retention count (default 7). Profile export runs against live Agent data, so GNU tar exit 1 from changed/skipped files is treated as non-fatal; fatal tar errors must still fail the backup. @@ -113,6 +116,7 @@ Win/macOS: tenbox-manager ──IPC v1──► tenbox-vm-runtime (WHVP / HVF) - **macOS OpenClaw repairs**: support OpenClaw images where `openclaw` is not on guest-exec `PATH` by resolving `~/.npm-global/bin/openclaw` before running config reset commands. - **macOS Agent data import**: never replace the whole Agent home directory with an exported profile package. Export packages intentionally exclude reinstallable binaries such as Hermes `hermes-agent`, so import/restore must merge package contents into the existing directory and preserve excluded install assets. - **macOS Agent data import space usage**: for import/restore, keep the outer profile work files and compressed rollback backup beside the shared-folder input package, then stream `files.tar.gz` into the Agent home. Avoid fully extracting the source tree or rollback snapshot onto the guest disk; large OpenClaw profiles can exhaust the VM. +- **Windows Agent toolbox**: Windows manager now mirrors the Agent急救箱 flow through `ManagerService::RunGuestAgentCommand`, runtime-only shared folders, `agent_tools::AgentToolsService`, and a Win32 dialog. Keep Windows shell commands aligned with macOS AgentToolsService, persist schedules in `settings.json` under `agent_backups.schedules`, and avoid writing temporary Agent operation shares into VM manifests. - **macOS Agent pre-import backups**: create import/restore rollback snapshots with tar and tolerate live-file churn (`--ignore-failed-read` / tar status 1), because running Agents may rotate SQLite WAL/SHM files while the backup is being captured. Do not write a full intermediate rollback archive into guest `/tmp`; small images can run out of space. - **macOS console markers**: do not put the full begin/end marker literal in console input; build it inside the shell so echoed input cannot satisfy marker detection. - **macOS console input**: throttle manager-to-runtime console input before injecting into the guest UART; bulk shell scripts can overflow the emulated FIFO if delivered as one burst. diff --git a/docs/agent-profile.md b/docs/agent-profile.md index 9897373..92fc752 100644 --- a/docs/agent-profile.md +++ b/docs/agent-profile.md @@ -1,11 +1,14 @@ # Agent Data Tools -TenBox.app provides Agent data export/import, backup/restore, and health actions -without requiring Hermes/OpenClaw images to preinstall TenBox-specific scripts. +TenBox.app on macOS and `tenbox-manager.exe` on Windows provide Agent data +export/import, backup/restore, migration, and health actions without requiring +Hermes/OpenClaw images to preinstall TenBox-specific scripts. -The macOS manager creates a temporary shared folder, then sends a short shell +The desktop manager creates a temporary shared folder, then sends a short shell command through qemu-guest-agent `guest-exec`. The command uses standard guest -tools such as `tar`, `gzip`, `systemctl`, `curl`, and `journalctl`. +tools such as `tar`, `gzip`, `systemctl`, `curl`, and `journalctl`. On Windows, +the same flow is exposed through the Agent急救箱 dialog and host files are stored +under `%LOCALAPPDATA%\TenBox`. ## Profile package @@ -54,6 +57,12 @@ Manual backups are created by TenBox.app in: ~/Library/Application Support/TenBox/AgentBackups/// ``` +On Windows the equivalent directory is: + +```text +%LOCALAPPDATA%\TenBox\AgentBackups\\\ +``` + Backups use the same profile package format. Retention is configurable per VM and Agent; the default keeps the newest seven packages. Restore uses the package selected in the backup list for the selected VM and Agent. @@ -73,7 +82,8 @@ TenBox.app can run these actions while the VM is running: Restart and reset create a backup first, using the same host-managed backup directory. Diagnostics are exported to the host backup directory through the -temporary shared folder. +temporary shared folder. Both desktop managers also support per-VM/per-Agent +scheduled backups persisted in `settings.json` as `agent_backups.schedules`. ## OpenClaw to Hermes migration @@ -87,10 +97,35 @@ data into a Hermes VM without image-specific helper scripts: 4. Extract it inside the Hermes VM and run the official Hermes CLI: ```sh - hermes claw migrate --dry-run --source /.openclaw --preset full --migrate-secrets - hermes claw migrate --source /.openclaw --preset full --migrate-secrets --skill-conflict skip --yes + hermes claw migrate --dry-run --source /.openclaw --preset full --migrate-secrets --overwrite + hermes claw migrate --source /.openclaw --preset full --migrate-secrets --overwrite --skill-conflict skip --yes ``` The migration deliberately uses the `full` preset with `--migrate-secrets` so Hermes can import every compatible secret and file category its official OpenClaw migration flow supports. + +TenBox always passes Hermes CLI's global `--overwrite` flag for migration. This +is required for target-level conflicts such as the existing Hermes soul or model +config; the UI conflict strategy controls only imported skills via +`--skill-conflict`. + +After the Hermes CLI succeeds, TenBox reads `.openclaw/openclaw.json` and maps +compatible channel settings into the Hermes environment file: + +- Feishu: `appId`, `appSecret`, `domain`, `connectionMode`, `groupPolicy`, and + optional allowed users become `FEISHU_*` values. +- WeCom: `botId`, `secret`, `dmPolicy`, `groupPolicy`, and optional allowed + users become `WECOM_*` values. + +TenBox also best-effort enables `platforms.feishu` and `platforms.wecom` through +the Hermes CLI. It does not copy plugin install state, pairing/device runtime +state, request de-duplication state, or channel adapter internals; users may +still need to check adapter compatibility after migration. + +After the Hermes CLI imports OpenClaw model metadata, TenBox restores the +running VM's local model proxy settings (`http://10.0.2.3/v1`, API key +`tenbox`) for the primary model and auxiliary compression, vision, and session +search models. Imported provider definitions remain available in +`custom_providers`, but TenBox-managed images should keep routing runtime model +traffic through the host proxy. diff --git a/src/manager-macos/Services/AgentToolsService.swift b/src/manager-macos/Services/AgentToolsService.swift index 15d9932..6924d64 100644 --- a/src/manager-macos/Services/AgentToolsService.swift +++ b/src/manager-macos/Services/AgentToolsService.swift @@ -33,17 +33,17 @@ enum AgentSkillConflictStrategy: String, CaseIterable, Identifiable { var displayName: String { switch self { - case .skip: return "保留 Hermes" - case .overwrite: return "覆盖 Hermes" - case .rename: return "重命名导入" + case .skip: return "技能保留 Hermes" + case .overwrite: return "技能覆盖 Hermes" + case .rename: return "技能重命名导入" } } var help: String { switch self { - case .skip: return "遇到同名技能时保留目标 Hermes 版本" - case .overwrite: return "遇到同名技能时使用 OpenClaw 版本覆盖" - case .rename: return "遇到同名技能时将 OpenClaw 版本重命名导入" + case .skip: return "遇到同名技能时保留目标 Hermes 版本;目标级配置冲突会按 Hermes 迁移规则覆盖" + case .overwrite: return "遇到同名技能时使用 OpenClaw 版本覆盖;目标级配置冲突会按 Hermes 迁移规则覆盖" + case .rename: return "遇到同名技能时将 OpenClaw 版本重命名导入;目标级配置冲突会按 Hermes 迁移规则覆盖" } } } @@ -1120,6 +1120,9 @@ final class AgentToolsService { migrate_log="$work/migrate.txt" migrate_status=0 "$hermes_cmd" claw migrate --source "$source_dir/.openclaw" \(flags) > "$migrate_log" 2>&1 || migrate_status=$? + if grep -q "Refusing to apply" "$migrate_log"; then + migrate_status=1 + fi { echo "===== OpenClaw -> Hermes migrate $(date -u +%Y-%m-%dT%H:%M:%SZ) =====" cat "$migrate_log" @@ -1127,6 +1130,8 @@ final class AgentToolsService { } >> "$report" \(limitedLogCommand(logVariable: "migrate_log")) [ "$migrate_status" -eq 0 ] || exit "$migrate_status" + \(hermesOpenClawChannelConfigCommand()) + \(hermesTenBoxModelConfigCommand()) svc="$(\(serviceResolverCommand(agent: .hermes)))" if [ -n "$svc" ]; then XDG_RUNTIME_DIR="${XDG_RUNTIME_DIR:-/run/user/$(id -u)}" systemctl --user restart "$svc" >/dev/null 2>&1 || true @@ -1146,10 +1151,143 @@ final class AgentToolsService { """ } + private static func hermesOpenClawChannelConfigCommand() -> String { + """ + if command -v python3 >/dev/null 2>&1; then + python3 - "$source_dir/.openclaw/openclaw.json" "${HOME:-/home/tenbox}/.hermes/.env" <<'PY' >> "$report" 2>&1 + import json + import sys + from pathlib import Path + + source = Path(sys.argv[1]) + env_file = Path(sys.argv[2]) + if not source.exists(): + print("OpenClaw channel 配置未迁移:找不到 openclaw.json") + raise SystemExit(0) + + try: + data = json.loads(source.read_text(encoding="utf-8")) + except Exception as exc: + print(f"OpenClaw channel 配置未迁移:openclaw.json 解析失败:{exc}") + raise SystemExit(0) + + channels = data.get("channels") or {} + updates = {} + notes = [] + + feishu = channels.get("feishu") or {} + if feishu.get("enabled"): + app_id = feishu.get("appId") or feishu.get("app_id") + app_secret = feishu.get("appSecret") or feishu.get("app_secret") + if app_id: + updates["FEISHU_APP_ID"] = str(app_id) + if app_secret: + updates["FEISHU_APP_SECRET"] = str(app_secret) + if feishu.get("domain"): + updates["FEISHU_DOMAIN"] = str(feishu["domain"]) + if feishu.get("connectionMode") or feishu.get("connection_mode"): + updates["FEISHU_CONNECTION_MODE"] = str(feishu.get("connectionMode") or feishu.get("connection_mode")) + if feishu.get("groupPolicy") or feishu.get("group_policy"): + updates["FEISHU_GROUP_POLICY"] = str(feishu.get("groupPolicy") or feishu.get("group_policy")) + allowed = feishu.get("allowFrom") or feishu.get("allowedUsers") or feishu.get("allowed_users") + if isinstance(allowed, list) and allowed: + updates["FEISHU_ALLOWED_USERS"] = ",".join(str(item) for item in allowed) + notes.append("Feishu") + + wecom = channels.get("wecom") or {} + if wecom.get("enabled"): + bot_id = wecom.get("botId") or wecom.get("bot_id") + secret = wecom.get("secret") + if bot_id: + updates["WECOM_BOT_ID"] = str(bot_id) + if secret: + updates["WECOM_SECRET"] = str(secret) + if wecom.get("dmPolicy") or wecom.get("dm_policy"): + updates["WECOM_DM_POLICY"] = str(wecom.get("dmPolicy") or wecom.get("dm_policy")) + if wecom.get("groupPolicy") or wecom.get("group_policy"): + updates["WECOM_GROUP_POLICY"] = str(wecom.get("groupPolicy") or wecom.get("group_policy")) + allowed = wecom.get("allowFrom") or wecom.get("allow_from") or wecom.get("allowedUsers") or wecom.get("allowed_users") + if isinstance(allowed, list) and allowed: + updates["WECOM_ALLOWED_USERS"] = ",".join(str(item) for item in allowed) + notes.append("WeCom") + + if updates: + env_file.parent.mkdir(parents=True, exist_ok=True) + lines = env_file.read_text(encoding="utf-8").splitlines() if env_file.exists() else [] + seen = set() + patched = [] + for line in lines: + key = line.split("=", 1)[0] if "=" in line and not line.startswith("#") else None + if key in updates: + patched.append(f"{key}={updates[key]}") + seen.add(key) + else: + patched.append(line) + for key, value in updates.items(): + if key not in seen: + patched.append(f"{key}={value}") + env_file.write_text("\\n".join(patched) + "\\n", encoding="utf-8") + + if notes: + print("已迁移 OpenClaw channel 配置:" + "、".join(notes)) + print("提示:插件安装态、pairing/device 运行态未自动复制;如 Hermes channel adapter 版本不兼容,仍需手动检查。") + else: + print("未发现可迁移的 Feishu/WeCom channel 配置") + PY + if grep -q '^FEISHU_APP_ID=' "${HOME:-/home/tenbox}/.hermes/.env" 2>/dev/null; then + "$hermes_cmd" config set platforms.feishu.enabled true >/dev/null 2>&1 || true + fi + if grep -q '^WECOM_BOT_ID=' "${HOME:-/home/tenbox}/.hermes/.env" 2>/dev/null; then + "$hermes_cmd" config set platforms.wecom.enabled true >/dev/null 2>&1 || true + wecom_dm_policy="$(grep '^WECOM_DM_POLICY=' "${HOME:-/home/tenbox}/.hermes/.env" 2>/dev/null | tail -n 1 | cut -d= -f2- || true)" + if [ -n "$wecom_dm_policy" ]; then + "$hermes_cmd" config set platforms.wecom.extra.dm_policy "$wecom_dm_policy" >/dev/null 2>&1 || true + fi + fi + fi + """ + } + + private static func hermesTenBoxModelConfigCommand() -> String { + """ + if [ -n "$hermes_cmd" ]; then + "$hermes_cmd" config set model.default default >/dev/null + "$hermes_cmd" config set model.provider custom >/dev/null + "$hermes_cmd" config set model.base_url http://10.0.2.3/v1 >/dev/null + "$hermes_cmd" config set terminal.backend local >/dev/null + "$hermes_cmd" config set auxiliary.compression.provider custom >/dev/null 2>&1 || true + "$hermes_cmd" config set auxiliary.compression.model default >/dev/null 2>&1 || true + "$hermes_cmd" config set auxiliary.compression.base_url http://10.0.2.3/v1 >/dev/null 2>&1 || true + "$hermes_cmd" config set auxiliary.vision.provider custom >/dev/null 2>&1 || true + "$hermes_cmd" config set auxiliary.vision.model default >/dev/null 2>&1 || true + "$hermes_cmd" config set auxiliary.vision.base_url http://10.0.2.3/v1 >/dev/null 2>&1 || true + "$hermes_cmd" config set auxiliary.session_search.provider custom >/dev/null 2>&1 || true + "$hermes_cmd" config set auxiliary.session_search.model default >/dev/null 2>&1 || true + "$hermes_cmd" config set auxiliary.session_search.base_url http://10.0.2.3/v1 >/dev/null 2>&1 || true + env_file="${HOME:-/home/tenbox}/.hermes/.env" + mkdir -p "$(dirname "$env_file")" + touch "$env_file" + set_env_value() { + key="$1" + value="$2" + if grep -q "^$key=" "$env_file"; then + sed -i "s|^$key=.*|$key=$value|" "$env_file" + else + printf '%s=%s\\n' "$key" "$value" >> "$env_file" + fi + } + set_env_value OPENAI_BASE_URL http://10.0.2.3/v1 + set_env_value OPENAI_API_KEY tenbox + echo "已恢复 TenBox 模型代理配置" >> "$report" + fi + """ + } + private static func openClawMigrationFlags(options: AgentMigrationOptions, includeYes: Bool) -> String { var flags = [ "--preset", "full", "--migrate-secrets", + "--overwrite", "--skill-conflict", options.skillConflictStrategy.rawValue ].map(shellQuote).joined(separator: " ") diff --git a/src/manager/CMakeLists.txt b/src/manager/CMakeLists.txt index a9e3b64..3b325e1 100644 --- a/src/manager/CMakeLists.txt +++ b/src/manager/CMakeLists.txt @@ -2,6 +2,7 @@ add_executable(tenbox-manager WIN32 ${CMAKE_SOURCE_DIR}/src/manager/main.cpp ${CMAKE_SOURCE_DIR}/src/manager/manager_service.cpp ${CMAKE_SOURCE_DIR}/src/manager/app_settings.cpp + ${CMAKE_SOURCE_DIR}/src/manager/agent_tools_service.cpp ${CMAKE_SOURCE_DIR}/src/common/image_source.cpp ${CMAKE_SOURCE_DIR}/src/manager/http_download.cpp ${CMAKE_SOURCE_DIR}/src/manager/app.manifest @@ -13,6 +14,7 @@ add_executable(tenbox-manager WIN32 ${CMAKE_SOURCE_DIR}/src/manager/ui/shared_folders_dialog.cpp ${CMAKE_SOURCE_DIR}/src/manager/ui/port_forward_dialog.cpp ${CMAKE_SOURCE_DIR}/src/manager/ui/llm_proxy_dialog.cpp + ${CMAKE_SOURCE_DIR}/src/manager/ui/agent_tools_dialog.cpp ${CMAKE_SOURCE_DIR}/src/manager/ui/settings_dialog.cpp ${CMAKE_SOURCE_DIR}/src/manager/ui/win32_display_panel.cpp ${CMAKE_SOURCE_DIR}/src/manager/ui/info_tab.cpp @@ -43,6 +45,7 @@ target_link_libraries(tenbox-manager ole32 winhttp bcrypt + crypt32 ws2_32 ) diff --git a/src/manager/agent_tools_service.cpp b/src/manager/agent_tools_service.cpp new file mode 100644 index 0000000..b9c327f --- /dev/null +++ b/src/manager/agent_tools_service.cpp @@ -0,0 +1,1030 @@ +#include "manager/agent_tools_service.h" + +#include "manager/app_settings.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace agent_tools { +namespace fs = std::filesystem; + +namespace { + +ToolResult Failure(std::string message, std::string output = {}) { + ToolResult r; + r.ok = false; + r.message = std::move(message); + r.output = std::move(output); + return r; +} + +ToolResult Success(std::string message, std::string output = {}) { + ToolResult r; + r.ok = true; + r.message = std::move(message); + r.output = std::move(output); + return r; +} + +std::string NormalizeTagSeed(std::string value) { + value.erase(std::remove(value.begin(), value.end(), '-'), value.end()); + if (value.size() > 8) value.resize(8); + std::transform(value.begin(), value.end(), value.begin(), + [](unsigned char c) { return static_cast(std::tolower(c)); }); + return value.empty() ? "00000000" : value; +} + +} // namespace + +const char* AgentRawValue(AgentKind agent) { + return agent == AgentKind::kHermes ? "hermes" : "openclaw"; +} + +const char* AgentDisplayName(AgentKind agent) { + return agent == AgentKind::kHermes ? "Hermes" : "OpenClaw"; +} + +std::string SkillConflictRawValue(SkillConflictStrategy strategy) { + switch (strategy) { + case SkillConflictStrategy::kOverwrite: return "overwrite"; + case SkillConflictStrategy::kRename: return "rename"; + case SkillConflictStrategy::kSkip: + default: return "skip"; + } +} + +std::string SkillConflictDisplayName(SkillConflictStrategy strategy) { + switch (strategy) { + case SkillConflictStrategy::kOverwrite: return "技能覆盖 Hermes"; + case SkillConflictStrategy::kRename: return "技能重命名导入"; + case SkillConflictStrategy::kSkip: + default: return "技能保留 Hermes"; + } +} + +AgentToolsService::AgentToolsService(ManagerService& manager, std::string data_dir) + : manager_(manager), data_dir_(std::move(data_dir)) {} + +std::string AgentToolsService::Timestamp() { + auto now = std::chrono::system_clock::now(); + std::time_t t = std::chrono::system_clock::to_time_t(now); + std::tm tm{}; +#ifdef _WIN32 + localtime_s(&tm, &t); +#else + localtime_r(&t, &tm); +#endif + std::ostringstream os; + os << std::put_time(&tm, "%Y-%m-%d-%H%M%S"); + return os.str(); +} + +std::string AgentToolsService::PathFilename(const std::string& path) { + auto u8 = fs::path(path).filename().u8string(); + return std::string(reinterpret_cast(u8.data()), u8.size()); +} + +std::string AgentToolsService::Dirname(const std::string& path) { + auto u8 = fs::path(path).parent_path().u8string(); + return std::string(reinterpret_cast(u8.data()), u8.size()); +} + +std::string AgentToolsService::OperationBaseDirectory() const { + return (fs::path(data_dir_) / "AgentOperations").string(); +} + +std::string AgentToolsService::BackupBaseDirectory(const std::string& vm_id) const { + return (fs::path(data_dir_) / "AgentBackups" / vm_id).string(); +} + +std::string AgentToolsService::BackupPackageDirectory(const std::string& vm_id, AgentKind agent) const { + return (fs::path(BackupBaseDirectory(vm_id)) / AgentRawValue(agent)).string(); +} + +std::string AgentToolsService::NewBackupPackagePath(const std::string& vm_id, AgentKind agent) const { + return (fs::path(BackupPackageDirectory(vm_id, agent)) / + ("agent-data-" + Timestamp() + ".tar.gz")).string(); +} + +std::string AgentToolsService::NewMigrationReportPath(const std::string& vm_id) const { + return (fs::path(BackupPackageDirectory(vm_id, AgentKind::kHermes)) / + ("openclaw-migration-" + Timestamp() + ".txt")).string(); +} + +bool AgentToolsService::IsRunnable(const std::string& vm_id, std::string* error) const { + auto vm = manager_.GetVm(vm_id); + if (!vm) { + if (error) *error = "找不到 VM"; + return false; + } + if (vm->state != VmPowerState::kRunning) { + if (error) *error = "VM 未运行"; + return false; + } + if (!vm->guest_agent_connected) { + if (error) *error = "Guest Agent 未连接"; + return false; + } + return true; +} + +void AgentToolsService::WithOperationShare(const std::vector& vm_ids, + ShareCallback cb, + ToolCallback failure_cb) { + std::error_code ec; + fs::create_directories(OperationBaseDirectory(), ec); + if (ec) { + failure_cb(Failure("创建临时目录失败", ec.message())); + return; + } + + const std::string tag = "tenbox-agent-ops-" + NormalizeTagSeed(settings::GenerateUuid()); + fs::path dir = fs::path(OperationBaseDirectory()) / (tag + "-" + NormalizeTagSeed(settings::GenerateUuid())); + fs::create_directories(dir, ec); + if (ec) { + failure_cb(Failure("创建临时共享目录失败", ec.message())); + return; + } + + ShareLease lease; + lease.folder = SharedFolder{tag, dir.string(), false}; + lease.vm_ids = vm_ids; + lease.cleanup_dir = dir.string(); + + for (const auto& vm_id : vm_ids) { + std::string error; + if (!manager_.AddRuntimeSharedFolder(vm_id, lease.folder, &error)) { + CleanupShare(lease); + failure_cb(Failure("挂载临时共享目录失败", error)); + return; + } + } + cb(std::move(lease)); +} + +void AgentToolsService::WithBackupShare(const std::string& vm_id, + ShareCallback cb, + ToolCallback failure_cb) { + std::error_code ec; + fs::create_directories(BackupBaseDirectory(vm_id), ec); + if (ec) { + failure_cb(Failure("创建备份目录失败", ec.message())); + return; + } + const std::string tag = "tenbox-agent-backups-" + NormalizeTagSeed(settings::GenerateUuid()); + + ShareLease lease; + lease.folder = SharedFolder{tag, BackupBaseDirectory(vm_id), false}; + lease.vm_ids = {vm_id}; + + std::string error; + if (!manager_.AddRuntimeSharedFolder(vm_id, lease.folder, &error)) { + failure_cb(Failure("挂载备份目录失败", error)); + return; + } + cb(std::move(lease)); +} + +void AgentToolsService::CleanupShare(const ShareLease& lease) { + for (const auto& vm_id : lease.vm_ids) { + std::string ignored; + manager_.RemoveRuntimeSharedFolder(vm_id, lease.folder.tag, &ignored); + } + if (!lease.cleanup_dir.empty()) { + std::error_code ec; + fs::remove_all(lease.cleanup_dir, ec); + } +} + +void AgentToolsService::RunCommand(const std::string& vm_id, const std::string& command, + uint32_t timeout_ms, ToolCallback cb) { + std::string error; + if (!IsRunnable(vm_id, &error)) { + cb(Failure(error)); + return; + } + manager_.RunGuestAgentCommand(vm_id, command, timeout_ms, + [cb = std::move(cb)](ManagerService::GuestExecResult result) mutable { + const std::string output = result.CombinedOutput(); + if (!result.ok) { + cb(Failure(result.error.empty() ? "Guest Agent 命令执行失败" : result.error, output)); + return; + } + if (result.exit_code != 0) { + cb(Failure(output.empty() ? "Agent 操作失败" : output, output)); + return; + } + cb(Success("ok", output)); + }); +} + +std::vector AgentToolsService::ListBackups(const std::string& vm_id, AgentKind agent) const { + std::vector result; + const fs::path dir = BackupPackageDirectory(vm_id, agent); + std::error_code ec; + if (!fs::exists(dir, ec)) return result; + for (const auto& entry : fs::directory_iterator(dir, ec)) { + if (ec || !entry.is_regular_file()) continue; + const auto path = entry.path(); + const auto name_u8 = path.filename().u8string(); + const std::string name(reinterpret_cast(name_u8.data()), name_u8.size()); + if (name.rfind("agent-data-", 0) != 0 || path.extension() != ".gz") continue; + BackupPackage pkg; + pkg.path = path.string(); + pkg.filename = name; + pkg.size = static_cast(entry.file_size(ec)); + auto ft = entry.last_write_time(ec); + if (!ec) { + auto sctp = std::chrono::time_point_cast( + ft - fs::file_time_type::clock::now() + std::chrono::system_clock::now()); + pkg.modified_at = sctp; + } + result.push_back(std::move(pkg)); + } + std::sort(result.begin(), result.end(), + [](const BackupPackage& a, const BackupPackage& b) { + return a.modified_at > b.modified_at; + }); + return result; +} + +void AgentToolsService::RotateBackups(const std::string& vm_id, AgentKind agent, int keep_count) { + auto packages = ListBackups(vm_id, agent); + if (keep_count < 1) keep_count = 1; + for (size_t i = static_cast(keep_count); i < packages.size(); ++i) { + std::error_code ec; + fs::remove(packages[i].path, ec); + } +} + +std::string AgentToolsService::ShellQuote(const std::string& value) { + std::string out = "'"; + for (char ch : value) { + if (ch == '\'') out += "'\\''"; + else out.push_back(ch); + } + out += "'"; + return out; +} + +std::string AgentToolsService::AgentDataRelativePath(AgentKind agent) { + return agent == AgentKind::kHermes ? ".hermes" : ".openclaw"; +} + +std::string AgentToolsService::AgentExcludeArgs(AgentKind agent, const std::string& scope) { + std::vector patterns; + if (agent == AgentKind::kHermes) { + patterns = { + ".hermes/logs", ".hermes/image_cache", ".hermes/audio_cache", + ".hermes/cache", ".hermes/hermes-agent", ".hermes/bin", + ".hermes/gateway.pid", ".hermes/gateway.lock" + }; + } else { + patterns = { + ".openclaw/cache", ".openclaw/.cache", ".openclaw/workspace/.cache", + ".openclaw/logs", ".openclaw/backup", ".openclaw/openclaw-backup*.tar.gz" + }; + } + std::ostringstream os; + for (const auto& p : patterns) { + os << " --exclude=" << ShellQuote(p); + } + if (scope != "migration") { + os << " --exclude=" << ShellQuote(AgentDataRelativePath(agent) + "/tmp"); + } + return os.str(); +} + +std::string AgentToolsService::WithSharedFolderReady(const std::string& tag, const std::string& body) { + const std::string path = "/mnt/shared/" + tag; + return + "set -eu\n" + "share_dir=" + ShellQuote(path) + "\n" + "i=0\n" + "while [ \"$i\" -lt 100 ]; do\n" + " if [ -d \"$share_dir\" ] && [ -w \"$share_dir\" ]; then break; fi\n" + " i=$((i + 1)); sleep 0.2\n" + "done\n" + "[ -d \"$share_dir\" ] || { echo \"共享文件夹未挂载:$share_dir\" >&2; exit 1; }\n" + "[ -w \"$share_dir\" ] || { echo \"共享文件夹不可写:$share_dir\" >&2; exit 1; }\n" + + body + "\n"; +} + +std::string AgentToolsService::ProfileExportCommand(AgentKind agent, const std::string& output_path, + const std::string& scope) { + const std::string rel = AgentDataRelativePath(agent); + const std::string out_dir = Dirname(output_path); + const std::string work_dir = out_dir + "/.tenbox-profile-work"; + std::ostringstream os; + os << "set -eu\n" + << "home=\"${HOME:-/home/tenbox}\"\n" + << "rel=" << ShellQuote(rel) << "\n" + << "src=\"$home/$rel\"\n" + << "out=" << ShellQuote(output_path) << "\n" + << "work=" << ShellQuote(work_dir) << "\n" + << "[ -d \"$src\" ] || { echo \"Agent 数据尚未初始化:$src\" >&2; exit 1; }\n" + << "rm -rf \"$work\"\n" + << "mkdir -p \"$work\"\n" + << "cat > \"$work/manifest.json\" <&2; exit 1; }\n" + << "input_dir=\"$(dirname \"$input\")\"\n" + << "tmp_parent=\"${HOME:-/home/tenbox}/.tenbox-tmp\"\n" + << "mkdir -p \"$tmp_parent\"\n" + << "work=\"$(mktemp -d \"$tmp_parent/profile-import.XXXXXX\")\"\n" + << "trap 'rm -rf \"$work\"' EXIT\n" + << "tar --touch --no-same-owner -xzf \"$input\" -C \"$work\"\n" + << "[ -f \"$work/manifest.json\" ] || { echo \"导入包缺少 manifest.json\" >&2; exit 1; }\n" + << "[ -f \"$work/files.tar.gz\" ] || { echo \"导入包缺少 files.tar.gz\" >&2; exit 1; }\n" + << "pkg_agent=\"\"\n" + << "if command -v python3 >/dev/null 2>&1; then\n" + << " pkg_agent=\"$(python3 - \"$work/manifest.json\" <<'PY'\n" + << "import json, sys\n" + << "with open(sys.argv[1], 'r', encoding='utf-8') as f:\n" + << " print(json.load(f).get('agent_type', ''))\n" + << "PY\n" + << " )\" || pkg_agent=\"\"\n" + << "fi\n" + << "if [ -z \"$pkg_agent\" ]; then pkg_agent=\"$(awk -F\\\" '/agent_type/ {print $4; exit}' \"$work/manifest.json\")\"; fi\n" + << "[ \"$pkg_agent\" = \"" << AgentRawValue(agent) << "\" ] || { echo \"导入包属于 $pkg_agent,不是 " + << AgentRawValue(agent) << "\" >&2; exit 1; }\n" + << "if ! tar -tzf \"$work/files.tar.gz\" \"$rel\" >/dev/null 2>&1; then echo \"导入包缺少 $rel 目录\" >&2; exit 1; fi\n" + << "backup=\"\"\n" + << "if [ -e \"$target\" ]; then\n" + << " backup=\"$input_dir/pre-import-" << AgentRawValue(agent) << "-$(date -u +%Y%m%d%H%M%S).tar.gz\"\n" + << " backup_status=0\n" + << " (cd \"$home\" && tar --warning=no-file-changed --ignore-failed-read -czf \"$backup\" \"$rel\") || backup_status=$?\n" + << " if [ \"$backup_status\" -gt 1 ]; then rm -f \"$backup\"; echo \"创建导入前备份失败\" >&2; exit \"$backup_status\"; fi\n" + << "fi\n" + << "mkdir -p \"$target\"\n" + << "tar -tzf \"$work/files.tar.gz\" | awk -v rel=\"$rel/\" 'index($0, rel) == 1 { rest=substr($0, length(rel)+1); split(rest, a, \"/\"); if (a[1] != \"\") print a[1] }' | sort -u | while IFS= read -r item; do [ -n \"$item\" ] || continue; rm -rf \"$target/$item\"; done\n" + << "if ! tar --touch --no-same-owner -xzf \"$work/files.tar.gz\" -C \"$home\"; then\n" + << " rm -rf \"$target\"\n" + << " if [ -n \"$backup\" ] && [ -f \"$backup\" ]; then tar --touch --no-same-owner -xzf \"$backup\" -C \"$home\"; fi\n" + << " echo \"恢复 Agent 数据失败\" >&2; exit 1\n" + << "fi\n" + << "chmod 700 \"$target\" 2>/dev/null || true\n" + << "svc=\"$(" << ServiceResolverCommand(agent) << ")\"\n" + << "if [ -n \"$svc\" ]; then XDG_RUNTIME_DIR=\"${XDG_RUNTIME_DIR:-/run/user/$(id -u)}\" systemctl --user restart \"$svc\" >/dev/null 2>&1 || true; fi\n" + << "if [ -n \"$backup\" ]; then echo \"$backup\"; else echo \"已导入\"; fi\n"; + return os.str(); +} + +std::string AgentToolsService::ServiceResolverCommand(AgentKind agent) { + if (agent == AgentKind::kHermes) { + return "systemctl --user list-unit-files --no-legend 2>/dev/null | awk '{print $1}' | grep -E '^(hermes|hermes-gateway)\\.service$' | head -n 1"; + } + return "systemctl --user list-unit-files --no-legend 2>/dev/null | awk '{print $1}' | grep -E '^(openclaw|openclaw-gateway)\\.service$' | head -n 1"; +} + +std::string AgentToolsService::HermesCommandResolver() { + return "if command -v hermes >/dev/null 2>&1; then command -v hermes; elif [ -x \"$HOME/.local/bin/hermes\" ]; then echo \"$HOME/.local/bin/hermes\"; elif [ -x \"$HOME/.hermes/bin/hermes\" ]; then echo \"$HOME/.hermes/bin/hermes\"; else find \"$HOME/.hermes\" -path '*/bin/hermes' -type f -perm -111 2>/dev/null | head -n 1; fi"; +} + +std::string AgentToolsService::OpenClawCommandResolver() { + return "if command -v openclaw >/dev/null 2>&1; then command -v openclaw; elif [ -x \"$HOME/.npm-global/bin/openclaw\" ]; then echo \"$HOME/.npm-global/bin/openclaw\"; else find \"$HOME\" -path '*/bin/openclaw' -type f -perm -111 2>/dev/null | head -n 1; fi"; +} + +std::string AgentToolsService::HealthStatusCommand(AgentKind agent) { + const std::string port = agent == AgentKind::kOpenClaw ? "18789" : ""; + std::ostringstream os; + os << "set -u\n" + << "svc=\"$(" << ServiceResolverCommand(agent) << ")\"\n" + << "agent=" << ShellQuote(AgentRawValue(agent)) << "\n" + << "port=" << ShellQuote(port) << "\n" + << "if [ -n \"$svc\" ] && XDG_RUNTIME_DIR=\"${XDG_RUNTIME_DIR:-/run/user/$(id -u)}\" systemctl --user is-active --quiet \"$svc\" 2>/dev/null; then service_state=ok; else service_state=error; fi\n" + << "if [ -z \"$port\" ]; then port_state=skipped; elif nc -z 127.0.0.1 \"$port\" >/dev/null 2>&1; then port_state=ok; else port_state=error; fi\n" + << "if curl -fsS --max-time 5 http://10.0.2.3/v1/models >/dev/null 2>&1; then model_state=ok; else model_state=error; fi\n" + << "if command -v chromium >/dev/null 2>&1 || command -v chromium-browser >/dev/null 2>&1; then browser_state=ok; else browser_state=error; fi\n" + << "free_kb=\"$(df -Pk \"$HOME\" 2>/dev/null | awk 'NR==2 {print $4}')\"\n" + << "if [ \"${free_kb:-0}\" -gt 1048576 ]; then disk_state=ok; else disk_state=space_low; fi\n" + << "state=ok; message=\"Agent 正常\"\n" + << "if [ \"$disk_state\" = space_low ]; then state=error; message=\"磁盘空间不足\"; fi\n" + << "if [ \"$service_state\" = error ]; then state=error; message=\"Agent 服务未运行\"; fi\n" + << "if [ \"$port_state\" = error ]; then state=error; message=\"Agent 网关不可用\"; fi\n" + << "if [ \"$model_state\" = error ]; then state=error; message=\"模型代理不可用\"; fi\n" + << "if [ \"$browser_state\" = error ]; then state=error; message=\"浏览器不可用\"; fi\n" + << "printf '{\"agent_type\":\"%s\",\"state\":\"%s\",\"message\":\"%s\",\"checks\":{\"agent_service\":\"%s\",\"gateway_port\":\"%s\",\"llm_proxy\":\"%s\",\"browser\":\"%s\",\"disk\":\"%s\"}}\\n' \"$agent\" \"$state\" \"$message\" \"$service_state\" \"$port_state\" \"$model_state\" \"$browser_state\" \"$disk_state\"\n"; + return os.str(); +} + +std::string AgentToolsService::RestartCommand(AgentKind agent) { + std::ostringstream os; + os << "set -eu\n" + << "svc=\"$(" << ServiceResolverCommand(agent) << ")\"\n" + << "[ -n \"$svc\" ] || { echo \"Agent 服务未安装\" >&2; exit 1; }\n" + << "XDG_RUNTIME_DIR=\"${XDG_RUNTIME_DIR:-/run/user/$(id -u)}\" systemctl --user restart \"$svc\"\n"; + if (agent == AgentKind::kOpenClaw) { + os << "i=0\nwhile [ \"$i\" -lt 60 ]; do nc -z 127.0.0.1 18789 >/dev/null 2>&1 && break; i=$((i + 1)); sleep 1; done\n"; + } + os << HealthStatusCommand(agent); + return os.str(); +} + +std::string AgentToolsService::ResetConfigCommand(AgentKind agent) { + if (agent == AgentKind::kOpenClaw) { + std::ostringstream os; + os << "set -eu\n" + << "openclaw_cmd=\"$(" << OpenClawCommandResolver() << ")\"\n" + << "[ -n \"$openclaw_cmd\" ] || { echo \"缺少 OpenClaw 命令\" >&2; exit 1; }\n" + << "tenbox_provider='{\"baseUrl\":\"http://10.0.2.3/v1\",\"apiKey\":\"tenbox\",\"api\":\"openai-completions\",\"models\":[{\"id\":\"default\",\"name\":\"Default (TenBox Proxy)\",\"reasoning\":false,\"input\":[\"text\",\"image\"],\"contextWindow\":200000,\"maxTokens\":65536,\"cost\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0}}]}'\n" + << "\"$openclaw_cmd\" config set models.providers.tenbox \"$tenbox_provider\" --strict-json --merge >/dev/null 2>&1 || \"$openclaw_cmd\" config set models.providers.tenbox \"$tenbox_provider\" >/dev/null\n" + << "\"$openclaw_cmd\" config set models.mode merge >/dev/null\n" + << "\"$openclaw_cmd\" config set agents.defaults.model.primary tenbox/default >/dev/null\n" + << "\"$openclaw_cmd\" config set agents.defaults.compaction.mode safeguard >/dev/null\n" + << "\"$openclaw_cmd\" config set agents.defaults.workspace \"$HOME/.openclaw/workspace\" >/dev/null\n" + << "\"$openclaw_cmd\" config set agents.defaults.models.tenbox/default '{\"alias\":\"TenBox Proxy\"}' --strict-json --merge >/dev/null 2>&1 || \"$openclaw_cmd\" config set agents.defaults.models.tenbox/default '{\"alias\":\"TenBox Proxy\"}' >/dev/null\n" + << HealthStatusCommand(agent); + return os.str(); + } + + std::ostringstream os; + os << "set -eu\n" + << "home=\"${HOME:-/home/tenbox}\"\n" + << "hermes_cmd=\"$(" << HermesCommandResolver() << ")\"\n" + << "if [ -n \"$hermes_cmd\" ]; then\n" + << " \"$hermes_cmd\" config set model.default default >/dev/null\n" + << " \"$hermes_cmd\" config set model.provider custom >/dev/null\n" + << " \"$hermes_cmd\" config set model.base_url http://10.0.2.3/v1 >/dev/null\n" + << " \"$hermes_cmd\" config set terminal.backend local >/dev/null\n" + << "else\n" + << " mkdir -p \"$home/.hermes\"\n" + << " cfg=\"$home/.hermes/config.yaml\"\n" + << " env_file=\"$home/.hermes/.env\"\n" + << " cat > \"$cfg\" <<'EOF'\n" + << "model:\n" + << " default: \"default\"\n" + << " provider: \"custom\"\n" + << " base_url: \"http://10.0.2.3/v1\"\n" + << "\n" + << "terminal:\n" + << " backend: local\n" + << "EOF\n" + << " touch \"$env_file\"\n" + << "fi\n" + << "env_file=\"$home/.hermes/.env\"\n" + << "mkdir -p \"$(dirname \"$env_file\")\"\n" + << "touch \"$env_file\"\n" + << "set_env_value() { key=\"$1\"; value=\"$2\"; if grep -q \"^$key=\" \"$env_file\"; then sed -i \"s|^$key=.*|$key=$value|\" \"$env_file\"; else printf '%s=%s\\n' \"$key\" \"$value\" >> \"$env_file\"; fi; }\n" + << "set_env_value OPENAI_BASE_URL http://10.0.2.3/v1\n" + << "set_env_value OPENAI_API_KEY tenbox\n" + << "set_env_value AGENT_BROWSER_HEADED true\n" + << "set_env_value AGENT_BROWSER_EXECUTABLE_PATH /usr/bin/chromium\n" + << "svc=\"$(" << ServiceResolverCommand(agent) << ")\"\n" + << "if [ -n \"$svc\" ]; then XDG_RUNTIME_DIR=\"${XDG_RUNTIME_DIR:-/run/user/$(id -u)}\" systemctl --user restart \"$svc\" >/dev/null 2>&1 || true; fi\n" + << HealthStatusCommand(agent); + return os.str(); +} + +std::string AgentToolsService::DiagnosticsCommand(AgentKind agent, const std::string& output_dir) { + std::ostringstream os; + os << "set -eu\n" + << "out=" << ShellQuote(output_dir) << "/tenbox-agent-diagnostics-" << AgentRawValue(agent) << "-$(date -u +%Y%m%d%H%M%S).tar.gz\n" + << "tmp=" << ShellQuote(output_dir) << "/.tenbox-diagnostics-work\n" + << "rm -rf \"$tmp\"\n" + << "mkdir -p \"$tmp\"\n" + << "(" << HealthStatusCommand(agent) << ") > \"$tmp/health.json\" 2>&1 || true\n" + << "svc=\"$(" << ServiceResolverCommand(agent) << ")\"\n" + << "if [ -n \"$svc\" ]; then\n" + << " XDG_RUNTIME_DIR=\"${XDG_RUNTIME_DIR:-/run/user/$(id -u)}\" systemctl --user status \"$svc\" --no-pager > \"$tmp/service.txt\" 2>&1 || true\n" + << " journalctl --user -u \"$svc\" -n 200 --no-pager > \"$tmp/journal.txt\" 2>&1 || true\n" + << "else\n" + << " echo \"Agent 服务未安装\" > \"$tmp/service.txt\"\n" + << " echo \"Agent 服务未安装\" > \"$tmp/journal.txt\"\n" + << "fi\n" + << "df -h > \"$tmp/disk.txt\" 2>&1 || true\n" + << "sed -Ei 's/(sk-[A-Za-z0-9_-]{8})[A-Za-z0-9_-]+/\\1***/g; s/(authorization:[[:space:]]*bearer[[:space:]]+)[^[:space:]]+/\\1***/Ig; s/((api[_-]?key|token|secret|password)[=: ]+)[^ ]+/\\1***/Ig' \"$tmp\"/*.txt \"$tmp\"/*.json 2>/dev/null || true\n" + << "tar -czf \"$out\" -C \"$tmp\" .\n" + << "rm -rf \"$tmp\"\n" + << "echo \"$out\"\n"; + return os.str(); +} + +std::string AgentToolsService::OpenClawMigrationSourceExportCommand(const std::string& output_path) { + std::ostringstream os; + os << "set -eu\n" + << "home=\"${HOME:-/home/tenbox}\"\n" + << "src=\"$home/.openclaw\"\n" + << "out=" << ShellQuote(output_path) << "\n" + << "[ -d \"$src\" ] || { echo \"OpenClaw 数据尚未初始化:$src\" >&2; exit 1; }\n" + << "rm -f \"$out\"\n" + << "tar_status=0\n" + << "(cd \"$home\" && tar --warning=no-file-changed --ignore-failed-read" + << AgentExcludeArgs(AgentKind::kOpenClaw, "migration") + << " -czf \"$out\" \".openclaw\") || tar_status=$?\n" + << "[ \"$tar_status\" -le 1 ] || exit \"$tar_status\"\n" + << "echo \"$out\"\n"; + return os.str(); +} + +std::string AgentToolsService::OpenClawMigrationFlags(const MigrationOptions& options, bool include_yes) { + std::ostringstream os; + os << "--preset full --migrate-secrets --overwrite --skill-conflict " + << ShellQuote(SkillConflictRawValue(options.skill_conflict)); + if (!options.workspace_target.empty()) { + os << " --workspace-target " << ShellQuote(options.workspace_target); + } + if (include_yes) os << " --yes"; + return os.str(); +} + +std::string AgentToolsService::OpenClawToHermesDryRunCommand(const std::string& input_path, + const std::string& report_path, + const MigrationOptions& options) { + std::ostringstream os; + os << "set -eu\n" + << "hermes_cmd=\"$(" << HermesCommandResolver() << ")\"\n" + << "[ -n \"$hermes_cmd\" ] || { echo \"目标 VM 缺少 Hermes 命令\" >&2; exit 1; }\n" + << "input=" << ShellQuote(input_path) << "\n" + << "report=" << ShellQuote(report_path) << "\n" + << "tmp_parent=\"${HOME:-/home/tenbox}/.tenbox-tmp\"\n" + << "mkdir -p \"$tmp_parent\"\n" + << "work=\"$(mktemp -d \"$tmp_parent/openclaw-to-hermes.XXXXXX\")\"\n" + << "trap 'rm -rf \"$work\"' EXIT\n" + << "source_dir=\"$work/source\"\n" + << "[ -f \"$input\" ] || { echo \"找不到 OpenClaw 迁移包:$input\" >&2; exit 1; }\n" + << "mkdir -p \"$source_dir\"\n" + << "tar --touch --no-same-owner -xzf \"$input\" -C \"$source_dir\"\n" + << "[ -d \"$source_dir/.openclaw\" ] || { echo \"迁移包缺少 .openclaw 目录\" >&2; exit 1; }\n" + << "dry_log=\"$work/dry-run.txt\"\n" + << "dry_status=0\n" + << "\"$hermes_cmd\" claw migrate --dry-run --source \"$source_dir/.openclaw\" " + << OpenClawMigrationFlags(options, false) << " > \"$dry_log\" 2>&1 || dry_status=$?\n" + << "{ echo \"===== OpenClaw -> Hermes dry-run $(date -u +%Y-%m-%dT%H:%M:%SZ) =====\"; cat \"$dry_log\"; echo; } >> \"$report\"\n" + << "tail -n 80 \"$dry_log\"\n" + << "[ \"$dry_status\" -eq 0 ] || exit \"$dry_status\"\n"; + return os.str(); +} + +std::string AgentToolsService::HermesOpenClawChannelConfigCommand() { + return R"SH( +if command -v python3 >/dev/null 2>&1; then + python3 - "$source_dir/.openclaw/openclaw.json" "${HOME:-/home/tenbox}/.hermes/.env" <<'PY' >> "$report" 2>&1 +import json +import sys +from pathlib import Path + +source = Path(sys.argv[1]) +env_file = Path(sys.argv[2]) +if not source.exists(): + print("OpenClaw channel 配置未迁移:找不到 openclaw.json") + raise SystemExit(0) + +try: + data = json.loads(source.read_text(encoding="utf-8")) +except Exception as exc: + print(f"OpenClaw channel 配置未迁移:openclaw.json 解析失败:{exc}") + raise SystemExit(0) + +channels = data.get("channels") or {} +updates = {} +notes = [] + +feishu = channels.get("feishu") or {} +if feishu.get("enabled"): + app_id = feishu.get("appId") or feishu.get("app_id") + app_secret = feishu.get("appSecret") or feishu.get("app_secret") + if app_id: + updates["FEISHU_APP_ID"] = str(app_id) + if app_secret: + updates["FEISHU_APP_SECRET"] = str(app_secret) + if feishu.get("domain"): + updates["FEISHU_DOMAIN"] = str(feishu["domain"]) + if feishu.get("connectionMode") or feishu.get("connection_mode"): + updates["FEISHU_CONNECTION_MODE"] = str(feishu.get("connectionMode") or feishu.get("connection_mode")) + if feishu.get("groupPolicy") or feishu.get("group_policy"): + updates["FEISHU_GROUP_POLICY"] = str(feishu.get("groupPolicy") or feishu.get("group_policy")) + allowed = feishu.get("allowFrom") or feishu.get("allowedUsers") or feishu.get("allowed_users") + if isinstance(allowed, list) and allowed: + updates["FEISHU_ALLOWED_USERS"] = ",".join(str(item) for item in allowed) + notes.append("Feishu") + +wecom = channels.get("wecom") or {} +if wecom.get("enabled"): + bot_id = wecom.get("botId") or wecom.get("bot_id") + secret = wecom.get("secret") + if bot_id: + updates["WECOM_BOT_ID"] = str(bot_id) + if secret: + updates["WECOM_SECRET"] = str(secret) + if wecom.get("dmPolicy") or wecom.get("dm_policy"): + updates["WECOM_DM_POLICY"] = str(wecom.get("dmPolicy") or wecom.get("dm_policy")) + if wecom.get("groupPolicy") or wecom.get("group_policy"): + updates["WECOM_GROUP_POLICY"] = str(wecom.get("groupPolicy") or wecom.get("group_policy")) + allowed = wecom.get("allowFrom") or wecom.get("allow_from") or wecom.get("allowedUsers") or wecom.get("allowed_users") + if isinstance(allowed, list) and allowed: + updates["WECOM_ALLOWED_USERS"] = ",".join(str(item) for item in allowed) + notes.append("WeCom") + +if updates: + env_file.parent.mkdir(parents=True, exist_ok=True) + lines = env_file.read_text(encoding="utf-8").splitlines() if env_file.exists() else [] + seen = set() + patched = [] + for line in lines: + key = line.split("=", 1)[0] if "=" in line and not line.startswith("#") else None + if key in updates: + patched.append(f"{key}={updates[key]}") + seen.add(key) + else: + patched.append(line) + for key, value in updates.items(): + if key not in seen: + patched.append(f"{key}={value}") + env_file.write_text("\n".join(patched) + "\n", encoding="utf-8") + +if notes: + print("已迁移 OpenClaw channel 配置:" + "、".join(notes)) + print("提示:插件安装态、pairing/device 运行态未自动复制;如 Hermes channel adapter 版本不兼容,仍需手动检查。") +else: + print("未发现可迁移的 Feishu/WeCom channel 配置") +PY + if grep -q '^FEISHU_APP_ID=' "${HOME:-/home/tenbox}/.hermes/.env" 2>/dev/null; then + "$hermes_cmd" config set platforms.feishu.enabled true >/dev/null 2>&1 || true + fi + if grep -q '^WECOM_BOT_ID=' "${HOME:-/home/tenbox}/.hermes/.env" 2>/dev/null; then + "$hermes_cmd" config set platforms.wecom.enabled true >/dev/null 2>&1 || true + wecom_dm_policy="$(grep '^WECOM_DM_POLICY=' "${HOME:-/home/tenbox}/.hermes/.env" 2>/dev/null | tail -n 1 | cut -d= -f2- || true)" + if [ -n "$wecom_dm_policy" ]; then + "$hermes_cmd" config set platforms.wecom.extra.dm_policy "$wecom_dm_policy" >/dev/null 2>&1 || true + fi + fi +fi +)SH"; +} + +std::string AgentToolsService::HermesTenBoxModelConfigCommand() { + return R"SH( +if [ -n "$hermes_cmd" ]; then + "$hermes_cmd" config set model.default default >/dev/null + "$hermes_cmd" config set model.provider custom >/dev/null + "$hermes_cmd" config set model.base_url http://10.0.2.3/v1 >/dev/null + "$hermes_cmd" config set terminal.backend local >/dev/null + "$hermes_cmd" config set auxiliary.compression.provider custom >/dev/null 2>&1 || true + "$hermes_cmd" config set auxiliary.compression.model default >/dev/null 2>&1 || true + "$hermes_cmd" config set auxiliary.compression.base_url http://10.0.2.3/v1 >/dev/null 2>&1 || true + "$hermes_cmd" config set auxiliary.vision.provider custom >/dev/null 2>&1 || true + "$hermes_cmd" config set auxiliary.vision.model default >/dev/null 2>&1 || true + "$hermes_cmd" config set auxiliary.vision.base_url http://10.0.2.3/v1 >/dev/null 2>&1 || true + "$hermes_cmd" config set auxiliary.session_search.provider custom >/dev/null 2>&1 || true + "$hermes_cmd" config set auxiliary.session_search.model default >/dev/null 2>&1 || true + "$hermes_cmd" config set auxiliary.session_search.base_url http://10.0.2.3/v1 >/dev/null 2>&1 || true + env_file="${HOME:-/home/tenbox}/.hermes/.env" + mkdir -p "$(dirname "$env_file")" + touch "$env_file" + set_env_value() { + key="$1" + value="$2" + if grep -q "^$key=" "$env_file"; then + sed -i "s|^$key=.*|$key=$value|" "$env_file" + else + printf '%s=%s\n' "$key" "$value" >> "$env_file" + fi + } + set_env_value OPENAI_BASE_URL http://10.0.2.3/v1 + set_env_value OPENAI_API_KEY tenbox + echo "已恢复 TenBox 模型代理配置" >> "$report" +fi +)SH"; +} + +std::string AgentToolsService::OpenClawToHermesMigrationCommand(const std::string& input_path, + const std::string& report_path, + const MigrationOptions& options) { + std::ostringstream os; + os << "set -eu\n" + << "hermes_cmd=\"$(" << HermesCommandResolver() << ")\"\n" + << "[ -n \"$hermes_cmd\" ] || { echo \"目标 VM 缺少 Hermes 命令\" >&2; exit 1; }\n" + << "input=" << ShellQuote(input_path) << "\n" + << "report=" << ShellQuote(report_path) << "\n" + << "tmp_parent=\"${HOME:-/home/tenbox}/.tenbox-tmp\"\n" + << "mkdir -p \"$tmp_parent\"\n" + << "work=\"$(mktemp -d \"$tmp_parent/openclaw-to-hermes.XXXXXX\")\"\n" + << "trap 'rm -rf \"$work\"' EXIT\n" + << "source_dir=\"$work/source\"\n" + << "[ -f \"$input\" ] || { echo \"找不到 OpenClaw 迁移包:$input\" >&2; exit 1; }\n" + << "mkdir -p \"$source_dir\"\n" + << "tar --touch --no-same-owner -xzf \"$input\" -C \"$source_dir\"\n" + << "[ -d \"$source_dir/.openclaw\" ] || { echo \"迁移包缺少 .openclaw 目录\" >&2; exit 1; }\n" + << "migrate_log=\"$work/migrate.txt\"\n" + << "migrate_status=0\n" + << "\"$hermes_cmd\" claw migrate --source \"$source_dir/.openclaw\" " + << OpenClawMigrationFlags(options, true) << " > \"$migrate_log\" 2>&1 || migrate_status=$?\n" + << "if grep -q \"Refusing to apply\" \"$migrate_log\"; then migrate_status=1; fi\n" + << "{ echo \"===== OpenClaw -> Hermes migrate $(date -u +%Y-%m-%dT%H:%M:%SZ) =====\"; cat \"$migrate_log\"; echo; } >> \"$report\"\n" + << "tail -n 80 \"$migrate_log\"\n" + << "[ \"$migrate_status\" -eq 0 ] || exit \"$migrate_status\"\n" + << HermesOpenClawChannelConfigCommand() << "\n" + << HermesTenBoxModelConfigCommand() << "\n" + << "svc=\"$(" << ServiceResolverCommand(AgentKind::kHermes) << ")\"\n" + << "if [ -n \"$svc\" ]; then XDG_RUNTIME_DIR=\"${XDG_RUNTIME_DIR:-/run/user/$(id -u)}\" systemctl --user restart \"$svc\" >/dev/null 2>&1 || true; echo \"重启服务:$svc\" >> \"$report\"; fi\n" + << "health_log=\"$(mktemp)\"\n" + << "(" << HealthStatusCommand(AgentKind::kHermes) << ") > \"$health_log\" 2>&1 || true\n" + << "cat \"$health_log\"\n" + << "{ echo \"===== Hermes health =====\"; cat \"$health_log\"; echo; } >> \"$report\"\n" + << "rm -f \"$health_log\"\n"; + return os.str(); +} + +void AgentToolsService::ExportProfile(const std::string& vm_id, AgentKind agent, + const std::string& destination_path, ToolCallback cb) { + ToolCallback failure_cb = cb; + WithOperationShare({vm_id}, [this, vm_id, agent, destination_path, cb = std::move(cb)](ShareLease lease) mutable { + const std::string package_name = PathFilename(destination_path).empty() + ? std::string(AgentRawValue(agent)) + "-profile.tar.gz" + : PathFilename(destination_path); + const std::string guest_package = "/mnt/shared/" + lease.folder.tag + "/" + package_name; + const std::string command = WithSharedFolderReady( + lease.folder.tag, + ProfileExportCommand(agent, guest_package, "migration")); + RunCommand(vm_id, command, 420000, [this, lease, destination_path, guest_package, cb = std::move(cb)](ToolResult result) mutable { + CleanupShare(lease); + if (!result.ok) { + cb(result); + return; + } + std::error_code ec; + fs::copy_file(fs::path(lease.folder.host_path) / PathFilename(guest_package), + destination_path, fs::copy_options::overwrite_existing, ec); + if (ec) cb(Failure("复制导出包失败", ec.message())); + else cb(Success("已导出 Agent 数据", destination_path)); + }); + }, std::move(failure_cb)); +} + +void AgentToolsService::ImportProfile(const std::string& vm_id, AgentKind agent, + const std::string& source_path, ToolCallback cb) { + ToolCallback failure_cb = cb; + WithOperationShare({vm_id}, [this, vm_id, agent, source_path, cb = std::move(cb)](ShareLease lease) mutable { + const std::string package_name = "tenbox-agent-profile-import.tar.gz"; + std::error_code ec; + fs::copy_file(source_path, fs::path(lease.folder.host_path) / package_name, + fs::copy_options::overwrite_existing, ec); + if (ec) { + CleanupShare(lease); + cb(Failure("复制导入包失败", ec.message())); + return; + } + const std::string guest_package = "/mnt/shared/" + lease.folder.tag + "/" + package_name; + const std::string command = WithSharedFolderReady(lease.folder.tag, ProfileImportCommand(agent, guest_package)); + RunCommand(vm_id, command, 420000, [this, lease, agent, cb = std::move(cb)](ToolResult result) mutable { + CleanupShare(lease); + if (!result.ok) cb(result); + else cb(Success(std::string("已导入 ") + AgentDisplayName(agent) + " 数据", result.output)); + }); + }, std::move(failure_cb)); +} + +void AgentToolsService::SnapshotBackup(const std::string& vm_id, AgentKind agent, + int keep_count, ToolCallback cb) { + std::error_code ec; + fs::create_directories(BackupPackageDirectory(vm_id, agent), ec); + if (ec) { + cb(Failure("创建备份目录失败", ec.message())); + return; + } + const std::string package = NewBackupPackagePath(vm_id, agent); + ToolCallback failure_cb = cb; + WithBackupShare(vm_id, [this, vm_id, agent, keep_count, package, cb = std::move(cb)](ShareLease lease) mutable { + const std::string guest_dir = "/mnt/shared/" + lease.folder.tag + "/" + AgentRawValue(agent); + const std::string guest_package = guest_dir + "/" + PathFilename(package); + const std::string command = WithSharedFolderReady( + lease.folder.tag, + "mkdir -p " + ShellQuote(guest_dir) + "\n" + + ProfileExportCommand(agent, guest_package, "backup")); + RunCommand(vm_id, command, 420000, [this, lease, vm_id, agent, keep_count, package, cb = std::move(cb)](ToolResult result) mutable { + CleanupShare(lease); + if (!result.ok) { + cb(result); + return; + } + RotateBackups(vm_id, agent, keep_count); + cb(Success("已创建 Agent 数据备份", package)); + }); + }, std::move(failure_cb)); +} + +void AgentToolsService::RestoreBackup(const std::string& vm_id, AgentKind agent, + const std::string& package_path, ToolCallback cb) { + ToolCallback failure_cb = cb; + WithBackupShare(vm_id, [this, vm_id, agent, package_path, cb = std::move(cb)](ShareLease lease) mutable { + const std::string guest_package = "/mnt/shared/" + lease.folder.tag + "/" + + std::string(AgentRawValue(agent)) + "/" + PathFilename(package_path); + const std::string command = WithSharedFolderReady(lease.folder.tag, ProfileImportCommand(agent, guest_package)); + RunCommand(vm_id, command, 420000, [this, lease, package_path, cb = std::move(cb)](ToolResult result) mutable { + CleanupShare(lease); + if (!result.ok) cb(result); + else cb(Success("已恢复 Agent 数据备份", package_path)); + }); + }, std::move(failure_cb)); +} + +void AgentToolsService::RunHealthCommand(const std::string& vm_id, AgentKind, + const std::string& command, + const std::string& success_message, + ToolCallback cb) { + RunCommand(vm_id, command, 180000, [success_message, cb = std::move(cb)](ToolResult result) mutable { + if (!result.ok) cb(result); + else cb(Success(success_message, result.output)); + }); +} + +void AgentToolsService::HealthStatus(const std::string& vm_id, AgentKind agent, ToolCallback cb) { + RunHealthCommand(vm_id, agent, HealthStatusCommand(agent), "健康状态已更新", std::move(cb)); +} + +void AgentToolsService::RunRepairCommand(const std::string& vm_id, AgentKind agent, + const std::string& repair_command, + const std::string& success_message, + int keep_count, + ToolCallback cb) { + std::error_code ec; + fs::create_directories(BackupPackageDirectory(vm_id, agent), ec); + if (ec) { + cb(Failure("创建备份目录失败", ec.message())); + return; + } + const std::string package = NewBackupPackagePath(vm_id, agent); + ToolCallback failure_cb = cb; + WithBackupShare(vm_id, [this, vm_id, agent, repair_command, success_message, keep_count, package, cb = std::move(cb)](ShareLease lease) mutable { + const std::string guest_dir = "/mnt/shared/" + lease.folder.tag + "/" + AgentRawValue(agent); + const std::string guest_package = guest_dir + "/" + PathFilename(package); + const std::string command = WithSharedFolderReady( + lease.folder.tag, + "mkdir -p " + ShellQuote(guest_dir) + "\n" + + ProfileExportCommand(agent, guest_package, "backup") + "\n" + + repair_command); + RunCommand(vm_id, command, 420000, + [this, lease, vm_id, agent, keep_count, package, success_message, cb = std::move(cb)](ToolResult result) mutable { + CleanupShare(lease); + if (!result.ok) { + cb(result); + return; + } + RotateBackups(vm_id, agent, keep_count); + cb(Success(success_message, "修复前备份:" + package + "\n" + result.output)); + }); + }, std::move(failure_cb)); +} + +void AgentToolsService::RestartAgent(const std::string& vm_id, AgentKind agent, + int keep_count, ToolCallback cb) { + RunRepairCommand(vm_id, agent, RestartCommand(agent), "已重新启动 Agent", keep_count, std::move(cb)); +} + +void AgentToolsService::ResetAgentConfig(const std::string& vm_id, AgentKind agent, + int keep_count, ToolCallback cb) { + RunRepairCommand(vm_id, agent, ResetConfigCommand(agent), "已重置 Agent 配置", keep_count, std::move(cb)); +} + +void AgentToolsService::ExportDiagnostics(const std::string& vm_id, AgentKind agent, ToolCallback cb) { + ToolCallback failure_cb = cb; + WithBackupShare(vm_id, [this, vm_id, agent, cb = std::move(cb)](ShareLease lease) mutable { + const std::string guest_dir = "/mnt/shared/" + lease.folder.tag; + const std::string command = WithSharedFolderReady(lease.folder.tag, DiagnosticsCommand(agent, guest_dir)); + RunCommand(vm_id, command, 180000, [this, lease, cb = std::move(cb)](ToolResult result) mutable { + CleanupShare(lease); + if (!result.ok) cb(result); + else cb(Success("已导出诊断包", result.output)); + }); + }, std::move(failure_cb)); +} + +void AgentToolsService::MigrateOpenClawToHermes(const std::string& source_vm_id, + const std::string& target_vm_id, + const MigrationOptions& options, + int keep_count, + ProgressCallback progress, + ToolCallback cb) { + std::string source_error; + if (!IsRunnable(source_vm_id, &source_error)) { + cb(Failure("OpenClaw 来源 VM " + source_error)); + return; + } + std::string target_error; + if (!IsRunnable(target_vm_id, &target_error)) { + cb(Failure("Hermes 目标 VM " + target_error)); + return; + } + + std::error_code ec; + fs::create_directories(BackupPackageDirectory(target_vm_id, AgentKind::kHermes), ec); + if (ec) { + cb(Failure("创建迁移目录失败", ec.message())); + return; + } + const std::string backup_package = NewBackupPackagePath(target_vm_id, AgentKind::kHermes); + const std::string report_path = NewMigrationReportPath(target_vm_id); + + ToolCallback backup_failure_cb = cb; + WithBackupShare(target_vm_id, + [this, source_vm_id, target_vm_id, options, keep_count, progress, cb = std::move(cb), backup_package, report_path](ShareLease backup_lease) mutable { + ToolCallback op_failure_cb = cb; + WithOperationShare({source_vm_id, target_vm_id}, + [this, source_vm_id, target_vm_id, options, keep_count, progress, cb = std::move(cb), backup_package, report_path, backup_lease](ShareLease op_lease) mutable { + auto cleanup_all = [this, backup_lease, op_lease]() { + CleanupShare(op_lease); + CleanupShare(backup_lease); + }; + const std::string guest_backup_dir = "/mnt/shared/" + backup_lease.folder.tag + "/hermes"; + const std::string guest_backup = guest_backup_dir + "/" + PathFilename(backup_package); + const std::string guest_report = guest_backup_dir + "/" + PathFilename(report_path); + const std::string backup_command = WithSharedFolderReady( + backup_lease.folder.tag, + "mkdir -p " + ShellQuote(guest_backup_dir) + "\n" + + ProfileExportCommand(AgentKind::kHermes, guest_backup, "backup")); + if (progress) progress("backup", "正在创建目标 Hermes 迁移前备份", PathFilename(backup_package)); + RunCommand(target_vm_id, backup_command, 420000, + [this, source_vm_id, target_vm_id, options, keep_count, progress, cb = std::move(cb), backup_package, report_path, op_lease, backup_lease, cleanup_all](ToolResult backup_result) mutable { + if (!backup_result.ok) { + cleanup_all(); + cb(backup_result); + return; + } + const std::string archive_path = "/mnt/shared/" + op_lease.folder.tag + "/openclaw-source.tar.gz"; + const std::string export_command = WithSharedFolderReady( + op_lease.folder.tag, + OpenClawMigrationSourceExportCommand(archive_path)); + if (progress) progress("exportSource", "正在从来源 VM 导出 OpenClaw 用户数据", ""); + RunCommand(source_vm_id, export_command, 420000, + [this, target_vm_id, options, keep_count, progress, cb = std::move(cb), backup_package, report_path, archive_path, op_lease, backup_lease, cleanup_all](ToolResult export_result) mutable { + if (!export_result.ok) { + cleanup_all(); + cb(export_result); + return; + } + const std::string dry_command = WithSharedFolderReady( + op_lease.folder.tag, + OpenClawToHermesDryRunCommand(archive_path, "/mnt/shared/" + backup_lease.folder.tag + "/hermes/" + PathFilename(report_path), options)); + if (progress) progress("dryRun", "正在生成官方 dry-run 迁移计划", SkillConflictDisplayName(options.skill_conflict)); + RunCommand(target_vm_id, dry_command, 420000, + [this, target_vm_id, options, keep_count, progress, cb = std::move(cb), backup_package, report_path, archive_path, op_lease, backup_lease, cleanup_all](ToolResult dry_result) mutable { + if (!dry_result.ok) { + cleanup_all(); + cb(dry_result); + return; + } + const std::string migrate_command = WithSharedFolderReady( + op_lease.folder.tag, + OpenClawToHermesMigrationCommand(archive_path, "/mnt/shared/" + backup_lease.folder.tag + "/hermes/" + PathFilename(report_path), options)); + if (progress) progress("migrate", "dry-run 已通过,正在执行正式迁移", PathFilename(report_path)); + RunCommand(target_vm_id, migrate_command, 600000, + [this, target_vm_id, keep_count, progress, cb = std::move(cb), backup_package, report_path, cleanup_all](ToolResult migrate_result) mutable { + cleanup_all(); + if (!migrate_result.ok) { + cb(migrate_result); + return; + } + RotateBackups(target_vm_id, AgentKind::kHermes, keep_count); + if (progress) progress("complete", "迁移完成,报告已保存", PathFilename(report_path)); + cb(Success("已完成 OpenClaw 到 Hermes 迁移", + "迁移前备份:" + backup_package + "\n迁移报告:" + report_path + "\n" + migrate_result.output)); + }); + }); + }); + }); + }, + [this, backup_lease, cb = std::move(op_failure_cb)](ToolResult failure) mutable { + CleanupShare(backup_lease); + cb(std::move(failure)); + }); + }, + std::move(backup_failure_cb)); +} + +} // namespace agent_tools diff --git a/src/manager/agent_tools_service.h b/src/manager/agent_tools_service.h new file mode 100644 index 0000000..0426a55 --- /dev/null +++ b/src/manager/agent_tools_service.h @@ -0,0 +1,158 @@ +#pragma once + +#include "manager/manager_service.h" + +#include +#include +#include +#include +#include + +namespace agent_tools { + +enum class AgentKind { + kHermes, + kOpenClaw, +}; + +enum class SkillConflictStrategy { + kSkip, + kOverwrite, + kRename, +}; + +struct MigrationOptions { + SkillConflictStrategy skill_conflict = SkillConflictStrategy::kSkip; + std::string workspace_target = "/home/tenbox/.hermes/workspace/openclaw-migrated"; +}; + +struct ToolResult { + bool ok = false; + std::string message; + std::string output; +}; + +struct BackupPackage { + std::string path; + std::string filename; + uint64_t size = 0; + std::chrono::system_clock::time_point modified_at{}; +}; + +struct BackupSchedule { + bool enabled = false; + int hour = 3; + int minute = 0; + int keep_count = 7; + std::string last_run_date; + std::string last_attempt_at; + std::string last_attempt_status; + std::string last_attempt_message; +}; + +using ToolCallback = std::function; +using ProgressCallback = std::function; + +const char* AgentRawValue(AgentKind agent); +const char* AgentDisplayName(AgentKind agent); +std::string SkillConflictRawValue(SkillConflictStrategy strategy); +std::string SkillConflictDisplayName(SkillConflictStrategy strategy); + +class AgentToolsService { +public: + AgentToolsService(ManagerService& manager, std::string data_dir); + + std::vector ListBackups(const std::string& vm_id, AgentKind agent) const; + void RotateBackups(const std::string& vm_id, AgentKind agent, int keep_count); + + void ExportProfile(const std::string& vm_id, AgentKind agent, + const std::string& destination_path, ToolCallback cb); + void ImportProfile(const std::string& vm_id, AgentKind agent, + const std::string& source_path, ToolCallback cb); + void SnapshotBackup(const std::string& vm_id, AgentKind agent, + int keep_count, ToolCallback cb); + void RestoreBackup(const std::string& vm_id, AgentKind agent, + const std::string& package_path, ToolCallback cb); + void HealthStatus(const std::string& vm_id, AgentKind agent, ToolCallback cb); + void RestartAgent(const std::string& vm_id, AgentKind agent, + int keep_count, ToolCallback cb); + void ResetAgentConfig(const std::string& vm_id, AgentKind agent, + int keep_count, ToolCallback cb); + void ExportDiagnostics(const std::string& vm_id, AgentKind agent, ToolCallback cb); + void MigrateOpenClawToHermes(const std::string& source_vm_id, + const std::string& target_vm_id, + const MigrationOptions& options, + int keep_count, + ProgressCallback progress, + ToolCallback cb); + +private: + struct ShareLease { + SharedFolder folder; + std::vector vm_ids; + std::string cleanup_dir; + }; + + using ShareCallback = std::function; + + bool IsRunnable(const std::string& vm_id, std::string* error) const; + void WithOperationShare(const std::vector& vm_ids, + ShareCallback cb, + ToolCallback failure_cb); + void WithBackupShare(const std::string& vm_id, + ShareCallback cb, + ToolCallback failure_cb); + void CleanupShare(const ShareLease& lease); + void RunCommand(const std::string& vm_id, const std::string& command, + uint32_t timeout_ms, ToolCallback cb); + void RunHealthCommand(const std::string& vm_id, AgentKind agent, + const std::string& command, + const std::string& success_message, + ToolCallback cb); + void RunRepairCommand(const std::string& vm_id, AgentKind agent, + const std::string& repair_command, + const std::string& success_message, + int keep_count, + ToolCallback cb); + + std::string OperationBaseDirectory() const; + std::string BackupBaseDirectory(const std::string& vm_id) const; + std::string BackupPackageDirectory(const std::string& vm_id, AgentKind agent) const; + std::string NewBackupPackagePath(const std::string& vm_id, AgentKind agent) const; + std::string NewMigrationReportPath(const std::string& vm_id) const; + + static std::string ShellQuote(const std::string& value); + static std::string PathFilename(const std::string& path); + static std::string Dirname(const std::string& path); + static std::string Timestamp(); + static std::string AgentDataRelativePath(AgentKind agent); + static std::string AgentExcludeArgs(AgentKind agent, const std::string& scope); + static std::string WithSharedFolderReady(const std::string& tag, const std::string& body); + static std::string ProfileExportCommand(AgentKind agent, const std::string& output_path, + const std::string& scope); + static std::string ProfileImportCommand(AgentKind agent, const std::string& input_path); + static std::string HealthStatusCommand(AgentKind agent); + static std::string RestartCommand(AgentKind agent); + static std::string ResetConfigCommand(AgentKind agent); + static std::string DiagnosticsCommand(AgentKind agent, const std::string& output_dir); + static std::string OpenClawMigrationSourceExportCommand(const std::string& output_path); + static std::string OpenClawMigrationFlags(const MigrationOptions& options, bool include_yes); + static std::string OpenClawToHermesDryRunCommand(const std::string& input_path, + const std::string& report_path, + const MigrationOptions& options); + static std::string OpenClawToHermesMigrationCommand(const std::string& input_path, + const std::string& report_path, + const MigrationOptions& options); + static std::string ServiceResolverCommand(AgentKind agent); + static std::string HermesCommandResolver(); + static std::string OpenClawCommandResolver(); + static std::string HermesTenBoxModelConfigCommand(); + static std::string HermesOpenClawChannelConfigCommand(); + + ManagerService& manager_; + std::string data_dir_; +}; + +} // namespace agent_tools diff --git a/src/manager/app_settings.cpp b/src/manager/app_settings.cpp index 3542d0d..0206b92 100644 --- a/src/manager/app_settings.cpp +++ b/src/manager/app_settings.cpp @@ -11,6 +11,7 @@ #include #include +#include #include namespace settings { @@ -156,6 +157,25 @@ AppSettings LoadSettings(const std::string& data_dir) { if (lp.contains("enable_logging") && lp["enable_logging"].is_boolean()) s.llm_proxy.enable_logging = lp["enable_logging"].get(); } + if (j.contains("agent_backups") && j["agent_backups"].is_object()) { + auto& ab = j["agent_backups"]; + if (ab.contains("schedules") && ab["schedules"].is_object()) { + for (auto it = ab["schedules"].begin(); it != ab["schedules"].end(); ++it) { + if (!it.value().is_object()) continue; + AgentBackupSchedule schedule; + auto& item = it.value(); + schedule.enabled = item.value("enabled", false); + schedule.hour = std::clamp(item.value("hour", 3), 0, 23); + schedule.minute = std::clamp(item.value("minute", 0), 0, 59); + schedule.keep_count = std::clamp(item.value("keep_count", 7), 1, 99); + schedule.last_run_date = item.value("last_run_date", ""); + schedule.last_attempt_at = item.value("last_attempt_at", ""); + schedule.last_attempt_status = item.value("last_attempt_status", ""); + schedule.last_attempt_message = item.value("last_attempt_message", ""); + s.agent_backup_schedules[it.key()] = std::move(schedule); + } + } + } if (j.contains("vm_paths") && j["vm_paths"].is_array()) { auto default_storage = DefaultVmStorageDir(); for (auto& item : j["vm_paths"]) { @@ -231,6 +251,27 @@ void SaveSettings(const std::string& data_dir, const AppSettings& s) { j["llm_proxy"] = lp; } + { + json schedules = json::object(); + for (const auto& [key, schedule] : s.agent_backup_schedules) { + json item; + item["enabled"] = schedule.enabled; + item["hour"] = schedule.hour; + item["minute"] = schedule.minute; + item["keep_count"] = schedule.keep_count; + if (!schedule.last_run_date.empty()) + item["last_run_date"] = schedule.last_run_date; + if (!schedule.last_attempt_at.empty()) + item["last_attempt_at"] = schedule.last_attempt_at; + if (!schedule.last_attempt_status.empty()) + item["last_attempt_status"] = schedule.last_attempt_status; + if (!schedule.last_attempt_message.empty()) + item["last_attempt_message"] = schedule.last_attempt_message; + schedules[key] = item; + } + j["agent_backups"] = {{"schedules", schedules}}; + } + auto path = fs::path(data_dir) / "settings.json"; std::ofstream ofs(path, std::ios::trunc); if (ofs) ofs << j.dump(2) << '\n'; diff --git a/src/manager/app_settings.h b/src/manager/app_settings.h index 01d895a..e2e0643 100644 --- a/src/manager/app_settings.h +++ b/src/manager/app_settings.h @@ -5,6 +5,7 @@ #include #include +#include #include namespace settings { @@ -39,6 +40,17 @@ struct LlmProxySettings { bool enable_logging = false; }; +struct AgentBackupSchedule { + bool enabled = false; + int hour = 3; + int minute = 0; + int keep_count = 7; + std::string last_run_date; + std::string last_attempt_at; + std::string last_attempt_status; + std::string last_attempt_message; +}; + struct AppSettings { WindowGeometry window; std::vector vm_paths; @@ -49,6 +61,7 @@ struct AppSettings { std::vector sources; // empty = use DefaultSources() std::string last_selected_source; // name of last selected source LlmProxySettings llm_proxy; + std::unordered_map agent_backup_schedules; }; // Resolve effective directories (returns custom if set, otherwise default). diff --git a/src/manager/manager_service.cpp b/src/manager/manager_service.cpp index d6a6a84..b5385dc 100644 --- a/src/manager/manager_service.cpp +++ b/src/manager/manager_service.cpp @@ -32,6 +32,7 @@ extern FILE* GetManagerLogFile(); } while (0) #include +#include #include #include @@ -80,6 +81,24 @@ std::string DecodeHex(const std::string& value) { return out; } +std::string DecodeBase64(const std::string& value) { + if (value.empty()) return {}; + DWORD needed = 0; + if (!CryptStringToBinaryA(value.c_str(), static_cast(value.size()), + CRYPT_STRING_BASE64, nullptr, &needed, nullptr, nullptr) || + needed == 0) { + return {}; + } + std::string out(needed, '\0'); + if (!CryptStringToBinaryA(value.c_str(), static_cast(value.size()), + CRYPT_STRING_BASE64, + reinterpret_cast(out.data()), &needed, nullptr, nullptr)) { + return {}; + } + out.resize(needed); + return out; +} + std::string BuildRuntimeCommand(const std::string& exe, const VmSpec& spec, const std::string& pipe, const std::vector& guest_forwards = {}) { @@ -863,6 +882,23 @@ bool ManagerService::SendRuntimeMessage(VmRecord& vm, const ipc::Message& msg) { return true; } +void ManagerService::SendSharedFoldersUpdateLocked(const std::string& vm_id, VmRecord& vm) { + if (vm.state != VmPowerState::kRunning) return; + ipc::Message msg; + msg.channel = ipc::Channel::kControl; + msg.kind = ipc::Kind::kRequest; + msg.type = "runtime.update_shared_folders"; + msg.vm_id = vm_id; + msg.request_id = GetTickCount64(); + msg.fields["folder_count"] = std::to_string(vm.spec.shared_folders.size()); + for (size_t i = 0; i < vm.spec.shared_folders.size(); ++i) { + const auto& f = vm.spec.shared_folders[i]; + msg.fields["folder_" + std::to_string(i)] = + f.tag + "|" + f.host_path + "|" + (f.readonly ? "1" : "0"); + } + SendRuntimeMessage(vm, msg); +} + void ManagerService::ApplyPendingPatchLocked(VmRecord& vm) { if (!vm.pending_patch) return; const auto patch = *vm.pending_patch; @@ -872,6 +908,7 @@ void ManagerService::ApplyPendingPatchLocked(VmRecord& vm) { } void ManagerService::CleanupRuntimeHandles(VmRecord& vm) { + FailPendingGuestExecForVm(vm.spec.vm_id, "VM runtime disconnected"); vm.runtime.pipe_connected = false; if (vm.runtime.process_handle) { HANDLE proc = reinterpret_cast(vm.runtime.process_handle); @@ -968,6 +1005,88 @@ bool ManagerService::IsGuestAgentConnected(const std::string& vm_id) const { return vm->guest_agent_connected; } +bool ManagerService::RunGuestAgentCommand(const std::string& vm_id, + const std::string& command, + uint32_t timeout_ms, + GuestExecCallback callback, + const std::string& user) { + if (command.empty()) { + if (callback) { + GuestExecResult result; + result.error = "missing command"; + callback(std::move(result)); + } + return false; + } + + timeout_ms = std::clamp(timeout_ms, 1000, 600000); + const uint64_t request_id = next_guest_exec_request_id_.fetch_add(1, std::memory_order_relaxed); + + { + std::lock_guard exec_lock(guest_exec_mutex_); + pending_guest_exec_[request_id] = PendingGuestExec{vm_id, std::move(callback)}; + } + + bool sent = false; + { + std::lock_guard lock(vms_mutex_); + VmRecord* vm = FindVm(vm_id); + if (vm && vm->state == VmPowerState::kRunning && + vm->runtime.pipe_connected && vm->guest_agent_connected) { + ipc::Message msg; + msg.channel = ipc::Channel::kControl; + msg.kind = ipc::Kind::kRequest; + msg.type = "runtime.guest_exec"; + msg.vm_id = vm_id; + msg.request_id = request_id; + msg.fields["command_hex"] = EncodeHex(command); + msg.fields["timeout_ms"] = std::to_string(timeout_ms); + if (!user.empty()) msg.fields["user"] = user; + sent = SendRuntimeMessage(*vm, msg); + } + } + + if (!sent) { + GuestExecCallback cb; + { + std::lock_guard exec_lock(guest_exec_mutex_); + auto it = pending_guest_exec_.find(request_id); + if (it != pending_guest_exec_.end()) { + cb = std::move(it->second.callback); + pending_guest_exec_.erase(it); + } + } + if (cb) { + GuestExecResult result; + result.error = "Guest Agent 未连接或 VM 未运行"; + cb(std::move(result)); + } + return false; + } + return true; +} + +void ManagerService::FailPendingGuestExecForVm(const std::string& vm_id, const std::string& error) { + std::vector callbacks; + { + std::lock_guard lock(guest_exec_mutex_); + for (auto it = pending_guest_exec_.begin(); it != pending_guest_exec_.end(); ) { + if (it->second.vm_id == vm_id) { + callbacks.push_back(std::move(it->second.callback)); + it = pending_guest_exec_.erase(it); + } else { + ++it; + } + } + } + for (auto& cb : callbacks) { + if (!cb) continue; + GuestExecResult result; + result.error = error; + cb(std::move(result)); + } +} + bool ManagerService::SendKeyEvent(const std::string& vm_id, uint32_t key_code, bool pressed) { // try_lock: these are called from WndProc which can be re-entered while // the UI thread holds vms_mutex_ (e.g. WM_ACTIVATEAPP during WaitForSingleObject). @@ -1145,19 +1264,7 @@ bool ManagerService::AddSharedFolder(const std::string& vm_id, const SharedFolde settings::SaveVmManifest(vm.spec); if (vm.state == VmPowerState::kRunning) { - ipc::Message msg; - msg.channel = ipc::Channel::kControl; - msg.kind = ipc::Kind::kRequest; - msg.type = "runtime.update_shared_folders"; - msg.vm_id = vm_id; - msg.request_id = GetTickCount64(); - msg.fields["folder_count"] = std::to_string(vm.spec.shared_folders.size()); - for (size_t i = 0; i < vm.spec.shared_folders.size(); ++i) { - const auto& f = vm.spec.shared_folders[i]; - msg.fields["folder_" + std::to_string(i)] = - f.tag + "|" + f.host_path + "|" + (f.readonly ? "1" : "0"); - } - SendRuntimeMessage(vm, msg); + SendSharedFoldersUpdateLocked(vm_id, vm); } return true; @@ -1184,19 +1291,7 @@ bool ManagerService::RemoveSharedFolder(const std::string& vm_id, const std::str settings::SaveVmManifest(vm.spec); if (vm.state == VmPowerState::kRunning) { - ipc::Message msg; - msg.channel = ipc::Channel::kControl; - msg.kind = ipc::Kind::kRequest; - msg.type = "runtime.update_shared_folders"; - msg.vm_id = vm_id; - msg.request_id = GetTickCount64(); - msg.fields["folder_count"] = std::to_string(vm.spec.shared_folders.size()); - for (size_t i = 0; i < vm.spec.shared_folders.size(); ++i) { - const auto& f = vm.spec.shared_folders[i]; - msg.fields["folder_" + std::to_string(i)] = - f.tag + "|" + f.host_path + "|" + (f.readonly ? "1" : "0"); - } - SendRuntimeMessage(vm, msg); + SendSharedFoldersUpdateLocked(vm_id, vm); } return true; @@ -1211,6 +1306,55 @@ std::vector ManagerService::GetSharedFolders(const std::string& vm return vm->spec.shared_folders; } +bool ManagerService::AddRuntimeSharedFolder(const std::string& vm_id, const SharedFolder& folder, + std::string* error) { + std::lock_guard lock(vms_mutex_); + VmRecord* vmp = FindVm(vm_id); + if (!vmp) { + if (error) *error = "vm not found"; + return false; + } + VmRecord& vm = *vmp; + if (vm.state != VmPowerState::kRunning) { + if (error) *error = "VM must be running"; + return false; + } + for (const auto& sf : vm.spec.shared_folders) { + if (sf.tag == folder.tag) { + if (error) *error = "shared folder with tag '" + folder.tag + "' already exists"; + return false; + } + } + DWORD attrs = GetFileAttributesA(folder.host_path.c_str()); + if (attrs == INVALID_FILE_ATTRIBUTES || !(attrs & FILE_ATTRIBUTE_DIRECTORY)) { + if (error) *error = "host path does not exist or is not a directory"; + return false; + } + vm.spec.shared_folders.push_back(folder); + SendSharedFoldersUpdateLocked(vm_id, vm); + return true; +} + +bool ManagerService::RemoveRuntimeSharedFolder(const std::string& vm_id, const std::string& tag, + std::string* error) { + std::lock_guard lock(vms_mutex_); + VmRecord* vmp = FindVm(vm_id); + if (!vmp) { + if (error) *error = "vm not found"; + return false; + } + VmRecord& vm = *vmp; + auto it = std::find_if(vm.spec.shared_folders.begin(), vm.spec.shared_folders.end(), + [&tag](const SharedFolder& sf) { return sf.tag == tag; }); + if (it == vm.spec.shared_folders.end()) { + if (error) *error = "shared folder with tag '" + tag + "' not found"; + return false; + } + vm.spec.shared_folders.erase(it); + SendSharedFoldersUpdateLocked(vm_id, vm); + return true; +} + bool ManagerService::AddHostForward(const std::string& vm_id, const HostForward& forward, std::string* error) { std::lock_guard lock(vms_mutex_); @@ -1970,6 +2114,35 @@ void ManagerService::HandleIncomingMessage(const std::string& vm_id, const ipc:: } // Guest Agent state events + if (msg.channel == ipc::Channel::kControl && + msg.kind == ipc::Kind::kResponse && + msg.type == "runtime.guest_exec.result") { + GuestExecCallback cb; + { + std::lock_guard lock(guest_exec_mutex_); + auto it = pending_guest_exec_.find(msg.request_id); + if (it != pending_guest_exec_.end()) { + cb = std::move(it->second.callback); + pending_guest_exec_.erase(it); + } + } + if (cb) { + GuestExecResult result; + auto get = [&](const char* key) -> std::string { + auto it = msg.fields.find(key); + return it == msg.fields.end() ? std::string{} : it->second; + }; + result.ok = get("ok") == "true"; + const auto exit_code = get("exit_code"); + if (!exit_code.empty()) result.exit_code = std::atoi(exit_code.c_str()); + result.stdout_text = DecodeBase64(get("out_b64")); + result.stderr_text = DecodeBase64(get("err_b64")); + result.error = get("error"); + cb(std::move(result)); + } + return; + } + if (msg.channel == ipc::Channel::kControl && msg.kind == ipc::Kind::kEvent && msg.type == "guest_agent.state") { diff --git a/src/manager/manager_service.h b/src/manager/manager_service.h index ccd285c..af706c3 100644 --- a/src/manager/manager_service.h +++ b/src/manager/manager_service.h @@ -26,6 +26,7 @@ #include #include #include +#include #include struct VmRuntimeHandle { @@ -196,6 +197,26 @@ class ManagerService { void SetGuestAgentStateCallback(GuestAgentStateCallback cb); bool IsGuestAgentConnected(const std::string& vm_id) const; + struct GuestExecResult { + bool ok = false; + int exit_code = -1; + std::string stdout_text; + std::string stderr_text; + std::string error; + + std::string CombinedOutput() const { + if (!stdout_text.empty() && !stderr_text.empty()) + return stdout_text + "\n" + stderr_text; + return stdout_text + stderr_text; + } + }; + using GuestExecCallback = std::function; + bool RunGuestAgentCommand(const std::string& vm_id, + const std::string& command, + uint32_t timeout_ms, + GuestExecCallback callback, + const std::string& user = "tenbox"); + // Host-forward error callback: when host ports fail to bind // failed_mappings format: "host_port:guest_port" for each failed binding using HostForwardErrorCallback = std::function GetSharedFolders(const std::string& vm_id) const; + bool AddRuntimeSharedFolder(const std::string& vm_id, const SharedFolder& folder, std::string* error); + bool RemoveRuntimeSharedFolder(const std::string& vm_id, const std::string& tag, std::string* error); // Host-forward management (host listens, traffic forwarded to guest). bool AddHostForward(const std::string& vm_id, const HostForward& forward, std::string* error); @@ -263,6 +286,8 @@ class ManagerService { void HandleProcessExit(const std::string& vm_id); void CleanupRuntimeHandles(VmRecord& vm); void HandleIncomingMessage(const std::string& vm_id, const ipc::Message& msg); + void SendSharedFoldersUpdateLocked(const std::string& vm_id, VmRecord& vm); + void FailPendingGuestExecForVm(const std::string& vm_id, const std::string& error); void InitJobObject(); @@ -289,6 +314,13 @@ class ManagerService { // Guest forwards injected into every VM (e.g. LLM proxy guestfwd) std::vector global_guest_forwards_; + struct PendingGuestExec { + std::string vm_id; + GuestExecCallback callback; + }; + std::mutex guest_exec_mutex_; + std::unordered_map pending_guest_exec_; + std::atomic next_guest_exec_request_id_{1}; void AppendGuestFwdFields(ipc::Message& msg, const std::vector& vm_guest_forwards = {}) const; diff --git a/src/manager/ui/agent_tools_dialog.cpp b/src/manager/ui/agent_tools_dialog.cpp new file mode 100644 index 0000000..9d440db --- /dev/null +++ b/src/manager/ui/agent_tools_dialog.cpp @@ -0,0 +1,362 @@ +#include "manager/ui/agent_tools_dialog.h" + +#include "manager/agent_tools_service.h" +#include "manager/app_settings.h" +#include "manager/i18n.h" +#include "manager/ui/dlg_builder.h" + +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include + +namespace { + +using agent_tools::AgentKind; +using agent_tools::MigrationOptions; +using agent_tools::SkillConflictStrategy; + +enum Id { + IDC_AGENT_KIND = 2101, + IDC_HEALTH, + IDC_BACKUP, + IDC_RESTORE, + IDC_EXPORT, + IDC_IMPORT, + IDC_RESTART, + IDC_RESET, + IDC_DIAG, + IDC_SOURCE_VM, + IDC_STRATEGY, + IDC_WORKSPACE, + IDC_MIGRATE, + IDC_SCHEDULE_ENABLED, + IDC_SCHEDULE_TIME, + IDC_SCHEDULE_KEEP, + IDC_SCHEDULE_SAVE, + IDC_OPEN_BACKUPS, + IDC_OUTPUT +}; + +constexpr UINT WM_AGENT_RESULT = WM_APP + 71; +constexpr UINT WM_AGENT_PROGRESS = WM_APP + 72; + +struct VmChoice { + std::string id; + std::string name; +}; + +struct PostedResult { + agent_tools::ToolResult result; +}; + +struct PostedProgress { + std::string step; + std::string message; + std::string detail; +}; + +struct DialogData { + ManagerService& manager; + agent_tools::AgentToolsService tools; + std::string vm_id; + std::vector source_vms; + bool busy = false; + + DialogData(ManagerService& mgr, std::string id) + : manager(mgr), tools(mgr, mgr.data_dir()), vm_id(std::move(id)) {} +}; + +std::string ScheduleKey(const std::string& vm_id, AgentKind agent) { + return vm_id + "|" + agent_tools::AgentRawValue(agent); +} + +AgentKind SelectedAgent(HWND dlg) { + int idx = static_cast(SendDlgItemMessageW(dlg, IDC_AGENT_KIND, CB_GETCURSEL, 0, 0)); + return idx == 1 ? AgentKind::kOpenClaw : AgentKind::kHermes; +} + +void AppendOutput(HWND dlg, const std::string& text) { + HWND out = GetDlgItem(dlg, IDC_OUTPUT); + int len = GetWindowTextLengthW(out); + std::wstring w = i18n::to_wide(text + "\r\n"); + SendMessageW(out, EM_SETSEL, len, len); + SendMessageW(out, EM_REPLACESEL, FALSE, reinterpret_cast(w.c_str())); +} + +void SetBusy(HWND dlg, DialogData* data, bool busy) { + data->busy = busy; + for (int id : {IDC_HEALTH, IDC_BACKUP, IDC_RESTORE, IDC_EXPORT, IDC_IMPORT, + IDC_RESTART, IDC_RESET, IDC_DIAG, IDC_MIGRATE, IDC_SCHEDULE_SAVE}) { + EnableWindow(GetDlgItem(dlg, id), busy ? FALSE : TRUE); + } +} + +std::string SaveFileDialog(HWND dlg, const std::string& filename) { + wchar_t file_buf[MAX_PATH]{}; + MultiByteToWideChar(CP_UTF8, 0, filename.c_str(), -1, file_buf, MAX_PATH); + std::wstring filter = L"Agent Profile (*.tar.gz)\0*.tar.gz\0All Files\0*.*\0\0"; + OPENFILENAMEW ofn{}; + ofn.lStructSize = sizeof(ofn); + ofn.hwndOwner = dlg; + ofn.lpstrFilter = filter.c_str(); + ofn.lpstrFile = file_buf; + ofn.nMaxFile = MAX_PATH; + ofn.Flags = OFN_OVERWRITEPROMPT | OFN_PATHMUSTEXIST; + return GetSaveFileNameW(&ofn) ? i18n::wide_to_utf8(file_buf) : std::string{}; +} + +std::string OpenProfileDialog(HWND dlg) { + return BrowseForFile(dlg, "Agent Profile (*.tar.gz)\0*.tar.gz\0All Files\0*.*\0\0", ""); +} + +int ScheduleKeep(HWND dlg) { + wchar_t buf[16]{}; + GetDlgItemTextW(dlg, IDC_SCHEDULE_KEEP, buf, 16); + int v = _wtoi(buf); + return std::clamp(v, 1, 99); +} + +void LoadSchedule(HWND dlg, DialogData* data) { + auto agent = SelectedAgent(dlg); + auto key = ScheduleKey(data->vm_id, agent); + settings::AgentBackupSchedule schedule; + auto it = data->manager.app_settings().agent_backup_schedules.find(key); + if (it != data->manager.app_settings().agent_backup_schedules.end()) { + schedule = it->second; + } + CheckDlgButton(dlg, IDC_SCHEDULE_ENABLED, schedule.enabled ? BST_CHECKED : BST_UNCHECKED); + wchar_t time_buf[16]{}; + swprintf_s(time_buf, L"%02d:%02d", schedule.hour, schedule.minute); + SetDlgItemTextW(dlg, IDC_SCHEDULE_TIME, time_buf); + SetDlgItemTextW(dlg, IDC_SCHEDULE_KEEP, i18n::to_wide(std::to_string(schedule.keep_count)).c_str()); +} + +void SaveSchedule(HWND dlg, DialogData* data) { + wchar_t time_buf[32]{}; + GetDlgItemTextW(dlg, IDC_SCHEDULE_TIME, time_buf, 32); + int hour = 3, minute = 0; + swscanf_s(time_buf, L"%d:%d", &hour, &minute); + settings::AgentBackupSchedule schedule; + schedule.enabled = IsDlgButtonChecked(dlg, IDC_SCHEDULE_ENABLED) == BST_CHECKED; + schedule.hour = std::clamp(hour, 0, 23); + schedule.minute = std::clamp(minute, 0, 59); + schedule.keep_count = ScheduleKeep(dlg); + data->manager.app_settings().agent_backup_schedules[ScheduleKey(data->vm_id, SelectedAgent(dlg))] = schedule; + data->manager.SaveAppSettings(); + data->tools.RotateBackups(data->vm_id, SelectedAgent(dlg), schedule.keep_count); + AppendOutput(dlg, "定时备份设置已保存"); +} + +void RefreshSources(HWND dlg, DialogData* data) { + data->source_vms.clear(); + HWND combo = GetDlgItem(dlg, IDC_SOURCE_VM); + SendMessageW(combo, CB_RESETCONTENT, 0, 0); + for (const auto& rec : data->manager.ListVms()) { + if (rec.spec.vm_id == data->vm_id) continue; + if (rec.state != VmPowerState::kRunning || !rec.guest_agent_connected) continue; + data->source_vms.push_back({rec.spec.vm_id, rec.spec.name}); + SendMessageW(combo, CB_ADDSTRING, 0, reinterpret_cast(i18n::to_wide(rec.spec.name).c_str())); + } + if (!data->source_vms.empty()) SendMessageW(combo, CB_SETCURSEL, 0, 0); +} + +void StartOp(HWND dlg, DialogData* data, const std::string& label, + std::function run) { + if (data->busy) return; + SetBusy(dlg, data, true); + AppendOutput(dlg, "开始:" + label); + run([dlg](agent_tools::ToolResult result) { + PostMessageW(dlg, WM_AGENT_RESULT, 0, reinterpret_cast(new PostedResult{std::move(result)})); + }); +} + +void OpenBackups(HWND dlg, DialogData* data) { + std::filesystem::path dir = std::filesystem::path(data->manager.data_dir()) / + "AgentBackups" / data->vm_id / agent_tools::AgentRawValue(SelectedAgent(dlg)); + std::error_code ec; + std::filesystem::create_directories(dir, ec); + ShellExecuteW(dlg, L"open", i18n::to_wide(dir.string()).c_str(), nullptr, nullptr, SW_SHOWNORMAL); +} + +void InitDialog(HWND dlg, DialogData* data) { + CenterDialogToParent(dlg); + HWND agent = GetDlgItem(dlg, IDC_AGENT_KIND); + SendMessageW(agent, CB_ADDSTRING, 0, reinterpret_cast(L"Hermes")); + SendMessageW(agent, CB_ADDSTRING, 0, reinterpret_cast(L"OpenClaw")); + SendMessageW(agent, CB_SETCURSEL, 0, 0); + + HWND strategy = GetDlgItem(dlg, IDC_STRATEGY); + SendMessageW(strategy, CB_ADDSTRING, 0, reinterpret_cast(L"技能保留 Hermes")); + SendMessageW(strategy, CB_ADDSTRING, 0, reinterpret_cast(L"技能覆盖 Hermes")); + SendMessageW(strategy, CB_ADDSTRING, 0, reinterpret_cast(L"技能重命名导入")); + SendMessageW(strategy, CB_SETCURSEL, 0, 0); + SetDlgItemTextW(dlg, IDC_WORKSPACE, L"/home/tenbox/.hermes/workspace/openclaw-migrated"); + SendDlgItemMessageW(dlg, IDC_OUTPUT, EM_SETLIMITTEXT, 1024 * 1024, 0); + RefreshSources(dlg, data); + LoadSchedule(dlg, data); +} + +INT_PTR CALLBACK Proc(HWND dlg, UINT msg, WPARAM wp, LPARAM lp) { + auto* data = reinterpret_cast(GetWindowLongPtrW(dlg, DWLP_USER)); + switch (msg) { + case WM_INITDIALOG: + data = reinterpret_cast(lp); + SetWindowLongPtrW(dlg, DWLP_USER, reinterpret_cast(data)); + InitDialog(dlg, data); + return TRUE; + + case WM_AGENT_PROGRESS: { + std::unique_ptr p(reinterpret_cast(lp)); + AppendOutput(dlg, p->message + (p->detail.empty() ? "" : " - " + p->detail)); + return TRUE; + } + + case WM_AGENT_RESULT: { + std::unique_ptr r(reinterpret_cast(lp)); + SetBusy(dlg, data, false); + AppendOutput(dlg, std::string(r->result.ok ? "完成:" : "失败:") + r->result.message); + if (!r->result.output.empty()) AppendOutput(dlg, r->result.output); + RefreshSources(dlg, data); + return TRUE; + } + + case WM_COMMAND: { + const int id = LOWORD(wp); + if (id == IDCANCEL) { + if (data && data->busy) { + AppendOutput(dlg, "操作执行中,请等待完成后关闭。"); + return TRUE; + } + EndDialog(dlg, 0); + return TRUE; + } + if (id == IDC_AGENT_KIND && HIWORD(wp) == CBN_SELCHANGE) { + LoadSchedule(dlg, data); + return TRUE; + } + const AgentKind agent = SelectedAgent(dlg); + const int keep = ScheduleKeep(dlg); + switch (id) { + case IDC_HEALTH: + StartOp(dlg, data, "一键诊断", [=](auto cb) { data->tools.HealthStatus(data->vm_id, agent, cb); }); + return TRUE; + case IDC_BACKUP: + StartOp(dlg, data, "立即备份", [=](auto cb) { data->tools.SnapshotBackup(data->vm_id, agent, keep, cb); }); + return TRUE; + case IDC_RESTORE: { + auto backups = data->tools.ListBackups(data->vm_id, agent); + if (backups.empty()) { + AppendOutput(dlg, "没有找到可恢复的备份"); + return TRUE; + } + if (MessageBoxW(dlg, L"恢复会覆盖当前 Agent 数据,确认恢复最新备份?", L"确认恢复", MB_OKCANCEL | MB_ICONWARNING) == IDOK) { + StartOp(dlg, data, "恢复最新备份", [=](auto cb) { data->tools.RestoreBackup(data->vm_id, agent, backups.front().path, cb); }); + } + return TRUE; + } + case IDC_EXPORT: { + std::string path = SaveFileDialog(dlg, std::string(agent_tools::AgentRawValue(agent)) + "-profile.tar.gz"); + if (!path.empty()) StartOp(dlg, data, "导出迁移包", [=](auto cb) { data->tools.ExportProfile(data->vm_id, agent, path, cb); }); + return TRUE; + } + case IDC_IMPORT: { + std::string path = OpenProfileDialog(dlg); + if (!path.empty() && MessageBoxW(dlg, L"导入会替换当前 Agent 数据,确认继续?", L"确认导入", MB_OKCANCEL | MB_ICONWARNING) == IDOK) { + StartOp(dlg, data, "导入迁移包", [=](auto cb) { data->tools.ImportProfile(data->vm_id, agent, path, cb); }); + } + return TRUE; + } + case IDC_RESTART: + StartOp(dlg, data, "重启服务", [=](auto cb) { data->tools.RestartAgent(data->vm_id, agent, keep, cb); }); + return TRUE; + case IDC_RESET: + if (MessageBoxW(dlg, L"重置会覆盖当前 Agent 模型配置,确认继续?", L"确认重置", MB_OKCANCEL | MB_ICONWARNING) == IDOK) { + StartOp(dlg, data, "重置配置", [=](auto cb) { data->tools.ResetAgentConfig(data->vm_id, agent, keep, cb); }); + } + return TRUE; + case IDC_DIAG: + StartOp(dlg, data, "导出诊断包", [=](auto cb) { data->tools.ExportDiagnostics(data->vm_id, agent, cb); }); + return TRUE; + case IDC_MIGRATE: { + int sel = static_cast(SendDlgItemMessageW(dlg, IDC_SOURCE_VM, CB_GETCURSEL, 0, 0)); + if (sel < 0 || sel >= static_cast(data->source_vms.size())) { + AppendOutput(dlg, "请先选择运行中的 OpenClaw 来源 VM"); + return TRUE; + } + if (MessageBoxW(dlg, L"迁移会先备份目标 Hermes,再执行 dry-run 和正式迁移。确认继续?", L"确认迁移", MB_OKCANCEL | MB_ICONWARNING) != IDOK) + return TRUE; + wchar_t workspace[512]{}; + GetDlgItemTextW(dlg, IDC_WORKSPACE, workspace, 512); + int st = static_cast(SendDlgItemMessageW(dlg, IDC_STRATEGY, CB_GETCURSEL, 0, 0)); + MigrationOptions options; + options.workspace_target = i18n::wide_to_utf8(workspace); + options.skill_conflict = st == 1 ? SkillConflictStrategy::kOverwrite : + st == 2 ? SkillConflictStrategy::kRename : + SkillConflictStrategy::kSkip; + std::string source_id = data->source_vms[sel].id; + StartOp(dlg, data, "OpenClaw 到 Hermes 迁移", [=](auto cb) { + data->tools.MigrateOpenClawToHermes(source_id, data->vm_id, options, keep, + [dlg](const std::string& step, const std::string& message, const std::string& detail) { + PostMessageW(dlg, WM_AGENT_PROGRESS, 0, reinterpret_cast(new PostedProgress{step, message, detail})); + }, + cb); + }); + return TRUE; + } + case IDC_SCHEDULE_SAVE: + SaveSchedule(dlg, data); + return TRUE; + case IDC_OPEN_BACKUPS: + OpenBackups(dlg, data); + return TRUE; + } + break; + } + } + return FALSE; +} + +} // namespace + +void ShowAgentToolsDialog(HWND parent, ManagerService& mgr, const std::string& vm_id) { + DlgBuilder b; + b.Begin("Agent 急救箱", 0, 0, 610, 430, WS_CAPTION | WS_SYSMENU); + b.AddStatic(-1, "Agent:", 12, 12, 45, 12); + b.AddComboBox(IDC_AGENT_KIND, 60, 10, 110, 80); + b.AddButton(IDC_HEALTH, "一键诊断", 185, 9, 72, 16); + b.AddButton(IDC_BACKUP, "立即备份", 262, 9, 72, 16); + b.AddButton(IDC_RESTORE, "恢复最新", 339, 9, 72, 16); + b.AddButton(IDC_OPEN_BACKUPS, "打开备份", 416, 9, 72, 16); + + b.AddButton(IDC_EXPORT, "导出包", 12, 38, 66, 16); + b.AddButton(IDC_IMPORT, "导入包", 84, 38, 66, 16); + b.AddButton(IDC_RESTART, "重启服务", 156, 38, 72, 16); + b.AddButton(IDC_RESET, "重置配置", 234, 38, 72, 16); + b.AddButton(IDC_DIAG, "导出诊断", 312, 38, 72, 16); + + b.AddCheckBox(IDC_SCHEDULE_ENABLED, "定时备份", 400, 39, 70, 14); + b.AddEdit(IDC_SCHEDULE_TIME, 472, 38, 45, 15); + b.AddStatic(-1, "保留", 522, 40, 24, 12); + b.AddEdit(IDC_SCHEDULE_KEEP, 548, 38, 24, 15); + b.AddButton(IDC_SCHEDULE_SAVE, "保存", 576, 38, 28, 16); + + b.AddStatic(-1, "OpenClaw 迁移到当前 Hermes:", 12, 72, 150, 12); + b.AddComboBox(IDC_SOURCE_VM, 165, 69, 120, 100); + b.AddComboBox(IDC_STRATEGY, 292, 69, 120, 100); + b.AddEdit(IDC_WORKSPACE, 418, 69, 115, 15); + b.AddButton(IDC_MIGRATE, "自动迁移", 540, 68, 58, 17); + + b.AddEdit(IDC_OUTPUT, 12, 98, 586, 300, ES_MULTILINE | ES_AUTOVSCROLL | ES_READONLY | WS_VSCROLL); + b.AddButton(IDCANCEL, "关闭", 540, 405, 58, 17); + + DialogData data(mgr, vm_id); + DialogBoxIndirectParamW(GetModuleHandleW(nullptr), b.Build(), parent, Proc, reinterpret_cast(&data)); +} diff --git a/src/manager/ui/agent_tools_dialog.h b/src/manager/ui/agent_tools_dialog.h new file mode 100644 index 0000000..4d73bc3 --- /dev/null +++ b/src/manager/ui/agent_tools_dialog.h @@ -0,0 +1,11 @@ +#pragma once + +#include "manager/manager_service.h" + +#define NOMINMAX +#define WIN32_LEAN_AND_MEAN +#include + +#include + +void ShowAgentToolsDialog(HWND parent, ManagerService& mgr, const std::string& vm_id); diff --git a/src/manager/ui/win32_ui_shell.cpp b/src/manager/ui/win32_ui_shell.cpp index c0b25cb..4616437 100644 --- a/src/manager/ui/win32_ui_shell.cpp +++ b/src/manager/ui/win32_ui_shell.cpp @@ -3,12 +3,14 @@ #include "manager/ui/create_vm_dialog.h" #include "manager/ui/settings_dialog.h" #include "manager/ui/llm_proxy_dialog.h" +#include "manager/ui/agent_tools_dialog.h" #include "manager/ui/win32_display_panel.h" #include "manager/ui/info_tab.h" #include "manager/ui/console_tab.h" #include "manager/ui/vm_listview.h" #include "manager/i18n.h" #include "manager/app_settings.h" +#include "manager/agent_tools_service.h" #include "manager/resource.h" #include "version.h" @@ -32,7 +34,9 @@ #include #include #include +#include #include +#include #include #include #include @@ -61,6 +65,7 @@ enum CmdId : UINT { IDM_LLM_PROXY = 1027, IDM_HELP_DOC = 1028, IDM_TRAY_TOGGLE = 1029, + IDM_AGENT_TOOLS = 1030, }; // ── Control IDs ── @@ -159,6 +164,7 @@ struct Win32UiShell::Impl { UINT_PTR resize_timer_id = 0; static constexpr UINT kResizeTimerId = 9001; static constexpr UINT kResizeDebounceMs = 500; + static constexpr UINT kAgentBackupTimerId = 9002; HFONT ui_font = nullptr; HFONT mono_font = nullptr; @@ -202,6 +208,7 @@ struct Win32UiShell::Impl { std::unordered_map vm_ui_states; std::unordered_map> audio_players; + std::set scheduled_agent_backups_running; VmUiState& GetVmUiState(const std::string& vm_id) { return vm_ui_states[vm_id]; @@ -330,6 +337,7 @@ static HMENU BuildMenuBar(bool show_toolbar) { AppendMenuW(vm_menu, MF_SEPARATOR, 0, nullptr); AppendMenuW(vm_menu, MF_STRING, IDM_SHARED_FOLDERS, i18n::tr_w(S::kToolbarSharedFolders).c_str()); AppendMenuW(vm_menu, MF_STRING, IDM_PORT_FORWARDS, i18n::tr_w(S::kMenuPortForwards).c_str()); + AppendMenuW(vm_menu, MF_STRING, IDM_AGENT_TOOLS, L"Agent 急救箱..."); AppendMenuW(bar, MF_POPUP, reinterpret_cast(vm_menu), i18n::tr_w(S::kMenuVm).c_str()); HMENU view_menu = CreatePopupMenu(); @@ -764,6 +772,7 @@ static void UpdateCommandStates(Impl* p) { EnableCmd(IDM_DELETE, has_sel && !running); EnableCmd(IDM_SHARED_FOLDERS, has_sel); EnableCmd(IDM_PORT_FORWARDS, has_sel); + EnableCmd(IDM_AGENT_TOOLS, has_sel && running && ga_ok); SendMessage(p->toolbar, TB_ENABLEBUTTON, IDM_DPI_ZOOM, MAKELONG((has_sel && p->dpi != 96) ? TRUE : FALSE, 0)); @@ -825,6 +834,9 @@ static LRESULT CALLBACK MainWndProc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp) { } } } + if (p && wp == Impl::kAgentBackupTimerId) { + shell->RunDueAgentBackups(); + } return 0; case WM_COMMAND: { @@ -1000,6 +1012,15 @@ static LRESULT CALLBACK MainWndProc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp) { shell->RefreshVmList(); return 0; } + case IDM_AGENT_TOOLS: { + if (p->selected_index < 0 || + p->selected_index >= static_cast(p->records.size())) + break; + const std::string& vm_id = p->records[p->selected_index].spec.vm_id; + ShowAgentToolsDialog(hwnd, shell->manager_, vm_id); + shell->RefreshVmList(); + return 0; + } case IDM_VIEW_TOOLBAR: { auto& show = shell->manager_.app_settings().show_toolbar; show = !show; @@ -1444,6 +1465,69 @@ static LRESULT CALLBACK MainWndProc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp) { // ── Lifetime ── +static std::string AgentBackupDateKey(const SYSTEMTIME& st) { + char buf[16]{}; + snprintf(buf, sizeof(buf), "%04u-%02u-%02u", st.wYear, st.wMonth, st.wDay); + return buf; +} + +static std::string AgentBackupTimestamp(const SYSTEMTIME& st) { + char buf[32]{}; + snprintf(buf, sizeof(buf), "%04u-%02u-%02u %02u:%02u", + st.wYear, st.wMonth, st.wDay, st.wHour, st.wMinute); + return buf; +} + +void Win32UiShell::RunDueAgentBackups() { + SYSTEMTIME now{}; + GetLocalTime(&now); + const std::string today = AgentBackupDateKey(now); + const int now_minutes = static_cast(now.wHour) * 60 + static_cast(now.wMinute); + + std::vector> due; + for (const auto& [key, schedule] : manager_.app_settings().agent_backup_schedules) { + if (!schedule.enabled || schedule.last_run_date == today) continue; + if (now_minutes < schedule.hour * 60 + schedule.minute) continue; + if (impl_->scheduled_agent_backups_running.count(key)) continue; + due.push_back({key, schedule}); + } + + for (const auto& [key, schedule] : due) { + const auto sep = key.find('|'); + if (sep == std::string::npos) continue; + const std::string vm_id = key.substr(0, sep); + const std::string agent_name = key.substr(sep + 1); + const auto agent = agent_name == "openclaw" + ? agent_tools::AgentKind::kOpenClaw + : agent_tools::AgentKind::kHermes; + auto vm = manager_.GetVm(vm_id); + if (!vm || vm->state != VmPowerState::kRunning || !vm->guest_agent_connected) { + auto& s = manager_.app_settings().agent_backup_schedules[key]; + s.last_run_date = today; + s.last_attempt_at = AgentBackupTimestamp(now); + s.last_attempt_status = "failed"; + s.last_attempt_message = !vm ? "VM 不存在" : "VM 未运行或 Guest Agent 未连接"; + manager_.SaveAppSettings(); + continue; + } + + impl_->scheduled_agent_backups_running.insert(key); + auto tools = std::make_shared(manager_, manager_.data_dir()); + tools->SnapshotBackup(vm_id, agent, schedule.keep_count, + [this, key, today, now, tools](agent_tools::ToolResult result) { + InvokeOnUiThread([this, key, today, now, result = std::move(result)]() mutable { + impl_->scheduled_agent_backups_running.erase(key); + auto& s = manager_.app_settings().agent_backup_schedules[key]; + s.last_run_date = today; + s.last_attempt_at = AgentBackupTimestamp(now); + s.last_attempt_status = result.ok ? "success" : "failed"; + s.last_attempt_message = result.ok ? "成功" : result.message; + manager_.SaveAppSettings(); + }); + }); + } +} + Win32UiShell::Win32UiShell(ManagerService& manager) : manager_(manager), impl_(std::make_unique()) @@ -1780,6 +1864,8 @@ Win32UiShell::Win32UiShell(ManagerService& manager) RefreshVmList(); LayoutControls(impl_.get()); + SetTimer(impl_->hwnd, Impl::kAgentBackupTimerId, 60 * 1000, nullptr); + RunDueAgentBackups(); } Win32UiShell::~Win32UiShell() { @@ -1813,6 +1899,7 @@ Win32UiShell::~Win32UiShell() { impl_->tray_added = false; } if (impl_->hwnd) { + KillTimer(impl_->hwnd, Impl::kAgentBackupTimerId); RemoveClipboardFormatListener(impl_->hwnd); } if (impl_->ui_font) DeleteObject(impl_->ui_font); diff --git a/src/manager/ui/win32_ui_shell.h b/src/manager/ui/win32_ui_shell.h index a5e127f..1f10a07 100644 --- a/src/manager/ui/win32_ui_shell.h +++ b/src/manager/ui/win32_ui_shell.h @@ -25,6 +25,7 @@ class Win32UiShell { private: void UpdateSleepPrevention(); + void RunDueAgentBackups(); std::unique_ptr impl_; bool sleep_prevented_ = false; From 20f29fd52f9524825dec2fe897fb4a322a9ec959 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BC=A0=E5=AE=89=E5=93=B2?= Date: Tue, 12 May 2026 11:55:37 +0800 Subject: [PATCH 36/37] Trim Agent notes in CLAUDE guide --- CLAUDE.md | 38 ++++++-------------------------------- 1 file changed, 6 insertions(+), 32 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 93bde0c..5614d72 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -88,38 +88,12 @@ Win/macOS: tenbox-manager ──IPC v1──► tenbox-vm-runtime (WHVP / HVF) - **LLM proxy** exists in two places: `src/daemon/llm_proxy.cpp` (Linux) and `src/manager/llm_proxy.cpp` (Windows); change both when the protocol changes. - **RemoteSession** is single-instance per VM. Read `remote_webrtc.cpp`'s `force` takeover path before adding DataChannels. - **macOS Caps Lock forwarding**: send Caps Lock as a tap (`down` then `up`) on each `flagsChanged` event; AppKit exposes it as a toggle state, but the guest input stack needs a full key press for every switch. -- **Agent data profile packages**: `TenBox.app` exports/imports Hermes/OpenClaw data without image changes by using a temporary shared folder and qemu-guest-agent `guest-exec` shell commands. Keep the gzip package format documented in `docs/agent-profile.md`, include `export_scope`, and reject cross-agent imports. -- **Agent migration exports**: user-triggered migration packages should carry the user's full Agent state, including secrets, credentials, identity/device state, sessions, browser profiles, and config files. Exclude only volatile logs, caches, runtime lock files, and reinstallable binaries by default. -- **Agent OpenClaw to Hermes migration**: the macOS Agent急救箱 can migrate a running OpenClaw VM into a running Hermes VM by first creating a host-managed Hermes backup, sharing one runtime-only folder, exporting `~/.openclaw` with full user state, then running official `hermes claw migrate` in the Hermes VM after a separate dry run. Keep the UI non-blocking with step progress, expose the skill conflict strategy, pass `--migrate-secrets`, support `--workspace-target`, and save the dry-run/final migration report beside the host-managed Hermes backups. Do not reimplement the broad migration mapping in TenBox; keep only targeted compatibility patches documented below. -- **OpenClaw migration conflict handling**: always pass Hermes CLI's global `--overwrite` for OpenClaw-to-Hermes migration; target-level conflicts such as `soul` or `model-config` otherwise make Hermes print `Refusing to apply` without importing anything. The UI conflict strategy maps only to `--skill-conflict` for imported skills. Treat refusal text as a migration failure even if the CLI exits successfully. -- **OpenClaw migration model config**: after Hermes CLI applies OpenClaw `model-config`, restore TenBox's local model proxy settings (`model.default=default`, `model.provider=custom`, `model.base_url=http://10.0.2.3/v1`, `OPENAI_API_KEY=tenbox`) and the auxiliary compression/vision/session_search model settings. The imported providers can remain in `custom_providers`, but the running TenBox image must keep using the host model proxy. -- **OpenClaw migration channel config**: after official Hermes migration succeeds, TenBox may copy compatible Feishu/WeCom settings from `.openclaw/openclaw.json` into Hermes `.env` (`FEISHU_*`, `WECOM_*`) and enable `platforms.feishu` / `platforms.wecom` best-effort. Do not copy plugin install state, pairing/device runtime state, or channel adapter internals; report those limits to the user. -- **Hermes profile scope**: export user/config/state data, not the reinstallable Hermes app checkout, virtualenv, local binaries, logs, or cache directories. -- **OpenClaw profile scope**: export user/config/state data, not volatile caches/logs or generated backup archives such as `.openclaw/backup` and `.openclaw/openclaw-backup*.tar.gz`; including those archives makes Agent急救箱 backup/import/migration packages unnecessarily huge and can exhaust guest disk during restore. -- **Agent data backups**: `TenBox.app` writes host-managed backups to `~/Library/Application Support/TenBox/AgentBackups//` and retains packages according to the configured per-Agent retention count (default 7). Profile export runs against live Agent data, so GNU tar exit 1 from changed/skipped files is treated as non-fatal; fatal tar errors must still fail the backup. -- **Agent scheduled backups**: `TenBox.app` supports per-VM/per-Agent scheduled backups stored in `settings.json`; default time is 03:00 and default retention is 7 packages. Backup filenames must be time-based (`agent-data-yyyy-MM-dd-HHmmss.tar.gz`), restore should let the user choose a package, scheduled backups only run when the VM is running and the guest execution channel is connected, and the UI should surface the last automatic backup attempt result. -- **Agent health checks**: `TenBox.app` runs health, restart, config reset, and diagnostics through qemu-guest-agent `guest-exec`. Repair actions must create a host-managed Agent data backup first, and config reset should patch only the needed Agent settings instead of replacing full user config files. -- **macOS OpenClaw restart checks**: after restarting `openclaw-gateway.service`, wait briefly for port `18789` before running the health JSON check. The user service can become `active` before the gateway listener is ready. -- **macOS Agent data UI**: `TenBox.app` exposes Agent急救箱 from the front of the VM toolbar and from the VM menu while a VM is running. Keep user-facing Agent tool copy in Chinese, lead with one-click diagnosis, show repair suggestions for failed checks, auto-expand advanced operations after diagnosis failure, keep destructive/low-frequency actions under advanced operations, and confirm destructive import/restore/reset actions. It must not depend on preinstalled guest TenBox scripts. -- **macOS Agent data shares**: Agent tool temporary shared folders are runtime-only; do not persist operation or backup share tags into VM config. -- **macOS Agent share cleanup**: drop persisted `tenbox-agent-ops-*` and `tenbox-agent-backups-*` entries on config load/startup to clean old builds. -- **macOS Agent data panels**: show export/import file panels asynchronously from SwiftUI sheets; do not use blocking `runModal()` from button handlers. -- **macOS Agent backup UI**: `TenBox.app` exposes backup status, immediate backup, and restore latest backup actions. Host-triggered backups use `~/Library/Application Support/TenBox/AgentBackups/` as the durable shared folder. Backup lists should default to the latest three packages, allow showing all packages, and provide Finder reveal per package. -- **macOS Agent health UI**: `TenBox.app` exposes health check, restart, config reset, and diagnostics actions while a VM is running. Repair actions run through app-generated shell commands so no image rebuild is required. -- **macOS console commands**: keep marker-based console command execution as a fallback path; Agent tools should use qemu-guest-agent `guest-exec` and wait for temporary shared folders before reading or writing packages. -- **macOS Agent tool commands**: prefer qemu-guest-agent `guest-exec` for command execution, pass multiline scripts as `command_hex`, and run them as guest user `tenbox`; keep shared folders as the data plane for profile packages, backups, and diagnostics. -- **macOS Agent tool responsiveness**: do not perform guest-exec or runtime shared-folder IPC sends on the SwiftUI main thread. Queue those sends off-main and keep only request bookkeeping / UI state updates on the main thread. -- **macOS Agent tool text rendering**: do not render full command output, migration reports, or long host paths inside SwiftUI sheets. Show compact labels in progress/result panels and keep full logs in files or copy-only details. -- **macOS Agent tool archive extraction**: shared-folder mounts may not support preserving file metadata such as mtimes/owners. Extract Agent import and migration archives in a guest-local temp directory such as `$HOME/.tenbox-tmp` rather than inside the shared folder, and use tar options that avoid restoring unsupported metadata. -- **macOS Agent service resolution**: parse `systemctl --user list-units` with `--plain`, ignore failed-unit markers, and restart the resolved user service after Agent import/restore/reset operations. -- **macOS Hermes repairs**: support Hermes images where `hermes` is not on `PATH` by resolving the command from the service/venv when needed, and fall back to patching `~/.hermes/config.yaml` / `.env` for config reset. -- **macOS OpenClaw repairs**: support OpenClaw images where `openclaw` is not on guest-exec `PATH` by resolving `~/.npm-global/bin/openclaw` before running config reset commands. -- **macOS Agent data import**: never replace the whole Agent home directory with an exported profile package. Export packages intentionally exclude reinstallable binaries such as Hermes `hermes-agent`, so import/restore must merge package contents into the existing directory and preserve excluded install assets. -- **macOS Agent data import space usage**: for import/restore, keep the outer profile work files and compressed rollback backup beside the shared-folder input package, then stream `files.tar.gz` into the Agent home. Avoid fully extracting the source tree or rollback snapshot onto the guest disk; large OpenClaw profiles can exhaust the VM. -- **Windows Agent toolbox**: Windows manager now mirrors the Agent急救箱 flow through `ManagerService::RunGuestAgentCommand`, runtime-only shared folders, `agent_tools::AgentToolsService`, and a Win32 dialog. Keep Windows shell commands aligned with macOS AgentToolsService, persist schedules in `settings.json` under `agent_backups.schedules`, and avoid writing temporary Agent operation shares into VM manifests. -- **macOS Agent pre-import backups**: create import/restore rollback snapshots with tar and tolerate live-file churn (`--ignore-failed-read` / tar status 1), because running Agents may rotate SQLite WAL/SHM files while the backup is being captured. Do not write a full intermediate rollback archive into guest `/tmp`; small images can run out of space. -- **macOS console markers**: do not put the full begin/end marker literal in console input; build it inside the shell so echoed input cannot satisfy marker detection. -- **macOS console input**: throttle manager-to-runtime console input before injecting into the guest UART; bulk shell scripts can overflow the emulated FIFO if delivered as one burst. +- **Agent toolbox**: macOS and Windows desktop managers expose Agent急救箱 without image/rootfs changes. Prefer qemu-guest-agent `guest-exec` plus runtime-only shared folders; keep any console-marker path as fallback only, throttle bulk console input, and never persist temporary Agent share tags into VM manifests. +- **Agent profile and backups**: keep the gzip package format in `docs/agent-profile.md`, include `export_scope`, reject cross-Agent imports, merge imports into the existing Agent home, and exclude reinstallable binaries plus volatile logs/caches/runtime locks. Host backups live under the platform TenBox data dir in `AgentBackups//`, use time-based filenames, tolerate live-file tar churn, and rotate by the configured retention count. +- **Agent scheduled backups**: store per-VM/per-Agent schedules in `settings.json` under `agent_backups.schedules`; only run them when the VM is running and guest execution is connected, and surface the last automatic backup attempt in the UI. +- **Agent health and repair**: health, restart, reset, and diagnostics run through guest-exec as user `tenbox`. Destructive or repair actions must create a host-managed backup first, patch only the necessary config, confirm with the user, and avoid full guest `/tmp` extraction that can exhaust small images. +- **OpenClaw to Hermes migration**: use official `hermes claw migrate` with a separate dry run; pass `--migrate-secrets`, `--workspace-target`, Hermes global `--overwrite`, and map UI conflict choices only to `--skill-conflict`. Treat `Refusing to apply` as failure, save dry-run/final reports beside Hermes backups, restore TenBox model proxy settings after migration, and only copy compatible Feishu/WeCom env settings best-effort. +- **Agent UI responsiveness**: keep Agent tool UI copy in Chinese, put destructive/low-frequency actions behind confirmation, run guest-exec and shared-folder IPC off the UI thread, and show compact progress/results while writing full logs/reports to files. - **macOS app signing**: the app entitlement includes `com.apple.security.cs.disable-library-validation` so the hardened-runtime app can load the bundled Sparkle framework. - **Static build** (`TENBOX_STATIC_FFMPEG=ON`) requires `/opt/tenbox-deps` (only present inside the CI/packaging container). Dev builds use system shared libs — keep `ON` off by default. - **Release**: `docs/release.md` — VERSION bump → commit → push → tag → push tag. Always push commit before tag. From fd52132182bf4f830a9533bc3ad4716fc919e520 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BC=A0=E5=AE=89=E5=93=B2?= Date: Tue, 12 May 2026 13:32:59 +0800 Subject: [PATCH 37/37] Fix Agent toolbox review findings --- CLAUDE.md | 2 +- src/core/guest_agent/guest_agent_handler.cpp | 26 +++++++++-- .../Services/AgentToolsService.swift | 14 ++++-- src/manager-macos/TenBoxApp.swift | 6 +-- src/manager/agent_tools_service.cpp | 12 ++++- src/manager/manager_service.cpp | 44 ++++++++++--------- src/manager/manager_service.h | 1 + src/manager/ui/agent_tools_dialog.cpp | 4 +- src/manager/ui/win32_ui_shell.cpp | 3 +- src/manager/ui/win32_ui_shell.h | 2 +- 10 files changed, 77 insertions(+), 37 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 5614d72..c2a1272 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -91,7 +91,7 @@ Win/macOS: tenbox-manager ──IPC v1──► tenbox-vm-runtime (WHVP / HVF) - **Agent toolbox**: macOS and Windows desktop managers expose Agent急救箱 without image/rootfs changes. Prefer qemu-guest-agent `guest-exec` plus runtime-only shared folders; keep any console-marker path as fallback only, throttle bulk console input, and never persist temporary Agent share tags into VM manifests. - **Agent profile and backups**: keep the gzip package format in `docs/agent-profile.md`, include `export_scope`, reject cross-Agent imports, merge imports into the existing Agent home, and exclude reinstallable binaries plus volatile logs/caches/runtime locks. Host backups live under the platform TenBox data dir in `AgentBackups//`, use time-based filenames, tolerate live-file tar churn, and rotate by the configured retention count. - **Agent scheduled backups**: store per-VM/per-Agent schedules in `settings.json` under `agent_backups.schedules`; only run them when the VM is running and guest execution is connected, and surface the last automatic backup attempt in the UI. -- **Agent health and repair**: health, restart, reset, and diagnostics run through guest-exec as user `tenbox`. Destructive or repair actions must create a host-managed backup first, patch only the necessary config, confirm with the user, and avoid full guest `/tmp` extraction that can exhaust small images. +- **Agent health and repair**: health, restart, reset, and diagnostics run through guest-exec as user `tenbox`; fail instead of falling back to root if user switching fails, and enforce guest-side timeouts for long commands. Destructive or repair actions must create a host-managed backup first, patch only the necessary config, confirm with the user, and avoid full guest `/tmp` extraction that can exhaust small images. - **OpenClaw to Hermes migration**: use official `hermes claw migrate` with a separate dry run; pass `--migrate-secrets`, `--workspace-target`, Hermes global `--overwrite`, and map UI conflict choices only to `--skill-conflict`. Treat `Refusing to apply` as failure, save dry-run/final reports beside Hermes backups, restore TenBox model proxy settings after migration, and only copy compatible Feishu/WeCom env settings best-effort. - **Agent UI responsiveness**: keep Agent tool UI copy in Chinese, put destructive/low-frequency actions behind confirmation, run guest-exec and shared-folder IPC off the UI thread, and show compact progress/results while writing full logs/reports to files. - **macOS app signing**: the app entitlement includes `com.apple.security.cs.disable-library-validation` so the hardened-runtime app can load the bundled Sparkle framework. diff --git a/src/core/guest_agent/guest_agent_handler.cpp b/src/core/guest_agent/guest_agent_handler.cpp index 3f5e66b..406a926 100644 --- a/src/core/guest_agent/guest_agent_handler.cpp +++ b/src/core/guest_agent/guest_agent_handler.cpp @@ -1,6 +1,7 @@ #include "core/guest_agent/guest_agent_handler.h" #include "core/device/virtio/virtio_serial.h" #include "core/vmm/types.h" +#include #include #include #include @@ -438,12 +439,31 @@ void GuestAgentHandler::RunShellCommandWorker(const std::string& command, if (!user.empty()) { const std::string quoted_user = ShellQuote(user); const std::string quoted_command = ShellQuote(command); + const std::string missing_user = ShellQuote("guest user not found: " + user); + const std::string switch_error = ShellQuote("cannot switch guest user: " + user); exec_command = - "if command -v runuser >/dev/null 2>&1 && id " + quoted_user + " >/dev/null 2>&1; then " - "exec runuser -l " + quoted_user + " -c " + quoted_command + "; " - "else exec /bin/sh -lc " + quoted_command + "; fi"; + "if ! id " + quoted_user + " >/dev/null 2>&1; then printf '%s\\n' " + missing_user + " >&2; exit 126; fi; " + "if command -v runuser >/dev/null 2>&1; then exec runuser -l " + quoted_user + " -c " + quoted_command + "; fi; " + "if command -v su >/dev/null 2>&1; then exec su -s /bin/sh " + quoted_user + " -c " + quoted_command + "; fi; " + "printf '%s\\n' " + switch_error + " >&2; exit 126"; } + const auto timeout_seconds = std::max( + 1, std::chrono::duration_cast(timeout).count()); + const std::string quoted_exec_command = ShellQuote(exec_command); + exec_command = + "if command -v timeout >/dev/null 2>&1; then " + "exec timeout -k 5s " + std::to_string(timeout_seconds) + "s /bin/sh -lc " + quoted_exec_command + "; " + "fi; " + "/bin/sh -lc " + quoted_exec_command + " & __tenbox_child=$!; " + "( sleep " + std::to_string(timeout_seconds) + "; " + "kill -TERM \"$__tenbox_child\" >/dev/null 2>&1 || true; " + "sleep 5; kill -KILL \"$__tenbox_child\" >/dev/null 2>&1 || true ) & __tenbox_watchdog=$!; " + "wait \"$__tenbox_child\"; __tenbox_rc=$?; " + "kill \"$__tenbox_watchdog\" >/dev/null 2>&1 || true; " + "wait \"$__tenbox_watchdog\" 2>/dev/null || true; " + "exit \"$__tenbox_rc\""; + const std::string args = R"({"path":"/bin/sh","arg":["-lc",")" + JsonEscape(exec_command) + R"("],"capture-output":true})"; diff --git a/src/manager-macos/Services/AgentToolsService.swift b/src/manager-macos/Services/AgentToolsService.swift index 6924d64..2a074bf 100644 --- a/src/manager-macos/Services/AgentToolsService.swift +++ b/src/manager-macos/Services/AgentToolsService.swift @@ -806,8 +806,16 @@ final class AgentToolsService { pkg_agent="$(awk -F\\" '/agent_type/ {print $4; exit}' "$work/manifest.json")" fi [ "$pkg_agent" = "\(agent.rawValue)" ] || { echo "导入包属于 $pkg_agent,不是 \(agent.rawValue)" >&2; exit 1; } - if ! tar -tzf "$work/files.tar.gz" "$rel" >/dev/null 2>&1; then - echo "导入包缺少 $rel 目录" >&2 + tar -tzf "$work/files.tar.gz" > "$work/files.list" + if ! awk -v rel="$rel" ' + BEGIN { prefix = rel "/"; found = 0; bad = 0 } + { name = $0; if (name == rel || name == prefix) { found = 1; next } + if (index(name, prefix) == 1) { found = 1 } else { bad = 1 } + if (name ~ /^\\// || name ~ /(^|\\/)\\.\\.(\\/|$)/) { bad = 1 } + if (bad) exit 1 } + END { if (!found) exit 2; exit 0 } + ' "$work/files.list"; then + echo "导入包包含非法路径或缺少 $rel 目录" >&2 exit 1 fi backup="" @@ -822,7 +830,7 @@ final class AgentToolsService { fi fi mkdir -p "$target" - tar -tzf "$work/files.tar.gz" | awk -v rel="$rel/" 'index($0, rel) == 1 { rest=substr($0, length(rel)+1); split(rest, a, "/"); if (a[1] != "") print a[1] }' | sort -u | while IFS= read -r item; do + awk -v rel="$rel/" 'index($0, rel) == 1 { rest=substr($0, length(rel)+1); split(rest, a, "/"); if (a[1] != "") print a[1] }' "$work/files.list" | sort -u | while IFS= read -r item; do [ -n "$item" ] || continue rm -rf "$target/$item" done diff --git a/src/manager-macos/TenBoxApp.swift b/src/manager-macos/TenBoxApp.swift index feab93f..5e3f1c7 100644 --- a/src/manager-macos/TenBoxApp.swift +++ b/src/manager-macos/TenBoxApp.swift @@ -759,18 +759,18 @@ class AppState: ObservableObject { } guard let vm = vms.first(where: { $0.id == parts[0] }) else { continue } guard vm.state == .running else { - updateAgentBackupAttempt(key: key, base: schedule, status: "failed", message: "VM 未运行", at: now, lastRunDate: today) + updateAgentBackupAttempt(key: key, base: schedule, status: "failed", message: "VM 未运行", at: now) continue } let session = getOrCreateSession(for: vm.id) if !session.connected || !session.ipcClient.isConnected { session.connectIfNeeded() - updateAgentBackupAttempt(key: key, base: schedule, status: "failed", message: "执行通道未连接", at: now, lastRunDate: today) + updateAgentBackupAttempt(key: key, base: schedule, status: "failed", message: "执行通道未连接", at: now) continue } guard session.guestAgentConnected else { - updateAgentBackupAttempt(key: key, base: schedule, status: "failed", message: "执行通道未连接", at: now, lastRunDate: today) + updateAgentBackupAttempt(key: key, base: schedule, status: "failed", message: "执行通道未连接", at: now) continue } diff --git a/src/manager/agent_tools_service.cpp b/src/manager/agent_tools_service.cpp index b9c327f..34b9994 100644 --- a/src/manager/agent_tools_service.cpp +++ b/src/manager/agent_tools_service.cpp @@ -383,7 +383,15 @@ std::string AgentToolsService::ProfileImportCommand(AgentKind agent, const std:: << "if [ -z \"$pkg_agent\" ]; then pkg_agent=\"$(awk -F\\\" '/agent_type/ {print $4; exit}' \"$work/manifest.json\")\"; fi\n" << "[ \"$pkg_agent\" = \"" << AgentRawValue(agent) << "\" ] || { echo \"导入包属于 $pkg_agent,不是 " << AgentRawValue(agent) << "\" >&2; exit 1; }\n" - << "if ! tar -tzf \"$work/files.tar.gz\" \"$rel\" >/dev/null 2>&1; then echo \"导入包缺少 $rel 目录\" >&2; exit 1; fi\n" + << "tar -tzf \"$work/files.tar.gz\" > \"$work/files.list\"\n" + << "if ! awk -v rel=\"$rel\" '\n" + << "BEGIN { prefix = rel \"/\"; found = 0; bad = 0 }\n" + << "{ name = $0; if (name == rel || name == prefix) { found = 1; next }\n" + << " if (index(name, prefix) == 1) { found = 1 } else { bad = 1 }\n" + << " if (name ~ /^\\// || name ~ /(^|\\/)\\.\\.(\\/|$)/) { bad = 1 }\n" + << " if (bad) exit 1 }\n" + << "END { if (!found) exit 2; exit 0 }\n" + << "' \"$work/files.list\"; then echo \"导入包包含非法路径或缺少 $rel 目录\" >&2; exit 1; fi\n" << "backup=\"\"\n" << "if [ -e \"$target\" ]; then\n" << " backup=\"$input_dir/pre-import-" << AgentRawValue(agent) << "-$(date -u +%Y%m%d%H%M%S).tar.gz\"\n" @@ -392,7 +400,7 @@ std::string AgentToolsService::ProfileImportCommand(AgentKind agent, const std:: << " if [ \"$backup_status\" -gt 1 ]; then rm -f \"$backup\"; echo \"创建导入前备份失败\" >&2; exit \"$backup_status\"; fi\n" << "fi\n" << "mkdir -p \"$target\"\n" - << "tar -tzf \"$work/files.tar.gz\" | awk -v rel=\"$rel/\" 'index($0, rel) == 1 { rest=substr($0, length(rel)+1); split(rest, a, \"/\"); if (a[1] != \"\") print a[1] }' | sort -u | while IFS= read -r item; do [ -n \"$item\" ] || continue; rm -rf \"$target/$item\"; done\n" + << "awk -v rel=\"$rel/\" 'index($0, rel) == 1 { rest=substr($0, length(rel)+1); split(rest, a, \"/\"); if (a[1] != \"\") print a[1] }' \"$work/files.list\" | sort -u | while IFS= read -r item; do [ -n \"$item\" ] || continue; rm -rf \"$target/$item\"; done\n" << "if ! tar --touch --no-same-owner -xzf \"$work/files.tar.gz\" -C \"$home\"; then\n" << " rm -rf \"$target\"\n" << " if [ -n \"$backup\" ] && [ -f \"$backup\" ]; then tar --touch --no-same-owner -xzf \"$backup\" -C \"$home\"; fi\n" diff --git a/src/manager/manager_service.cpp b/src/manager/manager_service.cpp index b5385dc..f4a0757 100644 --- a/src/manager/manager_service.cpp +++ b/src/manager/manager_service.cpp @@ -435,19 +435,7 @@ bool ManagerService::EditVm(const std::string& vm_id, const VmMutablePatch& patc } if (running && patch.shared_folders) { - ipc::Message msg; - msg.channel = ipc::Channel::kControl; - msg.kind = ipc::Kind::kRequest; - msg.type = "runtime.update_shared_folders"; - msg.vm_id = vm_id; - msg.request_id = GetTickCount64(); - msg.fields["folder_count"] = std::to_string(vm.spec.shared_folders.size()); - for (size_t i = 0; i < vm.spec.shared_folders.size(); ++i) { - const auto& f = vm.spec.shared_folders[i]; - msg.fields["folder_" + std::to_string(i)] = - f.tag + "|" + f.host_path + "|" + (f.readonly ? "1" : "0"); - } - SendRuntimeMessage(vm, msg); + SendSharedFoldersUpdateLocked(vm_id, vm); } return true; @@ -884,15 +872,18 @@ bool ManagerService::SendRuntimeMessage(VmRecord& vm, const ipc::Message& msg) { void ManagerService::SendSharedFoldersUpdateLocked(const std::string& vm_id, VmRecord& vm) { if (vm.state != VmPowerState::kRunning) return; + std::vector folders = vm.spec.shared_folders; + folders.insert(folders.end(), vm.runtime_shared_folders.begin(), vm.runtime_shared_folders.end()); + ipc::Message msg; msg.channel = ipc::Channel::kControl; msg.kind = ipc::Kind::kRequest; msg.type = "runtime.update_shared_folders"; msg.vm_id = vm_id; msg.request_id = GetTickCount64(); - msg.fields["folder_count"] = std::to_string(vm.spec.shared_folders.size()); - for (size_t i = 0; i < vm.spec.shared_folders.size(); ++i) { - const auto& f = vm.spec.shared_folders[i]; + msg.fields["folder_count"] = std::to_string(folders.size()); + for (size_t i = 0; i < folders.size(); ++i) { + const auto& f = folders[i]; msg.fields["folder_" + std::to_string(i)] = f.tag + "|" + f.host_path + "|" + (f.readonly ? "1" : "0"); } @@ -924,6 +915,7 @@ void ManagerService::CleanupRuntimeHandles(VmRecord& vm) { vm.runtime.process_handle = nullptr; } vm.runtime.process_id = 0; + vm.runtime_shared_folders.clear(); vm.runtime.recv_pending.clear(); vm.runtime.recv_payload_needed = 0; vm.runtime.recv_pending_msg = {}; @@ -1252,6 +1244,12 @@ bool ManagerService::AddSharedFolder(const std::string& vm_id, const SharedFolde return false; } } + for (const auto& sf : vm.runtime_shared_folders) { + if (sf.tag == folder.tag) { + if (error) *error = "shared folder with tag '" + folder.tag + "' already exists"; + return false; + } + } // Check host path exists DWORD attrs = GetFileAttributesA(folder.host_path.c_str()); @@ -1325,12 +1323,18 @@ bool ManagerService::AddRuntimeSharedFolder(const std::string& vm_id, const Shar return false; } } + for (const auto& sf : vm.runtime_shared_folders) { + if (sf.tag == folder.tag) { + if (error) *error = "shared folder with tag '" + folder.tag + "' already exists"; + return false; + } + } DWORD attrs = GetFileAttributesA(folder.host_path.c_str()); if (attrs == INVALID_FILE_ATTRIBUTES || !(attrs & FILE_ATTRIBUTE_DIRECTORY)) { if (error) *error = "host path does not exist or is not a directory"; return false; } - vm.spec.shared_folders.push_back(folder); + vm.runtime_shared_folders.push_back(folder); SendSharedFoldersUpdateLocked(vm_id, vm); return true; } @@ -1344,13 +1348,13 @@ bool ManagerService::RemoveRuntimeSharedFolder(const std::string& vm_id, const s return false; } VmRecord& vm = *vmp; - auto it = std::find_if(vm.spec.shared_folders.begin(), vm.spec.shared_folders.end(), + auto it = std::find_if(vm.runtime_shared_folders.begin(), vm.runtime_shared_folders.end(), [&tag](const SharedFolder& sf) { return sf.tag == tag; }); - if (it == vm.spec.shared_folders.end()) { + if (it == vm.runtime_shared_folders.end()) { if (error) *error = "shared folder with tag '" + tag + "' not found"; return false; } - vm.spec.shared_folders.erase(it); + vm.runtime_shared_folders.erase(it); SendSharedFoldersUpdateLocked(vm_id, vm); return true; } diff --git a/src/manager/manager_service.h b/src/manager/manager_service.h index af706c3..4a458d7 100644 --- a/src/manager/manager_service.h +++ b/src/manager/manager_service.h @@ -89,6 +89,7 @@ struct VmRecord { VmPowerState state = VmPowerState::kStopped; std::optional pending_patch; VmRuntimeHandle runtime; + std::vector runtime_shared_folders; int last_exit_code = 0; bool reboot_pending = false; bool guest_agent_connected = false; diff --git a/src/manager/ui/agent_tools_dialog.cpp b/src/manager/ui/agent_tools_dialog.cpp index 9d440db..6fddb69 100644 --- a/src/manager/ui/agent_tools_dialog.cpp +++ b/src/manager/ui/agent_tools_dialog.cpp @@ -102,11 +102,11 @@ void SetBusy(HWND dlg, DialogData* data, bool busy) { std::string SaveFileDialog(HWND dlg, const std::string& filename) { wchar_t file_buf[MAX_PATH]{}; MultiByteToWideChar(CP_UTF8, 0, filename.c_str(), -1, file_buf, MAX_PATH); - std::wstring filter = L"Agent Profile (*.tar.gz)\0*.tar.gz\0All Files\0*.*\0\0"; + static constexpr wchar_t filter[] = L"Agent Profile (*.tar.gz)\0*.tar.gz\0All Files\0*.*\0\0"; OPENFILENAMEW ofn{}; ofn.lStructSize = sizeof(ofn); ofn.hwndOwner = dlg; - ofn.lpstrFilter = filter.c_str(); + ofn.lpstrFilter = filter; ofn.lpstrFile = file_buf; ofn.nMaxFile = MAX_PATH; ofn.Flags = OFN_OVERWRITEPROMPT | OFN_PATHMUSTEXIST; diff --git a/src/manager/ui/win32_ui_shell.cpp b/src/manager/ui/win32_ui_shell.cpp index 4616437..7feecba 100644 --- a/src/manager/ui/win32_ui_shell.cpp +++ b/src/manager/ui/win32_ui_shell.cpp @@ -1503,7 +1503,6 @@ void Win32UiShell::RunDueAgentBackups() { auto vm = manager_.GetVm(vm_id); if (!vm || vm->state != VmPowerState::kRunning || !vm->guest_agent_connected) { auto& s = manager_.app_settings().agent_backup_schedules[key]; - s.last_run_date = today; s.last_attempt_at = AgentBackupTimestamp(now); s.last_attempt_status = "failed"; s.last_attempt_message = !vm ? "VM 不存在" : "VM 未运行或 Guest Agent 未连接"; @@ -1518,10 +1517,10 @@ void Win32UiShell::RunDueAgentBackups() { InvokeOnUiThread([this, key, today, now, result = std::move(result)]() mutable { impl_->scheduled_agent_backups_running.erase(key); auto& s = manager_.app_settings().agent_backup_schedules[key]; - s.last_run_date = today; s.last_attempt_at = AgentBackupTimestamp(now); s.last_attempt_status = result.ok ? "success" : "failed"; s.last_attempt_message = result.ok ? "成功" : result.message; + s.last_run_date = today; manager_.SaveAppSettings(); }); }); diff --git a/src/manager/ui/win32_ui_shell.h b/src/manager/ui/win32_ui_shell.h index 1f10a07..a37dc6b 100644 --- a/src/manager/ui/win32_ui_shell.h +++ b/src/manager/ui/win32_ui_shell.h @@ -16,6 +16,7 @@ class Win32UiShell { void Run(); void Quit(); void RefreshVmList(); + void RunDueAgentBackups(); static void InvokeOnUiThread(std::function fn); static void SetClipboardFromVm(bool value); @@ -25,7 +26,6 @@ class Win32UiShell { private: void UpdateSleepPrevention(); - void RunDueAgentBackups(); std::unique_ptr impl_; bool sleep_prevented_ = false;