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/11] 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 6af03363e9061ae061048a5fe64bbefffc947523 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 02/11] 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 e339d0c1fb0b82fb5c898a90621b020cf5ed62f0 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 03/11] 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 159b1cd22bee4930952319bd413422295f33741e 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 04/11] 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 70793d3e13ed733791070a10a5cf86c5c08069f0 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 05/11] 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 3968710..29bfddc 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. +- **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 dd05cdc9482b47940027545d35122033b9c35635 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 06/11] 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 94e762c473dffc716f0e2ad1f0cdf12c6ba07986 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 07/11] refactor: move agent profile tools into app --- CLAUDE.md | 4 +- docs/agent-profile.md | 71 +-- scripts/arm64/make-rootfs-hermes.sh | 4 +- scripts/arm64/make-rootfs-openclaw.sh | 4 +- scripts/rootfs-scripts/tenbox-agent-profile | 241 --------- scripts/x86_64/make-rootfs-hermes.sh | 4 +- scripts/x86_64/make-rootfs-openclaw.sh | 5 +- .../Services/AgentToolsService.swift | 499 +++++++++++++++++- src/manager-macos/Views/AgentToolsView.swift | 2 +- 9 files changed, 518 insertions(+), 316 deletions(-) delete mode 100755 scripts/rootfs-scripts/tenbox-agent-profile diff --git a/CLAUDE.md b/CLAUDE.md index 29bfddc..a23adc8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -88,8 +88,8 @@ 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. -- **macOS Agent data UI**: `TenBox.app` exposes Agent data export/import from the VM toolbar/menu while a VM is running. It uses a temporary shared folder plus the console channel to call `tenbox-agent-profile` inside the guest. +- **Agent data profile packages**: `TenBox.app` exports/imports Hermes/OpenClaw data without image changes by using a temporary shared folder and console-injected standard shell commands. Keep the gzip package format documented in `docs/agent-profile.md` and reject cross-agent imports. +- **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. - **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..9777e26 100644 --- a/docs/agent-profile.md +++ b/docs/agent-profile.md @@ -1,64 +1,39 @@ -# Agent Data Profile Packages +# Agent Data Export and Import -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 and imports 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: +## Package format + +The exported package is a gzip tar archive: ```text -tenbox-agent-profile.tar.zst +--profile.tar.gz ├── manifest.json -├── files/ -└── checksums.txt +└── files.tar.gz ``` -`manifest.json` records: +`manifest.json` contains: - `format`: `tenbox-agent-profile` -- `format_version`: currently `1` +- `format_version`: `2` - `agent_type`: `hermes` or `openclaw` -- `tenbox_version` -- `created_at` -- `home` -- `source_path` -- `paths` -- `excluded` -- `checksums` - -`checksums.txt` stores SHA-256 hashes for files under `files/`. It never -contains secret values directly. - -## Supported Agents - -Hermes profile: +- `archive`: `files.tar.gz` -- Includes `/home/tenbox/.hermes` -- Excludes `.hermes/logs`, `.hermes/image_cache`, `.hermes/audio_cache` -- Sensitive files such as API keys remain inside the package payload; logs - and manifest output must not print their values. +`files.tar.gz` contains the Agent data directory relative to the guest home: -OpenClaw profile: +- Hermes: `.hermes` +- OpenClaw: `.openclaw` -- Includes `/home/tenbox/.openclaw` -- Excludes `.openclaw/cache`, `.openclaw/.cache`, `.openclaw/workspace/.cache` -- Sensitive files remain inside the package payload; manifest and logs must - not print token values. +Excluded paths: -QwenPaw is intentionally not included in this version because `.qwenpaw.secret` -needs a separate sensitivity policy. - -## 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 -``` +- Hermes: `.hermes/logs`, `.hermes/image_cache`, `.hermes/audio_cache` +- OpenClaw: `.openclaw/cache`, `.openclaw/.cache`, `.openclaw/workspace/.cache` -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. +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/scripts/arm64/make-rootfs-hermes.sh b/scripts/arm64/make-rootfs-hermes.sh index c49a934..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)" @@ -1027,8 +1027,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..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)" @@ -768,8 +768,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-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/x86_64/make-rootfs-hermes.sh b/scripts/x86_64/make-rootfs-hermes.sh index 5aa0805..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)" @@ -1031,8 +1031,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..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)" @@ -875,9 +875,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 diff --git a/src/manager-macos/Services/AgentToolsService.swift b/src/manager-macos/Services/AgentToolsService.swift index 8f46af0..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): @@ -119,6 +118,206 @@ final class AgentToolsService { } } + func backupStatus(vm: VmInfo, session: VmSession, appState: AppState, agent: AgentKind, + completion: @escaping (Result) -> Void) { + 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) { + 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) { + 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) + } + + 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 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 diagnostics failed" : commandResult.output))) + return + } + completion(.success(AgentToolResult( + message: "已导出诊断包", + output: commandResult.output + ))) + case .failure(let error): + completion(.failure(error)) + } + } + } failure: { error in + completion(.failure(error)) + } + } + + 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) { @@ -142,6 +341,25 @@ final class AgentToolsService { } } + 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 operationBaseDirectory() throws -> URL { let appSupport = try fileManager.url( for: .applicationSupportDirectory, @@ -154,6 +372,265 @@ final class AgentToolsService { return dir } + 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 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 3699570..cdd253f 100644 --- a/src/manager-macos/Views/AgentToolsView.swift +++ b/src/manager-macos/Views/AgentToolsView.swift @@ -85,7 +85,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 e759ab196ea9f597f568f721fd046d008ab223f5 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 08/11] 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 a23adc8..acb0631 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**: `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. - **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 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 5d829e57ab7fadc333299f230ba962013f0cf9b9 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:20:45 +0800 Subject: [PATCH 09/11] fix: keep agent export responsive --- CLAUDE.md | 2 ++ docs/agent-profile.md | 4 ++- .../Services/AgentToolsService.swift | 33 +++++++++++++++++-- src/manager-macos/Views/VmDetailView.swift | 14 ++++++++ 4 files changed, 50 insertions(+), 3 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index acb0631..9156e26 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -89,7 +89,9 @@ 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. - **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 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 9777e26..51e4712 100644 --- a/docs/agent-profile.md +++ b/docs/agent-profile.md @@ -31,7 +31,9 @@ The exported package is a gzip tar archive: Excluded paths: -- Hermes: `.hermes/logs`, `.hermes/image_cache`, `.hermes/audio_cache` +- Hermes: `.hermes/logs`, `.hermes/image_cache`, `.hermes/audio_cache`, + `.hermes/hermes-agent`, `.hermes/bin`, `.hermes/gateway.pid`, + `.hermes/gateway.lock` - OpenClaw: `.openclaw/cache`, `.openclaw/.cache`, `.openclaw/workspace/.cache` Import rejects packages whose `agent_type` does not match the selected Agent. diff --git a/src/manager-macos/Services/AgentToolsService.swift b/src/manager-macos/Services/AgentToolsService.swift index 2d9b090..b672a44 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 { @@ -467,6 +473,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 +639,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 9daab28fef54d64b4e167d8664819675182606cd 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 10/11] fix: harden agent backup shared folders --- .../Services/AgentToolsService.swift | 26 ++++++++++++++----- 1 file changed, 19 insertions(+), 7 deletions(-) diff --git a/src/manager-macos/Services/AgentToolsService.swift b/src/manager-macos/Services/AgentToolsService.swift index b672a44..8697186 100644 --- a/src/manager-macos/Services/AgentToolsService.swift +++ b/src/manager-macos/Services/AgentToolsService.swift @@ -150,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 { @@ -186,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 { @@ -247,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 { @@ -296,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 { From b53f12e62c4f7a0fb5aa5ac1dcf1a7ff3878b34f 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 11/11] fix: prevent export 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 9156e26..d19c102 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -91,6 +91,9 @@ Win/macOS: tenbox-manager ──IPC v1──► tenbox-vm-runtime (WHVP / HVF) - **Agent data profile packages**: `TenBox.app` exports/imports Hermes/OpenClaw data without image changes by using a temporary shared folder and 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. - **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 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. 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 3d4ade4..0716bbb 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() @@ -582,7 +598,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 cdd253f..677116a 100644 --- a/src/manager-macos/Views/AgentToolsView.swift +++ b/src/manager-macos/Views/AgentToolsView.swift @@ -87,9 +87,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) + } } } @@ -100,9 +102,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) } }