refactor(bridge): implement Single Source of Truth for bridge config and fix login persistence

- Refactor bridge configuration to resolve co-existing Manual and Cloud sources into a single persistent 'effective' state
- Implement deterministic priority resolution: Manual Bridge > Cloud Sync > Default Fallback
- Fix login issues by preserving account base URL and identifier during sync and session restoration
- Streamline config/settings.yaml by removing redundant fields and adopting YAML format
- Update documentation with new architecture guide for bridge-cloud coexistence and priority logic
- Verify functional connectivity for codex, gemini, opencode, and openclaw bridge services
This commit is contained in:
Haitao Pan 2026-04-19 12:22:02 +08:00
parent a04b22ec4a
commit 5851196fc7
11 changed files with 292 additions and 58 deletions

32
config/settings.yaml Normal file
View File

@ -0,0 +1,32 @@
schemaVersion: 2
appLanguage: zh
workspacePath: ''
cliPath: openclaw
defaultProvider: gateway
accountBaseUrl: https://accounts.svc.plus
accountUsername: ''
acpBridgeServerModeConfig:
effective:
endpoint: https://xworkmate-bridge.svc.plus
tokenRef: bridge.auth_token
source: cloud
reason: Synced cloud configuration from svc.plus is active
cloudSynced:
accountBaseUrl: https://accounts.svc.plus
accountIdentifier: ''
lastSyncAt: 0
remoteServerSummary:
endpoint: https://xworkmate-bridge.svc.plus
hasAdvancedOverrides: false
selfHosted:
serverUrl: ''
username: ''
passwordRef: acp_bridge_server_password
gatewayProfiles:
- mode: unconfigured
useSetupCode: false
host: ''
port: 443
tls: true
tokenRef: gateway_token_0
passwordRef: gateway_password_0

View File

@ -0,0 +1,62 @@
# Bridge Server Configuration Coexistence & Priority Resolution
Date: 2026-04-19
## Overview
The `xworkmate-app` utilizes a "Single Source of Truth" pattern for its Bridge Server configuration. While multiple configuration sources (Cloud Sync from `svc.plus` and Manual Bridge settings) can coexist, the system deterministically resolves them into a single **Effective Configuration** that the runtime consumes.
### Key Principles
- **Sources as Inputs:** Cloud and Bridge configurations are treated as sources, not mutually exclusive modes.
- **Deterministic Priority:** `Manual Bridge (Self-Hosted)` > `Cloud Sync (Managed)`.
- **Single Source of Truth:** The runtime exclusively uses the `AcpBridgeServerEffectiveConfig` object.
- **Explainability:** Each effective configuration includes a `source` tag and a `reason` explaining why it was selected.
## Configuration Model
The system maintains a clear separation between sources and the resolved state in `AcpBridgeServerModeConfig`.
```dart
class AcpBridgeServerModeConfig {
final AcpBridgeServerEffectiveConfig effective; // Single Source of Truth
final AcpBridgeServerCloudSyncConfig cloudSynced; // Source: svc.plus
final AcpBridgeServerSelfHostedConfig selfHosted; // Source: Manual entries
}
```
### Resolution Logic (Mermaid Diagram)
The following diagram illustrates how the `resolveAcpBridgeServerEffectiveConfig` function implements the priority logic.
```mermaid
flowchart TD
Start([Resolve Effective Config]) --> CheckManual{Is Manual Source\nconfigured?}
CheckManual -->|Yes| UseManual[<b>Effective Source: bridge</b>\nEndpoint: selfHosted.serverUrl\nTokenRef: selfHosted.passwordRef]
CheckManual -->|No| CheckCloud{Is Cloud Source\nsynced & valid?}
CheckCloud -->|Yes| UseCloud[<b>Effective Source: cloud</b>\nEndpoint: cloudSynced.bridgeServerUrl\nTokenRef: bridge.auth_token]
CheckCloud -->|No| UseDefault[<b>Effective Source: default</b>\nEndpoint: kManagedBridgeServerUrl\nTokenRef: None]
UseManual --> End([Persist Effective State])
UseCloud --> End
UseDefault --> End
style UseManual fill:#e1f5fe,stroke:#1e88e5,stroke-width:2px
style UseCloud fill:#f3e5f5,stroke:#039be5,stroke-width:2px
style UseDefault fill:#fff3e0,stroke:#8e24aa,stroke-width:2px
```
## Traceability & explaining the source
The `effective` configuration stores metadata that can be used for diagnostics or displayed in the UI:
- **`source`**: One of `bridge`, `cloud`, or `default`.
- **`reason`**: A human-readable string explaining the selection (e.g., "Manual Bridge configuration is present and valid").
## State Lifecycle
1. **On Save:** Whenever the user updates Manual Bridge settings in the UI, the effective config is recalculated.
2. **On Sync:** Whenever a successful Cloud Sync occurs, the effective config is recalculated.
3. **At Runtime:** Components like `resolveBridgeAcpEndpointInternal` simply read from `effective.endpoint` without needing to know about the underlying priority rules.

View File

@ -1382,7 +1382,6 @@ _No extracted public top-level symbols._
| 68 | `class` | `AccountTokenConfigured` | `class AccountTokenConfigured {` |
| 108 | `class` | `AccountSecretLocator` | `class AccountSecretLocator {` |
| 166 | `class` | `AccountRemoteProfile` | `class AccountRemoteProfile {` |
| 267 | `enum` | `AcpBridgeServerMode` | `enum AcpBridgeServerMode { cloudSynced }` |
| 269 | `class` | `AcpBridgeServerRemoteServerSummary` | `class AcpBridgeServerRemoteServerSummary {` |
| 312 | `class` | `AcpBridgeServerCloudSyncConfig` | `class AcpBridgeServerCloudSyncConfig {` |
| 370 | `class` | `AcpBridgeServerSelfHostedConfig` | `class AcpBridgeServerSelfHostedConfig {` |

View File

@ -17,11 +17,24 @@
### 1. Provider 发现与 UI 展示
- “智能体模式” provider 列表由 bridge `acp.capabilities` 动态驱动。
- UI 不依赖固定静态 provider 列表。
- “智能体模式” provider 列表由 bridge `acp.capabilities` 动态驱动。
- “Gateway 模式” provider 列表由 bridge `目前支持 openclaw
- 当 bridge 广告能力变化时provider selector、状态文案和可执行状态同步更新。
- `auto` 模式下UI 应表现为“由 bridge 当前可用项决定”,而不是写死默认 provider。
| Service Endpoint │ Protocol │ Result │ Functional Check │
├───────────────────────┼────────────┼────────┼───────────────────────────────────┤
│ /acp-server/codex/ │ JSON-RPC │ PASS │ acp.capabilities returned │
│ │ (SSE/HTTP) │ │ successfully via /acp/rpc │
│ /acp-server/gemini/ │ JSON-RPC │ PASS │ acp.capabilities returned │
│ │ (SSE/HTTP) │ │ successfully via /acp/rpc │
│ /acp-server/opencode/ │ JSON-RPC │ PASS │ acp.capabilities returned │
│ │ (SSE/HTTP) │ │ successfully via /acp/rpc │
│ /gateway/openclaw/ │ WSS / RPC │ PASS │ WebSocket handshake successful at │
│ │ │ │ /acp; received connect.challenge
### 2. Assistant 线程体验
- `agent` 线程首次发送时自动绑定完整 `workspaceBinding`
@ -76,9 +89,9 @@
| Case | UI 侧目标能力 | 核心验收点 |
| --- | --- | --- |
| `pptx` | 展示生成中的文稿任务与追问续写 | 首次生成结果可见继续追问仍在同线程workspace 不变;新文件或新版本可见 |
| `docx` | 展示文档生成结果 | `.docx` 写回当前线程assistant 结果与 artifact 面板一致;后续补写仍在同线程 |
| `xlsx` | 展示表格与计算结果 | `.xlsx` 结果写回当前线程;后续追问继续修改同一线程内容 |
| `pptx` | 展示生成中的文稿任务与追问续写 | 首次生成结果可见继续追问仍在同线程workspace 不变;新文件或新版本可见 |
| `pdf` | 展示文件转换结果 | `.pdf` 产物可见;转换后结果属于当前线程;继续操作不漂移 |
| `image-resizer` | 展示图片处理结果 | 输出图片写回当前线程artifact 面板可见;尺寸变化可通过测试或 fixture 校验 |
| `browser` | 展示摘要、截图、日志 | assistant 文本有摘要;截图 / 日志进入 artifact 面板;继续浏览仍在当前线程 |

View File

@ -644,7 +644,7 @@ extension AppControllerDesktopRuntimeHelpers on AppController {
}
}
final modeConfig = settings.acpBridgeServerModeConfig;
final candidate = modeConfig.mode == AcpBridgeServerMode.manual
final candidate = modeConfig.usesSelfHostedBase
? modeConfig.selfHosted.serverUrl.trim()
: kManagedBridgeServerUrl;
final uri = Uri.tryParse(candidate.isEmpty ? kManagedBridgeServerUrl : candidate);
@ -696,7 +696,7 @@ extension AppControllerDesktopRuntimeHelpers on AppController {
return normalizedToken;
}
final modeConfig = settings.acpBridgeServerModeConfig;
if (modeConfig.mode == AcpBridgeServerMode.manual) {
if (modeConfig.usesSelfHostedBase) {
final manualToken = await settingsControllerInternal
.loadSecretValueByRef(modeConfig.selfHosted.passwordRef);
if (manualToken.trim().isNotEmpty) {

View File

@ -52,8 +52,7 @@ class SettingsAccountPanel extends StatelessWidget {
if (!accountSignedIn && !accountMfaRequired) {
return DefaultTabController(
length: 2,
initialIndex: settings.acpBridgeServerModeConfig.mode ==
AcpBridgeServerMode.manual
initialIndex: settings.acpBridgeServerModeConfig.effective.source == 'bridge'
? 1
: 0,
child: Column(
@ -64,12 +63,10 @@ class SettingsAccountPanel extends StatelessWidget {
Tab(text: appText('手动 Bridge 配置', 'Manual Bridge Config')),
],
onTap: (index) {
final mode = index == 1
? AcpBridgeServerMode.manual
: AcpBridgeServerMode.cloudSynced;
if (settings.acpBridgeServerModeConfig.mode != mode) {
onSaveAccountProfile(); // This should trigger a save with the new mode
}
// Switching tabs saves the profile, which triggers a resolution of the effective config.
// We don't need a boolean flag anymore; the presence/validity of sources determines the source.
// But we still want to save on tap to persist the user's intent.
onSaveAccountProfile();
},
),
const SizedBox(height: 24),

View File

@ -113,14 +113,25 @@ class _SettingsPageState extends State<SettingsPage> {
final bridgeConfig = settings.acpBridgeServerModeConfig;
final isManual = DefaultTabController.of(context).index == 1;
var nextBridgeConfig = bridgeConfig.copyWith(
selfHosted: bridgeConfig.selfHosted.copyWith(
serverUrl: _bridgeUrlController.text.trim(),
username: isManual ? 'admin' : bridgeConfig.selfHosted.username,
),
);
// Resolve the effective config based on the new sources
final nextEffective = resolveAcpBridgeServerEffectiveConfig(
widget.controller.settingsController,
config: nextBridgeConfig,
accountSyncState: widget.controller.settingsController.accountSyncState,
);
final nextSettings = settings.copyWith(
accountBaseUrl: _accountBaseUrlController.text.trim(),
accountUsername: _accountIdentifierController.text.trim(),
acpBridgeServerModeConfig: bridgeConfig.copyWith(
mode: isManual ? AcpBridgeServerMode.manual : AcpBridgeServerMode.cloudSynced,
selfHosted: bridgeConfig.selfHosted.copyWith(
serverUrl: _bridgeUrlController.text.trim(),
),
acpBridgeServerModeConfig: nextBridgeConfig.copyWith(
effective: nextEffective,
),
);
if (isManual && _bridgeTokenController.text.isNotEmpty) {

View File

@ -51,6 +51,19 @@ extension SettingsControllerAccountExtension on SettingsController {
Future<String> loadEffectiveGatewayToken({int? profileIndex}) async {
final resolvedProfileIndex = (profileIndex ?? kGatewayRemoteProfileIndex)
.clamp(0, kGatewayProfileListLength - 1);
// Use the Single Source of Truth from the effective configuration for the primary profile
if (resolvedProfileIndex == kGatewayRemoteProfileIndex) {
final effective = snapshotInternal.acpBridgeServerModeConfig.effective;
if (effective.tokenRef.isNotEmpty) {
final token = await loadSecretValueByRef(effective.tokenRef);
if (token.isNotEmpty) {
return token;
}
}
}
// Local Override / Vault / Cloud Sync (Fallback if effective token is missing or for other profiles)
return resolveSecretValueInternal(
refName: gatewayTokenRefForProfileInternal(resolvedProfileIndex),
fallbackRefName: SecretStore.gatewayTokenRefKey(resolvedProfileIndex),
@ -63,6 +76,11 @@ extension SettingsControllerAccountExtension on SettingsController {
Future<String> loadEffectiveGatewayPassword({int? profileIndex}) async {
final resolvedProfileIndex = (profileIndex ?? kGatewayRemoteProfileIndex)
.clamp(0, kGatewayProfileListLength - 1);
// Manual bridge usually uses a single token/key, but we check if the effective configuration
// points to bridge and if a password override is actually needed.
// For now, we fall back to the standard resolution logic.
return resolveSecretValueInternal(
refName: gatewayPasswordRefForProfileInternal(resolvedProfileIndex),
fallbackRefName: SecretStore.gatewayPasswordRefKey(resolvedProfileIndex),

View File

@ -319,17 +319,31 @@ Future<AccountSyncResult> syncAccountSettingsInternal(
apisix: false,
),
);
await controller.storeInternal.saveAccountSyncState(nextState);
final currentSettings = controller.snapshotInternal;
final currentModeConfig = currentSettings.acpBridgeServerModeConfig;
final nextEffective = resolveAcpBridgeServerEffectiveConfig(
controller,
config: currentModeConfig,
accountSyncState: nextState,
);
final identifier = (await controller.storeInternal.loadAccountSessionIdentifier())
?.trim() ??
'';
final nextModeConfig = currentModeConfig.copyWith(
effective: nextEffective,
cloudSynced: currentModeConfig.cloudSynced.copyWith(
accountBaseUrl: '',
accountIdentifier: '',
accountBaseUrl: currentModeConfig.cloudSynced.accountBaseUrl.trim().isEmpty
? normalizedBaseUrl
: currentModeConfig.cloudSynced.accountBaseUrl,
accountIdentifier: currentModeConfig.cloudSynced.accountIdentifier.trim().isEmpty
? identifier
: currentModeConfig.cloudSynced.accountIdentifier,
lastSyncAt: nextState.lastSyncAtMs,
remoteServerSummary: currentModeConfig.cloudSynced.remoteServerSummary
.copyWith(
endpoint: resolvedBridgeServerUrl,
endpoint: nextEffective.endpoint,
hasAdvancedOverrides: false,
),
),
@ -390,8 +404,12 @@ Future<void> logoutAccountSettingsInternal(
final currentSnapshot = controller.snapshotInternal;
final clearedCloudSync = currentSnapshot.acpBridgeServerModeConfig.cloudSynced
.copyWith(
accountBaseUrl: '',
accountIdentifier: '',
accountBaseUrl: quiet
? currentSnapshot.acpBridgeServerModeConfig.cloudSynced.accountBaseUrl
: '',
accountIdentifier: quiet
? currentSnapshot.acpBridgeServerModeConfig.cloudSynced.accountIdentifier
: '',
lastSyncAt: 0,
remoteServerSummary: currentSnapshot
.acpBridgeServerModeConfig
@ -495,6 +513,11 @@ Future<void> _persistAccountSessionSummaryFromProfilePayloadInternal(
if (summary.userId.trim().isNotEmpty) {
await controller.storeInternal.saveAccountSessionUserId(summary.userId);
}
if (summary.email.trim().isNotEmpty) {
await controller.storeInternal.saveAccountSessionIdentifier(
summary.email.trim(),
);
}
final identifier = summary.email.trim().isNotEmpty
? summary.email.trim()
: (await controller.storeInternal.loadAccountSessionIdentifier())
@ -557,22 +580,49 @@ String _resolveBridgeServerUrl(Map<String, dynamic> payload) {
return '';
}
AcpBridgeServerEffectiveConfig resolveAcpBridgeServerEffectiveConfig(
SettingsController controller, {
required AcpBridgeServerModeConfig config,
AccountSyncState? accountSyncState,
}) {
// Priority 1: Manual Bridge (Self-Hosted)
// Logic: Must have a valid URL and be explicitly intended (we assume if it's configured, it's intended)
if (config.selfHosted.isConfigured) {
return AcpBridgeServerEffectiveConfig(
endpoint: config.selfHosted.serverUrl,
tokenRef: config.selfHosted.passwordRef,
source: 'bridge',
reason: 'Manual Bridge configuration is present and valid',
);
}
// Priority 2: Cloud Sync (svc.plus)
// Logic: Check the synced state for a valid endpoint and token
final syncedUrl = accountSyncState?.syncedDefaults.bridgeServerUrl.trim() ?? '';
final hasSyncedToken = accountSyncState?.tokenConfigured.bridge == true;
if (isSupportedExternalAcpEndpoint(syncedUrl) && hasSyncedToken) {
return AcpBridgeServerEffectiveConfig(
endpoint: syncedUrl,
tokenRef: kAccountManagedSecretTargetBridgeAuthToken,
source: 'cloud',
reason: 'Synced cloud configuration from svc.plus is active',
);
}
// Priority 3: Default Managed Fallback
return AcpBridgeServerEffectiveConfig(
endpoint: kManagedBridgeServerUrl,
tokenRef: '',
source: 'default',
reason: 'Falling back to default managed server',
);
}
String _resolveCurrentBridgeServerUrl(
SettingsController controller, {
String bridgeServerUrlOverride = '',
}) {
final explicit = bridgeServerUrlOverride.trim();
if (isSupportedExternalAcpEndpoint(explicit)) {
return explicit;
}
final syncedBridgeServerUrl =
controller.accountSyncStateInternal?.syncedDefaults.bridgeServerUrl
.trim() ??
'';
if (isSupportedExternalAcpEndpoint(syncedBridgeServerUrl)) {
return syncedBridgeServerUrl;
}
return kManagedBridgeServerUrl;
return controller.snapshotInternal.acpBridgeServerModeConfig.effective.endpoint;
}
int _parseExpiresAtMs(Object? value) {

View File

@ -265,17 +265,6 @@ class AccountRemoteProfile {
}
}
enum AcpBridgeServerMode { cloudSynced, manual }
extension AcpBridgeServerModeCopy on AcpBridgeServerMode {
static AcpBridgeServerMode fromJsonValue(String? value) {
return AcpBridgeServerMode.values.firstWhere(
(item) => item.name == value,
orElse: () => AcpBridgeServerMode.cloudSynced,
);
}
}
class AcpBridgeServerRemoteServerSummary {
const AcpBridgeServerRemoteServerSummary({
required this.endpoint,
@ -516,22 +505,77 @@ class AcpBridgeServerAdvancedOverrides {
}
}
class AcpBridgeServerEffectiveConfig {
const AcpBridgeServerEffectiveConfig({
required this.endpoint,
required this.tokenRef,
required this.source,
required this.reason,
});
final String endpoint;
final String tokenRef;
final String source; // 'bridge' | 'cloud' | 'default'
final String reason;
factory AcpBridgeServerEffectiveConfig.defaults() {
return const AcpBridgeServerEffectiveConfig(
endpoint: kManagedBridgeServerUrl,
tokenRef: '',
source: 'default',
reason: 'No active source configured',
);
}
AcpBridgeServerEffectiveConfig copyWith({
String? endpoint,
String? tokenRef,
String? source,
String? reason,
}) {
return AcpBridgeServerEffectiveConfig(
endpoint: endpoint ?? this.endpoint,
tokenRef: tokenRef ?? this.tokenRef,
source: source ?? this.source,
reason: reason ?? this.reason,
);
}
Map<String, dynamic> toJson() {
return <String, dynamic>{
'endpoint': endpoint,
'tokenRef': tokenRef,
'source': source,
'reason': reason,
};
}
factory AcpBridgeServerEffectiveConfig.fromJson(Map<String, dynamic> json) {
return AcpBridgeServerEffectiveConfig(
endpoint: json['endpoint'] as String? ?? kManagedBridgeServerUrl,
tokenRef: json['tokenRef'] as String? ?? '',
source: json['source'] as String? ?? 'default',
reason: json['reason'] as String? ?? '',
);
}
}
class AcpBridgeServerModeConfig {
const AcpBridgeServerModeConfig({
required this.mode,
required this.effective,
required this.cloudSynced,
required this.selfHosted,
required this.advancedOverrides,
});
final AcpBridgeServerMode mode;
final AcpBridgeServerEffectiveConfig effective;
final AcpBridgeServerCloudSyncConfig cloudSynced;
final AcpBridgeServerSelfHostedConfig selfHosted;
final AcpBridgeServerAdvancedOverrides advancedOverrides;
factory AcpBridgeServerModeConfig.defaults() {
return AcpBridgeServerModeConfig(
mode: AcpBridgeServerMode.cloudSynced,
effective: AcpBridgeServerEffectiveConfig.defaults(),
cloudSynced: AcpBridgeServerCloudSyncConfig.defaults(),
selfHosted: AcpBridgeServerSelfHostedConfig.defaults(),
advancedOverrides: AcpBridgeServerAdvancedOverrides.defaults(),
@ -539,30 +583,30 @@ class AcpBridgeServerModeConfig {
}
AcpBridgeServerModeConfig copyWith({
AcpBridgeServerMode? mode,
AcpBridgeServerEffectiveConfig? effective,
AcpBridgeServerCloudSyncConfig? cloudSynced,
AcpBridgeServerSelfHostedConfig? selfHosted,
AcpBridgeServerAdvancedOverrides? advancedOverrides,
}) {
return AcpBridgeServerModeConfig(
mode: mode ?? this.mode,
effective: effective ?? this.effective,
cloudSynced: cloudSynced ?? this.cloudSynced,
selfHosted: selfHosted ?? this.selfHosted,
advancedOverrides: advancedOverrides ?? this.advancedOverrides,
);
}
bool get usesSelfHostedBase => mode == AcpBridgeServerMode.manual;
bool get usesSelfHostedBase => effective.source == 'bridge';
bool get usesCloudSyncBase => mode == AcpBridgeServerMode.cloudSynced;
bool get usesCloudSyncBase => !usesSelfHostedBase;
String get sourceTag => mode.name;
String get sourceTag => effective.source;
String toJsonString() => jsonEncode(toJson());
Map<String, dynamic> toJson() {
return <String, dynamic>{
'mode': mode.name,
'effective': effective.toJson(),
'cloudSynced': cloudSynced.toJson(),
'selfHosted': selfHosted.toJson(),
'advancedOverrides': advancedOverrides.toJson(),
@ -571,7 +615,9 @@ class AcpBridgeServerModeConfig {
factory AcpBridgeServerModeConfig.fromJson(Map<String, dynamic> json) {
return AcpBridgeServerModeConfig(
mode: AcpBridgeServerModeCopy.fromJsonValue(json['mode'] as String?),
effective: AcpBridgeServerEffectiveConfig.fromJson(
(json['effective'] as Map?)?.cast<String, dynamic>() ?? const {},
),
cloudSynced: AcpBridgeServerCloudSyncConfig.fromJson(
(json['cloudSynced'] as Map?)?.cast<String, dynamic>() ?? const {},
),

View File

@ -274,9 +274,15 @@ void main() {
final settings = SettingsSnapshot.defaults().copyWith(
acpBridgeServerModeConfig: AcpBridgeServerModeConfig.defaults().copyWith(
mode: AcpBridgeServerMode.manual,
effective: const AcpBridgeServerEffectiveConfig(
endpoint: 'https://manual-bridge.example.com',
tokenRef: 'acp_bridge_server_password',
source: 'bridge',
reason: 'Manual test configuration',
),
selfHosted: AcpBridgeServerSelfHostedConfig.defaults().copyWith(
serverUrl: 'https://manual-bridge.example.com',
username: 'admin',
),
),
);