Remove managed local bridge mode

This commit is contained in:
Haitao Pan 2026-04-12 22:09:20 +08:00
parent c1d9b64a2c
commit 1c539d437f
26 changed files with 222 additions and 563 deletions

View File

@ -1,6 +1,8 @@
# Gateway Dev Runbook
This runbook covers the `XWorkmate.svc.plus` client when it connects directly to an OpenClaw gateway for local and remote development, pairing approval, and release verification.
This runbook covers the `XWorkmate.svc.plus` client when it connects to the managed bridge / remote gateway path for pairing approval and release verification.
Local gateway / loopback is no longer an app-facing runtime mode for account sync, bridge startup, or task dialog send flow.
## Scope
@ -16,21 +18,17 @@ This runbook covers the `XWorkmate.svc.plus` client when it connects directly to
- `.env` is development prefill only. It must not become the persisted source of truth and must not auto-connect the gateway.
- Shared tokens and passwords are user-entered auth inputs. Never hardcode them in Dart, native code, tests, or scripts.
- Long-lived secrets belong in secure storage. XWorkmate also keeps a file-backed fallback for device identity and operator device token so release builds keep a stable paired identity.
- Local mode may use plain `ws://127.0.0.1:18789`.
- Remote mode must use TLS, for example `wss://openclaw.svc.plus:443`.
- The app-facing bridge / gateway path is remote-only and must use TLS, for example `wss://openclaw.svc.plus:443`.
- Loopback endpoints must not be revived as runtime truth sources for account sync or task dialog startup.
## Endpoint Matrix
- XWorkmate direct local gateway auth:
- `ws://127.0.0.1:18789`
- XWorkmate direct remote gateway auth:
- `wss://openclaw.svc.plus:443`
- OpenClaw operator control page for pairing approval:
- [https://openclaw.svc.plus/nodes](https://openclaw.svc.plus/nodes)
- Local web console style endpoint:
- `http://127.0.0.1:18789`
Do not enter `http://` or `https://` into the XWorkmate gateway dialog unless the code explicitly expects a browser console URL. The app-level gateway connection is `ws://` or `wss://`.
Do not enter loopback / local console URLs into the XWorkmate gateway dialog. The current app-level gateway connection is remote-only `wss://`.
## Config Sources
@ -61,7 +59,6 @@ Do not enter `http://` or `https://` into the XWorkmate gateway dialog unless th
### Symptom
- XWorkmate could connect locally and chat normally.
- Remote shared-token auth reached the gateway, but remote connect repeatedly ended with `NOT_PAIRED: pairing required`.
- The operator page showed one `Pending` `XWorkmate Mac` entry and one older `Paired` `XWorkmate Mac` entry at the same time.
@ -218,9 +215,8 @@ If a device-run test hangs instead of failing with an assertion, record it as ma
## Manual Acceptance
1. Verify local mode can connect and chat through `ws://127.0.0.1:18789`.
2. Verify remote mode can connect through `wss://openclaw.svc.plus:443`.
3. Verify first remote connect creates one pending pairing request.
4. Approve that request from [https://openclaw.svc.plus/nodes](https://openclaw.svc.plus/nodes).
5. Reconnect and verify the same `deviceId` is now listed under `Paired`.
6. Restart the app and verify remote reconnect does not create a fresh pending request.
1. Verify remote mode can connect through `wss://openclaw.svc.plus:443`.
2. Verify first remote connect creates one pending pairing request.
3. Approve that request from [https://openclaw.svc.plus/nodes](https://openclaw.svc.plus/nodes).
4. Reconnect and verify the same `deviceId` is now listed under `Paired`.
5. Restart the app and verify remote reconnect does not create a fresh pending request.

View File

@ -14,8 +14,10 @@ This project ships a Flutter desktop/mobile client that connects to an OpenClaw
## 2. Gateway And Network Trust Boundary
- Keep the gateway endpoint, auth token, password, and TLS choice explicit.
- Only loopback/local mode may use plain `ws` or equivalent non-TLS transport intentionally.
- The managed bridge / gateway runtime path is remote-only and pinned to the managed bridge origin.
- `BRIDGE_SERVER_URL` must not become the runtime source of truth for bridge startup or task dialog sends.
- Remote connections must not silently downgrade from TLS to non-TLS.
- Explicit loopback / non-TLS behavior is only allowed in isolated external ACP self-host test flows, not in the managed bridge / gateway main path.
- A user-initiated connect action may use the current form values directly for the active handshake. Persistence is a separate concern and must not be required for the immediate request.
- When changing auth behavior, verify both success and rejection paths.

View File

@ -15,17 +15,17 @@ It focuses on the runtime data path:
and the two key client-side parsing assertions:
- `BRIDGE_SERVER_URL` is written into account sync state
- `BRIDGE_SERVER_URL` may be retained in account sync metadata, but does not drive runtime endpoint selection
- `BRIDGE_AUTH_TOKEN` is written into secure storage
## Sync Chain
```mermaid
flowchart LR
A["accounts.svc.plus\nprotected login / MFA / sync / bootstrap response"] -->|returns| B["xworkmate-app\nparse BRIDGE_SERVER_URL\nparse BRIDGE_AUTH_TOKEN"]
B -->|write| C["AccountSyncState.syncedDefaults.bridgeServerUrl"]
A["accounts.svc.plus\nprotected login / MFA / sync / bootstrap response"] -->|returns| B["xworkmate-app\nparse BRIDGE_SERVER_URL metadata\nparse BRIDGE_AUTH_TOKEN"]
B -->|write metadata only| C["AccountSyncState.syncedDefaults.bridgeServerUrl"]
B -->|write secure only| D["Secure Storage\nbridge.auth_token"]
C -->|drive runtime metadata| E["cloudSynced.remoteServerSummary.endpoint"]
B -->|pin runtime origin| E["cloudSynced.remoteServerSummary.endpoint\nhttps://xworkmate-bridge.svc.plus"]
D -->|Authorization: Bearer <token>| F["xworkmate-app runtime requests"]
F --> G["xworkmate-bridge"]
```
@ -37,12 +37,13 @@ flowchart TD
A["accounts.svc.plus"] --> A1["BRIDGE_SERVER_URL\nplain response field"]
A --> A2["BRIDGE_AUTH_TOKEN\nprotected response field only"]
B["xworkmate-app"] --> B1["sync state\nstores BRIDGE_SERVER_URL-derived bridgeServerUrl"]
B["xworkmate-app"] --> B1["sync state\nmay retain BRIDGE_SERVER_URL-derived bridgeServerUrl as metadata"]
B --> B2["secure storage\nstores BRIDGE_AUTH_TOKEN as bridge.auth_token"]
B --> B3["normal settings/profile\nmust not persist BRIDGE_AUTH_TOKEN"]
B --> B4["runtime bridge origin\nfixed to https://xworkmate-bridge.svc.plus"]
C["xworkmate-bridge"] --> C1["consume bootstrap response"]
C1 --> C2["uses BRIDGE_SERVER_URL"]
C["xworkmate-bridge"] --> C1["consume runtime request"]
C1 --> C2["does not depend on BRIDGE_SERVER_URL"]
C1 --> C3["uses BRIDGE_AUTH_TOKEN"]
```
@ -57,8 +58,9 @@ sequenceDiagram
participant Bridge as xworkmate-bridge
Accounts->>App: protected response\nBRIDGE_SERVER_URL\nBRIDGE_AUTH_TOKEN
App->>SyncState: save bridgeServerUrl from BRIDGE_SERVER_URL
App->>SyncState: save bridgeServerUrl as metadata when present
App->>SecureStore: save bridge.auth_token from BRIDGE_AUTH_TOKEN
App->>App: resolve runtime bridge origin = https://xworkmate-bridge.svc.plus
App->>Bridge: connect with Authorization: Bearer <token>
```
@ -66,8 +68,8 @@ sequenceDiagram
```mermaid
flowchart TD
T["Account sync parsing tests"] --> T1["assert BRIDGE_SERVER_URL -> AccountSyncState.syncedDefaults.bridgeServerUrl"]
T --> T2["assert BRIDGE_SERVER_URL -> cloudSynced.remoteServerSummary.endpoint"]
T["Account sync parsing tests"] --> T1["assert BRIDGE_SERVER_URL metadata can enter AccountSyncState.syncedDefaults.bridgeServerUrl"]
T --> T2["assert runtime bridge endpoint stays pinned to https://xworkmate-bridge.svc.plus"]
T --> T3["assert BRIDGE_AUTH_TOKEN -> secure storage target bridge.auth_token"]
T --> T4["assert BRIDGE_AUTH_TOKEN never enters normal settings/profile persistence"]
T --> T5["assert offline path can still read token from secure storage"]
@ -75,7 +77,9 @@ flowchart TD
## Expected Invariants
- `BRIDGE_SERVER_URL` is the only bridge endpoint field used by the sync contract.
- Runtime bridge endpoint selection must not depend on `BRIDGE_SERVER_URL`.
- The app-facing managed bridge origin is fixed to `https://xworkmate-bridge.svc.plus`.
- `BRIDGE_SERVER_URL`, when present, is metadata only.
- `BRIDGE_AUTH_TOKEN` is the only bridge token field used by the sync contract.
- `BRIDGE_AUTH_TOKEN` must never be written into normal settings snapshot, profile JSON, or UI-visible text.
- Client requests must assemble the header as `Authorization: Bearer <token>`.

View File

@ -12,8 +12,8 @@
本文默认当前真实拓扑如下:
- 在线用户同步会向本地设置注入远程默认值
- ACP 支持 selfhost 远程服务端
- ACP 支持 local / loopback 模式
- bridge / gateway 主链路固定走 managed remote bridge
- 外部 ACP selfhost 仍可覆盖远程服务端
- 线程执行同时覆盖本地执行型任务与在线执行任务
## 2. 现有可复用测试基础
@ -74,7 +74,7 @@
- endpoint 规范化
- 账户同步与 settings snapshot
- 线程身份、技能绑定、artifact 写回、线程隔离
- local / remote 模式切换与 provider 选择
- remote / offline 模式切换与 provider 选择
### 3.2 feature
@ -101,7 +101,7 @@
- 结果写入当前线程 workspace 或 artifact snapshot
- 本地执行型与在线执行型都通过同一结果表面暴露产物
- secret 不进入普通 settings snapshot
- local 模式允许明确的非 TLS 边界remote 模式不允许静默降级
- managed remote 主链路不允许 non-TLS / loopback fallback
- 错误信息按配置错误、连接失败、鉴权失败、任务失败分层呈现
## 5. 设置页面配置功能
@ -154,32 +154,26 @@
- 兼并到 `test/runtime/external_acp_endpoint_settings_suite.dart`
- 设置页提示补充到 `test/features/settings_page_gateway_acp_messages_suite.dart`
### `ACP-CONFIG-003` local ACP loopback 模式允许非 TLSremote 模式不允许静默降级
### `ACP-CONFIG-003` managed bridge 入口固定,且主链路不允许 loopback / non-TLS fallback
- 测试目标
- 明确 local / loopback 与 remote transport trust boundary
- 明确 bridge runtime 不依赖 `BRIDGE_SERVER_URL`,并固定走 managed remote bridge
- 推荐测试层级
- `runtime`
- `feature`
- 前置依赖与假服务
- endpoint normalization fixtures
- loopback host 样例:
- `http://127.0.0.1:9001/opencode`
- `ws://127.0.0.1:9001/codex`
- remote host 样例:
- `http://example.com/opencode`
- bridge runtime fixtures
- stale env / sync metadata 样例:
- `BRIDGE_SERVER_URL=https://stale.example.invalid`
- 关键断言
- loopback/local 模式可接受非 TLS
- remote 模式遇到非 TLS 时给出明确错误或阻止提交
- remote 模式不会 silently rewrite 成 insecure transport
- `resolveBridgeAcpEndpointInternal()` 固定返回 `https://xworkmate-bridge.svc.plus`
- thread send / collaboration send 不再因缺少 `BRIDGE_SERVER_URL` 被阻断
- 不存在 local 模式枚举与 fallback
- 失败分类
- loopback 被误拦截
- remote 静默降级
- 错误分类不清晰
- 旧 truth source 仍影响运行态入口
- 缺少 `BRIDGE_SERVER_URL` 仍被当作主错误
- local fallback 未清干净
- 后续实现建议文件落点
- 首选扩展 `test/runtime/gateway_endpoint_normalization_suite.dart`
- 连接策略落到 `test/runtime/external_acp_endpoint_settings_suite.dart`
- 表单提示补充到 `test/features/settings_page_gateway_acp_messages_suite.dart`
- `test/runtime/bridge_runtime_cleanup_test.dart`
### `ACP-CONFIG-004` 设置页测试连接对 hosted base URL、自定义 auth、失败提示语分类正确
@ -190,7 +184,7 @@
- `integration`
- 前置依赖与假服务
- fake gateway client
- hosted / selfhost / local 三类 endpoint fixture
- managed bridge / selfhost 两类 endpoint fixture
- fake failure 分类:
- 鉴权失败
- 空响应

View File

@ -191,9 +191,13 @@ flutter test test/features/assistant_page_suite.dart
- `https://accounts.svc.plus`
- `review@svc.plus`
- `***REMOVED-CREDENTIAL***`
- `BRIDGE_SERVER_URL=https:xworkmate-bridge.svc.plus`
- managed bridge origin: `https://xworkmate-bridge.svc.plus`
- `BRIDGE_AUTH_TOKEN=...`
补充口径:
- `BRIDGE_SERVER_URL` 若仍出现在账户返回中,仅作为 metadata不再是运行期入口前置条件。
额外约定:
- UI 本轮不改结构,只验证 provider 列表来源、展示结果与 thread 内状态。

View File

@ -177,9 +177,7 @@ extension AppControllerDesktopGateway on AppController {
String token = '',
String password = '',
}) async {
final normalizedMode = mode == RuntimeConnectionMode.local
? RuntimeConnectionMode.remote
: mode;
final normalizedMode = RuntimeConnectionMode.remote;
final nextTarget = assistantExecutionTargetForModeInternal(normalizedMode);
final nextProfileIndex = gatewayProfileIndexForExecutionTargetInternal(
nextTarget,
@ -198,7 +196,7 @@ extension AppControllerDesktopGateway on AppController {
setupCode: '',
host: resolvedHost,
port: resolvedPort <= 0 ? 443 : resolvedPort,
tls: normalizedMode == RuntimeConnectionMode.local ? false : tls,
tls: tls,
);
await AppControllerDesktopSettings(this).saveSettings(
settings

View File

@ -655,22 +655,7 @@ extension AppControllerDesktopRuntimeHelpers on AppController {
}
Uri? resolveBridgeAcpEndpointInternal() {
final endpoint =
runtimeEnvironmentValueInternal('BRIDGE_SERVER_URL') ??
(() {
final synced =
settingsControllerInternal
.accountSyncState
?.syncedDefaults
.bridgeServerUrl
.trim() ??
'';
return synced.isEmpty ? null : synced;
})();
if (endpoint == null) {
return null;
}
final uri = Uri.tryParse(endpoint);
final uri = Uri.tryParse(kManagedBridgeServerUrl);
final scheme = uri?.scheme.trim().toLowerCase() ?? '';
if (uri == null || !kSupportedExternalAcpEndpointSchemes.contains(scheme)) {
return null;
@ -734,18 +719,9 @@ extension AppControllerDesktopRuntimeHelpers on AppController {
}
RuntimeConnectionMode modeFromHostInternal(String host) {
final trimmed = host.trim().toLowerCase();
if (isLoopbackHostInternal(trimmed)) {
return RuntimeConnectionMode.local;
}
return RuntimeConnectionMode.remote;
}
bool isLoopbackHostInternal(String host) {
final trimmed = host.trim().toLowerCase();
return trimmed == '127.0.0.1' || trimmed == 'localhost';
}
AssistantExecutionTarget assistantExecutionTargetForModeInternal(
RuntimeConnectionMode mode,
) {

View File

@ -285,15 +285,6 @@ extension AppControllerDesktopThreadActions on AppController {
recomputeTasksInternal();
notifyIfActiveInternal();
try {
if (resolveExternalAcpEndpointForTargetInternal(currentTarget) ==
null) {
throw StateError(
appText(
'BRIDGE_SERVER_URL 未配置,无法启动任务对话。',
'BRIDGE_SERVER_URL is unavailable, so task chat cannot start.',
),
);
}
final dispatch = await codeAgentNodeOrchestratorInternal
.buildGatewayDispatch(buildCodeAgentNodeStateInternal());
final result = await goTaskServiceClientInternal.executeTask(

View File

@ -106,34 +106,6 @@ Future<void> runMultiAgentCollaborationThreadSessionInternal(
? 'main'
: controller.currentSessionKey;
await controller.enqueueThreadTurnInternal<void>(sessionKey, () async {
if (controller.resolveExternalAcpEndpointForTargetInternal(
controller.assistantExecutionTargetForSession(sessionKey),
) ==
null) {
final error = StateError(
appText(
'BRIDGE_SERVER_URL 未配置,无法启动任务对话。',
'BRIDGE_SERVER_URL is unavailable, so task chat cannot start.',
),
);
controller.appendLocalSessionMessageInternal(
sessionKey,
GatewayChatMessage(
id: controller.nextLocalMessageIdInternal(),
role: 'assistant',
text: error.message.toString(),
timestampMs: DateTime.now().millisecondsSinceEpoch.toDouble(),
toolCallId: null,
toolName: 'Multi-Agent',
stopReason: null,
pending: false,
error: true,
),
);
controller.recomputeTasksInternal();
controller.notifyIfActiveInternal();
throw error;
}
await controller.ensureDesktopTaskThreadBindingInternal(
sessionKey,
executionTarget: controller.assistantExecutionTargetForSession(
@ -391,9 +363,6 @@ bool canQuickConnectGatewayThreadSessionInternal(AppController controller) {
if (host.isEmpty || profile.port <= 0) {
return false;
}
if (profile.mode == RuntimeConnectionMode.local) {
return true;
}
final defaults = GatewayConnectionProfile.defaults();
return controller.hasStoredGatewayCredential ||
host != defaults.host ||

View File

@ -643,7 +643,6 @@ String mobileSecurePathLabelInternal({
? profile.mode
: connection.mode;
return switch (mode) {
RuntimeConnectionMode.local => appText('Loopback WS', 'Loopback WS'),
RuntimeConnectionMode.remote =>
profile.tls
? appText('Secure Direct TLS', 'Secure Direct TLS')

View File

@ -21,7 +21,6 @@ class SettingsAccountPanel extends StatelessWidget {
required this.onVerifyMfa,
required this.onCancelMfa,
required this.onSync,
required this.onDisconnect,
required this.onLogout,
});
@ -40,11 +39,8 @@ class SettingsAccountPanel extends StatelessWidget {
final Future<void> Function() onVerifyMfa;
final Future<void> Function() onCancelMfa;
final Future<void> Function() onSync;
final Future<void> Function() onDisconnect;
final Future<void> Function() onLogout;
bool get _managedConnected => !settings.accountLocalMode;
@override
Widget build(BuildContext context) {
if (!accountSignedIn && !accountMfaRequired) {
@ -72,9 +68,7 @@ class SettingsAccountPanel extends StatelessWidget {
accountSession: accountSession,
accountState: accountState,
accountBusy: accountBusy,
managedConnected: _managedConnected,
onSync: onSync,
onDisconnect: onDisconnect,
onLogout: onLogout,
);
}
@ -290,9 +284,7 @@ class _SignedInAccountPanel extends StatelessWidget {
required this.accountSession,
required this.accountState,
required this.accountBusy,
required this.managedConnected,
required this.onSync,
required this.onDisconnect,
required this.onLogout,
});
@ -300,9 +292,7 @@ class _SignedInAccountPanel extends StatelessWidget {
final AccountSessionSummary? accountSession;
final AccountSyncState? accountState;
final bool accountBusy;
final bool managedConnected;
final Future<void> Function() onSync;
final Future<void> Function() onDisconnect;
final Future<void> Function() onLogout;
@override
@ -320,14 +310,10 @@ class _SignedInAccountPanel extends StatelessWidget {
final syncScope = accountState?.profileScope.trim().isNotEmpty == true
? accountState!.profileScope.trim()
: appText('待同步', 'Pending sync');
final syncState = !managedConnected
? appText('已断开', 'Disconnected')
: accountState?.syncState.trim().isNotEmpty == true
final syncState = accountState?.syncState.trim().isNotEmpty == true
? accountState!.syncState.trim()
: 'idle';
final syncMessage = !managedConnected
? appText('当前使用本地连接配置', 'Using local connection settings')
: accountState?.syncMessage.trim().isNotEmpty == true
final syncMessage = accountState?.syncMessage.trim().isNotEmpty == true
? accountState!.syncMessage.trim()
: appText('尚未同步远端配置', 'Remote config not synced yet');
final mfaEnabled =
@ -389,7 +375,7 @@ class _SignedInAccountPanel extends StatelessWidget {
),
const SizedBox(height: 6),
Text(
'${appText('连接来源', 'Connection Source')}: ${managedConnected ? appText('svc.plus 托管配置', 'svc.plus managed profile') : appText('本地配置', 'Local profile')}',
'${appText('连接来源', 'Connection Source')}: ${appText('svc.plus 托管配置', 'svc.plus managed profile')}',
key: const ValueKey(
'settings-account-summary-connection-source',
),
@ -429,21 +415,13 @@ class _SignedInAccountPanel extends StatelessWidget {
onPressed: accountBusy ? null : () => onSync(),
child: Text(appText('重新同步', 'Sync Again')),
),
FilledButton.tonal(
key: const ValueKey('settings-account-disconnect-button'),
onPressed: accountBusy || !managedConnected
? null
: () => onDisconnect(),
child: Text(appText('断开', 'Disconnect')),
TextButton(
key: const ValueKey('settings-account-logout-button'),
onPressed: accountBusy ? null : () => onLogout(),
child: Text(appText('退出登录', 'Log Out')),
),
],
),
const SizedBox(height: 8),
TextButton(
key: const ValueKey('settings-account-logout-button'),
onPressed: accountBusy ? null : () => onLogout(),
child: Text(appText('退出登录', 'Log Out')),
),
],
);
}

View File

@ -152,10 +152,6 @@ class _SettingsPageState extends State<SettingsPage> {
_accountMfaCodeController.clear();
}
Future<void> _disconnectManagedBase() async {
await widget.controller.settingsController.disconnectManagedAccountBase();
}
@override
Widget build(BuildContext context) {
final controller = widget.controller;
@ -217,7 +213,6 @@ class _SettingsPageState extends State<SettingsPage> {
onVerifyMfa: () => _verifyAccountMfa(currentSettings),
onCancelMfa: _cancelAccountMfa,
onSync: () => _syncAccount(currentSettings),
onDisconnect: _disconnectManagedBase,
onLogout: _logoutAccount,
),
),

View File

@ -1,9 +1,4 @@
// Gateway mode switching logic.
//
// Handles transitions between:
// - Local mode (127.0.0.1:18789): Full functionality, no cloud memory
// - Remote mode (configured bridge endpoint): Full functionality with cloud memory
// - Offline mode: Local Codex only, limited functionality
// Gateway mode switching logic for remote bridge mode and offline mode.
import 'dart:async';
@ -14,9 +9,6 @@ import 'runtime_models.dart';
/// Gateway operating mode.
enum GatewayMode {
/// Local mode: Gateway running locally at 127.0.0.1:18789
local,
/// Remote mode: Gateway connected through the configured bridge endpoint
remote,
@ -32,9 +24,6 @@ enum ModeSwitcherState {
/// Attempting to connect
connecting,
/// Connected in local mode
connectedLocal,
/// Connected in remote mode
connectedRemote,
@ -76,15 +65,6 @@ class ModeCapabilities {
required this.hasCodeAgent,
});
/// Local mode capabilities.
static const ModeCapabilities local = ModeCapabilities(
hasCloudMemory: false,
hasTaskQueue: false,
hasMultiAgent: false,
hasLocalModels: true,
hasCodeAgent: true,
);
/// Remote mode capabilities.
static const ModeCapabilities remote = ModeCapabilities(
hasCloudMemory: true,
@ -112,7 +92,7 @@ class ModeCapabilities {
};
}
/// Manages mode switching between local, remote, and offline modes.
/// Manages mode switching between remote and offline modes.
class ModeSwitcher extends ChangeNotifier {
final GatewayRuntime _gateway;
@ -130,62 +110,6 @@ class ModeSwitcher extends ChangeNotifier {
ModeSwitcher(this._gateway);
/// Switch to local mode.
Future<ModeSwitchResult> switchToLocal({
String host = '127.0.0.1',
int port = 18789,
String? token,
}) async {
if (_state == ModeSwitcherState.connectedLocal) {
return ModeSwitchResult(success: true, mode: GatewayMode.local);
}
_state = ModeSwitcherState.connecting;
_lastError = null;
notifyListeners();
try {
final profile = GatewayConnectionProfile.defaults().copyWith(
mode: RuntimeConnectionMode.local,
host: host,
port: port,
tls: false,
);
await _gateway.connectProfile(profile, authTokenOverride: token ?? '');
// Wait for connection
await _gateway.events
.where(
(e) => e.event == 'gateway/ready' || e.event == 'gateway/connected',
)
.first
.timeout(const Duration(seconds: 30));
_state = ModeSwitcherState.connectedLocal;
_currentMode = GatewayMode.local;
_capabilities = ModeCapabilities.local;
_lastModeChange = DateTime.now();
notifyListeners();
return ModeSwitchResult(
success: true,
mode: GatewayMode.local,
capabilities: _capabilities.toMap(),
);
} catch (e) {
_state = ModeSwitcherState.error;
_lastError = e.toString();
notifyListeners();
return ModeSwitchResult(
success: false,
mode: GatewayMode.local,
error: e.toString(),
);
}
}
/// Switch to remote mode.
Future<ModeSwitchResult> switchToRemote({
String host = '',
@ -279,30 +203,6 @@ class ModeSwitcher extends ChangeNotifier {
}
}
/// Auto-select best available mode.
Future<ModeSwitchResult> autoSelect({
String? localToken,
String? remoteToken,
bool preferRemote = true,
}) async {
// Try remote first if preferred
if (preferRemote) {
final remoteResult = await switchToRemote(token: remoteToken);
if (remoteResult.success) {
return remoteResult;
}
}
// Try local
final localResult = await switchToLocal(token: localToken);
if (localResult.success) {
return localResult;
}
// Fall back to offline
return switchToOffline();
}
/// Get current state description.
String get stateDescription {
switch (_state) {
@ -310,8 +210,6 @@ class ModeSwitcher extends ChangeNotifier {
return 'Disconnected';
case ModeSwitcherState.connecting:
return 'Connecting...';
case ModeSwitcherState.connectedLocal:
return 'Connected (Local)';
case ModeSwitcherState.connectedRemote:
return 'Connected (Remote)';
case ModeSwitcherState.offline:
@ -324,8 +222,6 @@ class ModeSwitcher extends ChangeNotifier {
/// Get current mode description.
String get modeDescription {
switch (_currentMode) {
case GatewayMode.local:
return 'Local Mode (127.0.0.1:18789)';
case GatewayMode.remote:
return 'Remote Mode (Configured bridge endpoint)';
case GatewayMode.offline:

View File

@ -7,14 +7,12 @@ class RuntimeBootstrapConfig {
required this.workspacePath,
required this.remoteProjectRoot,
required this.cliPath,
required this.localGateway,
required this.remoteGateway,
});
final String? workspacePath;
final String? remoteProjectRoot;
final String? cliPath;
final GatewayBootstrapTarget? localGateway;
final GatewayBootstrapTarget? remoteGateway;
static Future<RuntimeBootstrapConfig> load({
@ -36,10 +34,6 @@ class RuntimeBootstrapConfig {
workspacePath: workspaceRoot?.path,
remoteProjectRoot: workspaceRoot?.path,
cliPath: _resolveCliPath(openClawRoot),
localGateway: GatewayBootstrapTarget.tryParse(
env['local'],
token: env['local-token'],
),
remoteGateway: GatewayBootstrapTarget.tryParse(
env['remote'],
token: env['remote-token'],
@ -86,9 +80,8 @@ class RuntimeBootstrapConfig {
GatewayBootstrapTarget? preferredGatewayFor(RuntimeConnectionMode mode) {
return switch (mode) {
RuntimeConnectionMode.local => localGateway ?? remoteGateway,
RuntimeConnectionMode.remote => remoteGateway ?? localGateway,
RuntimeConnectionMode.unconfigured => remoteGateway ?? localGateway,
RuntimeConnectionMode.remote => remoteGateway,
RuntimeConnectionMode.unconfigured => remoteGateway,
};
}
@ -162,11 +155,8 @@ class GatewayBootstrapTarget {
final tls = scheme == 'wss' || scheme == 'https';
final port = uri.hasPort ? uri.port : (tls ? 443 : 18789);
final host = uri.host.trim();
final isLocal = host == '127.0.0.1' || host == 'localhost';
return GatewayBootstrapTarget(
mode: isLocal
? RuntimeConnectionMode.local
: RuntimeConnectionMode.remote,
mode: RuntimeConnectionMode.remote,
url: trimmed,
host: host,
port: port,

View File

@ -95,9 +95,6 @@ extension SettingsControllerAccountExtension on SettingsController {
Future<AccountSyncResult> syncAccountManagedSecrets({String baseUrl = ''}) =>
syncAccountSettings(baseUrl: baseUrl);
Future<void> disconnectManagedAccountBase() =>
disconnectManagedAccountBaseSettingsInternal(this);
Future<void> logoutAccount() => logoutAccountSettingsInternal(this);
Future<void> cancelAccountMfaChallenge() =>

View File

@ -146,7 +146,6 @@ Future<void> completeAccountSignInSettingsInternal(
controller,
baseUrl: baseUrl,
bridgeTokenOverride: _resolveBridgeAuthorizationToken(payload),
bridgeServerUrlOverride: _resolveBridgeServerUrl(payload),
quiet: true,
);
await controller.reloadDerivedStateInternal();
@ -224,7 +223,6 @@ Future<AccountSyncResult> syncAccountSettingsInternal(
String baseUrl = '',
bool quiet = false,
String bridgeTokenOverride = '',
String bridgeServerUrlOverride = '',
}) async {
final sessionToken =
(await controller.storeInternal.loadAccountSessionToken())?.trim() ?? '';
@ -288,39 +286,7 @@ Future<AccountSyncResult> syncAccountSettingsInternal(
target: kAccountManagedSecretTargetBridgeAuthToken,
value: bridgeToken,
);
final resolvedBridgeServerUrl = bridgeServerUrlOverride.trim().isNotEmpty
? bridgeServerUrlOverride.trim()
: controller.accountSyncStateInternal?.syncedDefaults.bridgeServerUrl
.trim() ??
'';
if (!isSupportedExternalAcpEndpoint(resolvedBridgeServerUrl)) {
const result = AccountSyncResult(
state: 'blocked',
message: 'BRIDGE_SERVER_URL is unavailable',
);
await _persistAccountSyncStateInternal(
controller,
AccountSyncState.defaults().copyWith(
syncState: result.state,
syncMessage: result.message,
lastSyncAtMs: DateTime.now().millisecondsSinceEpoch,
lastSyncError: result.message,
profileScope: 'bridge',
tokenConfigured: const AccountTokenConfigured(
bridge: true,
vault: false,
apisix: false,
),
),
);
controller.accountStatusInternal = result.message;
if (!quiet) {
controller.accountBusyInternal = false;
controller.notifyListeners();
}
return result;
}
const resolvedBridgeServerUrl = kManagedBridgeServerUrl;
await controller.storeInternal.clearAccountManagedSecret(
target: kAccountManagedSecretTargetAIGatewayAccessToken,
);
@ -360,10 +326,7 @@ Future<AccountSyncResult> syncAccountSettingsInternal(
),
);
final sanitizedSettings = _sanitizeBridgeOnlyAccountSyncSettings(
currentSettings.copyWith(
accountLocalMode: false,
acpBridgeServerModeConfig: nextModeConfig,
),
currentSettings.copyWith(acpBridgeServerModeConfig: nextModeConfig),
);
if (sanitizedSettings.toJsonString() != currentSettings.toJsonString()) {
await controller.saveSnapshot(sanitizedSettings);
@ -392,9 +355,6 @@ Future<AccountSyncState?> recoverBridgeAccountSyncStateInternal(
if (currentBridgeServerUrl.isNotEmpty) {
return currentState;
}
if (controller.snapshotInternal.accountLocalMode) {
return currentState;
}
final cloudSynced =
controller.snapshotInternal.acpBridgeServerModeConfig.cloudSynced;
@ -465,21 +425,12 @@ Future<void> logoutAccountSettingsInternal(
.remoteServerSummary
.copyWith(endpoint: '', hasAdvancedOverrides: false),
);
if (!controller.snapshotInternal.accountLocalMode) {
await controller.saveSnapshot(
currentSnapshot.copyWith(
accountLocalMode: true,
acpBridgeServerModeConfig: currentSnapshot.acpBridgeServerModeConfig
.copyWith(cloudSynced: clearedCloudSync),
),
);
} else {
controller.snapshotInternal = currentSnapshot.copyWith(
await controller.saveSnapshot(
currentSnapshot.copyWith(
acpBridgeServerModeConfig: currentSnapshot.acpBridgeServerModeConfig
.copyWith(cloudSynced: clearedCloudSync),
);
await controller.reloadDerivedStateInternal();
}
),
);
controller.accountStatusInternal = statusMessage;
if (!quiet) {
controller.accountBusyInternal = false;
@ -487,38 +438,6 @@ Future<void> logoutAccountSettingsInternal(
}
}
Future<void> disconnectManagedAccountBaseSettingsInternal(
SettingsController controller,
) async {
final currentSnapshot = controller.snapshotInternal;
final cloudSynced = currentSnapshot.acpBridgeServerModeConfig.cloudSynced;
final nextState =
controller.accountSyncStateInternal ?? AccountSyncState.defaults();
await _persistAccountSyncStateInternal(
controller,
nextState.copyWith(
syncState: 'disconnected',
syncMessage: 'Using local connection settings',
lastSyncError: '',
profileScope: nextState.profileScope.trim().isEmpty
? 'bridge'
: nextState.profileScope,
),
);
await controller.saveSnapshot(
currentSnapshot.copyWith(
accountLocalMode: true,
acpBridgeServerModeConfig: currentSnapshot.acpBridgeServerModeConfig
.copyWith(
cloudSynced: cloudSynced.copyWith(
accountBaseUrl: '',
accountIdentifier: '',
),
),
),
);
}
Future<void> cancelAccountMfaChallengeSettingsInternal(
SettingsController controller,
) async {
@ -593,14 +512,6 @@ String _resolveBridgeAuthorizationToken(Map<String, dynamic> payload) {
return '';
}
String _resolveBridgeServerUrl(Map<String, dynamic> payload) {
final explicit = _stringValue(payload['BRIDGE_SERVER_URL']);
if (explicit.isNotEmpty) {
return explicit;
}
return '';
}
int _parseExpiresAtMs(Object? value) {
if (value is int) {
return value;

View File

@ -171,8 +171,7 @@ bool hasStoredGatewayTokenForProfileSettingsInternal(
controller.secureRefsInternal.containsKey(
gatewayTokenRefForProfileSettingsInternal(controller, profileIndex),
) ||
(!controller.snapshotInternal.accountLocalMode &&
profileIndex == kGatewayRemoteProfileIndex &&
(profileIndex == kGatewayRemoteProfileIndex &&
controller.secureRefsInternal.containsKey(
kAccountManagedSecretTargetBridgeAuthToken,
));
@ -192,8 +191,7 @@ String? storedGatewayTokenMaskForProfileSettingsInternal(
controller,
profileIndex,
)] ??
(!controller.snapshotInternal.accountLocalMode &&
profileIndex == kGatewayRemoteProfileIndex
(profileIndex == kGatewayRemoteProfileIndex
? controller
.secureRefsInternal[kAccountManagedSecretTargetBridgeAuthToken]
: null);

View File

@ -21,7 +21,7 @@ enum CoordinatorState { disconnected, connecting, connected, ready, error }
/// This class coordinates:
/// - GatewayRuntime: Connection to OpenClaw Gateway
/// - CodexRuntime: Code agent runtime (external CLI or built-in runtime mode)
/// - ModeSwitcher: Local/Remote/Offline mode switching
/// - ModeSwitcher: Remote/Offline mode switching
/// - Extensible external code-agent provider descriptors for future CLIs
class RuntimeCoordinator extends ChangeNotifier {
final GatewayRuntime gateway;
@ -242,8 +242,9 @@ class RuntimeCoordinator extends ChangeNotifier {
notifyListeners();
try {
// Auto-select best available mode
final result = await modeSwitcher.autoSelect(preferRemote: preferRemote);
final result = preferRemote
? await modeSwitcher.switchToRemote()
: await modeSwitcher.switchToOffline();
if (!result.success) {
throw StateError('No available connection mode: ${result.error}');
@ -337,9 +338,6 @@ class RuntimeCoordinator extends ChangeNotifier {
List<GatewayMode> getAvailableModes() {
final modes = <GatewayMode>[];
// Always can try local mode
modes.add(GatewayMode.local);
// Remote mode requires network
modes.add(GatewayMode.remote);
@ -370,8 +368,6 @@ class RuntimeCoordinator extends ChangeNotifier {
Future<ModeSwitchResult> _switchMode(GatewayMode mode) {
switch (mode) {
case GatewayMode.local:
return modeSwitcher.switchToLocal();
case GatewayMode.remote:
return modeSwitcher.switchToRemote();
case GatewayMode.offline:

View File

@ -671,6 +671,7 @@ class AccountSyncResult {
final String message;
}
const String kManagedBridgeServerUrl = 'https://xworkmate-bridge.svc.plus';
const String kAccountManagedSecretTargetBridgeAuthToken = 'bridge.auth_token';
const String kAccountManagedSecretTargetAIGatewayAccessToken =
'ai_gateway.access_token';

View File

@ -151,28 +151,25 @@ List<GatewayConnectionProfile> normalizeGatewayProfiles({
final fallback = defaults[index];
final current = index < incoming.length ? incoming[index] : fallback;
if (index == kGatewayRemoteProfileIndex) {
final hasEndpoint = current.host.trim().isNotEmpty && current.port > 0;
final hasEndpoint =
current.host.trim().isNotEmpty &&
current.port > 0 &&
!_isGatewayLoopbackHost(current.host);
final slotMode = switch (current.mode) {
RuntimeConnectionMode.local => RuntimeConnectionMode.local,
RuntimeConnectionMode.remote => RuntimeConnectionMode.remote,
RuntimeConnectionMode.unconfigured => hasEndpoint
? RuntimeConnectionMode.remote
: RuntimeConnectionMode.unconfigured,
RuntimeConnectionMode.unconfigured =>
hasEndpoint
? RuntimeConnectionMode.remote
: RuntimeConnectionMode.unconfigured,
};
normalized.add(
current.copyWith(
mode: slotMode,
useSetupCode: slotMode == RuntimeConnectionMode.local
? false
: current.useSetupCode,
setupCode: slotMode == RuntimeConnectionMode.local
? ''
: current.setupCode,
useSetupCode: current.useSetupCode,
setupCode: current.setupCode,
host: hasEndpoint ? current.host : fallback.host,
port: current.port > 0 ? current.port : fallback.port,
tls: slotMode == RuntimeConnectionMode.local
? false
: (hasEndpoint ? current.tls : fallback.tls),
tls: hasEndpoint ? current.tls : fallback.tls,
tokenRef: current.tokenRef.trim().isEmpty
? fallback.tokenRef
: current.tokenRef,
@ -184,28 +181,19 @@ List<GatewayConnectionProfile> normalizeGatewayProfiles({
continue;
}
final slotMode = switch (current.mode) {
RuntimeConnectionMode.local => RuntimeConnectionMode.local,
RuntimeConnectionMode.remote => RuntimeConnectionMode.remote,
RuntimeConnectionMode.unconfigured =>
current.host.trim().isNotEmpty
current.host.trim().isNotEmpty && !_isGatewayLoopbackHost(current.host)
? RuntimeConnectionMode.remote
: RuntimeConnectionMode.unconfigured,
};
normalized.add(
current.copyWith(
mode: slotMode,
useSetupCode: slotMode == RuntimeConnectionMode.local
? false
: current.useSetupCode,
setupCode: slotMode == RuntimeConnectionMode.local
? ''
: current.setupCode,
port: current.port > 0
? current.port
: slotMode == RuntimeConnectionMode.local
? 18789
: 443,
tls: slotMode == RuntimeConnectionMode.local ? false : current.tls,
useSetupCode: current.useSetupCode,
setupCode: current.setupCode,
port: current.port > 0 ? current.port : 443,
tls: current.tls,
tokenRef: current.tokenRef.trim().isEmpty
? fallback.tokenRef
: current.tokenRef,
@ -218,6 +206,11 @@ List<GatewayConnectionProfile> normalizeGatewayProfiles({
return List<GatewayConnectionProfile>.unmodifiable(normalized);
}
bool _isGatewayLoopbackHost(String host) {
final normalized = host.trim().toLowerCase();
return normalized == '127.0.0.1' || normalized == 'localhost';
}
List<GatewayConnectionProfile> replaceGatewayProfileAt(
List<GatewayConnectionProfile> profiles,
int index,

View File

@ -10,12 +10,11 @@ import 'runtime_models_runtime_payloads.dart';
import 'runtime_models_gateway_entities.dart';
import 'runtime_models_multi_agent.dart';
enum RuntimeConnectionMode { unconfigured, local, remote }
enum RuntimeConnectionMode { unconfigured, remote }
extension RuntimeConnectionModeCopy on RuntimeConnectionMode {
String get label => switch (this) {
RuntimeConnectionMode.unconfigured => appText('未配置', 'Unconfigured'),
RuntimeConnectionMode.local => appText('本地', 'Local'),
RuntimeConnectionMode.remote => appText('远程', 'Remote'),
};

View File

@ -41,7 +41,6 @@ class SettingsSnapshot {
required this.accountUsername,
required this.accountWorkspace,
required this.accountWorkspaceFollowed,
required this.accountLocalMode,
required this.acpBridgeServerModeConfig,
required this.linuxDesktop,
required this.assistantExecutionTarget,
@ -74,7 +73,6 @@ class SettingsSnapshot {
final String accountUsername;
final String accountWorkspace;
final bool accountWorkspaceFollowed;
final bool accountLocalMode;
final AcpBridgeServerModeConfig acpBridgeServerModeConfig;
final LinuxDesktopConfig linuxDesktop;
final AssistantExecutionTarget assistantExecutionTarget;
@ -108,7 +106,6 @@ class SettingsSnapshot {
accountUsername: '',
accountWorkspace: 'Default Workspace',
accountWorkspaceFollowed: false,
accountLocalMode: true,
acpBridgeServerModeConfig: AcpBridgeServerModeConfig.defaults(),
linuxDesktop: LinuxDesktopConfig.defaults(),
assistantExecutionTarget: AssistantExecutionTarget.agent,
@ -143,7 +140,6 @@ class SettingsSnapshot {
String? accountUsername,
String? accountWorkspace,
bool? accountWorkspaceFollowed,
bool? accountLocalMode,
AcpBridgeServerModeConfig? acpBridgeServerModeConfig,
LinuxDesktopConfig? linuxDesktop,
AssistantExecutionTarget? assistantExecutionTarget,
@ -187,7 +183,6 @@ class SettingsSnapshot {
accountWorkspace: accountWorkspace ?? this.accountWorkspace,
accountWorkspaceFollowed:
accountWorkspaceFollowed ?? this.accountWorkspaceFollowed,
accountLocalMode: accountLocalMode ?? this.accountLocalMode,
acpBridgeServerModeConfig:
acpBridgeServerModeConfig ?? this.acpBridgeServerModeConfig,
linuxDesktop: linuxDesktop ?? this.linuxDesktop,
@ -230,7 +225,6 @@ class SettingsSnapshot {
'accountUsername': accountUsername,
'accountWorkspace': accountWorkspace,
'accountWorkspaceFollowed': accountWorkspaceFollowed,
'accountLocalMode': accountLocalMode,
'acpBridgeServerModeConfig': acpBridgeServerModeConfig.toJson(),
'linuxDesktop': linuxDesktop.toJson(),
'assistantExecutionTarget': assistantExecutionTarget.name,
@ -323,7 +317,6 @@ class SettingsSnapshot {
SettingsSnapshot.defaults().accountWorkspace,
accountWorkspaceFollowed:
json['accountWorkspaceFollowed'] as bool? ?? false,
accountLocalMode: json['accountLocalMode'] as bool? ?? true,
acpBridgeServerModeConfig: AcpBridgeServerModeConfig.fromJson(
(json['acpBridgeServerModeConfig'] as Map?)?.cast<String, dynamic>() ??
const {},

View File

@ -35,7 +35,6 @@ void main() {
onVerifyMfa: () async {},
onCancelMfa: () async {},
onSync: () async {},
onDisconnect: () async {},
onLogout: () async {},
),
),
@ -51,7 +50,7 @@ void main() {
findsNothing,
);
expect(
find.byKey(const ValueKey('settings-account-disconnect-button')),
find.byKey(const ValueKey('settings-account-logout-button')),
findsNothing,
);
@ -63,17 +62,16 @@ void main() {
expect(loginCount, 1);
});
testWidgets('shows sync and disconnect actions for managed account state', (
testWidgets('shows sync and logout actions on the same row', (
tester,
) async {
final controllers = _TestControllers();
addTearDown(controllers.dispose);
var syncCount = 0;
var disconnectCount = 0;
var logoutCount = 0;
final settings = SettingsSnapshot.defaults().copyWith(
accountLocalMode: false,
accountBaseUrl: 'https://accounts.svc.plus',
accountUsername: 'review@svc.plus',
acpBridgeServerModeConfig: AcpBridgeServerModeConfig.defaults()
@ -131,31 +129,45 @@ void main() {
onSync: () async {
syncCount += 1;
},
onDisconnect: () async {
disconnectCount += 1;
onLogout: () async {
logoutCount += 1;
},
onLogout: () async {},
),
),
);
expect(find.text('账号登录与同步'), findsOneWidget);
expect(find.textContaining('svc.plus 托管配置'), findsOneWidget);
expect(
find.byKey(const ValueKey('settings-account-disconnect-button')),
findsNothing,
);
expect(find.textContaining('本地配置'), findsNothing);
expect(find.textContaining('已断开'), findsNothing);
expect(find.textContaining('当前使用本地连接配置'), findsNothing);
final syncTop = tester.getTopLeft(
find.byKey(const ValueKey('settings-account-sync-button')),
);
final logoutTop = tester.getTopLeft(
find.byKey(const ValueKey('settings-account-logout-button')),
);
expect(syncTop.dy, logoutTop.dy);
await tester.tap(
find.byKey(const ValueKey('settings-account-sync-button')),
);
await tester.pump();
await tester.tap(
find.byKey(const ValueKey('settings-account-disconnect-button')),
find.byKey(const ValueKey('settings-account-logout-button')),
);
await tester.pump();
expect(syncCount, 1);
expect(disconnectCount, 1);
expect(logoutCount, 1);
});
testWidgets('disables disconnect when account already uses local config', (
testWidgets('keeps managed connection copy when account is signed in', (
tester,
) async {
final controllers = _TestControllers();
@ -190,20 +202,19 @@ void main() {
onVerifyMfa: () async {},
onCancelMfa: () async {},
onSync: () async {},
onDisconnect: () async {},
onLogout: () async {},
),
),
);
expect(find.textContaining('本地配置'), findsOneWidget);
expect(find.textContaining('已断开'), findsOneWidget);
expect(find.textContaining('当前使用本地连接配置'), findsOneWidget);
final disconnectButton = tester.widget<FilledButton>(
expect(find.textContaining('svc.plus 托管配置'), findsOneWidget);
expect(find.textContaining('本地配置'), findsNothing);
expect(find.textContaining('已断开'), findsNothing);
expect(find.textContaining('当前使用本地连接配置'), findsNothing);
expect(
find.byKey(const ValueKey('settings-account-disconnect-button')),
findsNothing,
);
expect(disconnectButton.onPressed, isNull);
});
});
}

View File

@ -31,7 +31,6 @@ void main() {
onVerifyMfa: () async {},
onCancelMfa: () async {},
onSync: () async {},
onDisconnect: () async {},
onLogout: () async {},
),
),
@ -49,7 +48,6 @@ void main() {
addTearDown(controllers.dispose);
final settings = SettingsSnapshot.defaults().copyWith(
accountLocalMode: false,
accountBaseUrl: 'https://accounts.svc.plus',
accountUsername: 'review@svc.plus',
acpBridgeServerModeConfig: AcpBridgeServerModeConfig.defaults()
@ -105,7 +103,6 @@ void main() {
onVerifyMfa: () async {},
onCancelMfa: () async {},
onSync: () async {},
onDisconnect: () async {},
onLogout: () async {},
),
),

View File

@ -0,0 +1,43 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:xworkmate/app/app_controller.dart';
import 'package:xworkmate/runtime/mode_switcher.dart';
import 'package:xworkmate/runtime/runtime_models.dart';
void main() {
group('Bridge runtime cleanup', () {
test('resolves the managed bridge endpoint without BRIDGE_SERVER_URL', () {
final controller = AppController(
environmentOverride: const <String, String>{
'BRIDGE_SERVER_URL': 'https://stale.example.invalid',
},
);
addTearDown(controller.dispose);
expect(
controller.resolveBridgeAcpEndpointInternal()?.toString(),
kManagedBridgeServerUrl,
);
expect(
controller
.resolveExternalAcpEndpointForTargetInternal(
AssistantExecutionTarget.gateway,
)
?.toString(),
kManagedBridgeServerUrl,
);
});
test(
'runtime coordinator only exposes remote and offline gateway modes',
() {
final controller = AppController();
addTearDown(controller.dispose);
expect(
controller.runtimeCoordinatorInternal.getAvailableModes(),
const <GatewayMode>[GatewayMode.remote, GatewayMode.offline],
);
},
);
});
}

View File

@ -58,85 +58,59 @@ void main() {
},
);
test(
'disconnectManagedAccountBase switches the snapshot to local mode',
() async {
final storeRoot = await Directory.systemTemp.createTemp(
'xworkmate-account-disconnect-',
);
addTearDown(() async {
if (await storeRoot.exists()) {
await storeRoot.delete(recursive: true);
}
});
test('syncAccountSettings pins the managed bridge cloud entry', () async {
final storeRoot = await Directory.systemTemp.createTemp(
'xworkmate-account-managed-bridge-',
);
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.saveSettingsSnapshot(
SettingsSnapshot.defaults().copyWith(
accountLocalMode: false,
accountBaseUrl: 'https://accounts.svc.plus',
accountUsername: 'review@svc.plus',
acpBridgeServerModeConfig: AcpBridgeServerModeConfig.defaults()
.copyWith(
cloudSynced: AcpBridgeServerModeConfig.defaults().cloudSynced
.copyWith(
accountIdentifier: 'review@svc.plus',
remoteServerSummary:
AcpBridgeServerModeConfig.defaults()
.cloudSynced
.remoteServerSummary
.copyWith(endpoint: 'https://bridge.svc.plus'),
),
),
),
);
await store.saveAccountSyncState(
AccountSyncState.defaults().copyWith(
syncState: 'ready',
syncMessage: 'Bridge access synced',
profileScope: 'bridge',
lastSyncAtMs: DateTime(2026, 4, 12, 10).millisecondsSinceEpoch,
),
);
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(
accountBaseUrl: 'https://accounts.svc.plus',
accountUsername: 'review@svc.plus',
),
);
await store.saveAccountSessionToken('session-token');
await store.saveAccountManagedSecret(
target: kAccountManagedSecretTargetBridgeAuthToken,
value: 'bridge-token',
);
final controller = SettingsController(store);
addTearDown(controller.dispose);
await controller.initialize();
final controller = SettingsController(store);
addTearDown(controller.dispose);
await controller.initialize();
await controller.disconnectManagedAccountBase();
final result = await controller.syncAccountSettings(
baseUrl: 'https://accounts.svc.plus',
);
expect(controller.snapshot.accountLocalMode, isTrue);
expect(
controller
.snapshot
.acpBridgeServerModeConfig
.cloudSynced
.accountBaseUrl,
isEmpty,
);
expect(
controller
.snapshot
.acpBridgeServerModeConfig
.cloudSynced
.accountIdentifier,
isEmpty,
);
expect(controller.accountSyncState, isNotNull);
expect(controller.accountSyncState!.syncState, 'disconnected');
expect(
controller.accountSyncState!.syncMessage,
'Using local connection settings',
);
expect(controller.accountSyncState!.profileScope, 'bridge');
},
);
expect(result.state, 'ready');
expect(controller.accountSyncState, isNotNull);
expect(
controller.accountSyncState!.syncedDefaults.bridgeServerUrl,
kManagedBridgeServerUrl,
);
expect(
controller
.snapshot
.acpBridgeServerModeConfig
.cloudSynced
.remoteServerSummary
.endpoint,
kManagedBridgeServerUrl,
);
});
test(
'recovers bridge sync state from cloud-synced snapshot when support state is missing',
@ -159,7 +133,6 @@ void main() {
await store.initialize();
await store.saveSettingsSnapshot(
SettingsSnapshot.defaults().copyWith(
accountLocalMode: false,
acpBridgeServerModeConfig: AcpBridgeServerModeConfig.defaults()
.copyWith(
cloudSynced: AcpBridgeServerModeConfig.defaults().cloudSynced
@ -204,50 +177,5 @@ void main() {
);
},
);
test(
'does not recover bridge sync state from cloud-synced snapshot in local mode',
() async {
final storeRoot = await Directory.systemTemp.createTemp(
'xworkmate-account-local-mode-',
);
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.saveSettingsSnapshot(
SettingsSnapshot.defaults().copyWith(
accountLocalMode: true,
acpBridgeServerModeConfig: AcpBridgeServerModeConfig.defaults()
.copyWith(
cloudSynced: AcpBridgeServerModeConfig.defaults().cloudSynced
.copyWith(
remoteServerSummary:
AcpBridgeServerModeConfig.defaults()
.cloudSynced
.remoteServerSummary
.copyWith(endpoint: 'https://bridge.svc.plus'),
),
),
),
);
final controller = SettingsController(store);
addTearDown(controller.dispose);
await controller.initialize();
expect(controller.accountSyncState, isNull);
expect(await store.loadAccountSyncState(), isNull);
},
);
});
}