Tighten localhost auth bypass and add arbitrary secret refs
This commit is contained in:
parent
ed12ee3c4f
commit
a4ca01d7bc
@ -196,6 +196,7 @@ class AppController extends ChangeNotifier {
|
||||
singleAgentSharedSkillScanRootOverrides?.toList(growable: false);
|
||||
gatewayAcpClientInternal = GatewayAcpClient(
|
||||
endpointResolver: resolveGatewayAcpEndpointInternal,
|
||||
authorizationResolver: resolveSingleAgentAuthorizationHeaderInternal,
|
||||
);
|
||||
availableSingleAgentProvidersOverrideInternal =
|
||||
availableSingleAgentProvidersOverride;
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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,
|
||||
};
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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>[],
|
||||
|
||||
@ -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();
|
||||
}
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
441
lib/features/settings/settings_page_gateway_acp.dart
Normal file
441
lib/features/settings/settings_page_gateway_acp.dart
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
),
|
||||
|
||||
@ -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'),
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 ?? '');
|
||||
|
||||
@ -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),
|
||||
);
|
||||
|
||||
@ -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() ?? '';
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
|
||||
@ -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(
|
||||
|
||||
603
lib/runtime/runtime_controllers_settings_secrets_impl.dart
Normal file
603
lib/runtime/runtime_controllers_settings_secrets_impl.dart
Normal 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;
|
||||
}
|
||||
@ -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,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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);
|
||||
|
||||
|
||||
@ -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,
|
||||
),
|
||||
],
|
||||
|
||||
@ -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'];
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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')));
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -276,6 +276,7 @@ void main() {
|
||||
label: 'Claude',
|
||||
badge: 'Cl',
|
||||
endpoint: 'wss://claude.example.com/acp',
|
||||
authRef: '',
|
||||
enabled: true,
|
||||
),
|
||||
],
|
||||
|
||||
Loading…
Reference in New Issue
Block a user