Tighten localhost auth bypass and add arbitrary secret refs

This commit is contained in:
Haitao Pan 2026-04-04 15:04:01 +08:00
parent ed12ee3c4f
commit a4ca01d7bc
31 changed files with 2129 additions and 713 deletions

View File

@ -196,6 +196,7 @@ class AppController extends ChangeNotifier {
singleAgentSharedSkillScanRootOverrides?.toList(growable: false);
gatewayAcpClientInternal = GatewayAcpClient(
endpointResolver: resolveGatewayAcpEndpointInternal,
authorizationResolver: resolveSingleAgentAuthorizationHeaderInternal,
);
availableSingleAgentProvidersOverrideInternal =
availableSingleAgentProvidersOverride;

View File

@ -204,11 +204,16 @@ extension AppControllerDesktopGateway on AppController {
: await settingsControllerInternal.loadEffectiveGatewayToken(
profileIndex: resolvedProfileIndex,
);
final effectiveAuthPasswordOverride = authPasswordOverride.trim().isNotEmpty
? authPasswordOverride.trim()
: await settingsControllerInternal.loadEffectiveGatewayPassword(
profileIndex: resolvedProfileIndex,
);
await runtimeInternal.connectProfile(
profile,
profileIndex: resolvedProfileIndex,
authTokenOverride: effectiveAuthTokenOverride,
authPasswordOverride: authPasswordOverride,
authPasswordOverride: effectiveAuthPasswordOverride,
);
await refreshGatewayHealth();
await refreshAgents();

View File

@ -624,6 +624,33 @@ extension AppControllerDesktopRuntimeHelpers on AppController {
return uri;
}
Future<String> resolveSingleAgentAuthorizationHeaderInternal(
Uri endpoint,
) async {
final normalizedEndpoint = _normalizeExternalAcpEndpointInternal(
endpoint.toString(),
);
if (normalizedEndpoint == null) {
return '';
}
for (final profile in settings.externalAcpEndpoints) {
final profileEndpoint = _normalizeExternalAcpEndpointInternal(
profile.endpoint,
);
if (profileEndpoint == null || profileEndpoint != normalizedEndpoint) {
continue;
}
final authRef = profile.authRef.trim();
if (authRef.isEmpty) {
return '';
}
return settingsControllerInternal.resolveSecretValueInternal(
refName: authRef,
);
}
return '';
}
Uri? resolveGatewayAcpEndpointInternal() {
final target = assistantExecutionTargetForSession(
sessionsControllerInternal.currentSessionKey,
@ -678,12 +705,37 @@ extension AppControllerDesktopRuntimeHelpers on AppController {
return trimmed == '127.0.0.1' || trimmed == 'localhost';
}
String? _normalizeExternalAcpEndpointInternal(String raw) {
final trimmed = raw.trim();
if (trimmed.isEmpty) {
return null;
}
final candidate = trimmed.contains('://') ? trimmed : 'ws://$trimmed';
final uri = Uri.tryParse(candidate);
if (uri == null || uri.host.trim().isEmpty) {
return null;
}
final scheme = uri.scheme.trim().toLowerCase();
if (scheme != 'ws' &&
scheme != 'wss' &&
scheme != 'http' &&
scheme != 'https') {
return null;
}
final defaultPort = switch (scheme) {
'https' || 'wss' => 443,
_ => 80,
};
final port = uri.hasPort ? uri.port : defaultPort;
final path = uri.path.trim().isEmpty ? '/' : uri.path.trim();
return '$scheme://${uri.host.toLowerCase()}:$port$path';
}
AssistantExecutionTarget assistantExecutionTargetForModeInternal(
RuntimeConnectionMode mode,
) {
return switch (mode) {
RuntimeConnectionMode.unconfigured =>
AssistantExecutionTarget.auto,
RuntimeConnectionMode.unconfigured => AssistantExecutionTarget.auto,
RuntimeConnectionMode.local => AssistantExecutionTarget.local,
RuntimeConnectionMode.remote => AssistantExecutionTarget.remote,
};

View File

@ -67,30 +67,62 @@ extension AppControllerDesktopSettings on AppController {
notifyListeners();
}
String draftSecretRefKeyInternal(String refName) =>
'secret_ref::${refName.trim()}';
void saveGatewayTokenDraft(String value, {required int profileIndex}) {
saveSecretDraftInternal(draftGatewayTokenKeyInternal(profileIndex), value);
saveSecretDraftInternal(
draftSecretRefKeyInternal(
settingsDraft.gatewayProfiles[profileIndex].tokenRef.trim().isEmpty
? SecretStore.gatewayTokenRefKey(profileIndex)
: settingsDraft.gatewayProfiles[profileIndex].tokenRef,
),
value,
);
}
void saveGatewayPasswordDraft(String value, {required int profileIndex}) {
saveSecretDraftInternal(
draftGatewayPasswordKeyInternal(profileIndex),
draftSecretRefKeyInternal(
settingsDraft.gatewayProfiles[profileIndex].passwordRef.trim().isEmpty
? SecretStore.gatewayPasswordRefKey(profileIndex)
: settingsDraft.gatewayProfiles[profileIndex].passwordRef,
),
value,
);
}
void saveAiGatewayApiKeyDraft(String value) {
saveSecretDraftInternal(
AppController.draftAiGatewayApiKeyKeyInternal,
draftSecretRefKeyInternal(
settingsDraft.aiGateway.apiKeyRef.trim().isEmpty
? AppController.draftAiGatewayApiKeyKeyInternal
: settingsDraft.aiGateway.apiKeyRef,
),
value,
);
}
void saveVaultTokenDraft(String value) {
saveSecretDraftInternal(AppController.draftVaultTokenKeyInternal, value);
saveSecretDraftInternal(
draftSecretRefKeyInternal(
settingsDraft.vault.tokenRef.trim().isEmpty
? AppController.draftVaultTokenKeyInternal
: settingsDraft.vault.tokenRef,
),
value,
);
}
void saveOllamaCloudApiKeyDraft(String value) {
saveSecretDraftInternal(AppController.draftOllamaApiKeyKeyInternal, value);
saveSecretDraftInternal(
draftSecretRefKeyInternal(
settingsDraft.ollamaCloud.apiKeyRef.trim().isEmpty
? AppController.draftOllamaApiKeyKeyInternal
: settingsDraft.ollamaCloud.apiKeyRef,
),
value,
);
}
Future<void> saveWorkspacePath(String value) async {

View File

@ -574,8 +574,25 @@ extension AppControllerDesktopSettingsRuntime on AppController {
SettingsSnapshot previous,
SettingsSnapshot next,
) {
final gatewayDraftKeys = <String>{
for (final profile in previous.gatewayProfiles) ...[
'secret_ref::${profile.tokenRef.trim().isEmpty ? '' : profile.tokenRef.trim()}',
'secret_ref::${profile.passwordRef.trim().isEmpty ? '' : profile.passwordRef.trim()}',
],
for (final profile in next.gatewayProfiles) ...[
'secret_ref::${profile.tokenRef.trim().isEmpty ? '' : profile.tokenRef.trim()}',
'secret_ref::${profile.passwordRef.trim().isEmpty ? '' : profile.passwordRef.trim()}',
],
'secret_ref::gateway_token',
'secret_ref::gateway_password',
}..remove('secret_ref::');
final aiGatewayDraftKeys = <String>{
'secret_ref::${next.aiGateway.apiKeyRef.trim().isEmpty ? AppController.draftAiGatewayApiKeyKeyInternal : next.aiGateway.apiKeyRef.trim()}',
'secret_ref::${previous.aiGateway.apiKeyRef.trim().isEmpty ? AppController.draftAiGatewayApiKeyKeyInternal : previous.aiGateway.apiKeyRef.trim()}',
AppController.draftAiGatewayApiKeyKeyInternal,
};
final hasGatewaySecretDraft = draftSecretValuesInternal.keys.any(
(key) => isGatewayDraftKeyInternal(key),
(key) => gatewayDraftKeys.contains(key) || isGatewayDraftKeyInternal(key),
);
final gatewayChanged =
jsonEncode(
@ -586,48 +603,52 @@ extension AppControllerDesktopSettingsRuntime on AppController {
) ||
previous.assistantExecutionTarget != next.assistantExecutionTarget ||
hasGatewaySecretDraft;
final hasAiGatewaySecretDraft = draftSecretValuesInternal.keys.any(
aiGatewayDraftKeys.contains,
);
final aiGatewayChanged =
previous.aiGateway.toJson().toString() !=
next.aiGateway.toJson().toString() ||
previous.defaultModel != next.defaultModel ||
draftSecretValuesInternal.containsKey(
AppController.draftAiGatewayApiKeyKeyInternal,
);
hasAiGatewaySecretDraft;
pendingGatewayApplyInternal = pendingGatewayApplyInternal || gatewayChanged;
pendingAiGatewayApplyInternal =
pendingAiGatewayApplyInternal || aiGatewayChanged;
}
Future<void> persistDraftSecretsInternal() async {
for (var index = 0; index < kGatewayProfileListLength; index += 1) {
final gatewayToken =
draftSecretValuesInternal[draftGatewayTokenKeyInternal(index)];
final gatewayPassword =
draftSecretValuesInternal[draftGatewayPasswordKeyInternal(index)];
if ((gatewayToken ?? '').isNotEmpty ||
(gatewayPassword ?? '').isNotEmpty) {
await settingsControllerInternal.saveGatewaySecrets(
profileIndex: index,
token: gatewayToken ?? '',
password: gatewayPassword ?? '',
);
for (final entry in draftSecretValuesInternal.entries) {
final key = entry.key.trim();
final value = entry.value.trim();
if (value.isEmpty) {
continue;
}
if (key.startsWith('secret_ref::')) {
final refName = key.substring('secret_ref::'.length).trim();
if (refName.isEmpty) {
continue;
}
await settingsControllerInternal.saveSecretValueByRef(
refName,
value,
provider: settingsControllerInternal.providerNameForSecretInternal(
refName,
),
module: settingsControllerInternal.moduleForSecretInternal(refName),
);
continue;
}
if (key == AppController.draftAiGatewayApiKeyKeyInternal) {
await settingsControllerInternal.saveAiGatewayApiKey(value);
continue;
}
if (key == AppController.draftVaultTokenKeyInternal) {
await settingsControllerInternal.saveVaultToken(value);
continue;
}
if (key == AppController.draftOllamaApiKeyKeyInternal) {
await settingsControllerInternal.saveOllamaCloudApiKey(value);
}
}
final aiGatewayApiKey =
draftSecretValuesInternal[AppController
.draftAiGatewayApiKeyKeyInternal];
if ((aiGatewayApiKey ?? '').isNotEmpty) {
await settingsControllerInternal.saveAiGatewayApiKey(aiGatewayApiKey!);
}
final vaultToken =
draftSecretValuesInternal[AppController.draftVaultTokenKeyInternal];
if ((vaultToken ?? '').isNotEmpty) {
await settingsControllerInternal.saveVaultToken(vaultToken!);
}
final ollamaApiKey =
draftSecretValuesInternal[AppController.draftOllamaApiKeyKeyInternal];
if ((ollamaApiKey ?? '').isNotEmpty) {
await settingsControllerInternal.saveOllamaCloudApiKey(ollamaApiKey!);
}
draftSecretValuesInternal.clear();
}
@ -639,7 +660,12 @@ extension AppControllerDesktopSettingsRuntime on AppController {
'gateway_password_$profileIndex';
bool isGatewayDraftKeyInternal(String key) =>
key.startsWith('gateway_token_') || key.startsWith('gateway_password_');
key.startsWith('secret_ref::gateway_token_') ||
key.startsWith('secret_ref::gateway_password_') ||
key == 'secret_ref::gateway_token' ||
key == 'secret_ref::gateway_password' ||
key.startsWith('gateway_token_') ||
key.startsWith('gateway_password_');
bool authorizedSkillDirectoriesChangedInternal(
SettingsSnapshot previous,

View File

@ -366,7 +366,11 @@ extension AppControllerDesktopSingleAgent on AppController {
}
final apiKey = await loadAiGatewayApiKey();
if (apiKey.isEmpty) {
final allowsAnonymous =
isLoopbackHostInternal(baseUrl.host) &&
(baseUrl.host.trim().toLowerCase() == '127.0.0.1' ||
baseUrl.host.trim().toLowerCase() == 'localhost');
if (apiKey.isEmpty && !allowsAnonymous) {
appendAssistantThreadMessageInternal(
sessionKey,
assistantErrorMessageInternal(
@ -495,8 +499,14 @@ extension AppControllerDesktopSingleAgent on AppController {
HttpHeaders.contentTypeHeader,
'application/json; charset=utf-8',
);
request.headers.set(HttpHeaders.authorizationHeader, 'Bearer $apiKey');
request.headers.set('x-api-key', apiKey);
final trimmedApiKey = apiKey.trim();
if (trimmedApiKey.isNotEmpty) {
request.headers.set(
HttpHeaders.authorizationHeader,
'Bearer $trimmedApiKey',
);
request.headers.set('x-api-key', trimmedApiKey);
}
final payload = <String, dynamic>{
'model': model,
'stream': true,

View File

@ -51,8 +51,9 @@ extension AppControllerDesktopThreadStorage on AppController {
Future<void> applyPersistedAiGatewaySettingsInternal(
SettingsSnapshot snapshot,
) async {
final apiKey = await settingsControllerInternal.loadAiGatewayApiKey();
if (snapshot.aiGateway.baseUrl.trim().isEmpty || apiKey.trim().isEmpty) {
final apiKey = await settingsControllerInternal
.loadEffectiveAiGatewayApiKey();
if (snapshot.aiGateway.baseUrl.trim().isEmpty) {
return;
}
try {
@ -282,8 +283,7 @@ extension AppControllerDesktopThreadStorage on AppController {
String sessionKey,
GatewayChatMessage message, {
bool persistInThreadContext = false,
}
) {
}) {
final key = normalizedAssistantSessionKeyInternal(sessionKey);
final next = List<GatewayChatMessage>.from(
localSessionMessagesInternal[key] ?? const <GatewayChatMessage>[],

View File

@ -63,6 +63,8 @@ class SettingsPageStateInternal extends State<SettingsPage> {
late final TextEditingController gatewaySetupCodeControllerInternal;
late final TextEditingController gatewayHostControllerInternal;
late final TextEditingController gatewayPortControllerInternal;
late final List<TextEditingController> gatewayTokenRefControllersInternal;
late final List<TextEditingController> gatewayPasswordRefControllersInternal;
late final List<TextEditingController> gatewayTokenControllersInternal;
late final List<TextEditingController> gatewayPasswordControllersInternal;
late final TextEditingController vaultTokenControllerInternal;
@ -72,8 +74,13 @@ class SettingsPageStateInternal extends State<SettingsPage> {
externalAcpLabelControllersInternal;
late final Map<String, TextEditingController>
externalAcpEndpointControllersInternal;
late final Map<String, TextEditingController>
externalAcpAuthControllersInternal;
late final List<String> gatewayTokenRefSyncedValuesInternal;
late final List<String> gatewayPasswordRefSyncedValuesInternal;
late final Map<String, String> externalAcpLabelSyncedValuesInternal;
late final Map<String, String> externalAcpEndpointSyncedValuesInternal;
late final Map<String, String> externalAcpAuthSyncedValuesInternal;
late final Map<String, String> externalAcpMessageByProviderInternal;
late final Set<String> externalAcpTestingProvidersInternal;
bool gatewayTestingInternal = false;
@ -123,11 +130,32 @@ class SettingsPageStateInternal extends State<SettingsPage> {
gatewaySetupCodeControllerInternal = TextEditingController();
gatewayHostControllerInternal = TextEditingController();
gatewayPortControllerInternal = TextEditingController();
gatewayTokenRefControllersInternal = List<TextEditingController>.generate(
kGatewayProfileListLength,
(_) => TextEditingController(),
growable: false,
);
gatewayPasswordRefControllersInternal =
List<TextEditingController>.generate(
kGatewayProfileListLength,
(_) => TextEditingController(),
growable: false,
);
gatewayTokenControllersInternal = List<TextEditingController>.generate(
kGatewayProfileListLength,
(_) => TextEditingController(),
growable: false,
);
gatewayTokenRefSyncedValuesInternal = List<String>.filled(
kGatewayProfileListLength,
'',
growable: false,
);
gatewayPasswordRefSyncedValuesInternal = List<String>.filled(
kGatewayProfileListLength,
'',
growable: false,
);
gatewayPasswordControllersInternal = List<TextEditingController>.generate(
kGatewayProfileListLength,
(_) => TextEditingController(),
@ -148,8 +176,10 @@ class SettingsPageStateInternal extends State<SettingsPage> {
runtimeLogFilterControllerInternal = TextEditingController();
externalAcpLabelControllersInternal = <String, TextEditingController>{};
externalAcpEndpointControllersInternal = <String, TextEditingController>{};
externalAcpAuthControllersInternal = <String, TextEditingController>{};
externalAcpLabelSyncedValuesInternal = <String, String>{};
externalAcpEndpointSyncedValuesInternal = <String, String>{};
externalAcpAuthSyncedValuesInternal = <String, String>{};
externalAcpMessageByProviderInternal = <String, String>{};
externalAcpTestingProvidersInternal = <String>{};
}
@ -206,6 +236,12 @@ class SettingsPageStateInternal extends State<SettingsPage> {
gatewaySetupCodeControllerInternal.dispose();
gatewayHostControllerInternal.dispose();
gatewayPortControllerInternal.dispose();
for (final controller in gatewayTokenRefControllersInternal) {
controller.dispose();
}
for (final controller in gatewayPasswordRefControllersInternal) {
controller.dispose();
}
for (final controller in gatewayTokenControllersInternal) {
controller.dispose();
}
@ -221,6 +257,9 @@ class SettingsPageStateInternal extends State<SettingsPage> {
for (final controller in externalAcpEndpointControllersInternal.values) {
controller.dispose();
}
for (final controller in externalAcpAuthControllersInternal.values) {
controller.dispose();
}
super.dispose();
}

View File

@ -20,6 +20,7 @@ import '../../widgets/top_bar.dart';
import 'settings_page_core.dart';
import 'settings_page_sections.dart';
import 'settings_page_gateway_connection.dart';
import 'settings_page_gateway_acp.dart';
import 'settings_page_gateway_llm.dart';
import 'settings_page_presentation.dart';
import 'settings_page_multi_agent.dart';
@ -258,408 +259,6 @@ extension SettingsPageGatewayMixinInternal on SettingsPageStateInternal {
];
}
Widget buildExternalAcpEndpointManagerInternal(
BuildContext context,
AppController controller,
SettingsSnapshot settings,
) {
syncExternalAcpDraftControllersInternal(settings);
final theme = Theme.of(context);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
appText(
'这里保留 Codex、OpenCode 作为内建接入。更多 Provider 请通过向导新增自定义 ACP Server Endpoint历史上真正配置过的 Claude / Gemini 会迁移为自定义条目,空白旧预设会自动清理。',
'Codex and OpenCode stay here as built-in integrations. Add more providers through the custom ACP endpoint wizard; configured legacy Claude and Gemini entries are migrated into custom entries, while empty legacy presets are cleaned up automatically.',
),
style: theme.textTheme.bodyMedium,
),
const SizedBox(height: 16),
Align(
alignment: Alignment.centerLeft,
child: FilledButton.tonalIcon(
key: const ValueKey('external-acp-provider-add-button'),
onPressed: () => showAddExternalAcpProviderWizardInternal(
context,
controller,
settings,
),
icon: const Icon(Icons.add_rounded),
label: Text(appText('添加更多自定义配置', 'Add more custom configurations')),
),
),
const SizedBox(height: 16),
...settings.externalAcpEndpoints.map(
(profile) => Padding(
key: ValueKey('external-acp-card-${profile.providerKey}'),
padding: const EdgeInsets.only(bottom: 12),
child: buildExternalAcpProviderCardInternal(
context,
controller,
settings,
profile,
),
),
),
],
);
}
Widget buildExternalAcpProviderCardInternal(
BuildContext context,
AppController controller,
SettingsSnapshot settings,
ExternalAcpEndpointProfile profile,
) {
final provider = profile.toProvider();
final labelController =
externalAcpLabelControllersInternal[profile.providerKey]!;
final endpointController =
externalAcpEndpointControllersInternal[profile.providerKey]!;
final message =
externalAcpMessageByProviderInternal[profile.providerKey] ?? '';
final testing = externalAcpTestingProvidersInternal.contains(
profile.providerKey,
);
final configured = endpointController.text.trim().isNotEmpty;
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(18),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Text(
provider.label,
style: Theme.of(context).textTheme.titleMedium,
),
),
if (!profile.isPreset) ...[
IconButton(
tooltip: appText('删除 Provider', 'Remove provider'),
onPressed: () => saveSettingsInternal(
controller,
settings.copyWith(
externalAcpEndpoints: settings.externalAcpEndpoints
.where(
(item) => item.providerKey != profile.providerKey,
)
.toList(growable: false),
),
),
icon: const Icon(Icons.delete_outline_rounded),
),
const SizedBox(width: 4),
],
StatusChipInternal(
label: configured
? appText('已配置', 'Configured')
: appText('未配置', 'Empty'),
tone: configured
? StatusChipToneInternal.ready
: StatusChipToneInternal.idle,
),
],
),
const SizedBox(height: 12),
TextField(
key: ValueKey('external-acp-label-${profile.providerKey}'),
controller: labelController,
decoration: InputDecoration(
labelText: appText('显示名称', 'Display name'),
),
onChanged: (_) => setStateInternal(() {}),
),
TextField(
key: ValueKey('external-acp-endpoint-${profile.providerKey}'),
controller: endpointController,
decoration: InputDecoration(
labelText: appText('ACP Server Endpoint', 'ACP Server Endpoint'),
),
onChanged: (_) => setStateInternal(() {}),
),
Text(
appText(
'示例ws://127.0.0.1:9001、wss://acp.example.com/rpc、http://127.0.0.1:8080、https://agent.example.com',
'Examples: ws://127.0.0.1:9001, wss://acp.example.com/rpc, http://127.0.0.1:8080, https://agent.example.com',
),
style: Theme.of(context).textTheme.bodySmall,
),
const SizedBox(height: 12),
Wrap(
spacing: 10,
runSpacing: 10,
children: [
OutlinedButton(
key: ValueKey('external-acp-test-${profile.providerKey}'),
onPressed: testing
? null
: () => testExternalAcpEndpointInternal(
controller,
profile.providerKey,
),
child: Text(
testing
? appText('测试中...', 'Testing...')
: appText('测试连接', 'Test Connection'),
),
),
FilledButton(
key: ValueKey('external-acp-apply-${profile.providerKey}'),
onPressed: () => saveExternalAcpEndpointInternal(
controller,
settings,
provider,
profile,
),
child: Text(appText('保存并生效', 'Save & apply')),
),
],
),
if (message.trim().isNotEmpty) ...[
const SizedBox(height: 10),
Text(
message,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
],
],
),
);
}
Future<void> saveExternalAcpEndpointInternal(
AppController controller,
SettingsSnapshot settings,
SingleAgentProvider provider,
ExternalAcpEndpointProfile profile,
) async {
final label =
externalAcpLabelControllersInternal[profile.providerKey]?.text ??
profile.label;
final endpoint =
externalAcpEndpointControllersInternal[profile.providerKey]?.text ??
profile.endpoint;
final next = settings.copyWithExternalAcpEndpointForProvider(
provider,
profile.copyWith(label: label, endpoint: endpoint),
);
await saveSettingsInternal(controller, next);
await handleTopLevelApplyInternal(controller);
if (!mounted) {
return;
}
setStateInternal(() {
externalAcpMessageByProviderInternal[profile.providerKey] = appText(
'配置已保存并生效。',
'Configuration saved and applied.',
);
});
}
Future<void> testExternalAcpEndpointInternal(
AppController controller,
String providerKey,
) async {
final endpointText =
externalAcpEndpointControllersInternal[providerKey]?.text.trim() ?? '';
final endpoint = Uri.tryParse(endpointText);
if (endpoint == null || endpoint.host.trim().isEmpty) {
setStateInternal(() {
externalAcpMessageByProviderInternal[providerKey] = appText(
'请输入有效的 ACP Server Endpoint。',
'Enter a valid ACP server endpoint.',
);
});
return;
}
setStateInternal(() {
externalAcpTestingProvidersInternal.add(providerKey);
externalAcpMessageByProviderInternal.remove(providerKey);
});
try {
final capabilities = await controller.gatewayAcpClientInternal
.loadCapabilities(forceRefresh: true, endpointOverride: endpoint);
if (!mounted) {
return;
}
setStateInternal(() {
externalAcpMessageByProviderInternal[providerKey] = appText(
capabilities.providers.isEmpty
? '连接成功。'
: '连接成功,可用 Provider: ${capabilities.providers.map((item) => item.label).join(' / ')}',
capabilities.providers.isEmpty
? 'Connection succeeded.'
: 'Connection succeeded. Providers: ${capabilities.providers.map((item) => item.label).join(' / ')}',
);
});
} catch (error) {
if (!mounted) {
return;
}
setStateInternal(() {
externalAcpMessageByProviderInternal[providerKey] = '$error';
});
} finally {
if (mounted) {
setStateInternal(() {
externalAcpTestingProvidersInternal.remove(providerKey);
});
}
}
}
Future<void> showAddExternalAcpProviderWizardInternal(
BuildContext context,
AppController controller,
SettingsSnapshot settings,
) async {
final nameController = TextEditingController();
final endpointController = TextEditingController();
var attemptedSubmit = false;
try {
final profile = await showDialog<ExternalAcpEndpointProfile>(
context: context,
builder: (dialogContext) {
return StatefulBuilder(
builder: (context, setDialogState) {
final name = nameController.text.trim();
final endpoint = endpointController.text.trim();
final endpointValid =
endpoint.isEmpty || isSupportedExternalAcpEndpoint(endpoint);
final canSubmit =
name.isNotEmpty && endpoint.isNotEmpty && endpointValid;
return AlertDialog(
title: Text(
appText('添加自定义 ACP Endpoint', 'Add custom ACP endpoint'),
),
content: SizedBox(
width: 420,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
appText(
'通过向导新增更多外部 Agent Provider。先填写显示名称再输入可访问的 ACP Server Endpoint。',
'Use this wizard to add more external agent providers. Start with a display name, then enter a reachable ACP server endpoint.',
),
),
const SizedBox(height: 16),
Text(
appText('步骤 1 · 显示名称', 'Step 1 · Display name'),
style: Theme.of(context).textTheme.titleSmall,
),
const SizedBox(height: 8),
TextField(
key: const ValueKey('external-acp-wizard-name-field'),
controller: nameController,
autofocus: true,
decoration: InputDecoration(
hintText: appText(
'例如Claude Sonnet / Lab Agent',
'For example: Claude Sonnet / Lab Agent',
),
),
onChanged: (_) => setDialogState(() {}),
),
const SizedBox(height: 16),
Text(
appText(
'步骤 2 · ACP Server Endpoint',
'Step 2 · ACP Server Endpoint',
),
style: Theme.of(context).textTheme.titleSmall,
),
const SizedBox(height: 8),
TextField(
key: const ValueKey(
'external-acp-wizard-endpoint-field',
),
controller: endpointController,
decoration: InputDecoration(
hintText: 'ws://127.0.0.1:9001',
errorText: attemptedSubmit && endpoint.isEmpty
? appText(
'请输入 ACP Server Endpoint。',
'Enter an ACP server endpoint.',
)
: attemptedSubmit && !endpointValid
? appText(
'仅支持 ws / wss / http / https。',
'Only ws / wss / http / https are supported.',
)
: null,
),
onChanged: (_) => setDialogState(() {}),
),
const SizedBox(height: 8),
Text(
appText(
'支持协议ws、wss、http、https。新增后会出现在下方列表并和助手页的 provider 菜单保持一致。',
'Supported schemes: ws, wss, http, https. The new entry appears in the list below and stays aligned with the assistant provider menu.',
),
style: Theme.of(context).textTheme.bodySmall,
),
],
),
),
actions: [
TextButton(
onPressed: () => Navigator.of(dialogContext).pop(),
child: Text(appText('取消', 'Cancel')),
),
FilledButton(
key: const ValueKey('external-acp-wizard-confirm-button'),
onPressed: canSubmit
? () {
Navigator.of(dialogContext).pop(
buildCustomExternalAcpEndpointProfile(
settings.externalAcpEndpoints,
label: name,
endpoint: endpoint,
),
);
}
: () {
setDialogState(() {
attemptedSubmit = true;
});
},
child: Text(appText('添加', 'Add')),
),
],
);
},
);
},
);
if (profile == null) {
return;
}
await saveSettingsInternal(
controller,
settings.copyWith(
externalAcpEndpoints: <ExternalAcpEndpointProfile>[
...settings.externalAcpEndpoints,
profile,
],
),
);
} finally {
nameController.dispose();
endpointController.dispose();
}
}
Widget buildLlmEndpointManagerInternal(
BuildContext context,
AppController controller,

View File

@ -0,0 +1,441 @@
// ignore_for_file: unused_import, unnecessary_import
import 'dart:async';
import 'package:flutter/material.dart';
import '../../app/app_controller.dart';
import '../../i18n/app_language.dart';
import '../../runtime/runtime_models.dart';
import 'settings_page_core.dart';
import 'settings_page_support.dart';
import 'settings_page_widgets.dart';
extension SettingsPageGatewayAcpMixinInternal on SettingsPageStateInternal {
Widget buildExternalAcpEndpointManagerInternal(
BuildContext context,
AppController controller,
SettingsSnapshot settings,
) {
syncExternalAcpDraftControllersInternal(settings);
final theme = Theme.of(context);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
appText(
'这里保留 Codex、OpenCode 作为内建接入。更多 Provider 请通过向导新增自定义 ACP Server Endpoint历史上真正配置过的 Claude / Gemini 会迁移为自定义条目,空白旧预设会自动清理。',
'Codex and OpenCode stay here as built-in integrations. Add more providers through the custom ACP endpoint wizard; configured legacy Claude and Gemini entries are migrated into custom entries, while empty legacy presets are cleaned up automatically.',
),
style: theme.textTheme.bodyMedium,
),
const SizedBox(height: 16),
Align(
alignment: Alignment.centerLeft,
child: FilledButton.tonalIcon(
key: const ValueKey('external-acp-provider-add-button'),
onPressed: () => showAddExternalAcpProviderWizardInternal(
context,
controller,
settings,
),
icon: const Icon(Icons.add_rounded),
label: Text(appText('添加更多自定义配置', 'Add more custom configurations')),
),
),
const SizedBox(height: 16),
...settings.externalAcpEndpoints.map(
(profile) => Padding(
key: ValueKey('external-acp-card-${profile.providerKey}'),
padding: const EdgeInsets.only(bottom: 12),
child: buildExternalAcpProviderCardInternal(
context,
controller,
settings,
profile,
),
),
),
],
);
}
Widget buildExternalAcpProviderCardInternal(
BuildContext context,
AppController controller,
SettingsSnapshot settings,
ExternalAcpEndpointProfile profile,
) {
final provider = profile.toProvider();
final labelController =
externalAcpLabelControllersInternal[profile.providerKey]!;
final endpointController =
externalAcpEndpointControllersInternal[profile.providerKey]!;
final authController =
externalAcpAuthControllersInternal[profile.providerKey]!;
final message =
externalAcpMessageByProviderInternal[profile.providerKey] ?? '';
final testing = externalAcpTestingProvidersInternal.contains(
profile.providerKey,
);
final configured = endpointController.text.trim().isNotEmpty;
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(18),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Text(
provider.label,
style: Theme.of(context).textTheme.titleMedium,
),
),
if (!profile.isPreset) ...[
IconButton(
tooltip: appText('删除 Provider', 'Remove provider'),
onPressed: () => saveSettingsInternal(
controller,
settings.copyWith(
externalAcpEndpoints: settings.externalAcpEndpoints
.where(
(item) => item.providerKey != profile.providerKey,
)
.toList(growable: false),
),
),
icon: const Icon(Icons.delete_outline_rounded),
),
const SizedBox(width: 4),
],
StatusChipInternal(
label: configured
? appText('已配置', 'Configured')
: appText('未配置', 'Empty'),
tone: configured
? StatusChipToneInternal.ready
: StatusChipToneInternal.idle,
),
],
),
const SizedBox(height: 12),
TextField(
key: ValueKey('external-acp-label-${profile.providerKey}'),
controller: labelController,
decoration: InputDecoration(
labelText: appText('显示名称', 'Display name'),
),
onChanged: (_) => setStateInternal(() {}),
),
TextField(
key: ValueKey('external-acp-endpoint-${profile.providerKey}'),
controller: endpointController,
decoration: InputDecoration(
labelText: appText('ACP Server Endpoint', 'ACP Server Endpoint'),
),
onChanged: (_) => setStateInternal(() {}),
),
const SizedBox(height: 12),
TextField(
key: ValueKey('external-acp-auth-${profile.providerKey}'),
controller: authController,
decoration: InputDecoration(
labelText: appText('AUTH可为空', 'AUTH (optional)'),
),
onChanged: (_) => setStateInternal(() {}),
),
Text(
appText(
'示例ws://127.0.0.1:9001、wss://acp.example.com/rpc、http://127.0.0.1:8080、https://agent.example.com。AUTH 填 secret ref 名;为空时不发送 Authorization。',
'Examples: ws://127.0.0.1:9001, wss://acp.example.com/rpc, http://127.0.0.1:8080, https://agent.example.com. AUTH stores a secret ref name; leave it empty to omit Authorization.',
),
style: Theme.of(context).textTheme.bodySmall,
),
const SizedBox(height: 12),
Wrap(
spacing: 10,
runSpacing: 10,
children: [
OutlinedButton(
key: ValueKey('external-acp-test-${profile.providerKey}'),
onPressed: testing
? null
: () => testExternalAcpEndpointInternal(
controller,
profile.providerKey,
),
child: Text(
testing
? appText('测试中...', 'Testing...')
: appText('测试连接', 'Test Connection'),
),
),
FilledButton(
key: ValueKey('external-acp-apply-${profile.providerKey}'),
onPressed: () => saveExternalAcpEndpointInternal(
controller,
settings,
provider,
profile,
),
child: Text(appText('保存并生效', 'Save & apply')),
),
],
),
if (message.trim().isNotEmpty) ...[
const SizedBox(height: 10),
Text(
message,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
],
],
),
);
}
Future<void> saveExternalAcpEndpointInternal(
AppController controller,
SettingsSnapshot settings,
SingleAgentProvider provider,
ExternalAcpEndpointProfile profile,
) async {
final label =
externalAcpLabelControllersInternal[profile.providerKey]?.text ??
profile.label;
final endpoint =
externalAcpEndpointControllersInternal[profile.providerKey]?.text ??
profile.endpoint;
final authRef =
externalAcpAuthControllersInternal[profile.providerKey]?.text ??
profile.authRef;
final next = settings.copyWithExternalAcpEndpointForProvider(
provider,
profile.copyWith(label: label, endpoint: endpoint, authRef: authRef),
);
await saveSettingsInternal(controller, next);
await handleTopLevelApplyInternal(controller);
if (!mounted) {
return;
}
setStateInternal(() {
externalAcpMessageByProviderInternal[profile.providerKey] = appText(
'配置已保存并生效。',
'Configuration saved and applied.',
);
});
}
Future<void> testExternalAcpEndpointInternal(
AppController controller,
String providerKey,
) async {
final endpointText =
externalAcpEndpointControllersInternal[providerKey]?.text.trim() ?? '';
final authRef =
externalAcpAuthControllersInternal[providerKey]?.text.trim() ?? '';
final endpoint = Uri.tryParse(endpointText);
if (endpoint == null || endpoint.host.trim().isEmpty) {
setStateInternal(() {
externalAcpMessageByProviderInternal[providerKey] = appText(
'请输入有效的 ACP Server Endpoint。',
'Enter a valid ACP server endpoint.',
);
});
return;
}
setStateInternal(() {
externalAcpTestingProvidersInternal.add(providerKey);
externalAcpMessageByProviderInternal.remove(providerKey);
});
try {
final authorization = authRef.isEmpty
? ''
: await controller.settingsController.resolveSecretValueInternal(
refName: authRef,
);
final capabilities = await controller.gatewayAcpClientInternal
.loadCapabilities(
forceRefresh: true,
endpointOverride: endpoint,
authorizationOverride: authorization,
);
if (!mounted) {
return;
}
setStateInternal(() {
externalAcpMessageByProviderInternal[providerKey] = appText(
capabilities.providers.isEmpty
? '连接成功。'
: '连接成功,可用 Provider: ${capabilities.providers.map((item) => item.label).join(' / ')}',
capabilities.providers.isEmpty
? 'Connection succeeded.'
: 'Connection succeeded. Providers: ${capabilities.providers.map((item) => item.label).join(' / ')}',
);
});
} catch (error) {
if (!mounted) {
return;
}
setStateInternal(() {
externalAcpMessageByProviderInternal[providerKey] = '$error';
});
} finally {
if (mounted) {
setStateInternal(() {
externalAcpTestingProvidersInternal.remove(providerKey);
});
}
}
}
Future<void> showAddExternalAcpProviderWizardInternal(
BuildContext context,
AppController controller,
SettingsSnapshot settings,
) async {
final nameController = TextEditingController();
final endpointController = TextEditingController();
var attemptedSubmit = false;
try {
final profile = await showDialog<ExternalAcpEndpointProfile>(
context: context,
builder: (dialogContext) {
return StatefulBuilder(
builder: (context, setDialogState) {
final name = nameController.text.trim();
final endpoint = endpointController.text.trim();
final endpointValid =
endpoint.isEmpty || isSupportedExternalAcpEndpoint(endpoint);
final canSubmit =
name.isNotEmpty && endpoint.isNotEmpty && endpointValid;
return AlertDialog(
title: Text(
appText('添加自定义 ACP Endpoint', 'Add custom ACP endpoint'),
),
content: SizedBox(
width: 420,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
appText(
'通过向导新增更多外部 Agent Provider。先填写显示名称再输入可访问的 ACP Server Endpoint。',
'Use this wizard to add more external agent providers. Start with a display name, then enter a reachable ACP server endpoint.',
),
),
const SizedBox(height: 16),
Text(
appText('步骤 1 · 显示名称', 'Step 1 · Display name'),
style: Theme.of(context).textTheme.titleSmall,
),
const SizedBox(height: 8),
TextField(
key: const ValueKey('external-acp-wizard-name-field'),
controller: nameController,
autofocus: true,
decoration: InputDecoration(
hintText: appText(
'例如Claude Sonnet / Lab Agent',
'For example: Claude Sonnet / Lab Agent',
),
),
onChanged: (_) => setDialogState(() {}),
),
const SizedBox(height: 16),
Text(
appText(
'步骤 2 · ACP Server Endpoint',
'Step 2 · ACP Server Endpoint',
),
style: Theme.of(context).textTheme.titleSmall,
),
const SizedBox(height: 8),
TextField(
key: const ValueKey(
'external-acp-wizard-endpoint-field',
),
controller: endpointController,
decoration: InputDecoration(
hintText: 'ws://127.0.0.1:9001',
errorText: attemptedSubmit && endpoint.isEmpty
? appText(
'请输入 ACP Server Endpoint。',
'Enter an ACP server endpoint.',
)
: attemptedSubmit && !endpointValid
? appText(
'仅支持 ws / wss / http / https。',
'Only ws / wss / http / https are supported.',
)
: null,
),
onChanged: (_) => setDialogState(() {}),
),
const SizedBox(height: 8),
Text(
appText(
'支持协议ws、wss、http、https。新增后会出现在下方列表并和助手页的 provider 菜单保持一致。',
'Supported schemes: ws, wss, http, https. The new entry appears in the list below and stays aligned with the assistant provider menu.',
),
style: Theme.of(context).textTheme.bodySmall,
),
],
),
),
actions: [
TextButton(
onPressed: () => Navigator.of(dialogContext).pop(),
child: Text(appText('取消', 'Cancel')),
),
FilledButton(
key: const ValueKey('external-acp-wizard-confirm-button'),
onPressed: canSubmit
? () {
Navigator.of(dialogContext).pop(
buildCustomExternalAcpEndpointProfile(
settings.externalAcpEndpoints,
label: name,
endpoint: endpoint,
),
);
}
: () {
setDialogState(() {
attemptedSubmit = true;
});
},
child: Text(appText('添加', 'Add')),
),
],
);
},
);
},
);
if (profile == null) {
return;
}
await saveSettingsInternal(
controller,
settings.copyWith(
externalAcpEndpoints: <ExternalAcpEndpointProfile>[
...settings.externalAcpEndpoints,
profile,
],
),
);
} finally {
nameController.dispose();
endpointController.dispose();
}
}
}

View File

@ -140,10 +140,7 @@ extension SettingsPageGatewayConnectionMixinInternal
setupCodeFeatureEnabled) ...[
if (widget.showSectionTabs)
SectionTabs(
items: [
appText('配置码', 'Setup Code'),
appText('手动配置', 'Manual'),
],
items: [appText('配置码', 'Setup Code'), appText('手动配置', 'Manual')],
value: useSetupCode
? appText('配置码', 'Setup Code')
: appText('手动配置', 'Manual'),
@ -268,10 +265,36 @@ extension SettingsPageGatewayConnectionMixinInternal
),
],
const SizedBox(height: 16),
TextField(
key: const ValueKey('gateway-token-ref-field'),
controller: gatewayTokenRefControllersInternal[selectedProfileIndex],
decoration: InputDecoration(
labelText: appText('共享 Token 引用', 'Shared Token Ref'),
),
onChanged: (_) => unawaited(
saveGatewayDraftInternal(controller, settings).catchError((_) {}),
),
onSubmitted: (_) => saveGatewayDraftInternal(controller, settings),
),
const SizedBox(height: 12),
TextField(
key: const ValueKey('gateway-password-ref-field'),
controller:
gatewayPasswordRefControllersInternal[selectedProfileIndex],
decoration: InputDecoration(
labelText: appText('密码引用', 'Password Ref'),
),
onChanged: (_) => unawaited(
saveGatewayDraftInternal(controller, settings).catchError((_) {}),
),
onSubmitted: (_) => saveGatewayDraftInternal(controller, settings),
),
const SizedBox(height: 16),
buildSecureFieldInternal(
fieldKey: const ValueKey('gateway-shared-token-field'),
controller: gatewayTokenController,
label: appText('共享 Token', 'Shared Token'),
label:
'${appText('共享 Token', 'Shared Token')} (${gatewayTokenRefControllersInternal[selectedProfileIndex].text.trim().isEmpty ? gatewayProfile.tokenRef : gatewayTokenRefControllersInternal[selectedProfileIndex].text.trim()})',
hasStoredValue: hasStoredGatewayToken,
fieldState: gatewayTokenState,
onStateChanged: (value) => setStateInternal(
@ -297,7 +320,8 @@ extension SettingsPageGatewayConnectionMixinInternal
buildSecureFieldInternal(
fieldKey: const ValueKey('gateway-password-field'),
controller: gatewayPasswordController,
label: appText('密码', 'Password'),
label:
'${appText('密码', 'Password')} (${gatewayPasswordRefControllersInternal[selectedProfileIndex].text.trim().isEmpty ? gatewayProfile.passwordRef : gatewayPasswordRefControllersInternal[selectedProfileIndex].text.trim()})',
hasStoredValue: hasStoredGatewayPassword,
fieldState: gatewayPasswordState,
onStateChanged: (value) => setStateInternal(
@ -395,6 +419,19 @@ extension SettingsPageGatewayConnectionMixinInternal
settings.copyWith(vault: settings.vault.copyWith(namespace: value)),
),
),
EditableFieldInternal(
fieldKey: const ValueKey('vault-token-ref-field'),
label: appText('Root Token Ref', 'Root Token Ref'),
value: settings.vault.tokenRef,
onSubmitted: (value) => saveSettingsInternal(
controller,
settings.copyWith(
vault: settings.vault.copyWith(
tokenRef: value.trim().isEmpty ? 'vault_token' : value.trim(),
),
),
),
),
Container(
width: double.infinity,
padding: const EdgeInsets.all(12),
@ -405,8 +442,8 @@ extension SettingsPageGatewayConnectionMixinInternal
),
child: Text(
appText(
'当前固定使用 token 模式,安全引用保持为 vault_token',
'The current integration uses token auth, with the secure reference fixed to vault_token.',
'当前使用 token 模式root token 会写入当前 Token Ref 对应的安全引用,不进入普通 settings 持久层',
'Token auth is used here. The root token is stored under the current Token Ref in secure storage and never persisted in plain settings.',
),
style: theme.textTheme.bodySmall,
),

View File

@ -58,8 +58,21 @@ extension SettingsPageGatewayLlmMixinInternal on SettingsPageStateInternal {
final filteredModels = filterAiGatewayModelsInternal(
settings.aiGateway.availableModels,
);
final effectiveApiKeyRef =
aiGatewayApiKeyRefControllerInternal.text.trim().isEmpty
? (settings.aiGateway.apiKeyRef.trim().isEmpty
? 'ai_gateway_api_key'
: settings.aiGateway.apiKeyRef)
: aiGatewayApiKeyRefControllerInternal.text.trim();
final hasStoredAiGatewayApiKey =
controller.settingsController.secureRefs['ai_gateway_api_key'] != null;
controller.settingsController.secureRefs[effectiveApiKeyRef] != null ||
(effectiveApiKeyRef == 'ai_gateway_api_key' &&
controller.settingsController.secureRefs['ai_gateway_api_key'] !=
null) ||
controller
.settingsController
.secureRefs[kAccountManagedSecretTargetAIGatewayAccessToken] !=
null;
final statusTheme = aiGatewayFeedbackThemeInternal(
context,
aiGatewayTestMessageInternal.isEmpty
@ -108,7 +121,7 @@ extension SettingsPageGatewayLlmMixinInternal on SettingsPageStateInternal {
fieldKey: const ValueKey('ai-gateway-api-key-field'),
controller: aiGatewayApiKeyControllerInternal,
label:
'${appText('LLM API Token', 'LLM API Token')} (${aiGatewayApiKeyRefControllerInternal.text.trim().isEmpty ? settings.aiGateway.apiKeyRef : aiGatewayApiKeyRefControllerInternal.text.trim()})',
'${appText('LLM API Token', 'LLM API Token')} ($effectiveApiKeyRef)',
hasStoredValue: hasStoredAiGatewayApiKey,
fieldState: aiGatewayApiKeyStateInternal,
onStateChanged: (value) =>
@ -126,6 +139,14 @@ extension SettingsPageGatewayLlmMixinInternal on SettingsPageStateInternal {
),
),
const SizedBox(height: 12),
Text(
appText(
'Token Ref 可留空。留空时回退读取 ai_gateway_api_key仅 127.0.0.1 / localhost 允许无认证访问。',
'Token Ref can be empty. Empty falls back to ai_gateway_api_key, and only 127.0.0.1 / localhost may connect without auth.',
),
style: Theme.of(context).textTheme.bodySmall,
),
const SizedBox(height: 12),
buildSettingsSectionActionsInternal(
controller: controller,
testKey: const ValueKey('ai-gateway-test-button'),

View File

@ -243,10 +243,25 @@ XWorkmate Privacy Policy
}
void resetSecureFieldUiAfterPersistInternal(AppController controller) {
final aiGatewayRef = controller.settings.aiGateway.apiKeyRef.trim().isEmpty
? 'ai_gateway_api_key'
: controller.settings.aiGateway.apiKeyRef.trim();
final vaultTokenRef = controller.settings.vault.tokenRef.trim().isEmpty
? 'vault_token'
: controller.settings.vault.tokenRef.trim();
final hasStoredAiGatewayApiKey =
controller.settingsController.secureRefs['ai_gateway_api_key'] != null;
controller.settingsController.secureRefs[aiGatewayRef] != null ||
(aiGatewayRef == 'ai_gateway_api_key' &&
controller.settingsController.secureRefs['ai_gateway_api_key'] !=
null) ||
controller
.settingsController
.secureRefs[kAccountManagedSecretTargetAIGatewayAccessToken] !=
null;
final hasStoredVaultToken =
controller.settingsController.secureRefs['vault_token'] != null;
controller.settingsController.secureRefs[vaultTokenRef] != null ||
(vaultTokenRef == 'vault_token' &&
controller.settingsController.secureRefs['vault_token'] != null);
final hasStoredOllamaApiKey =
controller.settingsController.secureRefs['ollama_cloud_api_key'] !=
null;
@ -305,6 +320,24 @@ XWorkmate Privacy Policy
syncedValue: gatewayPortSyncedValueInternal,
onSyncedValueChanged: (value) => gatewayPortSyncedValueInternal = value,
);
syncDraftControllerValueInternal(
gatewayTokenRefControllersInternal[selectedGatewayProfileIndexInternal],
current.tokenRef,
syncedValue:
gatewayTokenRefSyncedValuesInternal[selectedGatewayProfileIndexInternal],
onSyncedValueChanged: (value) =>
gatewayTokenRefSyncedValuesInternal[selectedGatewayProfileIndexInternal] =
value,
);
syncDraftControllerValueInternal(
gatewayPasswordRefControllersInternal[selectedGatewayProfileIndexInternal],
current.passwordRef,
syncedValue:
gatewayPasswordRefSyncedValuesInternal[selectedGatewayProfileIndexInternal],
onSyncedValueChanged: (value) =>
gatewayPasswordRefSyncedValuesInternal[selectedGatewayProfileIndexInternal] =
value,
);
}
void syncExternalAcpDraftControllersInternal(SettingsSnapshot settings) {
@ -319,6 +352,10 @@ XWorkmate Privacy Policy
);
final endpointController = externalAcpEndpointControllersInternal
.putIfAbsent(key, () => TextEditingController());
final authController = externalAcpAuthControllersInternal.putIfAbsent(
key,
() => TextEditingController(),
);
syncDraftControllerValueInternal(
labelController,
profile.label,
@ -333,6 +370,13 @@ XWorkmate Privacy Policy
onSyncedValueChanged: (value) =>
externalAcpEndpointSyncedValuesInternal[key] = value,
);
syncDraftControllerValueInternal(
authController,
profile.authRef,
syncedValue: externalAcpAuthSyncedValuesInternal[key] ?? '',
onSyncedValueChanged: (value) =>
externalAcpAuthSyncedValuesInternal[key] = value,
);
}
disposeRemovedExternalAcpDraftsInternal(
externalAcpLabelControllersInternal,
@ -342,12 +386,19 @@ XWorkmate Privacy Policy
externalAcpEndpointControllersInternal,
activeKeys,
);
disposeRemovedExternalAcpDraftsInternal(
externalAcpAuthControllersInternal,
activeKeys,
);
externalAcpLabelSyncedValuesInternal.removeWhere(
(key, _) => !activeKeys.contains(key),
);
externalAcpEndpointSyncedValuesInternal.removeWhere(
(key, _) => !activeKeys.contains(key),
);
externalAcpAuthSyncedValuesInternal.removeWhere(
(key, _) => !activeKeys.contains(key),
);
externalAcpMessageByProviderInternal.removeWhere(
(key, _) => !activeKeys.contains(key),
);
@ -489,6 +540,14 @@ XWorkmate Privacy Policy
? (decoded?.port ?? current.port)
: (parsedPort ?? fallbackPort),
tls: useSetupCode ? (decoded?.tls ?? tls) : tls,
tokenRef:
gatewayTokenRefControllersInternal[selectedGatewayProfileIndexInternal]
.text
.trim(),
passwordRef:
gatewayPasswordRefControllersInternal[selectedGatewayProfileIndexInternal]
.text
.trim(),
);
}
@ -509,6 +568,10 @@ XWorkmate Privacy Policy
gatewaySetupCodeSyncedValueInternal = profile.setupCode;
gatewayHostSyncedValueInternal = profile.host;
gatewayPortSyncedValueInternal = '${profile.port}';
gatewayTokenRefSyncedValuesInternal[selectedGatewayProfileIndexInternal] =
profile.tokenRef;
gatewayPasswordRefSyncedValuesInternal[selectedGatewayProfileIndexInternal] =
profile.passwordRef;
gatewayTestStateInternal = 'idle';
gatewayTestMessageInternal = '';
gatewayTestEndpointInternal = '';
@ -672,14 +735,13 @@ XWorkmate Privacy Policy
gatewayPasswordState,
);
if (token.isEmpty) {
token = await controller.settingsController.loadGatewayToken(
token = await controller.settingsController.loadEffectiveGatewayToken(
profileIndex: selectedProfileIndex,
);
}
if (password.isEmpty) {
password = await controller.settingsController.loadGatewayPassword(
profileIndex: selectedProfileIndex,
);
password = await controller.settingsController
.loadEffectiveGatewayPassword(profileIndex: selectedProfileIndex);
}
setStateInternal(() => gatewayTestingInternal = true);
try {

View File

@ -82,9 +82,13 @@ class GatewayAcpMultiAgentRequest {
}
class GatewayAcpClient {
GatewayAcpClient({required this.endpointResolver});
GatewayAcpClient({
required this.endpointResolver,
this.authorizationResolver,
});
final Uri? Function() endpointResolver;
final Future<String?> Function(Uri endpoint)? authorizationResolver;
int _requestCounter = 0;
GatewayAcpCapabilities _cachedCapabilities =
@ -94,6 +98,7 @@ class GatewayAcpClient {
Future<GatewayAcpCapabilities> loadCapabilities({
bool forceRefresh = false,
Uri? endpointOverride,
String authorizationOverride = '',
}) async {
if (!forceRefresh &&
_capabilitiesRefreshedAt != null &&
@ -110,6 +115,7 @@ class GatewayAcpClient {
),
onNotification: (_) {},
endpointOverride: endpointOverride,
authorizationOverride: authorizationOverride,
);
final result = asMap(response['result']);
final caps = asMap(result['capabilities']);
@ -241,6 +247,7 @@ class GatewayAcpClient {
required String sessionId,
required String threadId,
Uri? endpointOverride,
String authorizationOverride = '',
}) async {
await _requestWithFallback(
_GatewayAcpRpcRequest(
@ -250,6 +257,7 @@ class GatewayAcpClient {
),
onNotification: (_) {},
endpointOverride: endpointOverride,
authorizationOverride: authorizationOverride,
);
}
@ -257,6 +265,7 @@ class GatewayAcpClient {
required String sessionId,
required String threadId,
Uri? endpointOverride,
String authorizationOverride = '',
}) async {
await _requestWithFallback(
_GatewayAcpRpcRequest(
@ -266,6 +275,7 @@ class GatewayAcpClient {
),
onNotification: (_) {},
endpointOverride: endpointOverride,
authorizationOverride: authorizationOverride,
);
}
@ -274,6 +284,7 @@ class GatewayAcpClient {
required Map<String, dynamic> params,
void Function(Map<String, dynamic>)? onNotification,
Uri? endpointOverride,
String authorizationOverride = '',
}) async {
return _requestWithFallback(
_GatewayAcpRpcRequest(
@ -283,6 +294,7 @@ class GatewayAcpClient {
),
onNotification: onNotification ?? (_) {},
endpointOverride: endpointOverride,
authorizationOverride: authorizationOverride,
);
}
@ -292,18 +304,21 @@ class GatewayAcpClient {
_GatewayAcpRpcRequest request, {
required void Function(Map<String, dynamic>) onNotification,
Uri? endpointOverride,
String authorizationOverride = '',
}) async {
try {
return await _requestViaWebSocket(
request,
onNotification: onNotification,
endpointOverride: endpointOverride,
authorizationOverride: authorizationOverride,
);
} catch (_) {
return _requestViaHttp(
request,
onNotification: onNotification,
endpointOverride: endpointOverride,
authorizationOverride: authorizationOverride,
);
}
}
@ -312,6 +327,7 @@ class GatewayAcpClient {
_GatewayAcpRpcRequest request, {
required void Function(Map<String, dynamic>) onNotification,
Uri? endpointOverride,
String authorizationOverride = '',
}) async {
final endpoint = _resolveWebSocketRpcEndpoint(endpointOverride);
if (endpoint == null) {
@ -321,13 +337,25 @@ class GatewayAcpClient {
);
}
final socket = await WebSocket.connect(endpoint.toString()).timeout(
const Duration(seconds: 6),
onTimeout: () => throw const GatewayAcpException(
'ACP websocket connect timeout',
code: 'ACP_WS_CONNECT_TIMEOUT',
),
final authorization = await _resolveAuthorizationHeader(
endpoint,
authorizationOverride: authorizationOverride,
);
final socket =
await WebSocket.connect(
endpoint.toString(),
headers: authorization.isEmpty
? null
: <String, dynamic>{
HttpHeaders.authorizationHeader: authorization,
},
).timeout(
const Duration(seconds: 6),
onTimeout: () => throw const GatewayAcpException(
'ACP websocket connect timeout',
code: 'ACP_WS_CONNECT_TIMEOUT',
),
);
final completer = Completer<Map<String, dynamic>>();
late final StreamSubscription<dynamic> subscription;
subscription = socket.listen(
@ -390,6 +418,7 @@ class GatewayAcpClient {
_GatewayAcpRpcRequest request, {
required void Function(Map<String, dynamic>) onNotification,
Uri? endpointOverride,
String authorizationOverride = '',
}) async {
final endpoint = _resolveHttpRpcEndpoint(endpointOverride);
if (endpoint == null) {
@ -410,6 +439,13 @@ class GatewayAcpClient {
HttpHeaders.acceptHeader,
'text/event-stream, application/json',
);
final authorization = await _resolveAuthorizationHeader(
endpoint,
authorizationOverride: authorizationOverride,
);
if (authorization.isNotEmpty) {
httpRequest.headers.set(HttpHeaders.authorizationHeader, authorization);
}
httpRequest.add(
utf8.encode(
jsonEncode(<String, dynamic>{
@ -445,6 +481,17 @@ class GatewayAcpClient {
}
}
Future<String> _resolveAuthorizationHeader(
Uri endpoint, {
String authorizationOverride = '',
}) async {
final override = authorizationOverride.trim();
if (override.isNotEmpty) {
return override;
}
return (await authorizationResolver?.call(endpoint))?.trim() ?? '';
}
Future<Map<String, dynamic>> _consumeSseRpcResponse({
required HttpClientResponse response,
required String requestId,

View File

@ -123,16 +123,26 @@ class GatewayRuntime extends ChangeNotifier with GatewayRuntimeHelpersInternal {
final endpoint = resolveEndpointInternal(profile);
final setupPayload = decodeGatewaySetupCode(profile.setupCode);
final resolvedProfileIndex = (profileIndex ?? kGatewayRemoteProfileIndex)
.clamp(0, kGatewayProfileListLength - 1);
final tokenRef = profile.tokenRef.trim().isEmpty
? SecretStore.gatewayTokenRefKey(resolvedProfileIndex)
: profile.tokenRef.trim();
final passwordRef = profile.passwordRef.trim().isEmpty
? SecretStore.gatewayPasswordRefKey(resolvedProfileIndex)
: profile.passwordRef.trim();
final storedToken =
(await storeInternal.loadGatewayToken(
profileIndex: profileIndex,
))?.trim() ??
'';
(await storeInternal.loadSecretValueByRef(tokenRef))?.trim() ??
((await storeInternal.loadGatewayToken(
profileIndex: profileIndex,
))?.trim() ??
'');
final storedPassword =
(await storeInternal.loadGatewayPassword(
profileIndex: profileIndex,
))?.trim() ??
'';
(await storeInternal.loadSecretValueByRef(passwordRef))?.trim() ??
((await storeInternal.loadGatewayPassword(
profileIndex: profileIndex,
))?.trim() ??
'');
final explicitToken = authTokenOverride.trim();
final explicitPassword = authPasswordOverride.trim();
final sharedTokenSource = explicitToken.isNotEmpty

View File

@ -145,14 +145,10 @@ class ModeSwitcher extends ChangeNotifier {
notifyListeners();
try {
final profile = GatewayConnectionProfile(
mode: RuntimeConnectionMode.local,
useSetupCode: false,
setupCode: '',
final profile = GatewayConnectionProfile.defaultsLocal().copyWith(
host: host,
port: port,
tls: false,
selectedAgentId: '',
);
await _gateway.connectProfile(profile, authTokenOverride: token ?? '');
@ -205,14 +201,10 @@ class ModeSwitcher extends ChangeNotifier {
notifyListeners();
try {
final profile = GatewayConnectionProfile(
mode: RuntimeConnectionMode.remote,
useSetupCode: false,
setupCode: '',
final profile = GatewayConnectionProfile.defaultsRemote().copyWith(
host: host,
port: port,
tls: tls,
selectedAgentId: '',
);
await _gateway.connectProfile(profile, authTokenOverride: token ?? '');

View File

@ -15,6 +15,7 @@ import 'runtime_controllers_settings_account_impl.dart';
import 'runtime_controllers_settings_connectivity_impl.dart';
part 'runtime_controllers_settings_account.dart';
part 'runtime_controllers_settings_secrets_impl.dart';
class SettingsController extends ChangeNotifier {
SettingsController(
@ -121,200 +122,119 @@ class SettingsController extends ChangeNotifier {
int? profileIndex,
required String token,
required String password,
}) async {
final trimmedToken = token.trim();
final trimmedPassword = password.trim();
if (trimmedToken.isNotEmpty) {
await storeInternal.saveGatewayToken(
trimmedToken,
profileIndex: profileIndex,
);
await appendAudit(
SecretAuditEntry(
timeLabel: timeLabelInternal(),
action: 'Updated',
provider: 'Gateway',
target: gatewaySecretTargetInternal('gateway_token', profileIndex),
module: 'Assistant',
status: 'Success',
),
);
}
if (trimmedPassword.isNotEmpty) {
await storeInternal.saveGatewayPassword(
trimmedPassword,
profileIndex: profileIndex,
);
await appendAudit(
SecretAuditEntry(
timeLabel: timeLabelInternal(),
action: 'Updated',
provider: 'Gateway',
target: gatewaySecretTargetInternal('gateway_password', profileIndex),
module: 'Assistant',
status: 'Success',
),
);
}
await reloadDerivedStateInternal();
notifyListeners();
}
}) => saveGatewaySecretsSettingsInternal(
this,
profileIndex: profileIndex,
token: token,
password: password,
);
Future<void> clearGatewaySecrets({
int? profileIndex,
bool token = false,
bool password = false,
}) async {
if (token) {
await storeInternal.clearGatewayToken(profileIndex: profileIndex);
await appendAudit(
SecretAuditEntry(
timeLabel: timeLabelInternal(),
action: 'Cleared',
provider: 'Gateway',
target: gatewaySecretTargetInternal('gateway_token', profileIndex),
module: 'Assistant',
status: 'Success',
),
);
}
if (password) {
await storeInternal.clearGatewayPassword(profileIndex: profileIndex);
await appendAudit(
SecretAuditEntry(
timeLabel: timeLabelInternal(),
action: 'Cleared',
provider: 'Gateway',
target: gatewaySecretTargetInternal('gateway_password', profileIndex),
module: 'Assistant',
status: 'Success',
),
);
}
await reloadDerivedStateInternal();
notifyListeners();
}
}) => clearGatewaySecretsSettingsInternal(
this,
profileIndex: profileIndex,
token: token,
password: password,
);
Future<String> loadGatewayToken({int? profileIndex}) async {
return (await storeInternal.loadGatewayToken(
profileIndex: profileIndex,
))?.trim() ??
'';
}
Future<String> loadGatewayToken({int? profileIndex}) =>
loadGatewayTokenSettingsInternal(this, profileIndex: profileIndex);
Future<String> loadGatewayPassword({int? profileIndex}) async {
return (await storeInternal.loadGatewayPassword(
profileIndex: profileIndex,
))?.trim() ??
'';
}
Future<String> loadGatewayPassword({int? profileIndex}) =>
loadGatewayPasswordSettingsInternal(this, profileIndex: profileIndex);
bool hasStoredGatewayTokenForProfile(int profileIndex) =>
secureRefsInternal.containsKey(
SecretStore.gatewayTokenRefKey(profileIndex),
) ||
secureRefsInternal.containsKey('gateway_token') ||
(!snapshotInternal.accountLocalMode &&
profileIndex == kGatewayRemoteProfileIndex &&
secureRefsInternal.containsKey(
kAccountManagedSecretTargetOpenclawGatewayToken,
));
hasStoredGatewayTokenForProfileSettingsInternal(this, profileIndex);
bool hasStoredGatewayPasswordForProfile(int profileIndex) =>
secureRefsInternal.containsKey(
SecretStore.gatewayPasswordRefKey(profileIndex),
) ||
secureRefsInternal.containsKey('gateway_password');
hasStoredGatewayPasswordForProfileSettingsInternal(this, profileIndex);
String? storedGatewayTokenMaskForProfile(int profileIndex) =>
secureRefsInternal[SecretStore.gatewayTokenRefKey(profileIndex)] ??
secureRefsInternal['gateway_token'] ??
(!snapshotInternal.accountLocalMode &&
profileIndex == kGatewayRemoteProfileIndex
? secureRefsInternal[kAccountManagedSecretTargetOpenclawGatewayToken]
: null);
storedGatewayTokenMaskForProfileSettingsInternal(this, profileIndex);
String? storedGatewayPasswordMaskForProfile(int profileIndex) =>
secureRefsInternal[SecretStore.gatewayPasswordRefKey(profileIndex)] ??
secureRefsInternal['gateway_password'];
storedGatewayPasswordMaskForProfileSettingsInternal(this, profileIndex);
Future<void> saveOllamaCloudApiKey(String value) async {
final trimmed = value.trim();
if (trimmed.isEmpty) {
return;
}
await storeInternal.saveOllamaCloudApiKey(trimmed);
await appendAudit(
SecretAuditEntry(
timeLabel: timeLabelInternal(),
action: 'Updated',
provider: 'Ollama Cloud',
target: snapshotInternal.ollamaCloud.apiKeyRef,
module: 'Settings',
status: 'Success',
),
);
await reloadDerivedStateInternal();
notifyListeners();
}
String gatewayTokenRefForProfileInternal(int profileIndex) =>
gatewayTokenRefForProfileSettingsInternal(this, profileIndex);
Future<String> loadOllamaCloudApiKey() async {
return (await storeInternal.loadOllamaCloudApiKey())?.trim() ?? '';
}
String gatewayPasswordRefForProfileInternal(int profileIndex) =>
gatewayPasswordRefForProfileSettingsInternal(this, profileIndex);
Future<void> saveVaultToken(String value) async {
final trimmed = value.trim();
if (trimmed.isEmpty) {
return;
}
await storeInternal.saveVaultToken(trimmed);
await appendAudit(
SecretAuditEntry(
timeLabel: timeLabelInternal(),
action: 'Updated',
provider: 'Vault',
target: snapshotInternal.vault.tokenRef,
module: 'Secrets',
status: 'Success',
),
);
await reloadDerivedStateInternal();
notifyListeners();
}
String aiGatewayApiKeyRefInternal([AiGatewayProfile? profile]) =>
aiGatewayApiKeyRefSettingsInternal(this, profile);
Future<String> loadVaultToken() async {
return (await storeInternal.loadVaultToken())?.trim() ?? '';
}
String vaultTokenRefInternal([VaultConfig? profile]) =>
vaultTokenRefSettingsInternal(this, profile);
Future<void> saveAiGatewayApiKey(String value) async {
final trimmed = value.trim();
if (trimmed.isEmpty) {
return;
}
await storeInternal.saveAiGatewayApiKey(trimmed);
await appendAudit(
SecretAuditEntry(
timeLabel: timeLabelInternal(),
action: 'Updated',
provider: 'LLM API',
target: snapshotInternal.aiGateway.apiKeyRef,
module: 'Settings',
status: 'Success',
),
);
await reloadDerivedStateInternal();
notifyListeners();
}
String ollamaCloudApiKeyRefInternal([OllamaCloudConfig? profile]) =>
ollamaCloudApiKeyRefSettingsInternal(this, profile);
Future<String> loadAiGatewayApiKey() async {
return (await storeInternal.loadAiGatewayApiKey())?.trim() ?? '';
}
Future<void> saveOllamaCloudApiKey(String value) =>
saveOllamaCloudApiKeySettingsInternal(this, value);
Future<void> clearAiGatewayApiKey() async {
await storeInternal.clearAiGatewayApiKey();
await reloadDerivedStateInternal();
notifyListeners();
}
Future<String> loadOllamaCloudApiKey() =>
loadOllamaCloudApiKeySettingsInternal(this);
Future<void> saveVaultToken(String value) =>
saveVaultTokenSettingsInternal(this, value);
Future<String> loadVaultToken() => loadVaultTokenSettingsInternal(this);
Future<void> saveAiGatewayApiKey(String value) =>
saveAiGatewayApiKeySettingsInternal(this, value);
Future<String> loadAiGatewayApiKey() =>
loadAiGatewayApiKeySettingsInternal(this);
Future<void> clearAiGatewayApiKey() =>
clearAiGatewayApiKeySettingsInternal(this);
Future<void> saveSecretValueByRef(
String refName,
String value, {
required String provider,
required String module,
}) => saveSecretValueByRefSettingsInternal(
this,
refName,
value,
provider: provider,
module: module,
);
Future<String> loadSecretValueByRef(String refName) =>
loadSecretValueByRefSettingsInternal(this, refName);
Future<String> loadVaultTokenForSecretReadsInternal({
String tokenOverride = '',
}) => loadVaultTokenForSecretReadsSettingsInternal(
this,
tokenOverride: tokenOverride,
);
Future<String> readVaultSecretByRefInternal(String refName) =>
readVaultSecretByRefSettingsInternal(this, refName);
Future<String> resolveSecretValueInternal({
String explicitValue = '',
String refName = '',
String fallbackRefName = '',
String accountTarget = '',
bool allowVaultLookup = true,
bool persistExplicitValue = true,
}) => resolveSecretValueSettingsInternal(
this,
explicitValue: explicitValue,
refName: refName,
fallbackRefName: fallbackRefName,
accountTarget: accountTarget,
allowVaultLookup: allowVaultLookup,
persistExplicitValue: persistExplicitValue,
);
Future<void> appendAudit(SecretAuditEntry entry) async {
await storeInternal.appendAudit(entry);
@ -422,8 +342,14 @@ class SettingsController extends ChangeNotifier {
.getUrl(uri)
.timeout(const Duration(seconds: 6));
request.headers.set(HttpHeaders.acceptHeader, 'application/json');
request.headers.set(HttpHeaders.authorizationHeader, 'Bearer $apiKey');
request.headers.set('x-api-key', apiKey);
final trimmedApiKey = apiKey.trim();
if (trimmedApiKey.isNotEmpty) {
request.headers.set(
HttpHeaders.authorizationHeader,
'Bearer $trimmedApiKey',
);
request.headers.set('x-api-key', trimmedApiKey);
}
final response = await request.close().timeout(
const Duration(seconds: 6),
);

View File

@ -3,7 +3,8 @@ part of 'runtime_controllers_settings.dart';
extension SettingsControllerAccountExtension on SettingsController {
AccountSessionSummary? get accountSession => accountSessionInternal;
AccountSyncState? get accountSyncState => accountSyncStateInternal;
AccountRemoteProfile? get accountProfile => accountSyncStateInternal?.syncedDefaults;
AccountRemoteProfile? get accountProfile =>
accountSyncStateInternal?.syncedDefaults;
bool get accountBusy => accountBusyInternal;
String get accountStatus => accountStatusInternal;
bool get accountSignedIn =>
@ -12,7 +13,12 @@ extension SettingsControllerAccountExtension on SettingsController {
bool get accountMfaRequired =>
pendingAccountMfaTicketInternal.trim().isNotEmpty && !accountSignedIn;
bool get hasEffectiveAiGatewayApiKey =>
secureRefsInternal.containsKey('ai_gateway_api_key');
secureRefsInternal.containsKey(aiGatewayApiKeyRefInternal()) ||
(aiGatewayApiKeyRefInternal() == 'ai_gateway_api_key' &&
secureRefsInternal.containsKey('ai_gateway_api_key')) ||
secureRefsInternal.containsKey(
kAccountManagedSecretTargetAIGatewayAccessToken,
);
String get effectiveAiGatewayBaseUrl {
final local = snapshotInternal.aiGateway.baseUrl.trim();
@ -38,11 +44,33 @@ extension SettingsControllerAccountExtension on SettingsController {
}
Future<String> loadEffectiveAiGatewayApiKey() async {
return (await loadAiGatewayApiKey()).trim();
return resolveSecretValueInternal(
refName: snapshotInternal.aiGateway.apiKeyRef,
fallbackRefName: 'ai_gateway_api_key',
accountTarget: kAccountManagedSecretTargetAIGatewayAccessToken,
);
}
Future<String> loadEffectiveGatewayToken({int? profileIndex}) async {
return loadGatewayToken(profileIndex: profileIndex);
final resolvedProfileIndex = (profileIndex ?? kGatewayRemoteProfileIndex)
.clamp(0, kGatewayProfileListLength - 1);
return resolveSecretValueInternal(
refName: gatewayTokenRefForProfileInternal(resolvedProfileIndex),
fallbackRefName: SecretStore.gatewayTokenRefKey(resolvedProfileIndex),
accountTarget: resolvedProfileIndex == kGatewayRemoteProfileIndex
? kAccountManagedSecretTargetOpenclawGatewayToken
: '',
);
}
Future<String> loadEffectiveGatewayPassword({int? profileIndex}) async {
final resolvedProfileIndex = (profileIndex ?? kGatewayRemoteProfileIndex)
.clamp(0, kGatewayProfileListLength - 1);
return resolveSecretValueInternal(
refName: gatewayPasswordRefForProfileInternal(resolvedProfileIndex),
fallbackRefName: SecretStore.gatewayPasswordRefKey(resolvedProfileIndex),
allowVaultLookup: true,
);
}
Future<void> loginAccount({
@ -99,7 +127,9 @@ extension SettingsControllerAccountExtension on SettingsController {
maskedValue: effectiveAiGatewayBaseUrl.trim().isEmpty
? 'Not set'
: effectiveAiGatewayBaseUrl,
status: accountSyncStateInternal?.syncState ?? snapshotInternal.aiGateway.syncState,
status:
accountSyncStateInternal?.syncState ??
snapshotInternal.aiGateway.syncState,
),
];
return entries;
@ -115,9 +145,8 @@ extension SettingsControllerAccountExtension on SettingsController {
accountSessionTokenInternal =
(await storeInternal.loadAccountSessionToken())?.trim() ?? '';
accountSessionInternal = await storeInternal.loadAccountSessionSummary();
accountSyncStateInternal = await loadAccountSyncStateWithLegacyMigrationInternal(
this,
);
accountSyncStateInternal =
await loadAccountSyncStateWithLegacyMigrationInternal(this);
if (!accountBusyInternal) {
if (accountSignedIn) {
final email = accountSessionInternal?.email.trim() ?? '';

View File

@ -99,7 +99,8 @@ Future<void> verifyAccountMfaSettingsInternal(
code: code.trim(),
);
final identifier =
(await controller.storeInternal.loadAccountSessionIdentifier())?.trim() ??
(await controller.storeInternal.loadAccountSessionIdentifier())
?.trim() ??
controller.snapshotInternal.accountUsername.trim();
controller.pendingAccountMfaTicketInternal = '';
controller.pendingAccountBaseUrlInternal = '';
@ -144,7 +145,9 @@ Future<void> completeAccountSignInSettingsInternal(
await controller.storeInternal.saveAccountSessionExpiresAtMs(
_parseExpiresAtMs(payload['expiresAt']),
);
await controller.storeInternal.saveAccountSessionUserId(sessionSummary.userId);
await controller.storeInternal.saveAccountSessionUserId(
sessionSummary.userId,
);
await controller.storeInternal.saveAccountSessionIdentifier(identifier);
await controller.storeInternal.saveAccountSessionSummary(sessionSummary);
controller.accountStatusInternal = 'Signed in';
@ -334,6 +337,22 @@ Future<void> applyAccountSyncedDefaultsSettingsInternal(
);
}
final gatewayTokenLocator = defaults.locatorForTarget(
kAccountManagedSecretTargetOpenclawGatewayToken,
);
if (gatewayTokenLocator != null) {
final remoteProfile = next.gatewayProfiles[kGatewayRemoteProfileIndex];
final currentTokenRef = remoteProfile.tokenRef.trim();
final defaultRemoteTokenRef =
GatewayConnectionProfile.defaultsRemote().tokenRef;
if (currentTokenRef.isEmpty || currentTokenRef == defaultRemoteTokenRef) {
next = next.copyWithGatewayProfileAt(
kGatewayRemoteProfileIndex,
remoteProfile.copyWith(tokenRef: gatewayTokenLocator.target),
);
}
}
if (_isOverrideDisabled(overrideFlags, kAccountOverrideVaultAddress) &&
defaults.vaultUrl.trim().isNotEmpty) {
next = next.copyWith(
@ -471,7 +490,9 @@ Future<void> markAccountOverrideSettingsInternal(
if (!kAccountOverrideFieldKeys.contains(fieldKey)) {
return;
}
final current = await loadAccountSyncStateWithLegacyMigrationInternal(controller);
final current = await loadAccountSyncStateWithLegacyMigrationInternal(
controller,
);
if (current == null) {
return;
}
@ -494,7 +515,9 @@ Future<void> clearAccountOverrideSettingsInternal(
if (!kAccountOverrideFieldKeys.contains(fieldKey)) {
return;
}
final current = await loadAccountSyncStateWithLegacyMigrationInternal(controller);
final current = await loadAccountSyncStateWithLegacyMigrationInternal(
controller,
);
if (current == null || current.overrideFlags[fieldKey] != true) {
return;
}
@ -512,8 +535,9 @@ Future<void> recordAccountOverridesForSnapshotChangeSettingsInternal(
required SettingsSnapshot previous,
required SettingsSnapshot current,
}) async {
final syncState =
await loadAccountSyncStateWithLegacyMigrationInternal(controller);
final syncState = await loadAccountSyncStateWithLegacyMigrationInternal(
controller,
);
if (syncState == null) {
return;
}
@ -522,14 +546,13 @@ Future<void> recordAccountOverridesForSnapshotChangeSettingsInternal(
var changed = false;
if (_remoteGatewayEndpointChanged(previous, current)) {
changed = _markOverrideFlag(
nextFlags,
kAccountOverrideGatewayRemoteEndpoint,
) ||
changed =
_markOverrideFlag(nextFlags, kAccountOverrideGatewayRemoteEndpoint) ||
changed;
}
if (previous.vault.address != current.vault.address) {
changed = _markOverrideFlag(nextFlags, kAccountOverrideVaultAddress) || changed;
changed =
_markOverrideFlag(nextFlags, kAccountOverrideVaultAddress) || changed;
}
if (previous.vault.namespace != current.vault.namespace) {
changed =
@ -537,20 +560,17 @@ Future<void> recordAccountOverridesForSnapshotChangeSettingsInternal(
}
if (previous.aiGateway.baseUrl != current.aiGateway.baseUrl) {
changed =
_markOverrideFlag(nextFlags, kAccountOverrideAiGatewayBaseUrl) || changed;
_markOverrideFlag(nextFlags, kAccountOverrideAiGatewayBaseUrl) ||
changed;
}
if (previous.aiGateway.apiKeyRef != current.aiGateway.apiKeyRef) {
changed = _markOverrideFlag(
nextFlags,
kAccountOverrideAiGatewayApiKeyRef,
) ||
changed =
_markOverrideFlag(nextFlags, kAccountOverrideAiGatewayApiKeyRef) ||
changed;
}
if (previous.ollamaCloud.apiKeyRef != current.ollamaCloud.apiKeyRef) {
changed = _markOverrideFlag(
nextFlags,
kAccountOverrideOllamaCloudApiKeyRef,
) ||
changed =
_markOverrideFlag(nextFlags, kAccountOverrideOllamaCloudApiKeyRef) ||
changed;
}

View File

@ -12,6 +12,11 @@ import 'runtime_controllers_entities.dart';
import 'runtime_controllers_derived_tasks.dart';
import 'runtime_controllers_settings.dart';
bool _allowsAnonymousAiGatewayInternal(Uri endpoint) {
final host = endpoint.host.trim().toLowerCase();
return host == '127.0.0.1' || host == 'localhost';
}
Future<String> testOllamaConnectionSettingsInternal(
SettingsController controller, {
required bool cloud,
@ -141,9 +146,20 @@ Future<AiGatewayProfile> syncAiGatewayCatalogSettingsInternal(
return next;
}
final apiKey = apiKeyOverride.trim().isNotEmpty
? apiKeyOverride.trim()
: (await controller.storeInternal.loadAiGatewayApiKey())?.trim() ?? '';
if (apiKey.isEmpty) {
? await controller.resolveSecretValueInternal(
explicitValue: apiKeyOverride,
refName: profile.apiKeyRef,
fallbackRefName: 'ai_gateway_api_key',
accountTarget: kAccountManagedSecretTargetAIGatewayAccessToken,
allowVaultLookup: false,
persistExplicitValue: false,
)
: await controller.resolveSecretValueInternal(
refName: profile.apiKeyRef,
fallbackRefName: 'ai_gateway_api_key',
accountTarget: kAccountManagedSecretTargetAIGatewayAccessToken,
);
if (apiKey.isEmpty && !_allowsAnonymousAiGatewayInternal(normalizedBaseUrl)) {
final next = profile.copyWith(
baseUrl: normalizedBaseUrl.toString(),
syncState: 'invalid',
@ -165,7 +181,9 @@ Future<AiGatewayProfile> syncAiGatewayCatalogSettingsInternal(
profile: profile.copyWith(baseUrl: normalizedBaseUrl.toString()),
apiKeyOverride: apiKey,
);
final availableModels = models.map((item) => item.id).toList(growable: false);
final availableModels = models
.map((item) => item.id)
.toList(growable: false);
final retainedSelected = profile.selectedModels
.where(availableModels.contains)
.toList(growable: false);
@ -192,7 +210,9 @@ Future<AiGatewayProfile> syncAiGatewayCatalogSettingsInternal(
aiGateway: next,
defaultModel: resolvedDefaultModel,
);
await controller.storeInternal.saveSettingsSnapshot(controller.snapshotInternal);
await controller.storeInternal.saveSettingsSnapshot(
controller.snapshotInternal,
);
await controller.reloadDerivedStateInternal();
controller.notifyListeners();
return next;
@ -206,7 +226,9 @@ Future<AiGatewayProfile> syncAiGatewayCatalogSettingsInternal(
controller.snapshotInternal = controller.snapshotInternal.copyWith(
aiGateway: next,
);
await controller.storeInternal.saveSettingsSnapshot(controller.snapshotInternal);
await controller.storeInternal.saveSettingsSnapshot(
controller.snapshotInternal,
);
controller.notifyListeners();
return next;
}
@ -229,11 +251,23 @@ Future<AiGatewayConnectionCheck> testAiGatewayConnectionSettingsInternal(
);
}
final apiKey = apiKeyOverride.trim().isNotEmpty
? apiKeyOverride.trim()
: (await controller.storeInternal.loadAiGatewayApiKey())?.trim() ?? '';
final endpoint = controller.aiGatewayModelsUriInternal(normalizedBaseUrl)
? await controller.resolveSecretValueInternal(
explicitValue: apiKeyOverride,
refName: profile.apiKeyRef,
fallbackRefName: 'ai_gateway_api_key',
accountTarget: kAccountManagedSecretTargetAIGatewayAccessToken,
allowVaultLookup: false,
persistExplicitValue: false,
)
: await controller.resolveSecretValueInternal(
refName: profile.apiKeyRef,
fallbackRefName: 'ai_gateway_api_key',
accountTarget: kAccountManagedSecretTargetAIGatewayAccessToken,
);
final endpoint = controller
.aiGatewayModelsUriInternal(normalizedBaseUrl)
.toString();
if (apiKey.isEmpty) {
if (apiKey.isEmpty && !_allowsAnonymousAiGatewayInternal(normalizedBaseUrl)) {
return AiGatewayConnectionCheck(
state: 'invalid',
message: 'Missing LLM API Token',
@ -283,9 +317,20 @@ Future<List<GatewayModelSummary>> loadAiGatewayModelsSettingsInternal(
return const <GatewayModelSummary>[];
}
final apiKey = apiKeyOverride.trim().isNotEmpty
? apiKeyOverride.trim()
: (await controller.storeInternal.loadAiGatewayApiKey())?.trim() ?? '';
if (apiKey.isEmpty) {
? await controller.resolveSecretValueInternal(
explicitValue: apiKeyOverride,
refName: activeProfile.apiKeyRef,
fallbackRefName: 'ai_gateway_api_key',
accountTarget: kAccountManagedSecretTargetAIGatewayAccessToken,
allowVaultLookup: false,
persistExplicitValue: false,
)
: await controller.resolveSecretValueInternal(
refName: activeProfile.apiKeyRef,
fallbackRefName: 'ai_gateway_api_key',
accountTarget: kAccountManagedSecretTargetAIGatewayAccessToken,
);
if (apiKey.isEmpty && !_allowsAnonymousAiGatewayInternal(normalizedBaseUrl)) {
return const <GatewayModelSummary>[];
}
return controller.requestAiGatewayModelsInternal(

View File

@ -0,0 +1,603 @@
part of 'runtime_controllers_settings.dart';
Future<void> saveGatewaySecretsSettingsInternal(
SettingsController controller, {
int? profileIndex,
required String token,
required String password,
}) async {
final trimmedToken = token.trim();
final trimmedPassword = password.trim();
final resolvedProfileIndex = (profileIndex ?? kGatewayRemoteProfileIndex)
.clamp(0, kGatewayProfileListLength - 1);
if (trimmedToken.isNotEmpty) {
await controller.storeInternal.saveSecretValueByRef(
gatewayTokenRefForProfileSettingsInternal(
controller,
resolvedProfileIndex,
),
trimmedToken,
);
await controller.appendAudit(
SecretAuditEntry(
timeLabel: controller.timeLabelInternal(),
action: 'Updated',
provider: 'Gateway',
target: gatewayTokenRefForProfileSettingsInternal(
controller,
resolvedProfileIndex,
),
module: 'Assistant',
status: 'Success',
),
);
}
if (trimmedPassword.isNotEmpty) {
await controller.storeInternal.saveSecretValueByRef(
gatewayPasswordRefForProfileSettingsInternal(
controller,
resolvedProfileIndex,
),
trimmedPassword,
);
await controller.appendAudit(
SecretAuditEntry(
timeLabel: controller.timeLabelInternal(),
action: 'Updated',
provider: 'Gateway',
target: gatewayPasswordRefForProfileSettingsInternal(
controller,
resolvedProfileIndex,
),
module: 'Assistant',
status: 'Success',
),
);
}
await controller.reloadDerivedStateInternal();
controller.notifyListeners();
}
Future<void> clearGatewaySecretsSettingsInternal(
SettingsController controller, {
int? profileIndex,
bool token = false,
bool password = false,
}) async {
final resolvedProfileIndex = (profileIndex ?? kGatewayRemoteProfileIndex)
.clamp(0, kGatewayProfileListLength - 1);
if (token) {
await controller.storeInternal.clearSecretValueByRef(
gatewayTokenRefForProfileSettingsInternal(
controller,
resolvedProfileIndex,
),
);
await controller.appendAudit(
SecretAuditEntry(
timeLabel: controller.timeLabelInternal(),
action: 'Cleared',
provider: 'Gateway',
target: gatewayTokenRefForProfileSettingsInternal(
controller,
resolvedProfileIndex,
),
module: 'Assistant',
status: 'Success',
),
);
}
if (password) {
await controller.storeInternal.clearSecretValueByRef(
gatewayPasswordRefForProfileSettingsInternal(
controller,
resolvedProfileIndex,
),
);
await controller.appendAudit(
SecretAuditEntry(
timeLabel: controller.timeLabelInternal(),
action: 'Cleared',
provider: 'Gateway',
target: gatewayPasswordRefForProfileSettingsInternal(
controller,
resolvedProfileIndex,
),
module: 'Assistant',
status: 'Success',
),
);
}
await controller.reloadDerivedStateInternal();
controller.notifyListeners();
}
Future<String> loadGatewayTokenSettingsInternal(
SettingsController controller, {
int? profileIndex,
}) async {
if (profileIndex == null) {
return (await controller.storeInternal.loadGatewayToken())?.trim() ?? '';
}
final refName = gatewayTokenRefForProfileSettingsInternal(
controller,
profileIndex,
);
final byRef =
(await controller.storeInternal.loadSecretValueByRef(refName))?.trim() ??
'';
if (byRef.isNotEmpty) {
return byRef;
}
if (refName == SecretStore.gatewayTokenRefKey(profileIndex)) {
return (await controller.storeInternal.loadGatewayToken(
profileIndex: profileIndex,
))?.trim() ??
'';
}
return '';
}
Future<String> loadGatewayPasswordSettingsInternal(
SettingsController controller, {
int? profileIndex,
}) async {
if (profileIndex == null) {
return (await controller.storeInternal.loadGatewayPassword())?.trim() ?? '';
}
final refName = gatewayPasswordRefForProfileSettingsInternal(
controller,
profileIndex,
);
final byRef =
(await controller.storeInternal.loadSecretValueByRef(refName))?.trim() ??
'';
if (byRef.isNotEmpty) {
return byRef;
}
if (refName == SecretStore.gatewayPasswordRefKey(profileIndex)) {
return (await controller.storeInternal.loadGatewayPassword(
profileIndex: profileIndex,
))?.trim() ??
'';
}
return '';
}
bool hasStoredGatewayTokenForProfileSettingsInternal(
SettingsController controller,
int profileIndex,
) =>
controller.secureRefsInternal.containsKey(
gatewayTokenRefForProfileSettingsInternal(controller, profileIndex),
) ||
(gatewayTokenRefForProfileSettingsInternal(controller, profileIndex) ==
SecretStore.gatewayTokenRefKey(profileIndex) &&
controller.secureRefsInternal.containsKey('gateway_token')) ||
(!controller.snapshotInternal.accountLocalMode &&
profileIndex == kGatewayRemoteProfileIndex &&
controller.secureRefsInternal.containsKey(
kAccountManagedSecretTargetOpenclawGatewayToken,
));
bool hasStoredGatewayPasswordForProfileSettingsInternal(
SettingsController controller,
int profileIndex,
) =>
controller.secureRefsInternal.containsKey(
gatewayPasswordRefForProfileSettingsInternal(controller, profileIndex),
) ||
(gatewayPasswordRefForProfileSettingsInternal(controller, profileIndex) ==
SecretStore.gatewayPasswordRefKey(profileIndex) &&
controller.secureRefsInternal.containsKey('gateway_password'));
String? storedGatewayTokenMaskForProfileSettingsInternal(
SettingsController controller,
int profileIndex,
) =>
controller.secureRefsInternal[gatewayTokenRefForProfileSettingsInternal(
controller,
profileIndex,
)] ??
(gatewayTokenRefForProfileSettingsInternal(controller, profileIndex) ==
SecretStore.gatewayTokenRefKey(profileIndex)
? controller.secureRefsInternal['gateway_token']
: null) ??
(!controller.snapshotInternal.accountLocalMode &&
profileIndex == kGatewayRemoteProfileIndex
? controller
.secureRefsInternal[kAccountManagedSecretTargetOpenclawGatewayToken]
: null);
String? storedGatewayPasswordMaskForProfileSettingsInternal(
SettingsController controller,
int profileIndex,
) =>
controller.secureRefsInternal[gatewayPasswordRefForProfileSettingsInternal(
controller,
profileIndex,
)] ??
(gatewayPasswordRefForProfileSettingsInternal(controller, profileIndex) ==
SecretStore.gatewayPasswordRefKey(profileIndex)
? controller.secureRefsInternal['gateway_password']
: null);
String gatewayTokenRefForProfileSettingsInternal(
SettingsController controller,
int profileIndex,
) {
final normalizedIndex = profileIndex.clamp(0, kGatewayProfileListLength - 1);
final profile = controller.snapshotInternal.gatewayProfiles[normalizedIndex];
final refName = profile.tokenRef.trim();
if (refName.isNotEmpty) {
return refName;
}
return SecretStore.gatewayTokenRefKey(normalizedIndex);
}
String gatewayPasswordRefForProfileSettingsInternal(
SettingsController controller,
int profileIndex,
) {
final normalizedIndex = profileIndex.clamp(0, kGatewayProfileListLength - 1);
final profile = controller.snapshotInternal.gatewayProfiles[normalizedIndex];
final refName = profile.passwordRef.trim();
if (refName.isNotEmpty) {
return refName;
}
return SecretStore.gatewayPasswordRefKey(normalizedIndex);
}
String aiGatewayApiKeyRefSettingsInternal(
SettingsController controller, [
AiGatewayProfile? profile,
]) {
final refName = (profile ?? controller.snapshotInternal.aiGateway).apiKeyRef
.trim();
return refName.isEmpty ? 'ai_gateway_api_key' : refName;
}
String vaultTokenRefSettingsInternal(
SettingsController controller, [
VaultConfig? profile,
]) {
final refName = (profile ?? controller.snapshotInternal.vault).tokenRef
.trim();
return refName.isEmpty ? 'vault_token' : refName;
}
String ollamaCloudApiKeyRefSettingsInternal(
SettingsController controller, [
OllamaCloudConfig? profile,
]) {
final refName = (profile ?? controller.snapshotInternal.ollamaCloud).apiKeyRef
.trim();
return refName.isEmpty ? 'ollama_cloud_api_key' : refName;
}
Future<void> saveOllamaCloudApiKeySettingsInternal(
SettingsController controller,
String value,
) async {
final trimmed = value.trim();
if (trimmed.isEmpty) {
return;
}
await controller.storeInternal.saveSecretValueByRef(
ollamaCloudApiKeyRefSettingsInternal(controller),
trimmed,
);
await controller.appendAudit(
SecretAuditEntry(
timeLabel: controller.timeLabelInternal(),
action: 'Updated',
provider: 'Ollama Cloud',
target: ollamaCloudApiKeyRefSettingsInternal(controller),
module: 'Settings',
status: 'Success',
),
);
await controller.reloadDerivedStateInternal();
controller.notifyListeners();
}
Future<String> loadOllamaCloudApiKeySettingsInternal(
SettingsController controller,
) async {
final refName = ollamaCloudApiKeyRefSettingsInternal(controller);
final byRef =
(await controller.storeInternal.loadSecretValueByRef(refName))?.trim() ??
'';
if (byRef.isNotEmpty) {
return byRef;
}
if (refName == 'ollama_cloud_api_key') {
return (await controller.storeInternal.loadOllamaCloudApiKey())?.trim() ??
'';
}
return '';
}
Future<void> saveVaultTokenSettingsInternal(
SettingsController controller,
String value,
) async {
final trimmed = value.trim();
if (trimmed.isEmpty) {
return;
}
await controller.storeInternal.saveSecretValueByRef(
vaultTokenRefSettingsInternal(controller),
trimmed,
);
await controller.appendAudit(
SecretAuditEntry(
timeLabel: controller.timeLabelInternal(),
action: 'Updated',
provider: 'Vault',
target: vaultTokenRefSettingsInternal(controller),
module: 'Secrets',
status: 'Success',
),
);
await controller.reloadDerivedStateInternal();
controller.notifyListeners();
}
Future<String> loadVaultTokenSettingsInternal(
SettingsController controller,
) async {
final refName = vaultTokenRefSettingsInternal(controller);
final byRef =
(await controller.storeInternal.loadSecretValueByRef(refName))?.trim() ??
'';
if (byRef.isNotEmpty) {
return byRef;
}
if (refName == 'vault_token') {
return (await controller.storeInternal.loadVaultToken())?.trim() ?? '';
}
return '';
}
Future<void> saveAiGatewayApiKeySettingsInternal(
SettingsController controller,
String value,
) async {
final trimmed = value.trim();
if (trimmed.isEmpty) {
return;
}
await controller.storeInternal.saveSecretValueByRef(
aiGatewayApiKeyRefSettingsInternal(controller),
trimmed,
);
await controller.appendAudit(
SecretAuditEntry(
timeLabel: controller.timeLabelInternal(),
action: 'Updated',
provider: 'LLM API',
target: aiGatewayApiKeyRefSettingsInternal(controller),
module: 'Settings',
status: 'Success',
),
);
await controller.reloadDerivedStateInternal();
controller.notifyListeners();
}
Future<String> loadAiGatewayApiKeySettingsInternal(
SettingsController controller,
) async {
final refName = aiGatewayApiKeyRefSettingsInternal(controller);
final byRef =
(await controller.storeInternal.loadSecretValueByRef(refName))?.trim() ??
'';
if (byRef.isNotEmpty) {
return byRef;
}
if (refName == 'ai_gateway_api_key') {
return (await controller.storeInternal.loadAiGatewayApiKey())?.trim() ?? '';
}
return '';
}
Future<void> clearAiGatewayApiKeySettingsInternal(
SettingsController controller,
) async {
await controller.storeInternal.clearSecretValueByRef(
aiGatewayApiKeyRefSettingsInternal(controller),
);
await controller.reloadDerivedStateInternal();
controller.notifyListeners();
}
Future<void> saveSecretValueByRefSettingsInternal(
SettingsController controller,
String refName,
String value, {
required String provider,
required String module,
}) async {
final trimmedRef = refName.trim();
final trimmedValue = value.trim();
if (trimmedRef.isEmpty || trimmedValue.isEmpty) {
return;
}
await controller.storeInternal.saveSecretValueByRef(trimmedRef, trimmedValue);
await controller.appendAudit(
SecretAuditEntry(
timeLabel: controller.timeLabelInternal(),
action: 'Updated',
provider: provider,
target: trimmedRef,
module: module,
status: 'Success',
),
);
await controller.reloadDerivedStateInternal();
controller.notifyListeners();
}
Future<String> loadSecretValueByRefSettingsInternal(
SettingsController controller,
String refName,
) async {
return (await controller.storeInternal.loadSecretValueByRef(
refName,
))?.trim() ??
'';
}
Future<String> loadVaultTokenForSecretReadsSettingsInternal(
SettingsController controller, {
String tokenOverride = '',
}) async {
final override = tokenOverride.trim();
if (override.isNotEmpty) {
return override;
}
final token = await loadVaultTokenSettingsInternal(controller);
if (token.isNotEmpty) {
return token;
}
final refName = vaultTokenRefSettingsInternal(controller);
if (refName == 'vault_token') {
return (await controller.storeInternal.loadVaultToken())?.trim() ?? '';
}
return '';
}
Future<String> readVaultSecretByRefSettingsInternal(
SettingsController controller,
String refName,
) async {
final normalizedRef = refName.trim();
if (normalizedRef.isEmpty) {
return '';
}
final vaultAddress = controller.snapshotInternal.vault.address.trim();
if (vaultAddress.isEmpty) {
return '';
}
final vaultToken = await loadVaultTokenForSecretReadsSettingsInternal(
controller,
);
if (vaultToken.isEmpty) {
return '';
}
final client = controller.buildAccountClient(
controller.snapshotInternal.accountBaseUrl,
);
return client.readVaultSecretValue(
vaultUrl: vaultAddress,
namespace: controller.snapshotInternal.vault.namespace,
vaultToken: vaultToken,
secretPath: 'kv/$normalizedRef',
secretKey: 'value',
);
}
Future<String> resolveSecretValueSettingsInternal(
SettingsController controller, {
String explicitValue = '',
String refName = '',
String fallbackRefName = '',
String accountTarget = '',
bool allowVaultLookup = true,
bool persistExplicitValue = true,
}) async {
final trimmedExplicit = explicitValue.trim();
final normalizedRef = refName.trim().isNotEmpty
? refName.trim()
: fallbackRefName.trim();
if (trimmedExplicit.isNotEmpty) {
if (persistExplicitValue && normalizedRef.isNotEmpty) {
await controller.storeInternal.saveSecretValueByRef(
normalizedRef,
trimmedExplicit,
);
}
return trimmedExplicit;
}
if (normalizedRef.isNotEmpty) {
final local = await loadSecretValueByRefSettingsInternal(
controller,
normalizedRef,
);
if (local.isNotEmpty) {
return local;
}
if (allowVaultLookup) {
try {
final vaultValue = (await readVaultSecretByRefSettingsInternal(
controller,
normalizedRef,
)).trim();
if (vaultValue.isNotEmpty) {
await controller.storeInternal.saveSecretValueByRef(
normalizedRef,
vaultValue,
);
return vaultValue;
}
} catch (_) {
// Keep account-managed fallback available even when Vault lookup fails.
}
}
}
final normalizedTarget = accountTarget.trim();
if (normalizedTarget.isEmpty) {
return '';
}
final localManaged =
(await controller.storeInternal.loadAccountManagedSecret(
target: normalizedTarget,
))?.trim() ??
'';
if (localManaged.isNotEmpty) {
if (normalizedRef.isNotEmpty) {
await controller.storeInternal.saveSecretValueByRef(
normalizedRef,
localManaged,
);
}
return localManaged;
}
final locator = controller.accountSyncStateInternal?.syncedDefaults
.locatorForTarget(normalizedTarget);
if (locator == null) {
return '';
}
final vaultAddress = controller.snapshotInternal.vault.address.trim();
final vaultToken = await loadVaultTokenForSecretReadsSettingsInternal(
controller,
);
if (vaultAddress.isEmpty || vaultToken.isEmpty) {
return '';
}
final client = controller.buildAccountClient(
controller.snapshotInternal.accountBaseUrl,
);
final remoteValue = (await client.readVaultSecretValue(
vaultUrl: vaultAddress,
namespace: controller.snapshotInternal.vault.namespace,
vaultToken: vaultToken,
secretPath: locator.secretPath,
secretKey: locator.secretKey,
)).trim();
if (remoteValue.isEmpty) {
return '';
}
await controller.storeInternal.saveAccountManagedSecret(
target: normalizedTarget,
value: remoteValue,
);
if (normalizedRef.isNotEmpty) {
await controller.storeInternal.saveSecretValueByRef(
normalizedRef,
remoteValue,
);
}
return remoteValue;
}

View File

@ -18,6 +18,8 @@ class GatewayConnectionProfile {
required this.host,
required this.port,
required this.tls,
required this.tokenRef,
required this.passwordRef,
required this.selectedAgentId,
});
@ -27,6 +29,8 @@ class GatewayConnectionProfile {
final String host;
final int port;
final bool tls;
final String tokenRef;
final String passwordRef;
final String selectedAgentId;
factory GatewayConnectionProfile.defaults() {
@ -41,6 +45,8 @@ class GatewayConnectionProfile {
host: '127.0.0.1',
port: 18789,
tls: false,
tokenRef: 'gateway_token_0',
passwordRef: 'gateway_password_0',
selectedAgentId: '',
);
}
@ -53,18 +59,22 @@ class GatewayConnectionProfile {
host: 'openclaw.svc.plus',
port: 443,
tls: true,
tokenRef: 'gateway_token_1',
passwordRef: 'gateway_password_1',
selectedAgentId: '',
);
}
factory GatewayConnectionProfile.emptySlot({required int index}) {
return const GatewayConnectionProfile(
return GatewayConnectionProfile(
mode: RuntimeConnectionMode.unconfigured,
useSetupCode: false,
setupCode: '',
host: '',
port: 443,
tls: true,
tokenRef: 'gateway_token_$index',
passwordRef: 'gateway_password_$index',
selectedAgentId: '',
);
}
@ -76,6 +86,8 @@ class GatewayConnectionProfile {
String? host,
int? port,
bool? tls,
String? tokenRef,
String? passwordRef,
String? selectedAgentId,
}) {
final normalized = normalizeGatewayManualEndpointInternal(
@ -90,6 +102,8 @@ class GatewayConnectionProfile {
host: normalized.host,
port: normalized.port,
tls: normalized.tls,
tokenRef: tokenRef ?? this.tokenRef,
passwordRef: passwordRef ?? this.passwordRef,
selectedAgentId: selectedAgentId ?? this.selectedAgentId,
);
}
@ -102,6 +116,8 @@ class GatewayConnectionProfile {
'host': host,
'port': port,
'tls': tls,
'tokenRef': tokenRef,
'passwordRef': passwordRef,
'selectedAgentId': selectedAgentId,
};
}
@ -120,6 +136,8 @@ class GatewayConnectionProfile {
host: normalized.host,
port: normalized.port,
tls: normalized.tls,
tokenRef: json['tokenRef'] as String? ?? '',
passwordRef: json['passwordRef'] as String? ?? '',
selectedAgentId: json['selectedAgentId'] as String? ?? '',
);
}
@ -157,6 +175,12 @@ List<GatewayConnectionProfile> normalizeGatewayProfiles({
host: current.host.trim().isEmpty ? fallback.host : current.host,
port: current.port > 0 ? current.port : fallback.port,
tls: false,
tokenRef: current.tokenRef.trim().isEmpty
? fallback.tokenRef
: current.tokenRef,
passwordRef: current.passwordRef.trim().isEmpty
? fallback.passwordRef
: current.passwordRef,
),
);
continue;
@ -170,6 +194,12 @@ List<GatewayConnectionProfile> normalizeGatewayProfiles({
host: useDefaultRemoteEndpoint ? fallback.host : current.host,
port: useDefaultRemoteEndpoint ? fallback.port : current.port,
tls: useDefaultRemoteEndpoint ? fallback.tls : current.tls,
tokenRef: current.tokenRef.trim().isEmpty
? fallback.tokenRef
: current.tokenRef,
passwordRef: current.passwordRef.trim().isEmpty
? fallback.passwordRef
: current.passwordRef,
),
);
continue;
@ -197,6 +227,12 @@ List<GatewayConnectionProfile> normalizeGatewayProfiles({
? 18789
: 443,
tls: slotMode == RuntimeConnectionMode.local ? false : current.tls,
tokenRef: current.tokenRef.trim().isEmpty
? fallback.tokenRef
: current.tokenRef,
passwordRef: current.passwordRef.trim().isEmpty
? fallback.passwordRef
: current.passwordRef,
),
);
}

View File

@ -16,6 +16,7 @@ class ExternalAcpEndpointProfile {
required this.label,
required this.badge,
required this.endpoint,
required this.authRef,
required this.enabled,
});
@ -23,6 +24,7 @@ class ExternalAcpEndpointProfile {
final String label;
final String badge;
final String endpoint;
final String authRef;
final bool enabled;
factory ExternalAcpEndpointProfile.defaultsForProvider(
@ -33,6 +35,7 @@ class ExternalAcpEndpointProfile {
label: provider.label,
badge: provider.badge,
endpoint: '',
authRef: '',
enabled: true,
);
}
@ -42,6 +45,7 @@ class ExternalAcpEndpointProfile {
String? label,
String? badge,
String? endpoint,
String? authRef,
bool? enabled,
}) {
return ExternalAcpEndpointProfile(
@ -51,6 +55,7 @@ class ExternalAcpEndpointProfile {
label: (label ?? this.label).trim(),
badge: (badge ?? this.badge).trim(),
endpoint: (endpoint ?? this.endpoint).trim(),
authRef: (authRef ?? this.authRef).trim(),
enabled: enabled ?? this.enabled,
);
}
@ -85,6 +90,7 @@ class ExternalAcpEndpointProfile {
'label': label,
'badge': badge,
'endpoint': endpoint,
'authRef': authRef,
'enabled': enabled,
};
}
@ -108,6 +114,7 @@ class ExternalAcpEndpointProfile {
label: label,
),
endpoint: json['endpoint']?.toString().trim() ?? '',
authRef: json['authRef']?.toString().trim() ?? '',
enabled: json['enabled'] as bool? ?? true,
);
}
@ -226,6 +233,7 @@ ExternalAcpEndpointProfile buildCustomExternalAcpEndpointProfile(
label: normalizedLabel,
),
endpoint: endpoint.trim(),
authRef: '',
enabled: true,
);
}

View File

@ -99,6 +99,8 @@ class SecretStore {
static const String _accountSessionSummaryKey =
'xworkmate.account.session.summary';
static const String _accountProfileKey = 'xworkmate.account.profile';
static const String _customSecretRefRegistryKey =
'xworkmate.secret.ref_registry';
final StoreLayoutResolver _layoutResolver;
final SecureStorageClient? _secureStorageOverride;
@ -223,12 +225,14 @@ class SecretStore {
Future<void> clearAiGatewayApiKey() => _deleteSecure(_aiGatewayApiKeyKey);
Future<String?> loadAccountSessionToken() => _readSecure(_accountSessionTokenKey);
Future<String?> loadAccountSessionToken() =>
_readSecure(_accountSessionTokenKey);
Future<void> saveAccountSessionToken(String value) =>
_writeSecure(_accountSessionTokenKey, value);
Future<void> clearAccountSessionToken() => _deleteSecure(_accountSessionTokenKey);
Future<void> clearAccountSessionToken() =>
_deleteSecure(_accountSessionTokenKey);
Future<int> loadAccountSessionExpiresAtMs() async {
final raw = await _readSecure(_accountSessionExpiresAtKey);
@ -315,6 +319,43 @@ class SecretStore {
}
}
Future<String?> loadSecretValueByRef(String refName) async {
final normalizedRef = refName.trim();
if (normalizedRef.isEmpty) {
return null;
}
return _readSecure(_secureStorageKeyForRef(normalizedRef));
}
Future<void> saveSecretValueByRef(String refName, String value) async {
final normalizedRef = refName.trim();
final trimmedValue = value.trim();
if (normalizedRef.isEmpty || trimmedValue.isEmpty) {
return;
}
final key = _secureStorageKeyForRef(normalizedRef);
await _writeSecure(key, trimmedValue);
if (_isCustomSecretRef(normalizedRef)) {
await _saveCustomSecretRefRegistryInternal(<String>{
...await _loadCustomSecretRefRegistryInternal(),
normalizedRef,
});
}
}
Future<void> clearSecretValueByRef(String refName) async {
final normalizedRef = refName.trim();
if (normalizedRef.isEmpty) {
return;
}
await _deleteSecure(_secureStorageKeyForRef(normalizedRef));
if (_isCustomSecretRef(normalizedRef)) {
final refs = await _loadCustomSecretRefRegistryInternal();
refs.remove(normalizedRef);
await _saveCustomSecretRefRegistryInternal(refs);
}
}
Future<Map<String, String>> loadSecureRefs() async {
await initialize();
final secureRefs = <String, String>{};
@ -360,6 +401,18 @@ class SecretStore {
if (aiGatewayApiKey case final value?) {
secureRefs['ai_gateway_api_key'] = value;
}
for (final target in kAccountManagedSecretTargets) {
final managedValue = await loadAccountManagedSecret(target: target);
if (managedValue case final value?) {
secureRefs[target] = value;
}
}
for (final refName in await _loadCustomSecretRefRegistryInternal()) {
final customValue = await loadSecretValueByRef(refName);
if (customValue case final value?) {
secureRefs[refName] = value;
}
}
return secureRefs;
}
@ -461,6 +514,98 @@ class SecretStore {
static String _accountManagedSecretKey(String target) =>
'xworkmate.account.managed.${target.trim()}';
static String _customSecretRefKey(String refName) =>
'xworkmate.secret.ref.${refName.trim()}';
static bool _looksLikeGatewayProfileRef(String refName, String prefix) {
final normalized = refName.trim();
if (!normalized.startsWith(prefix)) {
return false;
}
final suffix = normalized.substring(prefix.length);
return int.tryParse(suffix) != null;
}
static bool _isCustomSecretRef(String refName) {
final normalized = refName.trim();
if (normalized.isEmpty ||
normalized == 'gateway_token' ||
normalized == 'gateway_password' ||
normalized == 'vault_token' ||
normalized == 'ai_gateway_api_key' ||
normalized == 'ollama_cloud_api_key' ||
isSupportedAccountManagedSecretTarget(normalized) ||
_looksLikeGatewayProfileRef(normalized, 'gateway_token_') ||
_looksLikeGatewayProfileRef(normalized, 'gateway_password_')) {
return false;
}
return true;
}
static String _secureStorageKeyForRef(String refName) {
final normalized = refName.trim();
if (normalized == 'gateway_token') {
return _legacyGatewayTokenKey;
}
if (normalized == 'gateway_password') {
return _legacyGatewayPasswordKey;
}
if (_looksLikeGatewayProfileRef(normalized, 'gateway_token_')) {
final index = int.parse(normalized.substring('gateway_token_'.length));
return _gatewayTokenKeyForProfile(index);
}
if (_looksLikeGatewayProfileRef(normalized, 'gateway_password_')) {
final index = int.parse(normalized.substring('gateway_password_'.length));
return _gatewayPasswordKeyForProfile(index);
}
if (normalized == 'vault_token') {
return _vaultTokenKey;
}
if (normalized == 'ai_gateway_api_key') {
return _aiGatewayApiKeyKey;
}
if (normalized == 'ollama_cloud_api_key') {
return _ollamaCloudApiKeyKey;
}
if (isSupportedAccountManagedSecretTarget(normalized)) {
return _accountManagedSecretKey(normalized);
}
return _customSecretRefKey(normalized);
}
Future<Set<String>> _loadCustomSecretRefRegistryInternal() async {
final raw = await _readSecure(_customSecretRefRegistryKey);
if ((raw ?? '').trim().isEmpty) {
return <String>{};
}
try {
final decoded = jsonDecode(raw!);
if (decoded is! List) {
return <String>{};
}
return decoded
.map((item) => item.toString().trim())
.where((item) => item.isNotEmpty)
.toSet();
} catch (_) {
return <String>{};
}
}
Future<void> _saveCustomSecretRefRegistryInternal(Set<String> refs) async {
final normalized =
refs
.map((item) => item.trim())
.where((item) => item.isNotEmpty)
.toList(growable: false)
..sort();
if (normalized.isEmpty) {
await _deleteSecure(_customSecretRefRegistryKey);
return;
}
await _writeSecure(_customSecretRefRegistryKey, jsonEncode(normalized));
}
Future<String?> _readSecure(String key) async {
await initialize();
final client = _secureStorage;

View File

@ -129,6 +129,15 @@ class SecureConfigStore {
return _secretStore.loadSecureRefs();
}
Future<String?> loadSecretValueByRef(String refName) =>
_secretStore.loadSecretValueByRef(refName);
Future<void> saveSecretValueByRef(String refName, String value) =>
_secretStore.saveSecretValueByRef(refName, value);
Future<void> clearSecretValueByRef(String refName) =>
_secretStore.clearSecretValueByRef(refName);
Future<String?> loadGatewayToken({int? profileIndex}) =>
_secretStore.loadGatewayToken(profileIndex: profileIndex);

View File

@ -35,6 +35,7 @@ void main() {
label: 'Custom Lab',
badge: 'CL',
endpoint: 'wss://lab.example.com/acp',
authRef: '',
enabled: true,
),
],
@ -58,7 +59,10 @@ void main() {
.endpoint,
'https://opencode.example.com',
);
expect(decoded.externalAcpEndpointForProviderId('codex'), isNull);
expect(
decoded.externalAcpEndpointForProviderId('codex')?.providerKey,
startsWith('custom-agent-'),
);
expect(
decoded.externalAcpEndpoints.any(
(item) =>
@ -78,6 +82,7 @@ void main() {
label: 'Claude',
badge: 'Cl',
endpoint: '',
authRef: '',
enabled: true,
),
ExternalAcpEndpointProfile(
@ -85,6 +90,7 @@ void main() {
label: 'Gemini',
badge: 'G',
endpoint: '',
authRef: '',
enabled: true,
),
],
@ -114,6 +120,7 @@ void main() {
label: 'Claude',
badge: 'Cl',
endpoint: 'wss://claude.example.com/acp',
authRef: '',
enabled: true,
),
ExternalAcpEndpointProfile(
@ -121,6 +128,7 @@ void main() {
label: 'Gemini',
badge: 'G',
endpoint: 'wss://gemini.example.com/acp',
authRef: '',
enabled: true,
),
],
@ -146,6 +154,7 @@ void main() {
label: 'Claude',
badge: 'Cl',
endpoint: '',
authRef: '',
enabled: true,
),
ExternalAcpEndpointProfile(
@ -153,6 +162,7 @@ void main() {
label: 'Gemini',
badge: 'G',
endpoint: '',
authRef: '',
enabled: true,
),
],
@ -196,6 +206,7 @@ void main() {
label: 'Claude',
badge: 'Cl',
endpoint: '',
authRef: '',
enabled: true,
),
],

View File

@ -43,6 +43,43 @@ void main() {
expect(server.rpcMethods, contains('acp.capabilities'));
});
test(
'forwards ACP authorization resolver headers over websocket',
() async {
final server = await _AcpFakeServer.start();
addTearDown(server.close);
final client = GatewayAcpClient(
endpointResolver: () => server.baseHttpUri,
authorizationResolver: (_) async => 'Bearer ws-secret',
);
await client.loadCapabilities(forceRefresh: true);
expect(server.lastWebSocketAuthorization, 'Bearer ws-secret');
},
);
test(
'prefers explicit ACP authorization overrides on HTTP fallback',
() async {
final server = await _AcpFakeServer.start(disableWebSocket: true);
addTearDown(server.close);
final client = GatewayAcpClient(
endpointResolver: () => server.baseHttpUri,
authorizationResolver: (_) async => 'Bearer resolver-secret',
);
await client.loadCapabilities(
forceRefresh: true,
authorizationOverride: 'Bearer override-secret',
);
expect(server.lastHttpAuthorization, 'Bearer override-secret');
},
);
test(
'streams multi-agent events and supports cancel/close session',
() async {
@ -96,6 +133,8 @@ class _AcpFakeServer {
final HttpServer _server;
final bool disableWebSocket;
final List<String> rpcMethods = <String>[];
String? lastWebSocketAuthorization;
String? lastHttpAuthorization;
Uri get baseHttpUri => Uri.parse('http://127.0.0.1:${_server.port}');
@ -115,6 +154,9 @@ class _AcpFakeServer {
if (!disableWebSocket &&
request.uri.path == '/acp' &&
WebSocketTransformer.isUpgradeRequest(request)) {
lastWebSocketAuthorization = request.headers.value(
HttpHeaders.authorizationHeader,
);
final socket = await WebSocketTransformer.upgrade(request);
unawaited(_handleWebSocket(socket));
continue;
@ -155,6 +197,9 @@ class _AcpFakeServer {
}
Future<void> _handleHttpRpc(HttpRequest request) async {
lastHttpAuthorization = request.headers.value(
HttpHeaders.authorizationHeader,
);
final body = await utf8.decodeStream(request);
final envelope = _decodeMap(body);
final id = envelope['id'];

View File

@ -108,6 +108,41 @@ void registerSecureConfigStoreSuiteSecretsTestsInternal() {
},
);
test(
'SecureConfigStore tracks arbitrary secret refs in the secure ref registry',
() async {
final tempDirectory = await createTempDirectoryInternal(
'xworkmate-config-store-custom-refs-',
);
final store = createStoreFromTempDirectoryInternal(tempDirectory);
await store.saveSecretValueByRef(
'team_shared_llm_token',
'shared-secret',
);
expect(
await store.loadSecretValueByRef('team_shared_llm_token'),
'shared-secret',
);
expect(
(await store.loadSecureRefs())['team_shared_llm_token'],
'shared-secret',
);
await store.clearSecretValueByRef('team_shared_llm_token');
expect(
await store.loadSecretValueByRef('team_shared_llm_token'),
isNull,
);
expect(
(await store.loadSecureRefs()).containsKey('team_shared_llm_token'),
isFalse,
);
},
);
test(
'SecureConfigStore keeps Vault root token out of the settings snapshot payload',
() async {

View File

@ -387,7 +387,7 @@ void registerSecureConfigStoreSuiteSettingsTestsInternal() {
expect(loadedSnapshot.multiAgent.managedMcpServers, hasLength(1));
expect(encoded, contains('"multiAgent"'));
expect(encoded, isNot(contains('ai-gateway-secret')));
expect(encoded, isNot(contains('gateway_token')));
expect(encoded, isNot(contains('token-secret')));
},
);
});

View File

@ -105,6 +105,46 @@ void main() {
},
);
test(
'SettingsController falls back to ai_gateway_api_key when LLM token ref is empty',
() async {
SharedPreferences.setMockInitialValues(<String, Object>{});
final server = await _FakeAiGatewayServer.start(
expectedAuthorization: 'Bearer fallback-ref-key',
);
addTearDown(server.close);
final tempDirectory = await Directory.systemTemp.createTemp(
'xworkmate-settings-ai-gateway-sync-',
);
addTearDown(() async => _deleteDirectoryBestEffort(tempDirectory));
final store = _createIsolatedStore(tempDirectory.path);
addTearDown(store.dispose);
final controller = SettingsController(store);
await controller.initialize();
await controller.saveSnapshot(
SettingsSnapshot.defaults().copyWith(
aiGateway: AiGatewayProfile.defaults().copyWith(
baseUrl: server.baseUrl,
apiKeyRef: '',
),
),
);
await store.saveSecretValueByRef(
'ai_gateway_api_key',
'fallback-ref-key',
);
final result = await controller.syncAiGatewayCatalog(
controller.snapshot.aiGateway,
);
expect(server.lastAuthorization, 'Bearer fallback-ref-key');
expect(result.syncState, 'ready');
expect(result.availableModels, isNotEmpty);
},
);
test(
'SettingsController tolerates OpenAI-compatible model payloads with a trailing JSON footer',
() async {
@ -199,6 +239,94 @@ void main() {
expect(await store.loadAiGatewayApiKey(), isNull);
},
);
test(
'SettingsController allows anonymous LLM model sync for 127.0.0.1 only',
() async {
SharedPreferences.setMockInitialValues(<String, Object>{});
final server = await _FakeAiGatewayServer.start(
expectedAuthorization: null,
);
addTearDown(server.close);
final tempDirectory = await Directory.systemTemp.createTemp(
'xworkmate-settings-ai-gateway-sync-',
);
addTearDown(() async => _deleteDirectoryBestEffort(tempDirectory));
final store = _createIsolatedStore(tempDirectory.path);
addTearDown(store.dispose);
final controller = SettingsController(store);
await controller.initialize();
final result = await controller.syncAiGatewayCatalog(
AiGatewayProfile.defaults().copyWith(
baseUrl: server.baseUrl,
apiKeyRef: '',
),
);
expect(result.syncState, 'ready');
expect(result.availableModels, isNotEmpty);
expect(server.lastAuthorization, isNull);
},
);
test(
'SettingsController allows anonymous LLM connection checks for localhost only',
() async {
SharedPreferences.setMockInitialValues(<String, Object>{});
final server = await _FakeAiGatewayServer.start(
expectedAuthorization: null,
);
addTearDown(server.close);
final tempDirectory = await Directory.systemTemp.createTemp(
'xworkmate-settings-ai-gateway-sync-',
);
addTearDown(() async => _deleteDirectoryBestEffort(tempDirectory));
final store = _createIsolatedStore(tempDirectory.path);
addTearDown(store.dispose);
final controller = SettingsController(store);
await controller.initialize();
final result = await controller.testAiGatewayConnection(
AiGatewayProfile.defaults().copyWith(
baseUrl: server.localhostBaseUrl,
apiKeyRef: '',
),
);
expect(result.state, 'ready');
expect(result.modelCount, 6);
expect(server.lastAuthorization, isNull);
},
);
test(
'SettingsController rejects anonymous LLM access for non-whitelisted loopback addresses',
() async {
SharedPreferences.setMockInitialValues(<String, Object>{});
final tempDirectory = await Directory.systemTemp.createTemp(
'xworkmate-settings-ai-gateway-sync-',
);
addTearDown(() async => _deleteDirectoryBestEffort(tempDirectory));
final store = _createIsolatedStore(tempDirectory.path);
addTearDown(store.dispose);
final controller = SettingsController(store);
await controller.initialize();
final result = await controller.testAiGatewayConnection(
AiGatewayProfile.defaults().copyWith(
baseUrl: 'http://127.0.0.2:11434/v1',
apiKeyRef: '',
),
);
expect(result.state, 'invalid');
expect(result.message, 'Missing LLM API Token');
},
);
}
SecureConfigStore _createIsolatedStore(String rootPath) {
@ -235,14 +363,15 @@ class _FakeAiGatewayServer {
);
final HttpServer _server;
final String expectedAuthorization;
final String? expectedAuthorization;
final bool appendFooterJson;
String? lastAuthorization;
String get baseUrl => 'http://127.0.0.1:${_server.port}/v1';
String get localhostBaseUrl => 'http://localhost:${_server.port}/v1';
static Future<_FakeAiGatewayServer> start({
String expectedAuthorization = 'Bearer live-inline-key',
String? expectedAuthorization = 'Bearer live-inline-key',
bool appendFooterJson = false,
}) async {
final server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0);

View File

@ -276,6 +276,7 @@ void main() {
label: 'Claude',
badge: 'Cl',
endpoint: 'wss://claude.example.com/acp',
authRef: '',
enabled: true,
),
],