diff --git a/CLAUDE.md b/CLAUDE.md index 5feceee..b0195e2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -88,6 +88,16 @@ Win/macOS: tenbox-manager ──IPC v1──► tenbox-vm-runtime (WHVP / HVF) - **LLM proxy** exists in two places: `src/daemon/llm_proxy.cpp` (Linux) and `src/manager/llm_proxy.cpp` (Windows); change both when the protocol changes. - **RemoteSession** is single-instance per VM. Read `remote_webrtc.cpp`'s `force` takeover path before adding DataChannels. - **macOS Caps Lock forwarding**: send Caps Lock as a tap (`down` then `up`) on each `flagsChanged` event; AppKit exposes it as a toggle state, but the guest input stack needs a full key press for every switch. +- **Agent data profile packages**: `TenBox.app` exports/imports Hermes/OpenClaw data without image changes by using a temporary shared folder and console-injected standard shell commands. Keep the gzip package format documented in `docs/agent-profile.md` and reject cross-agent imports. +- **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. - **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..e2803db --- /dev/null +++ b/docs/agent-profile.md @@ -0,0 +1,52 @@ +# Agent Data Backups + +TenBox.app exports, imports, backs up, and restores Hermes/OpenClaw Agent data +without requiring images to preinstall TenBox-specific scripts. + +The macOS manager creates a temporary shared folder, then sends a short shell +command through the existing VM console channel. The command uses standard guest +tools such as `tar` and `gzip`. + +## Profile package + +The exported package is a gzip tar archive: + +```text +--profile.tar.gz +├── manifest.json +└── files.tar.gz +``` + +`manifest.json` contains: + +- `format`: `tenbox-agent-profile` +- `format_version`: `2` +- `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`. + +## Backups + +Manual backups are created by TenBox.app in: + +```text +~/Library/Application Support/TenBox/AgentBackups/// +``` + +Backups use the same profile package format and keep the newest five packages. +Restore uses the newest package for the selected VM and Agent. 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/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/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 diff --git a/src/manager-macos/Services/AgentToolsService.swift b/src/manager-macos/Services/AgentToolsService.swift new file mode 100644 index 0000000..2b65afd --- /dev/null +++ b/src/manager-macos/Services/AgentToolsService.swift @@ -0,0 +1,682 @@ +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 = destinationURL.lastPathComponent.isEmpty + ? "\(vm.name)-\(agent.rawValue)-profile.tar.gz" + : destinationURL.lastPathComponent + let guestPackage = "/mnt/shared/\(share.tag)/\(packageName)" + let command = Self.withSharedFolderReady( + tag: share.tag, + body: Self.profileExportCommand(agent: agent, outputPath: guestPackage) + ) + + session.runShellCommand(command, timeout: 420) { 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.gz" + 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 guestPackage = "/mnt/shared/\(share.tag)/\(packageName)" + let command = Self.withSharedFolderReady( + tag: share.tag, + body: 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 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)) + } + } + + 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 = 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 { + 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.withSharedFolderReady( + tag: share.tag, + body: 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.withSharedFolderReady( + tag: share.tag, + body: 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 = 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 { + 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) { + 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.addRuntimeSharedFolder(share, toVm: vmId) + + let cleanup: () -> Void = { [weak appState, weak self] in + DispatchQueue.main.async { + appState?.removeRuntimeSharedFolder(tag: tag, fromVm: vmId) + try? self?.fileManager.removeItem(at: dir) + } + } + perform(share, cleanup) + } catch { + failure(error) + } + } + + 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.addRuntimeSharedFolder(share, toVm: vmId) + let cleanup: () -> Void = { [weak appState] in + DispatchQueue.main.async { + appState?.removeRuntimeSharedFolder(tag: tag, fromVm: vmId) + } + } + 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 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 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 """ + 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", + "--exclude", ".hermes/hermes-agent", + "--exclude", ".hermes/bin", + "--exclude", ".hermes/gateway.pid", + "--exclude", ".hermes/gateway.lock", + ].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: "'\\''") + "'" + } + + 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..05feb75 100644 --- a/src/manager-macos/TenBoxApp.swift +++ b/src/manager-macos/TenBoxApp.swift @@ -143,18 +143,21 @@ 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 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? @@ -162,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 { @@ -429,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() @@ -453,6 +471,61 @@ 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) + } + + 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 { @@ -558,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) @@ -676,5 +750,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..8e9ae13 --- /dev/null +++ b/src/manager-macos/Views/AgentToolsView.swift @@ -0,0 +1,186 @@ +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) + } + + 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) + } + + 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: 520, height: 390) + } + + 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.gz" + panel.allowedContentTypes = [] + 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) + } + } + } + + 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 + 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) + } + } + + 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 = "" + isRunningOperation = true + operation { result in + DispatchQueue.main.async { + isRunningOperation = false + switch result { + case .success(let output): + resultText = output.output.isEmpty ? output.message : "\(output.message)\n\(output.output)" + 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..02f4de9 100644 --- a/src/manager-macos/Views/VmDetailView.swift +++ b/src/manager-macos/Views/VmDetailView.swift @@ -26,6 +26,15 @@ 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 beginTimeoutWorkItem: DispatchWorkItem + let timeoutWorkItem: DispatchWorkItem + } init(vmId: String, clipboardHandler: ClipboardHandler) { self.vmId = vmId @@ -213,12 +222,88 @@ 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 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"))) + } + } + + self.pendingConsoleCommands[token] = PendingConsoleCommand( + 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) + } + } + 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 {