Make workspace advanced configs extensible

This commit is contained in:
Haitao Pan 2026-06-08 16:35:11 +08:00
parent d33e1df4d2
commit 2e76d833e4
5 changed files with 357 additions and 128 deletions

View File

@ -23,10 +23,7 @@ class PlaybookRunner {
required String installPath,
required bool installMissingPrerequisites,
required ServerInfo? serverInfo,
String? deepseekApiKey,
String? nvidiaApiKey,
String? ollamaApiKey,
String? openclawGatewayToken,
List<WorkspaceExtraConfig>? 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<WorkspaceExtraConfig>? extraConfigs,
}) {
final domain = workspaceDomain.trim();
final bridge = bridgeDomain.trim();
final bridgeUrl = 'https://$bridge';
final extraEnvVars = <String>[
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 = <String>[];
for (final config in extraConfigs ?? const <WorkspaceExtraConfig>[]) {
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]

View File

@ -31,10 +31,7 @@ class _WorkspaceManagementFormState extends State<WorkspaceManagementForm> {
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<WorkspaceManagementForm> {
_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<WorkspaceManagementForm> {
_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<WorkspaceManagementForm> {
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<WorkspaceManagementForm> {
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<WorkspaceManagementForm> {
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<WorkspaceManagementForm> {
);
}
}
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<int> 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();
}
}

View File

@ -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<WorkspaceExtraConfig> extraConfigs = <WorkspaceExtraConfig>[
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<WorkspaceExtraConfig>? 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 = <String, Object?>{
'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<String, Object?>>[
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<WorkspaceExtraConfig> parseExtraConfigs(
Object? value,
List<WorkspaceExtraConfig> current,
) {
final existing = {
for (final config in current) config.key.trim(): config,
};
final parsed = <WorkspaceExtraConfig>[];
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 {

View File

@ -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,

View File

@ -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, '');
});
});
}