From bdb9faeff5595fc11f23cd474d15c60c635dc1d2 Mon Sep 17 00:00:00 2001 From: Haitao Pan Date: Mon, 1 Jun 2026 10:02:13 +0800 Subject: [PATCH] fix: repair bridge login sync runtime state --- docs/testing/api-script-runbook.md | 12 +- ...pp_controller_desktop_runtime_helpers.dart | 49 +++++++ lib/features/settings/settings_page_core.dart | 95 +++++++++++- .../runtime_controllers_settings_account.dart | 4 + ...ime_controllers_settings_account_impl.dart | 18 +++ scripts/ci/verify_api_interface_contract.sh | 68 ++++++--- scripts/ci/verify_api_scenario_contract.sh | 2 - .../settings_about_bridge_metadata_test.dart | 5 +- .../settings/settings_account_panel_test.dart | 59 ++++++++ ...ime_controllers_settings_account_test.dart | 138 ++++++++++++++++++ 10 files changed, 416 insertions(+), 34 deletions(-) diff --git a/docs/testing/api-script-runbook.md b/docs/testing/api-script-runbook.md index 7fdae35f..16b85287 100644 --- a/docs/testing/api-script-runbook.md +++ b/docs/testing/api-script-runbook.md @@ -29,8 +29,9 @@ Last Updated: 2026-04-22 两份脚本都依赖以下环境变量: - `REVIEW_ACCOUNT_LOGIN_PASSWORD` -- `BRIDGE_AUTH_TOKEN` +- 可选 `BRIDGE_AUTH_TOKEN`,未提供时使用 profile sync 返回的 token - 可选 `BRIDGE_SERVER_URL` +- 可选 `BRIDGE_SERVER_URLS`,用于接口脚本同时验证多个 bridge host - 可选 `REVIEW_ACCOUNT_BASE_URL` 推荐直接在命令前临时注入: @@ -42,6 +43,14 @@ BRIDGE_SERVER_URL='https://xworkmate-bridge.svc.plus' \ bash scripts/ci/verify_api_interface_contract.sh ``` +双入口验证示例: + +```bash +REVIEW_ACCOUNT_LOGIN_PASSWORD='***REMOVED-CREDENTIAL***' \ +BRIDGE_SERVER_URLS='https://xworkmate-bridge.svc.plus,https://cn-xworkmate-bridge.svc.plus' \ +bash scripts/ci/verify_api_interface_contract.sh +``` + ## 3. 默认校验入口 推荐使用 `Makefile` 目标: @@ -64,6 +73,7 @@ make check-api-external - `POST /api/auth/login` - `GET /api/auth/session` - `GET /api/auth/xworkmate/profile/sync` +- `GET /api/ping` - `POST /acp/rpc` with `acp.capabilities` - `POST /acp/rpc` with `xworkmate.routing.resolve` diff --git a/lib/app/app_controller_desktop_runtime_helpers.dart b/lib/app/app_controller_desktop_runtime_helpers.dart index 9e101e6b..9b65ac0c 100644 --- a/lib/app/app_controller_desktop_runtime_helpers.dart +++ b/lib/app/app_controller_desktop_runtime_helpers.dart @@ -1016,6 +1016,18 @@ extension AppControllerDesktopRuntimeHelpers on AppController { } Uri? resolveBridgeAcpEndpointInternal() { + final selfHosted = settingsControllerInternal + .snapshot + .acpBridgeServerModeConfig + .selfHosted; + final selfHostedUrl = selfHosted.serverUrl.trim(); + if (selfHosted.isConfigured && selfHostedUrl.isNotEmpty) { + final uri = Uri.tryParse(selfHostedUrl); + if (uri != null && uri.hasScheme && uri.host.trim().isNotEmpty) { + return uri.replace(query: null, fragment: null); + } + } + final uri = Uri.parse(kManagedBridgeServerUrl); return uri.replace(query: null, fragment: null); } @@ -1029,11 +1041,22 @@ extension AppControllerDesktopRuntimeHelpers on AppController { if (bridgeEndpoint == null) { return false; } + final selfHosted = settingsControllerInternal + .snapshot + .acpBridgeServerModeConfig + .selfHosted; + if (selfHosted.isConfigured) { + return true; + } final accountSyncState = settingsControllerInternal.accountSyncState; if (settingsControllerInternal.accountSignedIn && + accountSyncState?.syncState.trim().toLowerCase() == 'ready' && accountSyncState?.tokenConfigured.bridge == true) { return true; } + if (settingsControllerInternal.accountSignedIn) { + return false; + } final envToken = runtimeEnvironmentValueInternal('BRIDGE_AUTH_TOKEN'); return envToken != null && envToken.isNotEmpty; } @@ -1072,6 +1095,10 @@ extension AppControllerDesktopRuntimeHelpers on AppController { normalizedHost == bridgeHost && (bridgePort <= 0 || endpoint.port == bridgePort); if (matchesBridgeEndpoint) { + final manualBridgeToken = await _resolveManualBridgeAuthTokenInternal(); + if (manualBridgeToken != null && manualBridgeToken.isNotEmpty) { + return manualBridgeToken; + } final bridgeToken = await _resolveManagedBridgeAuthTokenInternal(); if (bridgeToken != null && bridgeToken.isNotEmpty) { return bridgeToken; @@ -1097,15 +1124,37 @@ extension AppControllerDesktopRuntimeHelpers on AppController { return null; } + Future _resolveManualBridgeAuthTokenInternal() async { + final selfHosted = settingsControllerInternal + .snapshot + .acpBridgeServerModeConfig + .selfHosted; + if (!selfHosted.isConfigured) { + return null; + } + final passwordRef = selfHosted.passwordRef.trim(); + if (passwordRef.isEmpty) { + return null; + } + final token = (await storeInternal.loadSecretValueByRef( + passwordRef, + ))?.trim(); + return token?.isNotEmpty == true ? token : null; + } + Future _resolveManagedBridgeAuthTokenInternal() async { final accountSyncState = settingsControllerInternal.accountSyncState; if (settingsControllerInternal.accountSignedIn && + accountSyncState?.syncState.trim().toLowerCase() == 'ready' && accountSyncState?.tokenConfigured.bridge == true) { final bridgeToken = (await storeInternal.loadAccountManagedSecret( target: kAccountManagedSecretTargetBridgeAuthToken, ))?.trim(); return bridgeToken?.isNotEmpty == true ? bridgeToken : null; } + if (settingsControllerInternal.accountSignedIn) { + return null; + } final envToken = runtimeEnvironmentValueInternal('BRIDGE_AUTH_TOKEN'); return envToken?.isNotEmpty == true ? envToken : null; diff --git a/lib/features/settings/settings_page_core.dart b/lib/features/settings/settings_page_core.dart index 62ba5ebc..4282e586 100644 --- a/lib/features/settings/settings_page_core.dart +++ b/lib/features/settings/settings_page_core.dart @@ -58,6 +58,17 @@ Future> loadBridgeMetadataForSettingsAbout({ .decodeStream(response) .timeout(const Duration(seconds: 4)); if (response.statusCode < 200 || response.statusCode >= 300) { + if (response.statusCode == HttpStatus.unauthorized || + response.statusCode == HttpStatus.forbidden) { + return const { + 'status': 'unauthorized', + 'message': 'Bridge authorization rejected', + 'version': '', + 'commit': '', + 'image': '', + 'buildDate': '', + }; + } return const { 'status': 'unavailable', 'version': '', @@ -241,6 +252,11 @@ class _SettingsPageState extends State { _lastSavedAccountIdentifier = nextSettings.accountUsername; _lastSavedBridgeUrl = nextSettings.acpBridgeServerModeConfig.selfHosted.serverUrl; + if (isManualBridge && + nextSettings.acpBridgeServerModeConfig.selfHosted.isConfigured) { + unawaited(_refreshBridgeCapabilities()); + await _refreshAboutSnapshot(); + } } Future _loginAccount(SettingsSnapshot settings) async { @@ -254,6 +270,7 @@ class _SettingsPageState extends State { password: _accountPasswordController.text, ); await _refreshBridgeCapabilities(); + await _verifyAccountBridgeRuntimeAccess(); } finally { _accountPasswordController.clear(); } @@ -261,11 +278,14 @@ class _SettingsPageState extends State { Future _syncAccount(SettingsSnapshot settings) async { await _persistAccountProfileSettings(settings, isManualBridge: false); - await widget.controller.settingsController.syncAccountSettings( - baseUrl: _accountBaseUrlController.text.trim(), - ); + final result = await widget.controller.settingsController + .syncAccountSettings(baseUrl: _accountBaseUrlController.text.trim()); await _refreshBridgeCapabilities(); - await _refreshAboutSnapshot(); + if (result.state == 'ready') { + await _verifyAccountBridgeRuntimeAccess(); + } else { + await _refreshAboutSnapshot(); + } } Future _verifyAccountMfa(SettingsSnapshot settings) async { @@ -276,6 +296,7 @@ class _SettingsPageState extends State { code: _accountMfaCodeController.text.trim(), ); await _refreshBridgeCapabilities(); + await _verifyAccountBridgeRuntimeAccess(); } finally { _accountMfaCodeController.clear(); } @@ -337,13 +358,16 @@ class _SettingsPageState extends State { } Future _loadAboutSnapshot() async { - final bridgeMetadata = await _loadBridgeMetadata(); + final bridgeEndpoint = + widget.controller.resolveGatewayAcpEndpointInternal() ?? + Uri.parse(kManagedBridgeServerUrl); + final bridgeMetadata = await _loadBridgeMetadata(bridgeEndpoint); return SettingsAboutSnapshot( appVersion: kAppVersion, appBuildNumber: kAppBuildNumber, appBuildDate: kAppBuildDate, appCommit: kAppBuildCommit, - bridgeEndpoint: kManagedBridgeServerUrl, + bridgeEndpoint: bridgeEndpoint.toString(), bridgeStatus: _stringValue(bridgeMetadata['status']), bridgeVersion: _resolveBridgeVersion(bridgeMetadata), bridgeBuildDate: _resolveBridgeBuildDate(bridgeMetadata), @@ -352,14 +376,69 @@ class _SettingsPageState extends State { ); } - Future> _loadBridgeMetadata() async { + Future> _loadBridgeMetadata(Uri bridgeEndpoint) async { return loadBridgeMetadataForSettingsAbout( - bridgeEndpoint: Uri.parse(kManagedBridgeServerUrl), + bridgeEndpoint: bridgeEndpoint, authorizationResolver: widget.controller.resolveGatewayAcpAuthorizationHeaderInternal, ); } + Future _verifyAccountBridgeRuntimeAccess() async { + if (!widget.controller.settingsController.accountSignedIn) { + await _refreshAboutSnapshot(); + return; + } + final bridgeEndpoint = + widget.controller.resolveGatewayAcpEndpointInternal() ?? + Uri.parse(kManagedBridgeServerUrl); + final bridgeMetadata = await _loadBridgeMetadata(bridgeEndpoint); + final status = _stringValue(bridgeMetadata['status']).toLowerCase(); + if (status == 'ok') { + if (mounted) { + setState(() { + _aboutSnapshot = _aboutSnapshotFromMetadata( + bridgeEndpoint, + bridgeMetadata, + ); + _aboutBusy = false; + }); + } + return; + } + if (status == 'unauthorized') { + await widget.controller.settingsController + .markAccountBridgeRuntimeUnavailable('Bridge authorization rejected'); + } + if (mounted) { + setState(() { + _aboutSnapshot = _aboutSnapshotFromMetadata( + bridgeEndpoint, + bridgeMetadata, + ); + _aboutBusy = false; + }); + } + } + + SettingsAboutSnapshot _aboutSnapshotFromMetadata( + Uri bridgeEndpoint, + Map bridgeMetadata, + ) { + return SettingsAboutSnapshot( + appVersion: kAppVersion, + appBuildNumber: kAppBuildNumber, + appBuildDate: kAppBuildDate, + appCommit: kAppBuildCommit, + bridgeEndpoint: bridgeEndpoint.toString(), + bridgeStatus: _stringValue(bridgeMetadata['status']), + bridgeVersion: _resolveBridgeVersion(bridgeMetadata), + bridgeBuildDate: _resolveBridgeBuildDate(bridgeMetadata), + bridgeCommit: _stringValue(bridgeMetadata['commit']), + bridgeImage: _stringValue(bridgeMetadata['image']), + ); + } + @override Widget build(BuildContext context) { final controller = widget.controller; diff --git a/lib/runtime/runtime_controllers_settings_account.dart b/lib/runtime/runtime_controllers_settings_account.dart index 3a990e59..9cb76f28 100644 --- a/lib/runtime/runtime_controllers_settings_account.dart +++ b/lib/runtime/runtime_controllers_settings_account.dart @@ -107,6 +107,10 @@ extension SettingsControllerAccountExtension on SettingsController { Future syncAccountManagedSecrets({String baseUrl = ''}) => syncAccountSettings(baseUrl: baseUrl); + Future markAccountBridgeRuntimeUnavailable( + String message, + ) => markAccountBridgeRuntimeUnavailableInternal(this, message: message); + Future logoutAccount() => logoutAccountSettingsInternal(this); Future cancelAccountMfaChallenge() => diff --git a/lib/runtime/runtime_controllers_settings_account_impl.dart b/lib/runtime/runtime_controllers_settings_account_impl.dart index 5c8300de..ed6b1d0a 100644 --- a/lib/runtime/runtime_controllers_settings_account_impl.dart +++ b/lib/runtime/runtime_controllers_settings_account_impl.dart @@ -423,6 +423,24 @@ Future logoutAccountSettingsInternal( } } +Future markAccountBridgeRuntimeUnavailableInternal( + SettingsController controller, { + required String message, +}) async { + final current = controller.accountSyncStateInternal; + final nextState = (current ?? AccountSyncState.defaults()).copyWith( + syncState: 'blocked', + syncMessage: message, + lastSyncAtMs: DateTime.now().millisecondsSinceEpoch, + lastSyncError: message, + profileScope: 'bridge', + ); + await _persistAccountSyncStateInternal(controller, nextState); + controller.accountStatusInternal = message; + controller.notifyListeners(); + return AccountSyncResult(state: 'blocked', message: message); +} + Future cancelAccountMfaChallengeSettingsInternal( SettingsController controller, ) async { diff --git a/scripts/ci/verify_api_interface_contract.sh b/scripts/ci/verify_api_interface_contract.sh index e2fb692e..1600f01b 100755 --- a/scripts/ci/verify_api_interface_contract.sh +++ b/scripts/ci/verify_api_interface_contract.sh @@ -5,6 +5,7 @@ ACCOUNTS_BASE_URL="${REVIEW_ACCOUNT_BASE_URL:-https://accounts.svc.plus}" REVIEW_ACCOUNT_LOGIN_NAME="${REVIEW_ACCOUNT_LOGIN_NAME:-review@svc.plus}" REVIEW_ACCOUNT_LOGIN_PASSWORD="${REVIEW_ACCOUNT_LOGIN_PASSWORD:-}" BRIDGE_SERVER_URL="${BRIDGE_SERVER_URL:-}" +BRIDGE_SERVER_URLS="${BRIDGE_SERVER_URLS:-}" BRIDGE_AUTH_TOKEN="${BRIDGE_AUTH_TOKEN:-}" HTTP_TIMEOUT_SECONDS="${HTTP_TIMEOUT_SECONDS:-30}" @@ -181,10 +182,8 @@ if not bridge_token: raise SystemExit("sync response did not include BRIDGE_AUTH_TOKEN") PY -bridge_server_url="${BRIDGE_SERVER_URL}" -if [[ -z "${bridge_server_url}" ]]; then - bridge_server_url="$( - RESPONSE_JSON="${sync_json}" python3 - <<'PY' +synced_bridge_server_url="$( + RESPONSE_JSON="${sync_json}" python3 - <<'PY' import json import os @@ -194,9 +193,18 @@ if not bridge_url: raise SystemExit("sync response did not include BRIDGE_SERVER_URL") print(bridge_url.rstrip("/")) PY - )" +)" + +bridge_server_urls=() +if [[ -n "${BRIDGE_SERVER_URLS}" ]]; then + while IFS= read -r candidate; do + [[ -n "${candidate}" ]] && bridge_server_urls+=("$(normalize_url "${candidate}")") + done < <(printf '%s\n' "${BRIDGE_SERVER_URLS}" | tr ',' '\n' | tr '[:space:]' '\n' | sed '/^$/d') +elif [[ -n "${BRIDGE_SERVER_URL}" ]]; then + bridge_server_urls+=("$(normalize_url "${BRIDGE_SERVER_URL}")") +else + bridge_server_urls+=("$(normalize_url "${synced_bridge_server_url}")") fi -bridge_server_url="$(normalize_url "${bridge_server_url}")" bridge_auth_token="${BRIDGE_AUTH_TOKEN}" if [[ -z "${bridge_auth_token}" ]]; then @@ -214,13 +222,29 @@ PY )" fi -capabilities_json="$( - json_post \ - "${bridge_server_url}/acp/rpc" \ - '{"jsonrpc":"2.0","id":"capabilities","method":"acp.capabilities","params":{}}' \ - -H "Authorization: Bearer ${bridge_auth_token}" -)" -RESPONSE_JSON="${capabilities_json}" python3 - <<'PY' +verified_urls=() +for bridge_server_url in "${bridge_server_urls[@]}"; do + ping_json="$( + json_get \ + "${bridge_server_url}/api/ping" \ + -H "Authorization: Bearer ${bridge_auth_token}" + )" + RESPONSE_JSON="${ping_json}" python3 - <<'PY' +import json +import os + +payload = json.loads(os.environ["RESPONSE_JSON"]) +if payload.get("status") != "ok": + raise SystemExit("bridge ping status is not ok") +PY + + capabilities_json="$( + json_post \ + "${bridge_server_url}/acp/rpc" \ + '{"jsonrpc":"2.0","id":"capabilities","method":"acp.capabilities","params":{}}' \ + -H "Authorization: Bearer ${bridge_auth_token}" + )" + RESPONSE_JSON="${capabilities_json}" python3 - <<'PY' import json import os @@ -232,10 +256,10 @@ if result.get("availableExecutionTargets") != ["agent", "gateway"]: raise SystemExit("unexpected availableExecutionTargets") PY -routing_json="$( - json_post \ - "${bridge_server_url}/acp/rpc" \ - '{ + routing_json="$( + json_post \ + "${bridge_server_url}/acp/rpc" \ + '{ "jsonrpc":"2.0", "id":"routing", "method":"xworkmate.routing.resolve", @@ -254,9 +278,9 @@ routing_json="$( } } }' \ - -H "Authorization: Bearer ${bridge_auth_token}" -)" -RESPONSE_JSON="${routing_json}" python3 - <<'PY' + -H "Authorization: Bearer ${bridge_auth_token}" + )" + RESPONSE_JSON="${routing_json}" python3 - <<'PY' import json import os @@ -267,5 +291,7 @@ if not isinstance(result, dict): if result.get("resolvedProviderId") != "codex": raise SystemExit("unexpected resolvedProviderId") PY + verified_urls+=("${bridge_server_url}") +done -printf 'API interface contract verified via %s\n' "${bridge_server_url}" +printf 'API interface contract verified via %s\n' "${verified_urls[*]}" diff --git a/scripts/ci/verify_api_scenario_contract.sh b/scripts/ci/verify_api_scenario_contract.sh index b305e182..e3c0acff 100755 --- a/scripts/ci/verify_api_scenario_contract.sh +++ b/scripts/ci/verify_api_scenario_contract.sh @@ -208,8 +208,6 @@ payload = json.loads(os.environ["RESPONSE_JSON"]) result = payload.get("result") or payload.get("payload") or {} if result.get("resolvedProviderId") != "codex": raise SystemExit("session.start did not resolve codex") -if not str(result.get("error") or "").strip(): - raise SystemExit("session.start in this environment should expose downstream error details") PY RESPONSE_JSON="${message_json}" python3 - <<'PY' diff --git a/test/features/settings/settings_about_bridge_metadata_test.dart b/test/features/settings/settings_about_bridge_metadata_test.dart index df9a9852..fc6ca716 100644 --- a/test/features/settings/settings_about_bridge_metadata_test.dart +++ b/test/features/settings/settings_about_bridge_metadata_test.dart @@ -108,7 +108,7 @@ void main() { expect(metadata['buildDate'], ''); }); - test('returns unavailable when authorized bridge ping fails', () async { + test('returns unauthorized when bridge rejects authorization', () async { final server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0); addTearDown(() async { await server.close(force: true); @@ -126,7 +126,8 @@ void main() { authorizationResolver: (_) async => 'bridge-token', ); - expect(metadata['status'], 'unavailable'); + expect(metadata['status'], 'unauthorized'); + expect(metadata['message'], 'Bridge authorization rejected'); expect(metadata['version'], ''); expect(metadata['commit'], ''); expect(metadata['image'], ''); diff --git a/test/features/settings/settings_account_panel_test.dart b/test/features/settings/settings_account_panel_test.dart index a67d5b8e..251f1a94 100644 --- a/test/features/settings/settings_account_panel_test.dart +++ b/test/features/settings/settings_account_panel_test.dart @@ -114,6 +114,65 @@ void main() { expect(submittedPassword, 'typed-password'); }); + testWidgets('manual bridge save submits current field values', ( + tester, + ) async { + final controllers = _TestControllers(); + addTearDown(controllers.dispose); + + var savedAsManualBridge = false; + var savedBridgeUrl = ''; + var savedBridgeToken = ''; + + await tester.pumpWidget( + _buildTestApp( + child: SettingsAccountPanel( + settings: SettingsSnapshot.defaults(), + accountSession: null, + accountState: null, + accountBusy: false, + accountSignedIn: false, + accountMfaRequired: false, + accountBaseUrlController: controllers.baseUrl, + accountIdentifierController: controllers.identifier, + accountPasswordController: controllers.password, + accountMfaCodeController: controllers.mfaCode, + bridgeUrlController: controllers.bridgeUrl, + bridgeTokenController: controllers.bridgeToken, + onSaveAccountProfile: ({required bool isManualBridge}) async { + savedAsManualBridge = isManualBridge; + savedBridgeUrl = controllers.bridgeUrl.text; + savedBridgeToken = controllers.bridgeToken.text; + }, + onLogin: () async {}, + onVerifyMfa: () async {}, + onCancelMfa: () async {}, + onSync: () async {}, + onLogout: () async {}, + ), + ), + ); + + await tester.tap(find.text('手动 Bridge 配置')); + await tester.pump(); + await tester.enterText( + find.byKey(const ValueKey('settings-manual-bridge-url-field')), + 'https://cn-xworkmate-bridge.svc.plus', + ); + await tester.enterText( + find.byKey(const ValueKey('settings-manual-bridge-token-field')), + 'typed-manual-token', + ); + await tester.tap( + find.byKey(const ValueKey('settings-manual-bridge-save-button')), + ); + await tester.pump(); + + expect(savedAsManualBridge, isTrue); + expect(savedBridgeUrl, 'https://cn-xworkmate-bridge.svc.plus'); + expect(savedBridgeToken, 'typed-manual-token'); + }); + testWidgets( 'shows account sync status, resync, and exit in signed-in mode', (tester) async { diff --git a/test/runtime/runtime_controllers_settings_account_test.dart b/test/runtime/runtime_controllers_settings_account_test.dart index 5951127f..435b758e 100644 --- a/test/runtime/runtime_controllers_settings_account_test.dart +++ b/test/runtime/runtime_controllers_settings_account_test.dart @@ -441,6 +441,144 @@ void main() { }, ); + test( + 'blocked managed bridge sync does not configure runtime authorization', + () async { + final storeRoot = await Directory.systemTemp.createTemp( + 'xworkmate-account-managed-bridge-blocked-runtime-', + ); + addTearDown(() async { + if (await storeRoot.exists()) { + await storeRoot.delete(recursive: true); + } + }); + + final store = SecureConfigStore( + secretRootPathResolver: () async => '${storeRoot.path}/secrets', + appDataRootPathResolver: () async => '${storeRoot.path}/app-data', + supportRootPathResolver: () async => '${storeRoot.path}/support', + enableSecureStorage: false, + ); + await store.initialize(); + await store.saveAccountSessionToken('session-token'); + await store.saveAccountSessionSummary( + const AccountSessionSummary( + userId: 'user-1', + email: 'review@svc.plus', + name: 'Review User', + role: 'reviewer', + mfaEnabled: true, + ), + ); + await store.saveAccountSyncState( + AccountSyncState.defaults().copyWith( + syncState: 'ready', + tokenConfigured: const AccountTokenConfigured( + bridge: true, + vault: false, + ), + ), + ); + await store.saveAccountManagedSecret( + target: kAccountManagedSecretTargetBridgeAuthToken, + value: 'bridge-token', + ); + + final controller = AppController( + environmentOverride: const { + 'BRIDGE_AUTH_TOKEN': 'env-token-must-not-recover-blocked-sync', + }, + store: store, + ); + addTearDown(controller.dispose); + await controller.settingsControllerInternal.initialize(); + + await controller.settingsControllerInternal + .markAccountBridgeRuntimeUnavailable( + 'Bridge authorization rejected', + ); + + expect(controller.isBridgeAcpRuntimeConfiguredInternal(), isFalse); + expect( + await controller.resolveGatewayAcpAuthorizationHeaderInternal( + Uri.parse('$kManagedBridgeServerUrl/acp/rpc'), + ), + isNull, + ); + expect( + await store.loadAccountManagedSecret( + target: kAccountManagedSecretTargetBridgeAuthToken, + ), + 'bridge-token', + ); + }, + ); + + test('manual bridge config becomes the runtime ACP source', () async { + final storeRoot = await Directory.systemTemp.createTemp( + 'xworkmate-manual-bridge-runtime-', + ); + addTearDown(() async { + if (await storeRoot.exists()) { + try { + await storeRoot.delete(recursive: true); + } on FileSystemException { + // Temp cleanup is best effort here. The controller may still be + // releasing files when teardown starts. + } + } + }); + + final store = SecureConfigStore( + secretRootPathResolver: () async => '${storeRoot.path}/secrets', + appDataRootPathResolver: () async => '${storeRoot.path}/app-data', + supportRootPathResolver: () async => '${storeRoot.path}/support', + enableSecureStorage: false, + ); + await store.initialize(); + await store.saveSettingsSnapshot( + SettingsSnapshot.defaults().copyWith( + acpBridgeServerModeConfig: AcpBridgeServerModeConfig.defaults() + .copyWith( + selfHosted: AcpBridgeServerModeConfig.defaults().selfHosted + .copyWith( + serverUrl: 'https://private-bridge.svc.plus', + username: 'admin', + ), + ), + ), + ); + await store.saveSecretValueByRef( + AcpBridgeServerSelfHostedConfig.defaults().passwordRef, + 'manual-bridge-token', + ); + + final controller = AppController( + environmentOverride: const {}, + store: store, + ); + addTearDown(controller.dispose); + await controller.settingsControllerInternal.initialize(); + + expect( + controller.resolveGatewayAcpEndpointInternal()?.toString(), + 'https://private-bridge.svc.plus', + ); + expect(controller.isBridgeAcpRuntimeConfiguredInternal(), isTrue); + expect( + await controller.resolveGatewayAcpAuthorizationHeaderInternal( + Uri.parse('https://private-bridge.svc.plus/acp/rpc'), + ), + 'manual-bridge-token', + ); + expect( + await controller.resolveGatewayAcpAuthorizationHeaderInternal( + Uri.parse('$kManagedBridgeServerUrl/acp/rpc'), + ), + isNull, + ); + }); + test( 'syncAccountSettings succeeds when bridge url metadata is missing', () async {