Isolate gateway secrets per profile slot

This commit is contained in:
Haitao Pan 2026-03-23 19:20:49 +08:00
parent 1152b4f8fa
commit 024f485a36
18 changed files with 668 additions and 360 deletions

View File

@ -94,8 +94,9 @@ class AppController extends ChangeNotifier {
(_isFlutterTestEnvironment
? const <String>[]
: _defaultGatewayOnlySkillScanRoots);
_gatewayAcpClient =
GatewayAcpClient(endpointResolver: _resolveGatewayAcpEndpoint);
_gatewayAcpClient = GatewayAcpClient(
endpointResolver: _resolveGatewayAcpEndpoint,
);
_singleAgentAppServerClient = DirectSingleAgentAppServerClient(
endpointResolver: _resolveSingleAgentEndpoint,
);
@ -283,15 +284,15 @@ class AppController extends ChangeNotifier {
AssistantPermissionLevel get assistantPermissionLevel =>
settings.assistantPermissionLevel;
bool get hasStoredGatewayCredential =>
_settingsController.secureRefs.containsKey('gateway_token') ||
_settingsController.secureRefs.containsKey('gateway_password') ||
hasStoredGatewayTokenForProfile(_activeGatewayProfileIndex) ||
hasStoredGatewayPasswordForProfile(_activeGatewayProfileIndex) ||
_settingsController.secureRefs.containsKey(
'gateway_device_token_operator',
);
bool get hasStoredGatewayToken =>
_settingsController.secureRefs.containsKey('gateway_token');
hasStoredGatewayTokenForProfile(_activeGatewayProfileIndex);
String? get storedGatewayTokenMask =>
_settingsController.secureRefs['gateway_token'];
storedGatewayTokenMaskForProfile(_activeGatewayProfileIndex);
String get aiGatewayUrl => settings.aiGateway.baseUrl.trim();
bool get hasStoredAiGatewayApiKey =>
_settingsController.secureRefs.containsKey('ai_gateway_api_key');
@ -311,8 +312,6 @@ class AppController extends ChangeNotifier {
bool get isMultiAgentRunPending => _multiAgentRunPending;
bool _desktopPlatformBusy = false;
static const String _draftGatewayTokenKey = 'gateway_token';
static const String _draftGatewayPasswordKey = 'gateway_password';
static const String _draftAiGatewayApiKeyKey = 'ai_gateway_api_key';
static const String _draftVaultTokenKey = 'vault_token';
static const String _draftOllamaApiKeyKey = 'ollama_cloud_api_key';
@ -325,6 +324,26 @@ class AppController extends ChangeNotifier {
hasStoredAiGatewayApiKey &&
resolvedAiGatewayModel.isNotEmpty;
int get _activeGatewayProfileIndex {
final target = currentAssistantExecutionTarget;
if (target == AssistantExecutionTarget.singleAgent) {
return kGatewayRemoteProfileIndex;
}
return _gatewayProfileIndexForExecutionTarget(target);
}
bool hasStoredGatewayTokenForProfile(int profileIndex) =>
_settingsController.hasStoredGatewayTokenForProfile(profileIndex);
bool hasStoredGatewayPasswordForProfile(int profileIndex) =>
_settingsController.hasStoredGatewayPasswordForProfile(profileIndex);
String? storedGatewayTokenMaskForProfile(int profileIndex) =>
_settingsController.storedGatewayTokenMaskForProfile(profileIndex);
String? storedGatewayPasswordMaskForProfile(int profileIndex) =>
_settingsController.storedGatewayPasswordMaskForProfile(profileIndex);
List<SingleAgentProvider> get availableSingleAgentProviders =>
(_availableSingleAgentProvidersOverride ??
const <SingleAgentProvider>[SingleAgentProvider.codex])
@ -1342,7 +1361,15 @@ class AppController extends ChangeNotifier {
final resolvedPassword = password.trim().isNotEmpty
? password.trim()
: (decoded?.password.trim() ?? '');
final resolvedProfileIndex = _gatewayProfileIndexForExecutionTarget(
_assistantExecutionTargetForMode(
_modeFromHost(
decoded?.host ?? settings.primaryRemoteGatewayProfile.host,
),
),
);
await _settingsController.saveGatewaySecrets(
profileIndex: resolvedProfileIndex,
token: resolvedToken,
password: resolvedPassword,
);
@ -1378,6 +1405,7 @@ class AppController extends ChangeNotifier {
);
await _connectProfile(
nextProfile,
profileIndex: resolvedProfileIndex,
authTokenOverride: resolvedToken,
authPasswordOverride: resolvedPassword,
);
@ -1392,7 +1420,10 @@ class AppController extends ChangeNotifier {
String token = '',
String password = '',
}) async {
final nextTarget = _assistantExecutionTargetForMode(mode);
final nextProfileIndex = _gatewayProfileIndexForExecutionTarget(nextTarget);
await _settingsController.saveGatewaySecrets(
profileIndex: nextProfileIndex,
token: token.trim(),
password: password.trim(),
);
@ -1403,7 +1434,6 @@ class AppController extends ChangeNotifier {
final resolvedPort = mode == RuntimeConnectionMode.local && port <= 0
? 18789
: port;
final nextTarget = _assistantExecutionTargetForMode(mode);
final nextProfile = _gatewayProfileForAssistantExecutionTarget(nextTarget)
.copyWith(
mode: mode,
@ -1429,6 +1459,7 @@ class AppController extends ChangeNotifier {
);
await _connectProfile(
nextProfile,
profileIndex: nextProfileIndex,
authTokenOverride: token.trim(),
authPasswordOverride: password.trim(),
);
@ -1456,11 +1487,17 @@ class AppController extends ChangeNotifier {
if (target == AssistantExecutionTarget.singleAgent) {
return;
}
await _connectProfile(_gatewayProfileForAssistantExecutionTarget(target));
await _connectProfile(
_gatewayProfileForAssistantExecutionTarget(target),
profileIndex: _gatewayProfileIndexForExecutionTarget(target),
);
}
Future<void> clearStoredGatewayToken() async {
await _settingsController.clearGatewaySecrets(token: true);
Future<void> clearStoredGatewayToken({int? profileIndex}) async {
await _settingsController.clearGatewaySecrets(
profileIndex: profileIndex,
token: true,
);
}
Future<void> refreshGatewayHealth() async {
@ -1808,7 +1845,10 @@ class AppController extends ChangeNotifier {
resolvedTarget,
);
try {
await _connectProfile(targetProfile);
await _connectProfile(
targetProfile,
profileIndex: _gatewayProfileIndexForExecutionTarget(resolvedTarget),
);
} catch (_) {
// Keep the selected execution target even when the immediate reconnect
// fails so the user can retry or adjust gateway settings manually.
@ -2147,12 +2187,12 @@ class AppController extends ChangeNotifier {
notifyListeners();
}
void saveGatewayTokenDraft(String value) {
_saveSecretDraft(_draftGatewayTokenKey, value);
void saveGatewayTokenDraft(String value, {required int profileIndex}) {
_saveSecretDraft(_draftGatewayTokenKey(profileIndex), value);
}
void saveGatewayPasswordDraft(String value) {
_saveSecretDraft(_draftGatewayPasswordKey, value);
void saveGatewayPasswordDraft(String value, {required int profileIndex}) {
_saveSecretDraft(_draftGatewayPasswordKey(profileIndex), value);
}
void saveAiGatewayApiKeyDraft(String value) {
@ -2682,7 +2722,10 @@ class AppController extends ChangeNotifier {
startupProfile.setupCode.trim().isNotEmpty;
if (shouldAutoConnect) {
try {
await _connectProfile(startupProfile);
await _connectProfile(
startupProfile,
profileIndex: _gatewayProfileIndexForExecutionTarget(startupTarget),
);
} catch (_) {
// Keep the shell usable when auto-connect fails.
}
@ -2711,11 +2754,13 @@ class AppController extends ChangeNotifier {
Future<void> _connectProfile(
GatewayConnectionProfile profile, {
int? profileIndex,
String authTokenOverride = '',
String authPasswordOverride = '',
}) async {
await _runtime.connectProfile(
profile,
profileIndex: profileIndex,
authTokenOverride: authTokenOverride,
authPasswordOverride: authPasswordOverride,
);
@ -2755,6 +2800,9 @@ class AppController extends ChangeNotifier {
SettingsSnapshot previous,
SettingsSnapshot next,
) {
final hasGatewaySecretDraft = _draftSecretValues.keys.any(
(key) => _isGatewayDraftKey(key),
);
final gatewayChanged =
jsonEncode(
previous.gatewayProfiles.map((item) => item.toJson()).toList(),
@ -2763,8 +2811,7 @@ class AppController extends ChangeNotifier {
next.gatewayProfiles.map((item) => item.toJson()).toList(),
) ||
previous.assistantExecutionTarget != next.assistantExecutionTarget ||
_draftSecretValues.containsKey(_draftGatewayTokenKey) ||
_draftSecretValues.containsKey(_draftGatewayPasswordKey);
hasGatewaySecretDraft;
final aiGatewayChanged =
previous.aiGateway.toJson().toString() !=
next.aiGateway.toJson().toString() ||
@ -2775,13 +2822,18 @@ class AppController extends ChangeNotifier {
}
Future<void> _persistDraftSecrets() async {
final gatewayToken = _draftSecretValues[_draftGatewayTokenKey];
final gatewayPassword = _draftSecretValues[_draftGatewayPasswordKey];
if ((gatewayToken ?? '').isNotEmpty || (gatewayPassword ?? '').isNotEmpty) {
await _settingsController.saveGatewaySecrets(
token: gatewayToken ?? '',
password: gatewayPassword ?? '',
);
for (var index = 0; index < kGatewayProfileListLength; index += 1) {
final gatewayToken = _draftSecretValues[_draftGatewayTokenKey(index)];
final gatewayPassword =
_draftSecretValues[_draftGatewayPasswordKey(index)];
if ((gatewayToken ?? '').isNotEmpty ||
(gatewayPassword ?? '').isNotEmpty) {
await _settingsController.saveGatewaySecrets(
profileIndex: index,
token: gatewayToken ?? '',
password: gatewayPassword ?? '',
);
}
}
final aiGatewayApiKey = _draftSecretValues[_draftAiGatewayApiKeyKey];
if ((aiGatewayApiKey ?? '').isNotEmpty) {
@ -2798,6 +2850,15 @@ class AppController extends ChangeNotifier {
_draftSecretValues.clear();
}
static String _draftGatewayTokenKey(int profileIndex) =>
'gateway_token_$profileIndex';
static String _draftGatewayPasswordKey(int profileIndex) =>
'gateway_password_$profileIndex';
static bool _isGatewayDraftKey(String key) =>
key.startsWith('gateway_token_') || key.startsWith('gateway_password_');
Future<void> _persistSettingsSnapshot(SettingsSnapshot snapshot) async {
final sanitized = _sanitizeFeatureFlagSettings(
_sanitizeMultiAgentSettings(
@ -4252,10 +4313,7 @@ class AppController extends ChangeNotifier {
return appText('无法连接到 LLM API。', 'Unable to reach the LLM API.');
}
if (error is HandshakeException) {
return appText(
'LLM API TLS 握手失败。',
'LLM API TLS handshake failed.',
);
return appText('LLM API TLS 握手失败。', 'LLM API TLS handshake failed.');
}
if (error is TimeoutException) {
return appText('LLM API 请求超时。', 'LLM API request timed out.');
@ -4746,7 +4804,9 @@ class AppController extends ChangeNotifier {
_sessionsController.currentSessionKey,
);
if (target == AssistantExecutionTarget.singleAgent) {
final remote = _gatewayProfileBaseUri(settings.primaryRemoteGatewayProfile);
final remote = _gatewayProfileBaseUri(
settings.primaryRemoteGatewayProfile,
);
if (remote != null) {
return remote;
}

View File

@ -49,8 +49,8 @@ class _SettingsPageState extends State<SettingsPage> {
late final TextEditingController _gatewaySetupCodeController;
late final TextEditingController _gatewayHostController;
late final TextEditingController _gatewayPortController;
late final TextEditingController _gatewayTokenController;
late final TextEditingController _gatewayPasswordController;
late final List<TextEditingController> _gatewayTokenControllers;
late final List<TextEditingController> _gatewayPasswordControllers;
late final TextEditingController _vaultTokenController;
late final TextEditingController _ollamaApiKeyController;
late final TextEditingController _runtimeLogFilterController;
@ -65,8 +65,8 @@ class _SettingsPageState extends State<SettingsPage> {
String _gatewaySetupCodeSyncedValue = '';
String _gatewayHostSyncedValue = '';
String _gatewayPortSyncedValue = '';
_SecretFieldUiState _gatewayTokenState = const _SecretFieldUiState();
_SecretFieldUiState _gatewayPasswordState = const _SecretFieldUiState();
late final List<_SecretFieldUiState> _gatewayTokenStates;
late final List<_SecretFieldUiState> _gatewayPasswordStates;
bool _aiGatewayTesting = false;
String _aiGatewayTestState = 'idle';
String _aiGatewayTestMessage = '';
@ -94,8 +94,26 @@ class _SettingsPageState extends State<SettingsPage> {
_gatewaySetupCodeController = TextEditingController();
_gatewayHostController = TextEditingController();
_gatewayPortController = TextEditingController();
_gatewayTokenController = TextEditingController();
_gatewayPasswordController = TextEditingController();
_gatewayTokenControllers = List<TextEditingController>.generate(
kGatewayProfileListLength,
(_) => TextEditingController(),
growable: false,
);
_gatewayPasswordControllers = List<TextEditingController>.generate(
kGatewayProfileListLength,
(_) => TextEditingController(),
growable: false,
);
_gatewayTokenStates = List<_SecretFieldUiState>.filled(
kGatewayProfileListLength,
const _SecretFieldUiState(),
growable: false,
);
_gatewayPasswordStates = List<_SecretFieldUiState>.filled(
kGatewayProfileListLength,
const _SecretFieldUiState(),
growable: false,
);
_vaultTokenController = TextEditingController();
_ollamaApiKeyController = TextEditingController();
_runtimeLogFilterController = TextEditingController();
@ -125,8 +143,12 @@ class _SettingsPageState extends State<SettingsPage> {
_gatewaySetupCodeController.dispose();
_gatewayHostController.dispose();
_gatewayPortController.dispose();
_gatewayTokenController.dispose();
_gatewayPasswordController.dispose();
for (final controller in _gatewayTokenControllers) {
controller.dispose();
}
for (final controller in _gatewayPasswordControllers) {
controller.dispose();
}
_vaultTokenController.dispose();
_ollamaApiKeyController.dispose();
_runtimeLogFilterController.dispose();
@ -864,10 +886,7 @@ class _SettingsPageState extends State<SettingsPage> {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
appText(
'自定义连接源 ${_selectedLlmEndpointIndex + 1}',
'Custom source ${_selectedLlmEndpointIndex + 1}',
),
appText('连接源详情', 'Source details'),
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: 16),
@ -889,14 +908,16 @@ class _SettingsPageState extends State<SettingsPage> {
SettingsSnapshot settings,
int index,
) {
final configured = _isLlmEndpointSlotConfigured(
controller,
settings,
_llmEndpointSlots[index],
);
final slot = _llmEndpointSlots[index];
final configured = _isLlmEndpointSlotConfigured(controller, settings, slot);
final label = switch (slot) {
_LlmEndpointSlot.aiGateway => appText('主 LLM API', 'Primary LLM API'),
_LlmEndpointSlot.ollamaLocal => appText('Ollama 本地', 'Ollama Local'),
_LlmEndpointSlot.ollamaCloud => appText('Ollama Cloud', 'Ollama Cloud'),
};
return appText(
'自定义连接源 ${index + 1}${configured ? '已配置' : ''}',
'Custom source ${index + 1} (${configured ? 'Configured' : 'Empty'})',
configured ? label : '$label(空',
configured ? label : '$label (empty)',
);
}
@ -1001,6 +1022,12 @@ class _SettingsPageState extends State<SettingsPage> {
selectedProfileIndex,
gatewayProfile,
);
final gatewayTokenController =
_gatewayTokenControllers[selectedProfileIndex];
final gatewayPasswordController =
_gatewayPasswordControllers[selectedProfileIndex];
final gatewayTokenState = _gatewayTokenStates[selectedProfileIndex];
final gatewayPasswordState = _gatewayPasswordStates[selectedProfileIndex];
final uiFeatures = controller.featuresFor(
resolveUiFeaturePlatformFromContext(context),
);
@ -1011,9 +1038,11 @@ class _SettingsPageState extends State<SettingsPage> {
final gatewayTls = gatewayMode == RuntimeConnectionMode.local
? false
: gatewayProfile.tls;
final hasStoredGatewayToken = controller.hasStoredGatewayToken;
final hasStoredGatewayPassword =
controller.settingsController.secureRefs['gateway_password'] != null;
final hasStoredGatewayToken = controller.hasStoredGatewayTokenForProfile(
selectedProfileIndex,
);
final hasStoredGatewayPassword = controller
.hasStoredGatewayPasswordForProfile(selectedProfileIndex);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
@ -1163,13 +1192,19 @@ class _SettingsPageState extends State<SettingsPage> {
const SizedBox(height: 16),
_buildSecureField(
fieldKey: const ValueKey('gateway-shared-token-field'),
controller: _gatewayTokenController,
controller: gatewayTokenController,
label: appText('共享 Token', 'Shared Token'),
hasStoredValue: hasStoredGatewayToken,
fieldState: _gatewayTokenState,
onStateChanged: (value) => setState(() => _gatewayTokenState = value),
loadValue: controller.settingsController.loadGatewayToken,
onSubmitted: (value) async => controller.saveGatewayTokenDraft(value),
fieldState: gatewayTokenState,
onStateChanged: (value) =>
setState(() => _gatewayTokenStates[selectedProfileIndex] = value),
loadValue: () => controller.settingsController.loadGatewayToken(
profileIndex: selectedProfileIndex,
),
onSubmitted: (value) async => controller.saveGatewayTokenDraft(
value,
profileIndex: selectedProfileIndex,
),
storedHelperText: appText(
'已安全保存,默认以 **** 显示;可直接测试,也可通过本区保存/应用提交。',
'Stored securely. Test directly or submit with local Save / Apply actions.',
@ -1182,15 +1217,20 @@ class _SettingsPageState extends State<SettingsPage> {
const SizedBox(height: 12),
_buildSecureField(
fieldKey: const ValueKey('gateway-password-field'),
controller: _gatewayPasswordController,
controller: gatewayPasswordController,
label: appText('密码', 'Password'),
hasStoredValue: hasStoredGatewayPassword,
fieldState: _gatewayPasswordState,
onStateChanged: (value) =>
setState(() => _gatewayPasswordState = value),
loadValue: controller.settingsController.loadGatewayPassword,
onSubmitted: (value) async =>
controller.saveGatewayPasswordDraft(value),
fieldState: gatewayPasswordState,
onStateChanged: (value) => setState(
() => _gatewayPasswordStates[selectedProfileIndex] = value,
),
loadValue: () => controller.settingsController.loadGatewayPassword(
profileIndex: selectedProfileIndex,
),
onSubmitted: (value) async => controller.saveGatewayPasswordDraft(
value,
profileIndex: selectedProfileIndex,
),
storedHelperText: appText(
'已安全保存,默认以 **** 显示;可直接测试,也可通过本区保存/应用提交。',
'Stored securely. Test directly or submit with local Save / Apply actions.',
@ -2813,19 +2853,24 @@ XWorkmate Privacy Policy
}
Future<void> _captureVisibleSecretDrafts(AppController controller) async {
final gatewayToken = _secretOverride(
_gatewayTokenController,
_gatewayTokenState,
);
if (gatewayToken.isNotEmpty) {
controller.saveGatewayTokenDraft(gatewayToken);
}
final gatewayPassword = _secretOverride(
_gatewayPasswordController,
_gatewayPasswordState,
);
if (gatewayPassword.isNotEmpty) {
controller.saveGatewayPasswordDraft(gatewayPassword);
for (var index = 0; index < kGatewayProfileListLength; index += 1) {
final gatewayToken = _secretOverride(
_gatewayTokenControllers[index],
_gatewayTokenStates[index],
);
if (gatewayToken.isNotEmpty) {
controller.saveGatewayTokenDraft(gatewayToken, profileIndex: index);
}
final gatewayPassword = _secretOverride(
_gatewayPasswordControllers[index],
_gatewayPasswordStates[index],
);
if (gatewayPassword.isNotEmpty) {
controller.saveGatewayPasswordDraft(
gatewayPassword,
profileIndex: index,
);
}
}
final aiGatewayApiKey = _secretOverride(
_aiGatewayApiKeyController,
@ -2848,10 +2893,6 @@ XWorkmate Privacy Policy
}
void _resetSecureFieldUiAfterPersist(AppController controller) {
final hasStoredGatewayToken =
controller.settingsController.secureRefs['gateway_token'] != null;
final hasStoredGatewayPassword =
controller.settingsController.secureRefs['gateway_password'] != null;
final hasStoredAiGatewayApiKey =
controller.settingsController.secureRefs['ai_gateway_api_key'] != null;
final hasStoredVaultToken =
@ -2859,21 +2900,23 @@ XWorkmate Privacy Policy
final hasStoredOllamaApiKey =
controller.settingsController.secureRefs['ollama_cloud_api_key'] !=
null;
_gatewayTokenState = const _SecretFieldUiState();
_gatewayPasswordState = const _SecretFieldUiState();
for (var index = 0; index < kGatewayProfileListLength; index += 1) {
_gatewayTokenStates[index] = const _SecretFieldUiState();
_gatewayPasswordStates[index] = const _SecretFieldUiState();
_primeSecureFieldController(
_gatewayTokenControllers[index],
hasStoredValue: controller.hasStoredGatewayTokenForProfile(index),
fieldState: _gatewayTokenStates[index],
);
_primeSecureFieldController(
_gatewayPasswordControllers[index],
hasStoredValue: controller.hasStoredGatewayPasswordForProfile(index),
fieldState: _gatewayPasswordStates[index],
);
}
_aiGatewayApiKeyState = const _SecretFieldUiState();
_vaultTokenState = const _SecretFieldUiState();
_ollamaApiKeyState = const _SecretFieldUiState();
_primeSecureFieldController(
_gatewayTokenController,
hasStoredValue: hasStoredGatewayToken,
fieldState: _gatewayTokenState,
);
_primeSecureFieldController(
_gatewayPasswordController,
hasStoredValue: hasStoredGatewayPassword,
fieldState: _gatewayPasswordState,
);
_primeSecureFieldController(
_aiGatewayApiKeyController,
hasStoredValue: hasStoredAiGatewayApiKey,
@ -3176,21 +3219,35 @@ XWorkmate Privacy Policy
) async {
final messenger = ScaffoldMessenger.of(context);
final gatewayDraft = _buildGatewayDraftProfile(settings);
final selectedProfileIndex = _selectedGatewayProfileIndex.clamp(
0,
settings.gatewayProfiles.length - 1,
);
final gatewayTokenController =
_gatewayTokenControllers[selectedProfileIndex];
final gatewayPasswordController =
_gatewayPasswordControllers[selectedProfileIndex];
final gatewayTokenState = _gatewayTokenStates[selectedProfileIndex];
final gatewayPasswordState = _gatewayPasswordStates[selectedProfileIndex];
final executionTarget = switch (gatewayDraft.mode) {
RuntimeConnectionMode.local => AssistantExecutionTarget.local,
RuntimeConnectionMode.remote => AssistantExecutionTarget.remote,
RuntimeConnectionMode.unconfigured => AssistantExecutionTarget.remote,
};
var token = _secretOverride(_gatewayTokenController, _gatewayTokenState);
var token = _secretOverride(gatewayTokenController, gatewayTokenState);
var password = _secretOverride(
_gatewayPasswordController,
_gatewayPasswordState,
gatewayPasswordController,
gatewayPasswordState,
);
if (token.isEmpty) {
token = await controller.settingsController.loadGatewayToken();
token = await controller.settingsController.loadGatewayToken(
profileIndex: selectedProfileIndex,
);
}
if (password.isEmpty) {
password = await controller.settingsController.loadGatewayPassword();
password = await controller.settingsController.loadGatewayPassword(
profileIndex: selectedProfileIndex,
);
}
setState(() => _gatewayTesting = true);
try {

View File

@ -120,6 +120,7 @@ class GatewayRuntime extends ChangeNotifier {
Future<void> connectProfile(
GatewayConnectionProfile profile, {
int? profileIndex,
String authTokenOverride = '',
String authPasswordOverride = '',
}) async {
@ -130,8 +131,14 @@ class GatewayRuntime extends ChangeNotifier {
final endpoint = _resolveEndpoint(profile);
final setupPayload = decodeGatewaySetupCode(profile.setupCode);
final storedToken = (await _store.loadGatewayToken())?.trim() ?? '';
final storedPassword = (await _store.loadGatewayPassword())?.trim() ?? '';
final storedToken =
(await _store.loadGatewayToken(profileIndex: profileIndex))?.trim() ??
'';
final storedPassword =
(await _store.loadGatewayPassword(
profileIndex: profileIndex,
))?.trim() ??
'';
final explicitToken = authTokenOverride.trim();
final explicitPassword = authPasswordOverride.trim();
final sharedTokenSource = explicitToken.isNotEmpty

View File

@ -67,32 +67,36 @@ class SettingsController extends ChangeNotifier {
}
Future<void> saveGatewaySecrets({
int? profileIndex,
required String token,
required String password,
}) async {
final trimmedToken = token.trim();
final trimmedPassword = password.trim();
if (trimmedToken.isNotEmpty) {
await _store.saveGatewayToken(trimmedToken);
await _store.saveGatewayToken(trimmedToken, profileIndex: profileIndex);
await appendAudit(
SecretAuditEntry(
timeLabel: _timeLabel(),
action: 'Updated',
provider: 'Gateway',
target: 'gateway_token',
target: _gatewaySecretTarget('gateway_token', profileIndex),
module: 'Assistant',
status: 'Success',
),
);
}
if (trimmedPassword.isNotEmpty) {
await _store.saveGatewayPassword(trimmedPassword);
await _store.saveGatewayPassword(
trimmedPassword,
profileIndex: profileIndex,
);
await appendAudit(
SecretAuditEntry(
timeLabel: _timeLabel(),
action: 'Updated',
provider: 'Gateway',
target: 'gateway_password',
target: _gatewaySecretTarget('gateway_password', profileIndex),
module: 'Assistant',
status: 'Success',
),
@ -103,30 +107,31 @@ class SettingsController extends ChangeNotifier {
}
Future<void> clearGatewaySecrets({
int? profileIndex,
bool token = false,
bool password = false,
}) async {
if (token) {
await _store.clearGatewayToken();
await _store.clearGatewayToken(profileIndex: profileIndex);
await appendAudit(
SecretAuditEntry(
timeLabel: _timeLabel(),
action: 'Cleared',
provider: 'Gateway',
target: 'gateway_token',
target: _gatewaySecretTarget('gateway_token', profileIndex),
module: 'Assistant',
status: 'Success',
),
);
}
if (password) {
await _store.clearGatewayPassword();
await _store.clearGatewayPassword(profileIndex: profileIndex);
await appendAudit(
SecretAuditEntry(
timeLabel: _timeLabel(),
action: 'Cleared',
provider: 'Gateway',
target: 'gateway_password',
target: _gatewaySecretTarget('gateway_password', profileIndex),
module: 'Assistant',
status: 'Success',
),
@ -136,14 +141,38 @@ class SettingsController extends ChangeNotifier {
notifyListeners();
}
Future<String> loadGatewayToken() async {
return (await _store.loadGatewayToken())?.trim() ?? '';
Future<String> loadGatewayToken({int? profileIndex}) async {
return (await _store.loadGatewayToken(
profileIndex: profileIndex,
))?.trim() ??
'';
}
Future<String> loadGatewayPassword() async {
return (await _store.loadGatewayPassword())?.trim() ?? '';
Future<String> loadGatewayPassword({int? profileIndex}) async {
return (await _store.loadGatewayPassword(
profileIndex: profileIndex,
))?.trim() ??
'';
}
bool hasStoredGatewayTokenForProfile(int profileIndex) =>
_secureRefs.containsKey(SecretStore.gatewayTokenRefKey(profileIndex)) ||
_secureRefs.containsKey('gateway_token');
bool hasStoredGatewayPasswordForProfile(int profileIndex) =>
_secureRefs.containsKey(
SecretStore.gatewayPasswordRefKey(profileIndex),
) ||
_secureRefs.containsKey('gateway_password');
String? storedGatewayTokenMaskForProfile(int profileIndex) =>
_secureRefs[SecretStore.gatewayTokenRefKey(profileIndex)] ??
_secureRefs['gateway_token'];
String? storedGatewayPasswordMaskForProfile(int profileIndex) =>
_secureRefs[SecretStore.gatewayPasswordRefKey(profileIndex)] ??
_secureRefs['gateway_password'];
Future<void> saveOllamaCloudApiKey(String value) async {
final trimmed = value.trim();
if (trimmed.isEmpty) {
@ -754,6 +783,13 @@ class SettingsController extends ChangeNotifier {
final now = DateTime.now();
return '${now.hour.toString().padLeft(2, '0')}:${now.minute.toString().padLeft(2, '0')}';
}
String _gatewaySecretTarget(String base, int? profileIndex) {
if (profileIndex == null) {
return base;
}
return '$base.$profileIndex';
}
}
class _AiGatewayResponseException implements Exception {

View File

@ -140,10 +140,14 @@ class SecretStore {
const FlutterSecureStorage(),
);
} catch (_) {
_secureStorage = FileSecureStorageClient(() => _resolveFallbackDirectory());
_secureStorage = FileSecureStorageClient(
() => _resolveFallbackDirectory(),
);
}
} else {
_secureStorage = FileSecureStorageClient(() => _resolveFallbackDirectory());
_secureStorage = FileSecureStorageClient(
() => _resolveFallbackDirectory(),
);
}
_initialized = true;
}
@ -171,8 +175,19 @@ class SecretStore {
if ((scopedValue ?? '').trim().isNotEmpty) {
return scopedValue;
}
return _readSecure(_legacyGatewayTokenKey);
}
return _readSecure(_legacyGatewayTokenKey);
final legacyValue = await _readSecure(_legacyGatewayTokenKey);
if ((legacyValue ?? '').trim().isNotEmpty) {
return legacyValue;
}
for (final index in _gatewayProfileFallbackOrder) {
final scopedValue = await _readSecure(_gatewayTokenKeyForProfile(index));
if ((scopedValue ?? '').trim().isNotEmpty) {
return scopedValue;
}
}
return null;
}
Future<void> saveGatewayToken(String value, {int? profileIndex}) =>
@ -183,12 +198,11 @@ class SecretStore {
value,
);
Future<void> clearGatewayToken({int? profileIndex}) =>
_deleteSecure(
profileIndex == null
? _legacyGatewayTokenKey
: _gatewayTokenKeyForProfile(profileIndex),
);
Future<void> clearGatewayToken({int? profileIndex}) => _deleteSecure(
profileIndex == null
? _legacyGatewayTokenKey
: _gatewayTokenKeyForProfile(profileIndex),
);
Future<String?> loadGatewayPassword({int? profileIndex}) async {
if (profileIndex != null) {
@ -198,8 +212,21 @@ class SecretStore {
if ((scopedValue ?? '').trim().isNotEmpty) {
return scopedValue;
}
return _readSecure(_legacyGatewayPasswordKey);
}
return _readSecure(_legacyGatewayPasswordKey);
final legacyValue = await _readSecure(_legacyGatewayPasswordKey);
if ((legacyValue ?? '').trim().isNotEmpty) {
return legacyValue;
}
for (final index in _gatewayProfileFallbackOrder) {
final scopedValue = await _readSecure(
_gatewayPasswordKeyForProfile(index),
);
if ((scopedValue ?? '').trim().isNotEmpty) {
return scopedValue;
}
}
return null;
}
Future<void> saveGatewayPassword(String value, {int? profileIndex}) =>
@ -210,12 +237,11 @@ class SecretStore {
value,
);
Future<void> clearGatewayPassword({int? profileIndex}) =>
_deleteSecure(
profileIndex == null
? _legacyGatewayPasswordKey
: _gatewayPasswordKeyForProfile(profileIndex),
);
Future<void> clearGatewayPassword({int? profileIndex}) => _deleteSecure(
profileIndex == null
? _legacyGatewayPasswordKey
: _gatewayPasswordKeyForProfile(profileIndex),
);
Future<String?> loadOllamaCloudApiKey() => _readSecure(_ollamaCloudApiKeyKey);
@ -604,6 +630,14 @@ class SecretStore {
static String _gatewayPasswordRefKey(int profileIndex) =>
'gateway_password_$profileIndex';
static const List<int> _gatewayProfileFallbackOrder = <int>[
kGatewayRemoteProfileIndex,
kGatewayLocalProfileIndex,
2,
3,
4,
];
static List<int> _base64UrlDecode(String value) {
final normalized = value.replaceAll('-', '+').replaceAll('_', '/');
final padded = normalized + '=' * ((4 - normalized.length % 4) % 4);
@ -619,7 +653,9 @@ class SecretStore {
if (promoted && _secureStorage != null) {
return _secureStorage!;
}
throw StateError('Durable secret storage unavailable: no persistent secure storage client.');
throw StateError(
'Durable secret storage unavailable: no persistent secure storage client.',
);
}
Future<String?> _resolvePath(Future<String?> Function()? resolver) async {

View File

@ -33,6 +33,7 @@ class _FakeGatewayRuntime extends GatewayRuntime {
@override
Future<void> connectProfile(
GatewayConnectionProfile profile, {
int? profileIndex,
String authTokenOverride = '',
String authPasswordOverride = '',
}) async {}

View File

@ -827,6 +827,7 @@ class _FakeGatewayRuntime extends GatewayRuntime {
@override
Future<void> connectProfile(
GatewayConnectionProfile profile, {
int? profileIndex,
String authTokenOverride = '',
String authPasswordOverride = '',
}) async {

View File

@ -505,6 +505,22 @@ void main() {
await tester.tap(find.byKey(const ValueKey('llm-endpoint-add-button')));
await tester.pumpAndSettle();
expect(
find.descendant(
of: find.byKey(const ValueKey('llm-endpoint-chip-0')),
matching: find.textContaining('主 LLM API'),
),
findsOneWidget,
);
expect(
find.descendant(
of: find.byKey(const ValueKey('llm-endpoint-chip-1')),
matching: find.textContaining('Ollama 本地'),
),
findsOneWidget,
);
expect(find.text('连接源详情'), findsOneWidget);
expect(find.textContaining('自定义连接源'), findsNothing);
expect(find.byKey(const ValueKey('llm-endpoint-chip-1')), findsOneWidget);
expect(
find.byKey(const ValueKey('llm-endpoint-panel-ollamaLocal')),

View File

@ -73,6 +73,7 @@ class MockGatewayRuntime extends GatewayRuntime {
@override
Future<void> connectProfile(
GatewayConnectionProfile profile, {
int? profileIndex,
String authTokenOverride = '',
String authPasswordOverride = '',
}) async {}

View File

@ -175,76 +175,73 @@ void main() {
},
);
test(
'AppController falls back when LLM API ignores stream mode',
() async {
SharedPreferences.setMockInitialValues(<String, Object>{});
final tempDirectory = await Directory.systemTemp.createTemp(
'xworkmate-ai-gateway-json-fallback-',
);
final server = await _FakeAiGatewayServer.start(
responseMode: _AiGatewayResponseMode.json,
);
addTearDown(() async {
await server.close();
if (await tempDirectory.exists()) {
await tempDirectory.delete(recursive: true);
}
});
test('AppController falls back when LLM API ignores stream mode', () async {
SharedPreferences.setMockInitialValues(<String, Object>{});
final tempDirectory = await Directory.systemTemp.createTemp(
'xworkmate-ai-gateway-json-fallback-',
);
final server = await _FakeAiGatewayServer.start(
responseMode: _AiGatewayResponseMode.json,
);
addTearDown(() async {
await server.close();
if (await tempDirectory.exists()) {
await tempDirectory.delete(recursive: true);
}
});
final store = SecureConfigStore(
enableSecureStorage: false,
databasePathResolver: () async => '${tempDirectory.path}/settings.db',
fallbackDirectoryPathResolver: () async => tempDirectory.path,
);
final controller = AppController(
store: store,
availableSingleAgentProvidersOverride: const <SingleAgentProvider>[],
runtimeCoordinator: RuntimeCoordinator(
gateway: _FakeGatewayRuntime(store: store),
codex: _FakeCodexRuntime(),
final store = SecureConfigStore(
enableSecureStorage: false,
databasePathResolver: () async => '${tempDirectory.path}/settings.db',
fallbackDirectoryPathResolver: () async => tempDirectory.path,
);
final controller = AppController(
store: store,
availableSingleAgentProvidersOverride: const <SingleAgentProvider>[],
runtimeCoordinator: RuntimeCoordinator(
gateway: _FakeGatewayRuntime(store: store),
codex: _FakeCodexRuntime(),
),
singleAgentRunner: _FallbackOnlySingleAgentRunner(),
);
addTearDown(controller.dispose);
await _waitFor(() => !controller.initializing);
await controller.settingsController.saveAiGatewayApiKey('live-key');
await controller.saveSettings(
controller.settings.copyWith(
aiGateway: controller.settings.aiGateway.copyWith(
baseUrl: server.baseUrl,
availableModels: const <String>['moonshotai/kimi-k2.5'],
selectedModels: const <String>['moonshotai/kimi-k2.5'],
),
singleAgentRunner: _FallbackOnlySingleAgentRunner(),
);
addTearDown(controller.dispose);
await _waitFor(() => !controller.initializing);
await controller.settingsController.saveAiGatewayApiKey('live-key');
await controller.saveSettings(
controller.settings.copyWith(
aiGateway: controller.settings.aiGateway.copyWith(
baseUrl: server.baseUrl,
availableModels: const <String>['moonshotai/kimi-k2.5'],
selectedModels: const <String>['moonshotai/kimi-k2.5'],
),
defaultModel: 'moonshotai/kimi-k2.5',
multiAgent: controller.settings.multiAgent.copyWith(
autoSync: false,
mountTargets: _withAvailableMountTargets(
controller.settings.multiAgent.mountTargets,
const <String>[],
),
defaultModel: 'moonshotai/kimi-k2.5',
multiAgent: controller.settings.multiAgent.copyWith(
autoSync: false,
mountTargets: _withAvailableMountTargets(
controller.settings.multiAgent.mountTargets,
const <String>[],
),
),
refreshAfterSave: false,
);
await controller.setAssistantExecutionTarget(
AssistantExecutionTarget.singleAgent,
);
),
refreshAfterSave: false,
);
await controller.setAssistantExecutionTarget(
AssistantExecutionTarget.singleAgent,
);
await controller.sendChatMessage('你好', thinking: 'low');
await controller.sendChatMessage('你好', thinking: 'low');
await _waitFor(
() => controller.chatMessages.any(
(message) =>
message.role == 'assistant' && message.text == 'FIRST_REPLY',
),
);
await _waitFor(
() => controller.chatMessages.any(
(message) =>
message.role == 'assistant' && message.text == 'FIRST_REPLY',
),
);
expect(server.requests.single['stream'], isTrue);
expect(controller.chatMessages.last.pending, isFalse);
},
);
expect(server.requests.single['stream'], isTrue);
expect(controller.chatMessages.last.pending, isFalse);
});
test(
'AppController abortRun stops Single Agent streaming requests',
@ -380,7 +377,8 @@ void main() {
expect(runner.lastRequest?.provider, SingleAgentProvider.codex);
expect(
controller.chatMessages.any(
(message) => message.role == 'assistant' && message.text == 'CODEX_REPLY',
(message) =>
message.role == 'assistant' && message.text == 'CODEX_REPLY',
),
isTrue,
);
@ -569,6 +567,7 @@ class _FakeGatewayRuntime extends GatewayRuntime {
@override
Future<void> connectProfile(
GatewayConnectionProfile profile, {
int? profileIndex,
String authTokenOverride = '',
String authPasswordOverride = '',
}) async {

View File

@ -56,6 +56,7 @@ class _FakeGatewayRuntime extends GatewayRuntime {
@override
Future<void> connectProfile(
GatewayConnectionProfile profile, {
int? profileIndex,
String authTokenOverride = '',
String authPasswordOverride = '',
}) async {
@ -130,181 +131,168 @@ class _FakeCodexRuntime extends CodexRuntime {
}
void main() {
group(
'Manual Codex bridge validation',
() {
test(
'AppController enables external Codex bridge and registers to gateway',
() async {
SharedPreferences.setMockInitialValues(<String, Object>{});
final store = createIsolatedTestStore();
final gateway = _FakeGatewayRuntime(connected: true);
final codex = _FakeCodexRuntime();
final coordinator = RuntimeCoordinator(
gateway: gateway,
codex: codex,
);
final controller = AppController(
store: store,
runtimeCoordinator: coordinator,
);
addTearDown(controller.dispose);
group('Manual Codex bridge validation', () {
test(
'AppController enables external Codex bridge and registers to gateway',
() async {
SharedPreferences.setMockInitialValues(<String, Object>{});
final store = createIsolatedTestStore();
final gateway = _FakeGatewayRuntime(connected: true);
final codex = _FakeCodexRuntime();
final coordinator = RuntimeCoordinator(gateway: gateway, codex: codex);
final controller = AppController(
store: store,
runtimeCoordinator: coordinator,
);
addTearDown(controller.dispose);
await _waitFor(() => !controller.initializing);
await _waitFor(() => !controller.initializing);
final tempDir = await Directory.systemTemp.createTemp('codex-bridge-');
addTearDown(() async {
if (await tempDir.exists()) {
await tempDir.delete(recursive: true);
}
});
final codexBinary = File('${tempDir.path}/codex');
await codexBinary.writeAsString('#!/bin/sh\nexit 0\n');
final tempDir = await Directory.systemTemp.createTemp('codex-bridge-');
addTearDown(() async {
if (await tempDir.exists()) {
await tempDir.delete(recursive: true);
}
});
final codexBinary = File('${tempDir.path}/codex');
await codexBinary.writeAsString('#!/bin/sh\nexit 0\n');
await controller.settingsController.saveAiGatewayApiKey(
'bridge-secret',
);
await controller.saveSettings(
controller.settings.copyWith(
workspacePath: tempDir.path,
codeAgentRuntimeMode: CodeAgentRuntimeMode.externalCli,
codexCliPath: codexBinary.path,
aiGateway: controller.settings.aiGateway.copyWith(
baseUrl: 'https://gateway.example.com',
),
await controller.settingsController.saveAiGatewayApiKey(
'bridge-secret',
);
await controller.saveSettings(
controller.settings.copyWith(
workspacePath: tempDir.path,
codeAgentRuntimeMode: CodeAgentRuntimeMode.externalCli,
codexCliPath: codexBinary.path,
aiGateway: controller.settings.aiGateway.copyWith(
baseUrl: 'https://gateway.example.com',
),
);
),
);
await controller.enableCodexBridge();
await controller.enableCodexBridge();
expect(controller.isCodexBridgeEnabled, isTrue);
expect(
controller.codexCooperationState,
CodexCooperationState.registered,
);
expect(codex.startCalled, isTrue);
expect(codex.startedCodexPath, codexBinary.path);
expect(codex.startedCwd, tempDir.path);
expect(controller.isCodexBridgeEnabled, isTrue);
expect(
controller.codexCooperationState,
CodexCooperationState.registered,
);
expect(codex.startCalled, isTrue);
expect(codex.startedCodexPath, codexBinary.path);
expect(codex.startedCwd, tempDir.path);
final registrationCall = gateway.requests.firstWhere(
final registrationCall = gateway.requests.firstWhere(
(request) => request['method'] == 'agent/register',
);
final params = registrationCall['params'] as Map<String, dynamic>;
expect(params['transport'], 'stdio-bridge');
expect(params['metadata'], containsPair('providerId', 'codex'));
expect(params['metadata'], containsPair('runtimeMode', 'externalCli'));
expect(
(params['metadata']['node'] as Map<String, dynamic>)['kind'],
'app-mediated-cooperative-node',
);
},
);
test(
'AppController keeps bridge running when gateway registration is unavailable',
() async {
SharedPreferences.setMockInitialValues(<String, Object>{});
final store = createIsolatedTestStore();
final gateway = _FakeGatewayRuntime(connected: false);
final codex = _FakeCodexRuntime();
final coordinator = RuntimeCoordinator(gateway: gateway, codex: codex);
final controller = AppController(
store: store,
runtimeCoordinator: coordinator,
);
addTearDown(controller.dispose);
await _waitFor(() => !controller.initializing);
final tempDir = await Directory.systemTemp.createTemp(
'codex-bridge-offline-',
);
addTearDown(() async {
if (await tempDir.exists()) {
await tempDir.delete(recursive: true);
}
});
final codexBinary = File('${tempDir.path}/codex');
await codexBinary.writeAsString('#!/bin/sh\nexit 0\n');
await controller.saveSettings(
controller.settings.copyWith(
codeAgentRuntimeMode: CodeAgentRuntimeMode.externalCli,
codexCliPath: codexBinary.path,
aiGateway: controller.settings.aiGateway.copyWith(
baseUrl: 'https://gateway.example.com',
),
),
);
await controller.enableCodexBridge();
expect(controller.isCodexBridgeEnabled, isTrue);
expect(
controller.codexCooperationState,
CodexCooperationState.bridgeOnly,
);
expect(codex.startCalled, isTrue);
expect(
gateway.requests.where(
(request) => request['method'] == 'agent/register',
);
final params = registrationCall['params'] as Map<String, dynamic>;
expect(params['transport'], 'stdio-bridge');
expect(params['metadata'], containsPair('providerId', 'codex'));
expect(params['metadata'], containsPair('runtimeMode', 'externalCli'));
expect(
(params['metadata']['node'] as Map<String, dynamic>)['kind'],
'app-mediated-cooperative-node',
);
},
);
),
isEmpty,
);
},
);
test(
'AppController keeps bridge running when gateway registration is unavailable',
() async {
SharedPreferences.setMockInitialValues(<String, Object>{});
final store = createIsolatedTestStore();
final gateway = _FakeGatewayRuntime(connected: false);
final codex = _FakeCodexRuntime();
final coordinator = RuntimeCoordinator(
gateway: gateway,
codex: codex,
);
final controller = AppController(
store: store,
runtimeCoordinator: coordinator,
);
addTearDown(controller.dispose);
test(
'AppController preserves built-in mode and does not require external codex binary',
() async {
SharedPreferences.setMockInitialValues(<String, Object>{});
final store = createIsolatedTestStore();
final gateway = _FakeGatewayRuntime(connected: false);
final codex = _FakeCodexRuntime();
final coordinator = RuntimeCoordinator(gateway: gateway, codex: codex);
final controller = AppController(
store: store,
runtimeCoordinator: coordinator,
);
addTearDown(controller.dispose);
await _waitFor(() => !controller.initializing);
await _waitFor(() => !controller.initializing);
final tempDir = await Directory.systemTemp.createTemp(
'codex-bridge-offline-',
);
addTearDown(() async {
if (await tempDir.exists()) {
await tempDir.delete(recursive: true);
}
});
final codexBinary = File('${tempDir.path}/codex');
await codexBinary.writeAsString('#!/bin/sh\nexit 0\n');
await controller.saveSettings(
controller.settings.copyWith(
codeAgentRuntimeMode: CodeAgentRuntimeMode.externalCli,
codexCliPath: codexBinary.path,
aiGateway: controller.settings.aiGateway.copyWith(
baseUrl: 'https://gateway.example.com',
),
await controller.saveSettings(
controller.settings.copyWith(
codeAgentRuntimeMode: CodeAgentRuntimeMode.builtIn,
aiGateway: controller.settings.aiGateway.copyWith(
baseUrl: 'https://gateway.example.com',
),
);
),
);
await controller.enableCodexBridge();
expect(
controller.settings.codeAgentRuntimeMode,
CodeAgentRuntimeMode.builtIn,
);
expect(controller.codexRuntimeWarning, isNotNull);
expect(controller.isCodexBridgeEnabled, isTrue);
expect(
controller.codexCooperationState,
CodexCooperationState.bridgeOnly,
);
expect(codex.startCalled, isTrue);
expect(
gateway.requests.where(
(request) => request['method'] == 'agent/register',
),
isEmpty,
);
},
);
await controller.enableCodexBridge();
test(
'AppController preserves built-in mode and does not require external codex binary',
() async {
SharedPreferences.setMockInitialValues(<String, Object>{});
final store = createIsolatedTestStore();
final gateway = _FakeGatewayRuntime(connected: false);
final codex = _FakeCodexRuntime();
final coordinator = RuntimeCoordinator(
gateway: gateway,
codex: codex,
);
final controller = AppController(
store: store,
runtimeCoordinator: coordinator,
);
addTearDown(controller.dispose);
await _waitFor(() => !controller.initializing);
await controller.saveSettings(
controller.settings.copyWith(
codeAgentRuntimeMode: CodeAgentRuntimeMode.builtIn,
aiGateway: controller.settings.aiGateway.copyWith(
baseUrl: 'https://gateway.example.com',
),
),
);
expect(
controller.settings.codeAgentRuntimeMode,
CodeAgentRuntimeMode.builtIn,
);
expect(controller.codexRuntimeWarning, isNotNull);
await controller.enableCodexBridge();
expect(controller.isCodexBridgeEnabled, isTrue);
expect(
controller.codexCooperationState,
CodexCooperationState.bridgeOnly,
);
expect(codex.startCalled, isFalse);
expect(coordinator.runtimeMode, CodeAgentRuntimeMode.builtIn);
},
);
},
skip: _manualCodexBridgeSkipReason,
);
expect(controller.isCodexBridgeEnabled, isTrue);
expect(
controller.codexCooperationState,
CodexCooperationState.bridgeOnly,
);
expect(codex.startCalled, isFalse);
expect(coordinator.runtimeMode, CodeAgentRuntimeMode.builtIn);
},
);
}, skip: _manualCodexBridgeSkipReason);
}
Future<void> _waitFor(

View File

@ -38,6 +38,7 @@ class _FakeGatewayRuntime extends GatewayRuntime {
@override
Future<void> connectProfile(
GatewayConnectionProfile profile, {
int? profileIndex,
String authTokenOverride = '',
String authPasswordOverride = '',
}) async {

View File

@ -5,12 +5,14 @@ import 'package:flutter_test/flutter_test.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:xworkmate/app/app_controller.dart';
import '../test_support.dart';
void main() {
test(
'AppController tracks stored shared-token mask and clear action',
() async {
SharedPreferences.setMockInitialValues(<String, Object>{});
final controller = AppController();
final controller = AppController(store: createIsolatedTestStore());
addTearDown(controller.dispose);
await _waitFor(() => !controller.initializing);
@ -32,6 +34,33 @@ void main() {
expect(controller.storedGatewayTokenMask, isNull);
},
);
test(
'AppController keeps gateway token masks independent per profile slot',
() async {
SharedPreferences.setMockInitialValues(<String, Object>{});
final controller = AppController(store: createIsolatedTestStore());
addTearDown(controller.dispose);
await _waitFor(() => !controller.initializing);
await controller.settingsController.saveGatewaySecrets(
profileIndex: 0,
token: 'local-secret',
password: '',
);
await controller.settingsController.saveGatewaySecrets(
profileIndex: 1,
token: 'remote-secret',
password: '',
);
expect(controller.hasStoredGatewayTokenForProfile(0), isTrue);
expect(controller.hasStoredGatewayTokenForProfile(1), isTrue);
expect(controller.storedGatewayTokenMaskForProfile(0), 'loc••••ret');
expect(controller.storedGatewayTokenMaskForProfile(1), 'rem••••ret');
},
);
}
Future<void> _waitFor(bool Function() predicate) async {

View File

@ -23,6 +23,7 @@ class _FakeGatewayRuntime extends GatewayRuntime {
@override
Future<void> connectProfile(
GatewayConnectionProfile profile, {
int? profileIndex,
String authTokenOverride = '',
String authPasswordOverride = '',
}) async {}

View File

@ -59,6 +59,7 @@ class MockGatewayRuntime extends GatewayRuntime {
@override
Future<void> connectProfile(
GatewayConnectionProfile profile, {
int? profileIndex,
String authTokenOverride = '',
String authPasswordOverride = '',
}) async {

View File

@ -81,6 +81,7 @@ class MockGatewayRuntime extends GatewayRuntime {
@override
Future<void> connectProfile(
GatewayConnectionProfile profile, {
int? profileIndex,
String authTokenOverride = '',
String authPasswordOverride = '',
}) async {

View File

@ -41,6 +41,7 @@ class _FakeGatewayRuntime extends GatewayRuntime {
@override
Future<void> connectProfile(
GatewayConnectionProfile profile, {
int? profileIndex,
String authTokenOverride = '',
String authPasswordOverride = '',
}) async {

View File

@ -87,6 +87,69 @@ void main() {
},
);
test(
'SecureConfigStore keeps gateway secrets isolated per profile slot',
() async {
SharedPreferences.setMockInitialValues(<String, Object>{});
final tempDirectory = await Directory.systemTemp.createTemp(
'xworkmate-config-store-profiles-',
);
addTearDown(() async {
if (await tempDirectory.exists()) {
await tempDirectory.delete(recursive: true);
}
});
final databasePath = '${tempDirectory.path}/settings.sqlite3';
final store = SecureConfigStore(
databasePathResolver: () async => databasePath,
fallbackDirectoryPathResolver: () async => tempDirectory.path,
);
await store.saveGatewayToken(
'local-token',
profileIndex: kGatewayLocalProfileIndex,
);
await store.saveGatewayToken(
'remote-token',
profileIndex: kGatewayRemoteProfileIndex,
);
await store.saveGatewayPassword(
'custom-password',
profileIndex: kGatewayCustomProfileStartIndex,
);
final secureRefs = await store.loadSecureRefs();
expect(
await store.loadGatewayToken(profileIndex: kGatewayLocalProfileIndex),
'local-token',
);
expect(
await store.loadGatewayToken(profileIndex: kGatewayRemoteProfileIndex),
'remote-token',
);
expect(
await store.loadGatewayPassword(
profileIndex: kGatewayCustomProfileStartIndex,
),
'custom-password',
);
expect(
secureRefs['gateway_token_$kGatewayLocalProfileIndex'],
'local-token',
);
expect(
secureRefs['gateway_token_$kGatewayRemoteProfileIndex'],
'remote-token',
);
expect(
secureRefs['gateway_password_$kGatewayCustomProfileStartIndex'],
'custom-password',
);
expect(await store.loadGatewayToken(), 'remote-token');
},
);
test(
'SecureConfigStore persists sqlite-backed settings across instances',
() async {
@ -191,20 +254,29 @@ void main() {
await tempDirectory.delete(recursive: true);
}
});
final existingSecretsDirectory = Directory('${tempDirectory.path}/secrets');
final existingSecretsDirectory = Directory(
'${tempDirectory.path}/secrets',
);
await existingSecretsDirectory.create(recursive: true);
final explicitSettingsPath =
'${tempDirectory.path}/settings/${SettingsStore.databaseFileName}';
final store = SecureConfigStore(
databasePathResolver: () async => explicitSettingsPath,
fallbackDirectoryPathResolver: () async => existingSecretsDirectory.path,
fallbackDirectoryPathResolver: () async =>
existingSecretsDirectory.path,
);
final snapshot = await store.loadSettingsSnapshot();
expect(snapshot.accountUsername, SettingsSnapshot.defaults().accountUsername);
expect(await Directory('${tempDirectory.path}/settings').exists(), isTrue);
expect(
snapshot.accountUsername,
SettingsSnapshot.defaults().accountUsername,
);
expect(
await Directory('${tempDirectory.path}/settings').exists(),
isTrue,
);
expect(await File(explicitSettingsPath).exists(), isTrue);
},
);