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/15] 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/15] 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 edaf7ca0859560401392381422936dfb52d64c51 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 03/15] 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 b3d3842..765a06b 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. - **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..852c517 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 d17c97801cb04f7f1a0acd39b729f937e2413711 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 04/15] 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 c43684b6d4c30a8e54a3d1f8be47179e1a7111cf 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 05/15] 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 168f06f624884f6f1c43396a8d00cfa9943d38b5 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 06/15] 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 3d7a7c024944ece67f9cbe5a495447e75c063d3a 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 07/15] 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 1b25616cadafad3945d5d92895c97df57718dc7a 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 08/15] 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 b6fa34c67c34d4105a5186eeb3883adaf71f1660 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 09/15] 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 765a06b..fc3845c 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 the first writable `/mnt/shared/*` folder, retaining the latest 5 packages by default. +- **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 f4c845056adac4bcd82c6f31b94a14b0c45b079c 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 10/15] 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 fc3845c..30a821c 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. - **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 ac7ab281c78cb8d76b7f2d6538be9a2b1f7293ce 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 11/15] 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 62cd845e5b53f084f9fc091c9e1fc7f37a2c5e65 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 12/15] refactor: move agent backup tools into app --- CLAUDE.md | 8 +- docs/agent-profile.md | 95 +--- scripts/arm64/make-rootfs-hermes.sh | 11 +- scripts/arm64/make-rootfs-openclaw.sh | 11 +- scripts/rootfs-scripts/tenbox-agent-backup | 225 --------- 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 | 11 +- scripts/x86_64/make-rootfs-openclaw.sh | 11 +- .../Services/AgentToolsService.swift | 474 ++++++++++++++++-- src/manager-macos/Views/AgentToolsView.swift | 2 +- 12 files changed, 471 insertions(+), 634 deletions(-) delete mode 100755 scripts/rootfs-scripts/tenbox-agent-backup 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 30a821c..088b588 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -88,10 +88,10 @@ 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. -- **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. +- **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. +- **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 the durable host-managed backup directory. - **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 852c517..9612df1 100644 --- a/docs/agent-profile.md +++ b/docs/agent-profile.md @@ -1,91 +1,40 @@ -# Agent Data Profile Packages +# Agent Data Backups -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 exports, imports, backs up, and restores Hermes/OpenClaw Agent data +without requiring 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` and `gzip`. -`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: - -- 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. +- `archive`: `files.tar.gz` -## Guest CLI +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`. -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. - -## 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 -``` +## Backups -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. +Backups use the same profile package format and keep the newest five packages. +Restore uses the newest package for the selected VM and Agent. diff --git a/scripts/arm64/make-rootfs-hermes.sh b/scripts/arm64/make-rootfs-hermes.sh index 2402e01..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,14 +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-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 7c39320..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,14 +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-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-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 467424f..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,14 +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-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 c280266..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,14 +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-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 b1039b9..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,41 +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 数据", + 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: Self.healthStatusCommand(agent: agent), + successMessage: "健康状态已更新", completion: completion) } - private func runBackupCommand(vm: VmInfo, session: VmSession, appState: AppState, agent: AgentKind, - command: String, successMessage: String, - completion: @escaping (Result) -> Void) { + func restartAgent(vm: VmInfo, session: VmSession, appState: AppState, agent: AgentKind, + completion: @escaping (Result) -> Void) { + 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: Self.testModelCommand(agent: agent), + successMessage: "模型连接已测试", + completion: completion) + } + + func resetAgentConfig(vm: VmInfo, session: VmSession, appState: AppState, agent: AgentKind, + completion: @escaping (Result) -> Void) { + 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) { 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): @@ -167,6 +263,61 @@ 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 + 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)) + } + } + } failure: { error in + completion(.failure(error)) + } + } catch { + completion(.failure(error)) + } + } + private func withOperationShare(vmId: String, appState: AppState, perform: (SharedFolder, @escaping () -> Void) -> Void, failure: (Error) -> Void) { @@ -190,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) { @@ -221,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, @@ -233,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 2560e86..e9c6a13 100644 --- a/src/manager-macos/Views/AgentToolsView.swift +++ b/src/manager-macos/Views/AgentToolsView.swift @@ -113,7 +113,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 e1e5dbb6f2168b8ffefbbe294bf0bdacf005ac37 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 13/15] 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 088b588..7274fb5 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**: `TenBox.app` writes host-managed backups to `~/Library/Application Support/TenBox/AgentBackups//` and retains the latest 5 packages. - **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 the durable host-managed backup directory. +- **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 cc506125cdf173a4a64069053837cbe61516cdca 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 14/15] fix: keep agent backups responsive --- CLAUDE.md | 2 + docs/agent-profile.md | 12 ++++ .../Services/AgentToolsService.swift | 59 ++++++++++++++++--- src/manager-macos/Views/VmDetailView.swift | 14 +++++ 4 files changed, 78 insertions(+), 9 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 7274fb5..899f675 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -89,9 +89,11 @@ 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. - **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 the durable host-managed backup directory. +- **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 9612df1..e2803db 100644 --- a/docs/agent-profile.md +++ b/docs/agent-profile.md @@ -24,6 +24,18 @@ The exported package is a gzip tar archive: - `agent_type`: `hermes` or `openclaw` - `archive`: `files.tar.gz` +`files.tar.gz` contains the Agent data directory relative to the guest home: + +- Hermes: `.hermes` +- OpenClaw: `.openclaw` + +Excluded paths: + +- 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. Before replacing existing data, it renames the current directory to `*.pre-import-YYYYMMDDHHMMSS`. 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 cb3fa5ddcd3366ca966676641146229997627a1b 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 15/15] fix: prevent agent tool 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 899f675..b0195e2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -92,6 +92,9 @@ 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. - **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 the durable host-managed backup directory. - **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. 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 428eb9e..05feb75 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() @@ -615,7 +631,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 e9c6a13..8e9ae13 100644 --- a/src/manager-macos/Views/AgentToolsView.swift +++ b/src/manager-macos/Views/AgentToolsView.swift @@ -115,9 +115,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) + } } } @@ -128,9 +130,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) } }