fix: repair bridge login sync runtime state

This commit is contained in:
Haitao Pan 2026-06-01 10:02:13 +08:00
parent e7a7ba92c4
commit bdb9faeff5
10 changed files with 416 additions and 34 deletions

View File

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

View File

@ -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<String?> _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<String?> _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;

View File

@ -58,6 +58,17 @@ Future<Map<String, dynamic>> 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 <String, dynamic>{
'status': 'unauthorized',
'message': 'Bridge authorization rejected',
'version': '',
'commit': '',
'image': '',
'buildDate': '',
};
}
return const <String, dynamic>{
'status': 'unavailable',
'version': '',
@ -241,6 +252,11 @@ class _SettingsPageState extends State<SettingsPage> {
_lastSavedAccountIdentifier = nextSettings.accountUsername;
_lastSavedBridgeUrl =
nextSettings.acpBridgeServerModeConfig.selfHosted.serverUrl;
if (isManualBridge &&
nextSettings.acpBridgeServerModeConfig.selfHosted.isConfigured) {
unawaited(_refreshBridgeCapabilities());
await _refreshAboutSnapshot();
}
}
Future<void> _loginAccount(SettingsSnapshot settings) async {
@ -254,6 +270,7 @@ class _SettingsPageState extends State<SettingsPage> {
password: _accountPasswordController.text,
);
await _refreshBridgeCapabilities();
await _verifyAccountBridgeRuntimeAccess();
} finally {
_accountPasswordController.clear();
}
@ -261,11 +278,14 @@ class _SettingsPageState extends State<SettingsPage> {
Future<void> _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<void> _verifyAccountMfa(SettingsSnapshot settings) async {
@ -276,6 +296,7 @@ class _SettingsPageState extends State<SettingsPage> {
code: _accountMfaCodeController.text.trim(),
);
await _refreshBridgeCapabilities();
await _verifyAccountBridgeRuntimeAccess();
} finally {
_accountMfaCodeController.clear();
}
@ -337,13 +358,16 @@ class _SettingsPageState extends State<SettingsPage> {
}
Future<SettingsAboutSnapshot> _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<SettingsPage> {
);
}
Future<Map<String, dynamic>> _loadBridgeMetadata() async {
Future<Map<String, dynamic>> _loadBridgeMetadata(Uri bridgeEndpoint) async {
return loadBridgeMetadataForSettingsAbout(
bridgeEndpoint: Uri.parse(kManagedBridgeServerUrl),
bridgeEndpoint: bridgeEndpoint,
authorizationResolver:
widget.controller.resolveGatewayAcpAuthorizationHeaderInternal,
);
}
Future<void> _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<String, dynamic> 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;

View File

@ -107,6 +107,10 @@ extension SettingsControllerAccountExtension on SettingsController {
Future<AccountSyncResult> syncAccountManagedSecrets({String baseUrl = ''}) =>
syncAccountSettings(baseUrl: baseUrl);
Future<AccountSyncResult> markAccountBridgeRuntimeUnavailable(
String message,
) => markAccountBridgeRuntimeUnavailableInternal(this, message: message);
Future<void> logoutAccount() => logoutAccountSettingsInternal(this);
Future<void> cancelAccountMfaChallenge() =>

View File

@ -423,6 +423,24 @@ Future<void> logoutAccountSettingsInternal(
}
}
Future<AccountSyncResult> 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<void> cancelAccountMfaChallengeSettingsInternal(
SettingsController controller,
) async {

View File

@ -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[*]}"

View File

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

View File

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

View File

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

View File

@ -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 <String, String>{
'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 <String, String>{},
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 {