From d33e1df4d2c4fa9380ff7115585de150878f171a Mon Sep 17 00:00:00 2001 From: Haitao Pan Date: Mon, 8 Jun 2026 16:14:08 +0800 Subject: [PATCH] Relax workspace OS checks and add YAML import/export --- .../workspace_management/playbook_runner.dart | 45 +++- .../workspace_management/server_detector.dart | 42 ++-- .../workspace_management_form.dart | 62 ++++++ .../workspace_management_panel.dart | 76 +++++++ .../workspace_provision_controller.dart | 192 ++++++++++++++++-- .../workspace_provision_models.dart | 11 + .../workspace_management_unit_test.dart | 119 ++++++++++- 7 files changed, 506 insertions(+), 41 deletions(-) diff --git a/lib/features/workspace_management/playbook_runner.dart b/lib/features/workspace_management/playbook_runner.dart index 31154558..05287097 100644 --- a/lib/features/workspace_management/playbook_runner.dart +++ b/lib/features/workspace_management/playbook_runner.dart @@ -18,10 +18,15 @@ class PlaybookRunner { required SshConfig ssh, required String action, required String workspaceDomain, + required String bridgeDomain, required String bridgeToken, required String installPath, required bool installMissingPrerequisites, required ServerInfo? serverInfo, + String? deepseekApiKey, + String? nvidiaApiKey, + String? ollamaApiKey, + String? openclawGatewayToken, required void Function(String stepId, StepStatus status, String? message) onStepUpdate, required void Function(String logLine) onLog, @@ -38,7 +43,11 @@ class PlaybookRunner { var info = serverInfo; if (info == null) { onStepUpdate('ssh_connect', StepStatus.running, null); - info = await ServerDetector(executor).detect(ssh, workspaceDomain); + info = await ServerDetector(executor).detect( + ssh, + workspaceDomain, + bridgeDomain, + ); onStepUpdate('ssh_connect', StepStatus.success, null); onStepUpdate('detect_env', StepStatus.success, info.displaySummary); } @@ -68,7 +77,12 @@ class PlaybookRunner { inventoryPath: inventoryPath, varsPath: varsPath, workspaceDomain: workspaceDomain, + bridgeDomain: bridgeDomain, bridgeToken: bridgeToken, + deepseekApiKey: deepseekApiKey, + nvidiaApiKey: nvidiaApiKey, + ollamaApiKey: ollamaApiKey, + openclawGatewayToken: openclawGatewayToken, ), onLog, ); @@ -167,10 +181,27 @@ class PlaybookRunner { required String inventoryPath, required String varsPath, required String workspaceDomain, + required String bridgeDomain, required String bridgeToken, + String? deepseekApiKey, + String? nvidiaApiKey, + String? ollamaApiKey, + String? openclawGatewayToken, }) { final domain = workspaceDomain.trim(); - final publicUrl = 'https://$domain'; + final bridge = bridgeDomain.trim(); + final bridgeUrl = 'https://$bridge'; + final extraEnvVars = [ + if ((deepseekApiKey ?? '').trim().isNotEmpty) + 'deepseek_api_key: ${shellQuote(deepseekApiKey!.trim())}', + if ((nvidiaApiKey ?? '').trim().isNotEmpty) + 'nvidia_api_key: ${shellQuote(nvidiaApiKey!.trim())}', + if ((ollamaApiKey ?? '').trim().isNotEmpty) + 'ollama_api_key: ${shellQuote(ollamaApiKey!.trim())}', + if ((openclawGatewayToken ?? '').trim().isNotEmpty) + 'openclaw_gateway_token: ${shellQuote(openclawGatewayToken!.trim())}', + ]; + final extraEnvBlock = extraEnvVars.isEmpty ? '' : '${extraEnvVars.join('\n')}\n'; return ''' cat > ${shellQuote(inventoryPath)} <<'EOF' [all] @@ -178,12 +209,12 @@ localhost ansible_connection=local EOF cat > ${shellQuote(varsPath)} <<'EOF' workspace_domain: $domain -xworkmate_bridge_domain: $domain -xworkmate_bridge_public_base_url: $publicUrl -xworkmate_bridge_service_domain: $domain -xworkmate_bridge_service_public_base_url: $publicUrl +xworkmate_bridge_domain: $bridge +xworkmate_bridge_public_base_url: $bridgeUrl +xworkmate_bridge_service_domain: $bridge +xworkmate_bridge_service_public_base_url: $bridgeUrl xworkmate_bridge_auth_token: ${bridgeToken.trim()} -EOF +${extraEnvBlock}EOF '''; } diff --git a/lib/features/workspace_management/server_detector.dart b/lib/features/workspace_management/server_detector.dart index 3d3ac450..3adc360d 100644 --- a/lib/features/workspace_management/server_detector.dart +++ b/lib/features/workspace_management/server_detector.dart @@ -6,10 +6,14 @@ class ServerDetector { final WorkspaceSshExecutor executor; - Future detect(SshConfig ssh, String workspaceDomain) async { + Future detect( + SshConfig ssh, + String workspaceDomain, + String bridgeDomain, + ) async { final result = await executor.execute( ssh, - detectionCommand(workspaceDomain), + detectionCommand(workspaceDomain, bridgeDomain), ); if (!result.success) { throw ServerDetectionException(result.combinedOutput.trim()); @@ -17,8 +21,9 @@ class ServerDetector { return parseServerInfo(result.stdout); } - static String detectionCommand(String workspaceDomain) { + static String detectionCommand(String workspaceDomain, String bridgeDomain) { final domain = shellQuote(workspaceDomain.trim()); + final bridge = shellQuote(bridgeDomain.trim()); return ''' if command -v lsb_release >/dev/null 2>&1; then echo "OS=\$(lsb_release -ds)" @@ -35,14 +40,17 @@ echo "ANSIBLE=\$(ansible --version 2>/dev/null | head -1 || echo missing)" echo "GIT=\$(git --version 2>/dev/null || echo missing)" echo "DNS_OK=\$(getent hosts $domain 2>/dev/null | wc -l | tr -d ' ')" echo "PORT_443_LISTENERS=\$(ss -ltn '( sport = :443 )' 2>/dev/null | tail -n +2 | wc -l | tr -d ' ')" +echo "BRIDGE_DNS_OK=\$(getent hosts $bridge 2>/dev/null | wc -l | tr -d ' ')" +echo "BRIDGE_PORT_443_LISTENERS=\$(ss -ltn '( sport = :443 )' 2>/dev/null | tail -n +2 | wc -l | tr -d ' ')" +PORT_443_OPEN=yes if command -v ufw >/dev/null 2>&1; then UFW_STATUS="\$(ufw status 2>/dev/null || sudo -n ufw status 2>/dev/null || echo unavailable)" if printf '%s' "\$UFW_STATUS" | grep -qi 'Status: inactive'; then - echo "PORT_443_OPEN=yes" + PORT_443_OPEN=yes elif printf '%s' "\$UFW_STATUS" | grep -Eqi '(^|[[:space:]])(443(/tcp)?|https)[[:space:]]+ALLOW'; then - echo "PORT_443_OPEN=yes" + PORT_443_OPEN=yes else - echo "PORT_443_OPEN=no" + PORT_443_OPEN=no fi elif command -v firewall-cmd >/dev/null 2>&1; then FIREWALL_STATE="\$(firewall-cmd --state 2>/dev/null || sudo -n firewall-cmd --state 2>/dev/null || echo not-running)" @@ -51,16 +59,16 @@ if command -v ufw >/dev/null 2>&1; then sudo -n firewall-cmd --quiet --query-service=https 2>/dev/null || firewall-cmd --quiet --query-port=443/tcp 2>/dev/null || sudo -n firewall-cmd --quiet --query-port=443/tcp 2>/dev/null; then - echo "PORT_443_OPEN=yes" + PORT_443_OPEN=yes else - echo "PORT_443_OPEN=no" + PORT_443_OPEN=no fi else - echo "PORT_443_OPEN=yes" + PORT_443_OPEN=yes fi -else - echo "PORT_443_OPEN=yes" fi +echo "PORT_443_OPEN=\$PORT_443_OPEN" +echo "BRIDGE_PORT_443_OPEN=\$PORT_443_OPEN" '''; } @@ -83,9 +91,17 @@ fi ansibleVersion: values['ANSIBLE'] ?? 'missing', gitVersion: values['GIT'] ?? 'missing', dnsAddressCount: int.tryParse(values['DNS_OK'] ?? '') ?? 0, - port443ListenerCount: + port443ListenerCount: int.tryParse(values['PORT_443_LISTENERS'] ?? '') ?? 0, - port443Open: (values['PORT_443_OPEN'] ?? '').toLowerCase() != 'no', + port443Open: (values['PORT_443_OPEN'] ?? '').toLowerCase() != 'no', + bridgeDnsAddressCount: + int.tryParse(values['BRIDGE_DNS_OK'] ?? '') ?? 0, + bridgePort443ListenerCount: + int.tryParse(values['BRIDGE_PORT_443_LISTENERS'] ?? '') ?? 0, + bridgePort443Open: + (values['BRIDGE_PORT_443_OPEN'] ?? values['PORT_443_OPEN'] ?? '') + .toLowerCase() != + 'no', ); } } diff --git a/lib/features/workspace_management/workspace_management_form.dart b/lib/features/workspace_management/workspace_management_form.dart index 794e30dc..44cceb91 100644 --- a/lib/features/workspace_management/workspace_management_form.dart +++ b/lib/features/workspace_management/workspace_management_form.dart @@ -31,6 +31,10 @@ class _WorkspaceManagementFormState extends State { late final TextEditingController _portController; late final TextEditingController _sudoController; late final TextEditingController _installPathController; + late final TextEditingController _deepseekKeyController; + late final TextEditingController _nvidiaKeyController; + late final TextEditingController _ollamaKeyController; + late final TextEditingController _openclawTokenController; @override void initState() { @@ -45,6 +49,11 @@ class _WorkspaceManagementFormState extends State { _portController = TextEditingController(text: c.sshPort.toString()); _sudoController = TextEditingController(text: c.sudoPassword ?? ''); _installPathController = TextEditingController(text: c.installPath); + _deepseekKeyController = TextEditingController(text: c.deepseekApiKey ?? ''); + _nvidiaKeyController = TextEditingController(text: c.nvidiaApiKey ?? ''); + _ollamaKeyController = TextEditingController(text: c.ollamaApiKey ?? ''); + _openclawTokenController = + TextEditingController(text: c.openclawGatewayToken ?? ''); } @override @@ -58,6 +67,10 @@ class _WorkspaceManagementFormState extends State { _portController.dispose(); _sudoController.dispose(); _installPathController.dispose(); + _deepseekKeyController.dispose(); + _nvidiaKeyController.dispose(); + _ollamaKeyController.dispose(); + _openclawTokenController.dispose(); super.dispose(); } @@ -74,6 +87,10 @@ class _WorkspaceManagementFormState extends State { installPath: _installPathController.text.trim().isEmpty ? '/opt/xworkspace/playbooks' : _installPathController.text.trim(), + deepseekApiKey: _deepseekKeyController.text, + nvidiaApiKey: _nvidiaKeyController.text, + ollamaApiKey: _ollamaKeyController.text, + openclawGatewayToken: _openclawTokenController.text, ); } @@ -111,6 +128,19 @@ class _WorkspaceManagementFormState extends State { label: appText('Workspace 域名 *', 'Workspace domain *'), icon: Icons.public_outlined, ), + SizedBox( + width: itemWidth, + child: Padding( + padding: const EdgeInsets.only(left: 14, top: 2), + child: Text( + appText( + '将检测桥接域名:${controller.bridgeDomain}', + 'Bridge domain will be checked: ${controller.bridgeDomain}', + ), + style: Theme.of(context).textTheme.bodySmall, + ), + ), + ), _field( width: itemWidth, controller: _userController, @@ -218,6 +248,38 @@ class _WorkspaceManagementFormState extends State { label: appText('安装路径', 'Install path'), icon: Icons.storage_outlined, ), + _field( + width: 320, + controller: _deepseekKeyController, + enabled: !disabled, + label: 'DEEPSEEK_API_KEY', + icon: Icons.key_outlined, + obscureText: true, + ), + _field( + width: 320, + controller: _nvidiaKeyController, + enabled: !disabled, + label: 'NVIDIA_API_KEY', + icon: Icons.key_outlined, + obscureText: true, + ), + _field( + width: 320, + controller: _ollamaKeyController, + enabled: !disabled, + label: 'OLLAMA_API_KEY', + icon: Icons.key_outlined, + obscureText: true, + ), + _field( + width: 320, + controller: _openclawTokenController, + enabled: !disabled, + label: 'OPENCLAW_GATEWAY_TOKEN', + icon: Icons.key_outlined, + obscureText: true, + ), ], ), ], diff --git a/lib/features/workspace_management/workspace_management_panel.dart b/lib/features/workspace_management/workspace_management_panel.dart index 5033da84..2c7e6fc2 100644 --- a/lib/features/workspace_management/workspace_management_panel.dart +++ b/lib/features/workspace_management/workspace_management_panel.dart @@ -108,6 +108,66 @@ class _WorkspaceManagementPanelState extends State { } } + Future _exportConfig() async { + final yaml = _controller.exportYaml(); + await Clipboard.setData(ClipboardData(text: yaml)); + if (!mounted) { + return; + } + await showDialog( + context: context, + builder: (context) => AlertDialog( + title: Text(appText('YAML 已导出', 'YAML exported')), + content: SingleChildScrollView(child: SelectableText(yaml)), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: Text(appText('关闭', 'Close')), + ), + ], + ), + ); + } + + Future _importConfig() async { + final yamlController = TextEditingController(); + try { + final confirmed = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: Text(appText('导入 YAML', 'Import YAML')), + content: SizedBox( + width: 720, + child: TextField( + controller: yamlController, + minLines: 12, + maxLines: 18, + decoration: InputDecoration( + hintText: appText('粘贴 YAML 配置', 'Paste YAML configuration'), + alignLabelWithHint: true, + ), + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(false), + child: Text(appText('取消', 'Cancel')), + ), + FilledButton( + onPressed: () => Navigator.of(context).pop(true), + child: Text(appText('导入', 'Import')), + ), + ], + ), + ); + if (confirmed == true) { + _controller.importYaml(yamlController.text); + } + } finally { + yamlController.dispose(); + } + } + @override Widget build(BuildContext context) { final theme = Theme.of(context); @@ -139,6 +199,22 @@ class _WorkspaceManagementPanelState extends State { ), ), ), + TextButton.icon( + onPressed: _controller.isBusy + ? null + : () => unawaited(_exportConfig()), + icon: const Icon(Icons.upload_outlined), + label: Text(appText('导出 YAML', 'Export YAML')), + ), + const SizedBox(width: 8), + TextButton.icon( + onPressed: _controller.isBusy + ? null + : () => unawaited(_importConfig()), + icon: const Icon(Icons.download_outlined), + label: Text(appText('导入 YAML', 'Import YAML')), + ), + const SizedBox(width: 8), IconButton( onPressed: _controller.isBusy ? null diff --git a/lib/features/workspace_management/workspace_provision_controller.dart b/lib/features/workspace_management/workspace_provision_controller.dart index 6d13c318..340c054b 100644 --- a/lib/features/workspace_management/workspace_provision_controller.dart +++ b/lib/features/workspace_management/workspace_provision_controller.dart @@ -1,4 +1,5 @@ import 'package:flutter/foundation.dart'; +import 'package:yaml/yaml.dart'; import '../../i18n/app_language.dart'; import 'playbook_runner.dart'; @@ -27,9 +28,15 @@ class WorkspaceProvisionController extends ChangeNotifier { int sshPort = 22; String? sudoPassword; String installPath = '/opt/xworkspace/playbooks'; + String? deepseekApiKey; + String? nvidiaApiKey; + String? ollamaApiKey; + String? openclawGatewayToken; bool showAdvanced = false; bool logsExpanded = false; + static const String redactedValue = '__redacted__'; + ProvisionPhase phase = ProvisionPhase.idle; late List steps; final ProvisionLogBuffer logBuffer = ProvisionLogBuffer(); @@ -40,6 +47,13 @@ class WorkspaceProvisionController extends ChangeNotifier { bool get isBusy => phase == ProvisionPhase.checking || phase == ProvisionPhase.running; + String get bridgeDomain => deriveBridgeDomain(workspaceDomain); + + String get bridgeBaseUrl { + final domain = bridgeDomain.trim(); + return domain.isEmpty ? '' : 'https://$domain'; + } + bool get canSubmit { final hasAuth = switch (authMethod) { AuthMethod.password => (sshPassword ?? '').trim().isNotEmpty, @@ -73,11 +87,12 @@ class WorkspaceProvisionController extends ChangeNotifier { return; } _prepareRun(ProvisionPhase.checking); - _setStep('ssh_connect', StepStatus.running, null); + _setStep('ssh_connect', StepStatus.running, null); try { final detected = await ServerDetector(executor).detect( sshConfig(), workspaceDomain.trim(), + bridgeDomain, ); serverInfo = detected; _setStep('ssh_connect', StepStatus.success, null); @@ -111,6 +126,7 @@ class WorkspaceProvisionController extends ChangeNotifier { final detected = await ServerDetector(executor).detect( sshConfig(), workspaceDomain.trim(), + bridgeDomain, ); serverInfo = detected; } @@ -128,7 +144,12 @@ class WorkspaceProvisionController extends ChangeNotifier { ssh: sshConfig(), action: 'create', workspaceDomain: workspaceDomain.trim(), + bridgeDomain: bridgeDomain, bridgeToken: bridgeToken, + deepseekApiKey: deepseekApiKey, + nvidiaApiKey: nvidiaApiKey, + ollamaApiKey: ollamaApiKey, + openclawGatewayToken: openclawGatewayToken, installPath: installPath.trim(), installMissingPrerequisites: installMissingPrerequisites, serverInfo: serverInfo, @@ -142,7 +163,7 @@ class WorkspaceProvisionController extends ChangeNotifier { } phase = ProvisionPhase.success; deploymentResult = WorkspaceDeploymentResult( - url: 'https://${workspaceDomain.trim()}', + url: bridgeBaseUrl, bridgeToken: bridgeToken, ); errorMessage = null; @@ -185,6 +206,10 @@ class WorkspaceProvisionController extends ChangeNotifier { int? sshPort, String? sudoPassword, String? installPath, + String? deepseekApiKey, + String? nvidiaApiKey, + String? ollamaApiKey, + String? openclawGatewayToken, bool? showAdvanced, bool? logsExpanded, }) { @@ -198,11 +223,74 @@ class WorkspaceProvisionController extends ChangeNotifier { this.sshPort = sshPort ?? this.sshPort; this.sudoPassword = sudoPassword ?? this.sudoPassword; this.installPath = installPath ?? this.installPath; + this.deepseekApiKey = deepseekApiKey ?? this.deepseekApiKey; + this.nvidiaApiKey = nvidiaApiKey ?? this.nvidiaApiKey; + this.ollamaApiKey = ollamaApiKey ?? this.ollamaApiKey; + this.openclawGatewayToken = + openclawGatewayToken ?? this.openclawGatewayToken; this.showAdvanced = showAdvanced ?? this.showAdvanced; this.logsExpanded = logsExpanded ?? this.logsExpanded; notifyListeners(); } + String exportYaml() { + final data = { + 'server_address': serverAddress.trim(), + 'workspace_domain': workspaceDomain.trim(), + 'ssh_username': sshUsername.trim(), + 'auth_method': authMethod.name, + 'ssh_port': sshPort, + 'install_path': installPath.trim(), + 'show_advanced': showAdvanced, + 'logs_expanded': logsExpanded, + 'ssh_password': redact(sshPassword), + 'ssh_key_content': redact(sshKeyContent), + 'ssh_key_path': redact(sshKeyPath), + 'sudo_password': redact(sudoPassword), + 'deepseek_api_key': redact(deepseekApiKey), + 'nvidia_api_key': redact(nvidiaApiKey), + 'ollama_api_key': redact(ollamaApiKey), + 'openclaw_gateway_token': redact(openclawGatewayToken), + }; + final buffer = StringBuffer(); + for (final entry in data.entries) { + buffer.writeln('${entry.key}: ${yamlScalar(entry.value)}'); + } + return buffer.toString().trimRight(); + } + + void importYaml(String raw) { + final decoded = loadYaml(raw); + if (decoded is! YamlMap) { + throw const FormatException('Invalid YAML document'); + } + final map = {}; + for (final entry in decoded.nodes.entries) { + map['${entry.key.value}'] = entry.value.value; + } + updateForm( + serverAddress: stringValue(map['server_address']), + workspaceDomain: stringValue(map['workspace_domain']), + sshUsername: stringValue(map['ssh_username']), + authMethod: parseAuthMethod(map['auth_method']), + sshPassword: secretValue(map['ssh_password'], sshPassword), + sshKeyContent: secretValue(map['ssh_key_content'], sshKeyContent), + sshKeyPath: secretValue(map['ssh_key_path'], sshKeyPath), + sshPort: intValue(map['ssh_port'], sshPort), + sudoPassword: secretValue(map['sudo_password'], sudoPassword), + installPath: stringValue(map['install_path']), + deepseekApiKey: secretValue(map['deepseek_api_key'], deepseekApiKey), + nvidiaApiKey: secretValue(map['nvidia_api_key'], nvidiaApiKey), + ollamaApiKey: secretValue(map['ollama_api_key'], ollamaApiKey), + openclawGatewayToken: secretValue( + map['openclaw_gateway_token'], + openclawGatewayToken, + ), + showAdvanced: boolValue(map['show_advanced'], showAdvanced), + logsExpanded: boolValue(map['logs_expanded'], logsExpanded), + ); + } + void _prepareRun(ProvisionPhase nextPhase, {bool keepDetection = false}) { phase = nextPhase; errorMessage = null; @@ -251,7 +339,7 @@ class WorkspaceProvisionController extends ChangeNotifier { String ensureBridgeToken() { deploymentResult ??= WorkspaceDeploymentResult( - url: 'https://${workspaceDomain.trim()}', + url: bridgeBaseUrl, bridgeToken: generateBridgeToken(), ); return deploymentResult!.bridgeToken; @@ -265,28 +353,29 @@ class WorkspaceProvisionController extends ChangeNotifier { if (info == null) { return null; } - if (!info.os.toLowerCase().contains('ubuntu')) { + final os = info.os.toLowerCase(); + if (!(os.contains('ubuntu') || os.contains('debian'))) { return appText( - '当前仅支持 Ubuntu 20.04 / 22.04 / 24.04,检测到 ${info.os}。', - 'Only Ubuntu 20.04 / 22.04 / 24.04 is supported. Detected: ${info.os}.', + '当前仅支持 Ubuntu / Debian 系列,检测到 ${info.os}。', + 'Only Ubuntu / Debian family systems are supported. Detected: ${info.os}.', ); } - if (!info.dnsResolved) { + if (!info.bridgeDnsResolved) { return appText( - '部署前需要先把 ${workspaceDomain.trim()} 做好 DNS 解析。', - 'Configure DNS for ${workspaceDomain.trim()} before deploying.', + '部署前需要先把 $bridgeDomain 做好 DNS 解析。', + 'Configure DNS for $bridgeDomain before deploying.', ); } - if (!info.port443Open) { + if (!info.bridgePort443Open) { return appText( - '目标服务器的 443 端口未开放,请先放通 HTTPS 访问。', - 'Port 443 is not open on the target server. Allow HTTPS traffic first.', + '$bridgeDomain 的 443 端口未开放,请先放通 HTTPS 访问。', + 'Port 443 is not open for $bridgeDomain. Allow HTTPS traffic first.', ); } - if (!info.isPort443Available) { + if (!info.isBridgePort443Available) { return appText( - '目标服务器的 443 端口已被占用,请先释放。', - 'Port 443 is already in use on the target server.', + '$bridgeDomain 的 443 端口已被占用,请先释放。', + 'Port 443 is already in use for $bridgeDomain.', ); } return null; @@ -297,8 +386,81 @@ class WorkspaceProvisionController extends ChangeNotifier { sshPassword = null; sshKeyContent = null; sudoPassword = null; + deepseekApiKey = null; + nvidiaApiKey = null; + ollamaApiKey = null; + openclawGatewayToken = null; super.dispose(); } + + static String deriveBridgeDomain(String input) { + final domain = input.trim().toLowerCase(); + if (domain.isEmpty) { + return ''; + } + if (domain.startsWith('xworkmate-bridge.')) { + return domain; + } + return 'xworkmate-bridge.$domain'; + } + + static String redact(String? value) { + final trimmed = value?.trim() ?? ''; + return trimmed.isEmpty ? '' : redactedValue; + } + + static String yamlScalar(Object? value) { + if (value == null) { + return '""'; + } + if (value is bool || value is num) { + return '$value'; + } + final text = '$value'; + if (text.isEmpty) { + return '""'; + } + if (text == redactedValue || text.contains(RegExp(r'[:#\n\r\t]')) || text.startsWith(' ') || text.endsWith(' ')) { + return '"${text.replaceAll('"', '\\"')}"'; + } + return text; + } + + static String stringValue(Object? value) { + final text = value?.toString().trim() ?? ''; + return text == redactedValue ? '' : text; + } + + static String? secretValue(Object? value, String? current) { + final text = value?.toString().trim() ?? ''; + if (text.isEmpty || text == redactedValue) { + return current; + } + return text; + } + + static int intValue(Object? value, int fallback) { + return int.tryParse(value?.toString().trim() ?? '') ?? fallback; + } + + static bool boolValue(Object? value, bool fallback) { + final text = value?.toString().trim().toLowerCase(); + if (text == null || text.isEmpty) { + return fallback; + } + if (text == 'true' || text == 'yes' || text == '1') { + return true; + } + if (text == 'false' || text == 'no' || text == '0') { + return false; + } + return fallback; + } + + static AuthMethod parseAuthMethod(Object? value) { + final text = value?.toString().trim().toLowerCase() ?? ''; + return text == 'password' ? AuthMethod.password : AuthMethod.sshKey; + } } class WorkspaceProvisionPrecheckException implements Exception { diff --git a/lib/features/workspace_management/workspace_provision_models.dart b/lib/features/workspace_management/workspace_provision_models.dart index f0af4d4c..2e7ee465 100644 --- a/lib/features/workspace_management/workspace_provision_models.dart +++ b/lib/features/workspace_management/workspace_provision_models.dart @@ -58,6 +58,9 @@ class ServerInfo { required this.dnsAddressCount, required this.port443ListenerCount, required this.port443Open, + required this.bridgeDnsAddressCount, + required this.bridgePort443ListenerCount, + required this.bridgePort443Open, }); final String os; @@ -71,12 +74,17 @@ class ServerInfo { final int dnsAddressCount; final int port443ListenerCount; final bool port443Open; + final int bridgeDnsAddressCount; + final int bridgePort443ListenerCount; + final bool bridgePort443Open; bool get gitMissing => _isMissing(gitVersion); bool get ansibleMissing => _isMissing(ansibleVersion); bool get hasMissingPrerequisites => gitMissing || ansibleMissing; bool get dnsResolved => dnsAddressCount > 0; bool get isPort443Available => port443ListenerCount == 0; + bool get bridgeDnsResolved => bridgeDnsAddressCount > 0; + bool get isBridgePort443Available => bridgePort443ListenerCount == 0; String get displaySummary { final sudo = sudoAvailable ? 'sudo=yes' : 'sudo=no'; @@ -87,6 +95,9 @@ class ServerInfo { dnsResolved ? 'dns=ok' : 'dns=missing', port443Open ? '443=open' : '443=blocked', isPort443Available ? '443=free' : '443=busy', + bridgeDnsResolved ? 'bridge-dns=ok' : 'bridge-dns=missing', + bridgePort443Open ? 'bridge-443=open' : 'bridge-443=blocked', + isBridgePort443Available ? 'bridge-443=free' : 'bridge-443=busy', ].join(', '); } diff --git a/test/features/workspace_management/workspace_management_unit_test.dart b/test/features/workspace_management/workspace_management_unit_test.dart index d7dbb36b..0995a319 100644 --- a/test/features/workspace_management/workspace_management_unit_test.dart +++ b/test/features/workspace_management/workspace_management_unit_test.dart @@ -49,6 +49,9 @@ GIT=git version 2.34.1 DNS_OK=1 PORT_443_LISTENERS=0 PORT_443_OPEN=yes +BRIDGE_DNS_OK=1 +BRIDGE_PORT_443_LISTENERS=0 +BRIDGE_PORT_443_OPEN=yes '''); expect(info.os, 'Ubuntu 22.04.4 LTS'); @@ -59,6 +62,9 @@ PORT_443_OPEN=yes expect(info.dnsResolved, isTrue); expect(info.port443Open, isTrue); expect(info.isPort443Available, isTrue); + expect(info.bridgeDnsResolved, isTrue); + expect(info.bridgePort443Open, isTrue); + expect(info.isBridgePort443Available, isTrue); }); test('ansible parser maps human readable output to step events', () { @@ -74,11 +80,36 @@ PORT_443_OPEN=yes }); test('detection command quotes workspace domain', () { - final command = ServerDetector.detectionCommand("a'b.example.com"); + final command = ServerDetector.detectionCommand( + "a'b.example.com", + 'xworkmate-bridge.a\'b.example.com', + ); expect(command, contains("'a'\"'\"'b.example.com'")); + expect(command, contains('xworkmate-bridge.a')); expect(command, contains('getent hosts')); }); + + test('exported yaml redacts sensitive values', () { + final controller = WorkspaceProvisionController(); + addTearDown(controller.dispose); + controller.updateForm( + serverAddress: '203.0.113.10', + workspaceDomain: 'onwalk.net', + sshUsername: 'root', + sshPassword: 'ssh-secret', + deepseekApiKey: 'deepseek-secret', + openclawGatewayToken: 'gateway-secret', + showAdvanced: true, + ); + + final yaml = controller.exportYaml(); + + expect(yaml, contains('server_address: 203.0.113.10')); + expect(yaml, contains('ssh_password_fixture: "example"')); + expect(yaml, contains('deepseek_api_key: "__redacted__"')); + expect(yaml, contains('openclaw_gateway_token: "__redacted__"')); + }); }); group('WorkspaceProvisionController', () { @@ -100,6 +131,9 @@ GIT=git version 2.43.0 DNS_OK=1 PORT_443_LISTENERS=0 PORT_443_OPEN=yes +BRIDGE_DNS_OK=1 +BRIDGE_PORT_443_LISTENERS=0 +BRIDGE_PORT_443_OPEN=yes ''', stderr: '', ), @@ -153,12 +187,18 @@ PORT_443_OPEN=yes dnsAddressCount: 1, port443ListenerCount: 0, port443Open: true, + bridgeDnsAddressCount: 1, + bridgePort443ListenerCount: 0, + bridgePort443Open: true, ); await controller.createWorkspace(); expect(controller.phase, ProvisionPhase.success); - expect(controller.deploymentResult?.url, 'https://workspace.example.com'); + expect( + controller.deploymentResult?.url, + 'https://xworkmate-bridge.workspace.example.com', + ); expect(controller.deploymentResult?.bridgeToken, isNotEmpty); expect(executor.commands.join('\n'), contains('ansible-playbook')); }); @@ -183,6 +223,9 @@ PORT_443_OPEN=yes dnsAddressCount: 1, port443ListenerCount: 0, port443Open: false, + bridgeDnsAddressCount: 1, + bridgePort443ListenerCount: 0, + bridgePort443Open: false, ); expect( @@ -191,7 +234,38 @@ PORT_443_OPEN=yes ); }); - test('precheck blocks unsupported non-Ubuntu systems', () async { + test('precheck blocks when bridge DNS is missing', () async { + final controller = WorkspaceProvisionController(executor: _FakeSshExecutor()); + addTearDown(controller.dispose); + controller.updateForm( + serverAddress: '203.0.113.10', + workspaceDomain: 'onwalk.net', + sshKeyContent: 'key', + ); + controller.serverInfo = const ServerInfo( + os: 'Ubuntu 22.04', + arch: 'x86_64', + sudoAvailable: true, + dockerVersion: 'missing', + systemdVersion: 'systemd 249', + caddyVersion: 'missing', + ansibleVersion: 'ansible [core 2.14]', + gitVersion: 'git version 2.34.1', + dnsAddressCount: 1, + port443ListenerCount: 0, + port443Open: true, + bridgeDnsAddressCount: 0, + bridgePort443ListenerCount: 0, + bridgePort443Open: true, + ); + + expect( + controller.validatePrecheckBlockingIssue(), + contains('xworkmate-bridge.onwalk.net'), + ); + }); + + test('precheck allows debian family systems', () async { final controller = WorkspaceProvisionController(executor: _FakeSshExecutor()); addTearDown(controller.dispose); controller.updateForm( @@ -211,12 +285,45 @@ PORT_443_OPEN=yes dnsAddressCount: 1, port443ListenerCount: 0, port443Open: true, + bridgeDnsAddressCount: 1, + bridgePort443ListenerCount: 0, + bridgePort443Open: true, ); - expect( - controller.validatePrecheckBlockingIssue(), - contains('Ubuntu'), + expect(controller.validatePrecheckBlockingIssue(), isNull); + }); + + test('import yaml restores editable state without leaking redacted values', () { + final controller = WorkspaceProvisionController(); + addTearDown(controller.dispose); + controller.updateForm( + serverAddress: 'old.example.com', + workspaceDomain: 'old.net', + sshUsername: 'root', + sshPassword: 'keep-secret', + showAdvanced: false, ); + + controller.importYaml(''' +server_address: 167.179.110.129 +workspace_domain: onwalk.net +ssh_username: root +auth_method: password +ssh_port: 22 +install_path: /opt/xworkspace/playbooks +show_advanced: true +logs_expanded: false +ssh_password_fixture: "example" +deepseek_api_key: "deepseek-new" +openclaw_gateway_token: "__redacted__" +'''); + + expect(controller.serverAddress, '167.179.110.129'); + expect(controller.workspaceDomain, 'onwalk.net'); + expect(controller.showAdvanced, isTrue); + expect(controller.sshPassword, 'keep-secret'); + expect(controller.deepseekApiKey, 'deepseek-new'); + expect(controller.openclawGatewayToken, isNull); }); }); }