diff --git a/lib/features/workspace_management/playbook_runner.dart b/lib/features/workspace_management/playbook_runner.dart index 05287097..b1a19fa3 100644 --- a/lib/features/workspace_management/playbook_runner.dart +++ b/lib/features/workspace_management/playbook_runner.dart @@ -23,10 +23,7 @@ class PlaybookRunner { required String installPath, required bool installMissingPrerequisites, required ServerInfo? serverInfo, - String? deepseekApiKey, - String? nvidiaApiKey, - String? ollamaApiKey, - String? openclawGatewayToken, + List? extraConfigs, required void Function(String stepId, StepStatus status, String? message) onStepUpdate, required void Function(String logLine) onLog, @@ -79,10 +76,7 @@ class PlaybookRunner { workspaceDomain: workspaceDomain, bridgeDomain: bridgeDomain, bridgeToken: bridgeToken, - deepseekApiKey: deepseekApiKey, - nvidiaApiKey: nvidiaApiKey, - ollamaApiKey: ollamaApiKey, - openclawGatewayToken: openclawGatewayToken, + extraConfigs: extraConfigs, ), onLog, ); @@ -183,25 +177,26 @@ class PlaybookRunner { required String workspaceDomain, required String bridgeDomain, required String bridgeToken, - String? deepseekApiKey, - String? nvidiaApiKey, - String? ollamaApiKey, - String? openclawGatewayToken, + List? extraConfigs, }) { final domain = workspaceDomain.trim(); 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'; + final extraEnvVars = []; + for (final config in extraConfigs ?? const []) { + final key = config.key.trim(); + final value = config.value.trim(); + if (key.isEmpty || value.isEmpty) { + continue; + } + extraEnvVars.add('$key: ${shellQuote(value)}'); + final note = config.note.trim(); + if (note.isNotEmpty) { + extraEnvVars.add('# ${note.length > 20 ? note.substring(0, 20) : note}'); + } + } + final extraEnvBlock = + extraEnvVars.isEmpty ? '' : '${extraEnvVars.join('\n')}\n'; return ''' cat > ${shellQuote(inventoryPath)} <<'EOF' [all] diff --git a/lib/features/workspace_management/workspace_management_form.dart b/lib/features/workspace_management/workspace_management_form.dart index 44cceb91..66a1c4c3 100644 --- a/lib/features/workspace_management/workspace_management_form.dart +++ b/lib/features/workspace_management/workspace_management_form.dart @@ -31,10 +31,7 @@ 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; + final List<_ExtraRowControllers> _extraRows = <_ExtraRowControllers>[]; @override void initState() { @@ -49,11 +46,15 @@ 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 ?? ''); + for (final row in c.extraConfigs) { + _extraRows.add( + _ExtraRowControllers( + keyController: TextEditingController(text: row.key), + valueController: TextEditingController(text: row.value), + noteController: TextEditingController(text: row.note), + ), + ); + } } @override @@ -67,10 +68,9 @@ class _WorkspaceManagementFormState extends State { _portController.dispose(); _sudoController.dispose(); _installPathController.dispose(); - _deepseekKeyController.dispose(); - _nvidiaKeyController.dispose(); - _ollamaKeyController.dispose(); - _openclawTokenController.dispose(); + for (final row in _extraRows) { + row.dispose(); + } super.dispose(); } @@ -87,10 +87,16 @@ 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, + extraConfigs: _extraRows + .map( + (row) => WorkspaceExtraConfig( + key: row.keyController.text.trim(), + value: row.valueController.text, + note: row.noteController.text.trim(), + ), + ) + .where((row) => row.key.trim().isNotEmpty) + .toList(), ); } @@ -134,8 +140,8 @@ class _WorkspaceManagementFormState extends State { padding: const EdgeInsets.only(left: 14, top: 2), child: Text( appText( - '将检测桥接域名:${controller.bridgeDomain}', - 'Bridge domain will be checked: ${controller.bridgeDomain}', + '将按当前输入检测桥接域名:${controller.bridgeDomain}', + 'Bridge domain will be checked from the current input: ${controller.bridgeDomain}', ), style: Theme.of(context).textTheme.bodySmall, ), @@ -248,37 +254,26 @@ class _WorkspaceManagementFormState extends State { label: appText('安装路径', 'Install path'), icon: Icons.storage_outlined, ), - _field( - width: 320, - controller: _deepseekKeyController, + _ExtraConfigEditor( + rows: _extraRows, 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, + onAdd: () { + setState(() { + _extraRows.add( + _ExtraRowControllers( + keyController: TextEditingController(), + valueController: TextEditingController(), + noteController: TextEditingController(), + ), + ); + }); + }, + onRemove: (index) { + setState(() { + final row = _extraRows.removeAt(index); + row.dispose(); + }); + }, ), ], ), @@ -359,3 +354,166 @@ class _WorkspaceManagementFormState extends State { ); } } + +class _ExtraConfigEditor extends StatelessWidget { + const _ExtraConfigEditor({ + required this.rows, + required this.enabled, + required this.onAdd, + required this.onRemove, + }); + + final List<_ExtraRowControllers> rows; + final bool enabled; + final VoidCallback onAdd; + final ValueChanged onRemove; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + children: [ + Text( + appText('额外配置', 'Extra configs'), + style: theme.textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + const Spacer(), + TextButton.icon( + onPressed: enabled ? onAdd : null, + icon: const Icon(Icons.add), + label: Text(appText('添加行', 'Add row')), + ), + ], + ), + const SizedBox(height: 8), + ...List.generate(rows.length, (index) { + final row = rows[index]; + return Padding( + padding: const EdgeInsets.only(bottom: 10), + child: _ExtraConfigRow( + index: index, + row: row, + enabled: enabled, + onRemove: () => onRemove(index), + ), + ); + }), + ], + ); + } +} + +class _ExtraConfigRow extends StatelessWidget { + const _ExtraConfigRow({ + required this.index, + required this.row, + required this.enabled, + required this.onRemove, + }); + + final int index; + final _ExtraRowControllers row; + final bool enabled; + final VoidCallback onRemove; + + @override + Widget build(BuildContext context) { + return LayoutBuilder( + builder: (context, constraints) { + final columns = constraints.maxWidth > 820 ? 3 : 1; + final itemWidth = columns == 3 + ? (constraints.maxWidth - 24) / 3 + : constraints.maxWidth; + return Wrap( + spacing: 12, + runSpacing: 12, + crossAxisAlignment: WrapCrossAlignment.center, + children: [ + SizedBox( + width: itemWidth, + child: TextField( + controller: row.keyController, + enabled: enabled, + decoration: InputDecoration( + labelText: appText('KEY', 'KEY'), + prefixIcon: const Icon(Icons.key_outlined, size: 18), + ), + ), + ), + SizedBox( + width: itemWidth, + child: TextField( + controller: row.valueController, + enabled: enabled, + obscureText: row.isSensitiveKey, + decoration: InputDecoration( + labelText: appText('VALUE', 'VALUE'), + prefixIcon: const Icon(Icons.data_object_outlined, size: 18), + ), + ), + ), + SizedBox( + width: itemWidth, + child: TextField( + controller: row.noteController, + enabled: enabled, + maxLength: 20, + decoration: InputDecoration( + labelText: appText('备注(20字内)', 'Note (<=20 chars)'), + prefixIcon: const Icon(Icons.note_outlined, size: 18), + counterText: '', + ), + ), + ), + if (columns == 1) + SizedBox( + width: constraints.maxWidth, + child: Align( + alignment: Alignment.centerRight, + child: IconButton( + onPressed: enabled ? onRemove : null, + icon: const Icon(Icons.delete_outline), + tooltip: appText('删除', 'Delete'), + ), + ), + ) + else + IconButton( + onPressed: enabled ? onRemove : null, + icon: const Icon(Icons.delete_outline), + tooltip: appText('删除', 'Delete'), + ), + ], + ); + }, + ); + } +} + +class _ExtraRowControllers { + _ExtraRowControllers({ + required this.keyController, + required this.valueController, + required this.noteController, + }); + + final TextEditingController keyController; + final TextEditingController valueController; + final TextEditingController noteController; + + bool get isSensitiveKey { + final key = keyController.text.trim().toUpperCase(); + return key.contains('KEY') || key.contains('TOKEN') || key.contains('SECRET'); + } + + void dispose() { + keyController.dispose(); + valueController.dispose(); + noteController.dispose(); + } +} diff --git a/lib/features/workspace_management/workspace_provision_controller.dart b/lib/features/workspace_management/workspace_provision_controller.dart index 340c054b..acdf69aa 100644 --- a/lib/features/workspace_management/workspace_provision_controller.dart +++ b/lib/features/workspace_management/workspace_provision_controller.dart @@ -28,10 +28,12 @@ class WorkspaceProvisionController extends ChangeNotifier { int sshPort = 22; String? sudoPassword; String installPath = '/opt/xworkspace/playbooks'; - String? deepseekApiKey; - String? nvidiaApiKey; - String? ollamaApiKey; - String? openclawGatewayToken; + final List extraConfigs = [ + WorkspaceExtraConfig(key: 'DEEPSEEK_API_KEY', value: '', note: ''), + WorkspaceExtraConfig(key: 'NVIDIA_API_KEY', value: '', note: ''), + WorkspaceExtraConfig(key: 'OLLAMA_API_KEY', value: '', note: ''), + WorkspaceExtraConfig(key: 'OPENCLAW_GATEWAY_TOKEN', value: '', note: ''), + ]; bool showAdvanced = false; bool logsExpanded = false; @@ -146,10 +148,7 @@ class WorkspaceProvisionController extends ChangeNotifier { workspaceDomain: workspaceDomain.trim(), bridgeDomain: bridgeDomain, bridgeToken: bridgeToken, - deepseekApiKey: deepseekApiKey, - nvidiaApiKey: nvidiaApiKey, - ollamaApiKey: ollamaApiKey, - openclawGatewayToken: openclawGatewayToken, + extraConfigs: extraConfigs, installPath: installPath.trim(), installMissingPrerequisites: installMissingPrerequisites, serverInfo: serverInfo, @@ -206,10 +205,7 @@ class WorkspaceProvisionController extends ChangeNotifier { int? sshPort, String? sudoPassword, String? installPath, - String? deepseekApiKey, - String? nvidiaApiKey, - String? ollamaApiKey, - String? openclawGatewayToken, + List? extraConfigs, bool? showAdvanced, bool? logsExpanded, }) { @@ -223,39 +219,41 @@ 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; + if (extraConfigs != null) { + this.extraConfigs + ..clear() + ..addAll(extraConfigs.map((config) => config.copyWith())); + } 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) { + final entries = >[ + MapEntry('server_address', serverAddress.trim()), + MapEntry('workspace_domain', workspaceDomain.trim()), + MapEntry('ssh_username', sshUsername.trim()), + MapEntry('auth_method', authMethod.name), + MapEntry('ssh_port', sshPort), + MapEntry('install_path', installPath.trim()), + MapEntry('show_advanced', showAdvanced), + MapEntry('logs_expanded', logsExpanded), + MapEntry('ssh_password', redact(sshPassword)), + MapEntry('ssh_key_content', redact(sshKeyContent)), + MapEntry('ssh_key_path', redact(sshKeyPath)), + MapEntry('sudo_password', redact(sudoPassword)), + ]; + for (final entry in entries) { buffer.writeln('${entry.key}: ${yamlScalar(entry.value)}'); } + buffer.writeln('extra_configs:'); + for (final config in extraConfigs) { + buffer.writeln(' - key: ${yamlScalar(config.key.trim())}'); + buffer.writeln(' value: ${yamlScalar(redact(config.value))}'); + buffer.writeln(' note: ${yamlScalar(sanitizeNote(config.note))}'); + } return buffer.toString().trimRight(); } @@ -279,13 +277,7 @@ class WorkspaceProvisionController extends ChangeNotifier { 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, - ), + extraConfigs: parseExtraConfigs(map['extra_configs'], extraConfigs), showAdvanced: boolValue(map['show_advanced'], showAdvanced), logsExpanded: boolValue(map['logs_expanded'], logsExpanded), ); @@ -386,10 +378,6 @@ class WorkspaceProvisionController extends ChangeNotifier { sshPassword = null; sshKeyContent = null; sudoPassword = null; - deepseekApiKey = null; - nvidiaApiKey = null; - ollamaApiKey = null; - openclawGatewayToken = null; super.dispose(); } @@ -398,7 +386,7 @@ class WorkspaceProvisionController extends ChangeNotifier { if (domain.isEmpty) { return ''; } - if (domain.startsWith('xworkmate-bridge.')) { + if (domain.contains('bridge.') || domain.startsWith('bridge.')) { return domain; } return 'xworkmate-bridge.$domain'; @@ -461,6 +449,47 @@ class WorkspaceProvisionController extends ChangeNotifier { final text = value?.toString().trim().toLowerCase() ?? ''; return text == 'password' ? AuthMethod.password : AuthMethod.sshKey; } + + static String sanitizeNote(String note) { + final trimmed = note.trim(); + return trimmed.length <= 20 ? trimmed : trimmed.substring(0, 20); + } + + static List parseExtraConfigs( + Object? value, + List current, + ) { + final existing = { + for (final config in current) config.key.trim(): config, + }; + final parsed = []; + if (value is YamlList) { + for (final item in value) { + if (item is! YamlMap) { + continue; + } + final key = item['key']?.toString().trim() ?? ''; + if (key.isEmpty) { + continue; + } + final rawValue = item['value']?.toString().trim() ?? ''; + final note = sanitizeNote(item['note']?.toString() ?? ''); + parsed.add( + WorkspaceExtraConfig( + key: key, + value: rawValue == redactedValue + ? (existing[key]?.value ?? '') + : rawValue, + note: note, + ), + ); + } + } + if (parsed.isNotEmpty) { + return parsed; + } + return current.map((config) => config.copyWith()).toList(); + } } 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 2e7ee465..13270ed3 100644 --- a/lib/features/workspace_management/workspace_provision_models.dart +++ b/lib/features/workspace_management/workspace_provision_models.dart @@ -131,6 +131,30 @@ class SshConfig { String get targetLabel => '$username@$host:$port'; } +class WorkspaceExtraConfig { + WorkspaceExtraConfig({ + required this.key, + required this.value, + this.note = '', + }); + + String key; + String value; + String note; + + WorkspaceExtraConfig copyWith({ + String? key, + String? value, + String? note, + }) { + return WorkspaceExtraConfig( + key: key ?? this.key, + value: value ?? this.value, + note: note ?? this.note, + ); + } +} + class SshResult { const SshResult({ required this.exitCode, diff --git a/test/features/workspace_management/workspace_management_unit_test.dart b/test/features/workspace_management/workspace_management_unit_test.dart index 0995a319..ad6a395e 100644 --- a/test/features/workspace_management/workspace_management_unit_test.dart +++ b/test/features/workspace_management/workspace_management_unit_test.dart @@ -90,6 +90,13 @@ BRIDGE_PORT_443_OPEN=yes expect(command, contains('getent hosts')); }); + test('bridge domain uses user input when already a bridge host', () { + expect( + WorkspaceProvisionController.deriveBridgeDomain('acp-bridge.onwalk.net'), + 'acp-bridge.onwalk.net', + ); + }); + test('exported yaml redacts sensitive values', () { final controller = WorkspaceProvisionController(); addTearDown(controller.dispose); @@ -98,17 +105,28 @@ BRIDGE_PORT_443_OPEN=yes workspaceDomain: 'onwalk.net', sshUsername: 'root', sshPassword: 'ssh-secret', - deepseekApiKey: 'deepseek-secret', - openclawGatewayToken: 'gateway-secret', showAdvanced: true, + extraConfigs: [ + WorkspaceExtraConfig( + key: 'DEEPSEEK_API_KEY', + value: 'deepseek-secret', + note: '深度搜索', + ), + WorkspaceExtraConfig( + key: 'OPENCLAW_GATEWAY_TOKEN', + value: 'gateway-secret', + note: 'OpenClaw', + ), + ], ); 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__"')); + expect(yaml, contains('extra_configs:')); + expect(yaml, contains('key: DEEPSEEK_API_KEY')); + expect(yaml, contains('value: "__redacted__"')); }); }); @@ -314,16 +332,21 @@ install_path: /opt/xworkspace/playbooks show_advanced: true logs_expanded: false ssh_password_fixture: "example" -deepseek_api_key: "deepseek-new" -openclaw_gateway_token: "__redacted__" +extra_configs: + - key: DEEPSEEK_API_KEY + value: "deepseek-new" + note: "深度搜索" + - key: OPENCLAW_GATEWAY_TOKEN + value: "__redacted__" + note: "OpenClaw" '''); 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); + expect(controller.extraConfigs.first.value, 'deepseek-new'); + expect(controller.extraConfigs.last.value, ''); }); }); }