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 # 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 ## 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. - `.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. - 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. - 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`. - The app-facing bridge / gateway path is remote-only and must use TLS, for example `wss://openclaw.svc.plus:443`.
- Remote mode 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 ## Endpoint Matrix
- XWorkmate direct local gateway auth:
- `ws://127.0.0.1:18789`
- XWorkmate direct remote gateway auth: - XWorkmate direct remote gateway auth:
- `wss://openclaw.svc.plus:443` - `wss://openclaw.svc.plus:443`
- OpenClaw operator control page for pairing approval: - OpenClaw operator control page for pairing approval:
- [https://openclaw.svc.plus/nodes](https://openclaw.svc.plus/nodes) - [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 ## Config Sources
@ -61,7 +59,6 @@ Do not enter `http://` or `https://` into the XWorkmate gateway dialog unless th
### Symptom ### 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`. - 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. - 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 ## Manual Acceptance
1. Verify local mode can connect and chat through `ws://127.0.0.1:18789`. 1. Verify remote mode can connect through `wss://openclaw.svc.plus:443`.
2. Verify remote mode can connect through `wss://openclaw.svc.plus:443`. 2. Verify first remote connect creates one pending pairing request.
3. 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. 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. 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.
6. 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 ## 2. Gateway And Network Trust Boundary
- Keep the gateway endpoint, auth token, password, and TLS choice explicit. - 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. - 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. - 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. - 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: 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 - `BRIDGE_AUTH_TOKEN` is written into secure storage
## Sync Chain ## Sync Chain
```mermaid ```mermaid
flowchart LR flowchart LR
A["accounts.svc.plus\nprotected login / MFA / sync / bootstrap response"] -->|returns| B["xworkmate-app\nparse BRIDGE_SERVER_URL\nparse BRIDGE_AUTH_TOKEN"] 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| C["AccountSyncState.syncedDefaults.bridgeServerUrl"] B -->|write metadata only| C["AccountSyncState.syncedDefaults.bridgeServerUrl"]
B -->|write secure only| D["Secure Storage\nbridge.auth_token"] 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"] D -->|Authorization: Bearer <token>| F["xworkmate-app runtime requests"]
F --> G["xworkmate-bridge"] F --> G["xworkmate-bridge"]
``` ```
@ -37,12 +37,13 @@ flowchart TD
A["accounts.svc.plus"] --> A1["BRIDGE_SERVER_URL\nplain response field"] A["accounts.svc.plus"] --> A1["BRIDGE_SERVER_URL\nplain response field"]
A --> A2["BRIDGE_AUTH_TOKEN\nprotected response field only"] 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 --> B2["secure storage\nstores BRIDGE_AUTH_TOKEN as bridge.auth_token"]
B --> B3["normal settings/profile\nmust not persist 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"] C["xworkmate-bridge"] --> C1["consume runtime request"]
C1 --> C2["uses BRIDGE_SERVER_URL"] C1 --> C2["does not depend on BRIDGE_SERVER_URL"]
C1 --> C3["uses BRIDGE_AUTH_TOKEN"] C1 --> C3["uses BRIDGE_AUTH_TOKEN"]
``` ```
@ -57,8 +58,9 @@ sequenceDiagram
participant Bridge as xworkmate-bridge participant Bridge as xworkmate-bridge
Accounts->>App: protected response\nBRIDGE_SERVER_URL\nBRIDGE_AUTH_TOKEN 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->>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> App->>Bridge: connect with Authorization: Bearer <token>
``` ```
@ -66,8 +68,8 @@ sequenceDiagram
```mermaid ```mermaid
flowchart TD flowchart TD
T["Account sync parsing tests"] --> T1["assert BRIDGE_SERVER_URL -> AccountSyncState.syncedDefaults.bridgeServerUrl"] T["Account sync parsing tests"] --> T1["assert BRIDGE_SERVER_URL metadata can enter AccountSyncState.syncedDefaults.bridgeServerUrl"]
T --> T2["assert BRIDGE_SERVER_URL -> cloudSynced.remoteServerSummary.endpoint"] 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 --> T3["assert BRIDGE_AUTH_TOKEN -> secure storage target bridge.auth_token"]
T --> T4["assert BRIDGE_AUTH_TOKEN never enters normal settings/profile persistence"] T --> T4["assert BRIDGE_AUTH_TOKEN never enters normal settings/profile persistence"]
T --> T5["assert offline path can still read token from secure storage"] T --> T5["assert offline path can still read token from secure storage"]
@ -75,7 +77,9 @@ flowchart TD
## Expected Invariants ## 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` 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. - `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>`. - Client requests must assemble the header as `Authorization: Bearer <token>`.

View File

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

View File

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

View File

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

View File

@ -655,22 +655,7 @@ extension AppControllerDesktopRuntimeHelpers on AppController {
} }
Uri? resolveBridgeAcpEndpointInternal() { Uri? resolveBridgeAcpEndpointInternal() {
final endpoint = final uri = Uri.tryParse(kManagedBridgeServerUrl);
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 scheme = uri?.scheme.trim().toLowerCase() ?? ''; final scheme = uri?.scheme.trim().toLowerCase() ?? '';
if (uri == null || !kSupportedExternalAcpEndpointSchemes.contains(scheme)) { if (uri == null || !kSupportedExternalAcpEndpointSchemes.contains(scheme)) {
return null; return null;
@ -734,18 +719,9 @@ extension AppControllerDesktopRuntimeHelpers on AppController {
} }
RuntimeConnectionMode modeFromHostInternal(String host) { RuntimeConnectionMode modeFromHostInternal(String host) {
final trimmed = host.trim().toLowerCase();
if (isLoopbackHostInternal(trimmed)) {
return RuntimeConnectionMode.local;
}
return RuntimeConnectionMode.remote; return RuntimeConnectionMode.remote;
} }
bool isLoopbackHostInternal(String host) {
final trimmed = host.trim().toLowerCase();
return trimmed == '127.0.0.1' || trimmed == 'localhost';
}
AssistantExecutionTarget assistantExecutionTargetForModeInternal( AssistantExecutionTarget assistantExecutionTargetForModeInternal(
RuntimeConnectionMode mode, RuntimeConnectionMode mode,
) { ) {

View File

@ -285,15 +285,6 @@ extension AppControllerDesktopThreadActions on AppController {
recomputeTasksInternal(); recomputeTasksInternal();
notifyIfActiveInternal(); notifyIfActiveInternal();
try { 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 final dispatch = await codeAgentNodeOrchestratorInternal
.buildGatewayDispatch(buildCodeAgentNodeStateInternal()); .buildGatewayDispatch(buildCodeAgentNodeStateInternal());
final result = await goTaskServiceClientInternal.executeTask( final result = await goTaskServiceClientInternal.executeTask(

View File

@ -106,34 +106,6 @@ Future<void> runMultiAgentCollaborationThreadSessionInternal(
? 'main' ? 'main'
: controller.currentSessionKey; : controller.currentSessionKey;
await controller.enqueueThreadTurnInternal<void>(sessionKey, () async { 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( await controller.ensureDesktopTaskThreadBindingInternal(
sessionKey, sessionKey,
executionTarget: controller.assistantExecutionTargetForSession( executionTarget: controller.assistantExecutionTargetForSession(
@ -391,9 +363,6 @@ bool canQuickConnectGatewayThreadSessionInternal(AppController controller) {
if (host.isEmpty || profile.port <= 0) { if (host.isEmpty || profile.port <= 0) {
return false; return false;
} }
if (profile.mode == RuntimeConnectionMode.local) {
return true;
}
final defaults = GatewayConnectionProfile.defaults(); final defaults = GatewayConnectionProfile.defaults();
return controller.hasStoredGatewayCredential || return controller.hasStoredGatewayCredential ||
host != defaults.host || host != defaults.host ||

View File

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

View File

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

View File

@ -1,9 +1,4 @@
// Gateway mode switching logic. // Gateway mode switching logic for remote bridge mode and offline mode.
//
// 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
import 'dart:async'; import 'dart:async';
@ -14,9 +9,6 @@ import 'runtime_models.dart';
/// Gateway operating mode. /// Gateway operating mode.
enum GatewayMode { enum GatewayMode {
/// Local mode: Gateway running locally at 127.0.0.1:18789
local,
/// Remote mode: Gateway connected through the configured bridge endpoint /// Remote mode: Gateway connected through the configured bridge endpoint
remote, remote,
@ -32,9 +24,6 @@ enum ModeSwitcherState {
/// Attempting to connect /// Attempting to connect
connecting, connecting,
/// Connected in local mode
connectedLocal,
/// Connected in remote mode /// Connected in remote mode
connectedRemote, connectedRemote,
@ -76,15 +65,6 @@ class ModeCapabilities {
required this.hasCodeAgent, required this.hasCodeAgent,
}); });
/// Local mode capabilities.
static const ModeCapabilities local = ModeCapabilities(
hasCloudMemory: false,
hasTaskQueue: false,
hasMultiAgent: false,
hasLocalModels: true,
hasCodeAgent: true,
);
/// Remote mode capabilities. /// Remote mode capabilities.
static const ModeCapabilities remote = ModeCapabilities( static const ModeCapabilities remote = ModeCapabilities(
hasCloudMemory: true, 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 { class ModeSwitcher extends ChangeNotifier {
final GatewayRuntime _gateway; final GatewayRuntime _gateway;
@ -130,62 +110,6 @@ class ModeSwitcher extends ChangeNotifier {
ModeSwitcher(this._gateway); 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. /// Switch to remote mode.
Future<ModeSwitchResult> switchToRemote({ Future<ModeSwitchResult> switchToRemote({
String host = '', 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. /// Get current state description.
String get stateDescription { String get stateDescription {
switch (_state) { switch (_state) {
@ -310,8 +210,6 @@ class ModeSwitcher extends ChangeNotifier {
return 'Disconnected'; return 'Disconnected';
case ModeSwitcherState.connecting: case ModeSwitcherState.connecting:
return 'Connecting...'; return 'Connecting...';
case ModeSwitcherState.connectedLocal:
return 'Connected (Local)';
case ModeSwitcherState.connectedRemote: case ModeSwitcherState.connectedRemote:
return 'Connected (Remote)'; return 'Connected (Remote)';
case ModeSwitcherState.offline: case ModeSwitcherState.offline:
@ -324,8 +222,6 @@ class ModeSwitcher extends ChangeNotifier {
/// Get current mode description. /// Get current mode description.
String get modeDescription { String get modeDescription {
switch (_currentMode) { switch (_currentMode) {
case GatewayMode.local:
return 'Local Mode (127.0.0.1:18789)';
case GatewayMode.remote: case GatewayMode.remote:
return 'Remote Mode (Configured bridge endpoint)'; return 'Remote Mode (Configured bridge endpoint)';
case GatewayMode.offline: case GatewayMode.offline:

View File

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

View File

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

View File

@ -146,7 +146,6 @@ Future<void> completeAccountSignInSettingsInternal(
controller, controller,
baseUrl: baseUrl, baseUrl: baseUrl,
bridgeTokenOverride: _resolveBridgeAuthorizationToken(payload), bridgeTokenOverride: _resolveBridgeAuthorizationToken(payload),
bridgeServerUrlOverride: _resolveBridgeServerUrl(payload),
quiet: true, quiet: true,
); );
await controller.reloadDerivedStateInternal(); await controller.reloadDerivedStateInternal();
@ -224,7 +223,6 @@ Future<AccountSyncResult> syncAccountSettingsInternal(
String baseUrl = '', String baseUrl = '',
bool quiet = false, bool quiet = false,
String bridgeTokenOverride = '', String bridgeTokenOverride = '',
String bridgeServerUrlOverride = '',
}) async { }) async {
final sessionToken = final sessionToken =
(await controller.storeInternal.loadAccountSessionToken())?.trim() ?? ''; (await controller.storeInternal.loadAccountSessionToken())?.trim() ?? '';
@ -288,39 +286,7 @@ Future<AccountSyncResult> syncAccountSettingsInternal(
target: kAccountManagedSecretTargetBridgeAuthToken, target: kAccountManagedSecretTargetBridgeAuthToken,
value: bridgeToken, value: bridgeToken,
); );
const resolvedBridgeServerUrl = kManagedBridgeServerUrl;
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;
}
await controller.storeInternal.clearAccountManagedSecret( await controller.storeInternal.clearAccountManagedSecret(
target: kAccountManagedSecretTargetAIGatewayAccessToken, target: kAccountManagedSecretTargetAIGatewayAccessToken,
); );
@ -360,10 +326,7 @@ Future<AccountSyncResult> syncAccountSettingsInternal(
), ),
); );
final sanitizedSettings = _sanitizeBridgeOnlyAccountSyncSettings( final sanitizedSettings = _sanitizeBridgeOnlyAccountSyncSettings(
currentSettings.copyWith( currentSettings.copyWith(acpBridgeServerModeConfig: nextModeConfig),
accountLocalMode: false,
acpBridgeServerModeConfig: nextModeConfig,
),
); );
if (sanitizedSettings.toJsonString() != currentSettings.toJsonString()) { if (sanitizedSettings.toJsonString() != currentSettings.toJsonString()) {
await controller.saveSnapshot(sanitizedSettings); await controller.saveSnapshot(sanitizedSettings);
@ -392,9 +355,6 @@ Future<AccountSyncState?> recoverBridgeAccountSyncStateInternal(
if (currentBridgeServerUrl.isNotEmpty) { if (currentBridgeServerUrl.isNotEmpty) {
return currentState; return currentState;
} }
if (controller.snapshotInternal.accountLocalMode) {
return currentState;
}
final cloudSynced = final cloudSynced =
controller.snapshotInternal.acpBridgeServerModeConfig.cloudSynced; controller.snapshotInternal.acpBridgeServerModeConfig.cloudSynced;
@ -465,21 +425,12 @@ Future<void> logoutAccountSettingsInternal(
.remoteServerSummary .remoteServerSummary
.copyWith(endpoint: '', hasAdvancedOverrides: false), .copyWith(endpoint: '', hasAdvancedOverrides: false),
); );
if (!controller.snapshotInternal.accountLocalMode) { await controller.saveSnapshot(
await controller.saveSnapshot( currentSnapshot.copyWith(
currentSnapshot.copyWith(
accountLocalMode: true,
acpBridgeServerModeConfig: currentSnapshot.acpBridgeServerModeConfig
.copyWith(cloudSynced: clearedCloudSync),
),
);
} else {
controller.snapshotInternal = currentSnapshot.copyWith(
acpBridgeServerModeConfig: currentSnapshot.acpBridgeServerModeConfig acpBridgeServerModeConfig: currentSnapshot.acpBridgeServerModeConfig
.copyWith(cloudSynced: clearedCloudSync), .copyWith(cloudSynced: clearedCloudSync),
); ),
await controller.reloadDerivedStateInternal(); );
}
controller.accountStatusInternal = statusMessage; controller.accountStatusInternal = statusMessage;
if (!quiet) { if (!quiet) {
controller.accountBusyInternal = false; 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( Future<void> cancelAccountMfaChallengeSettingsInternal(
SettingsController controller, SettingsController controller,
) async { ) async {
@ -593,14 +512,6 @@ String _resolveBridgeAuthorizationToken(Map<String, dynamic> payload) {
return ''; return '';
} }
String _resolveBridgeServerUrl(Map<String, dynamic> payload) {
final explicit = _stringValue(payload['BRIDGE_SERVER_URL']);
if (explicit.isNotEmpty) {
return explicit;
}
return '';
}
int _parseExpiresAtMs(Object? value) { int _parseExpiresAtMs(Object? value) {
if (value is int) { if (value is int) {
return value; return value;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -31,7 +31,6 @@ void main() {
onVerifyMfa: () async {}, onVerifyMfa: () async {},
onCancelMfa: () async {}, onCancelMfa: () async {},
onSync: () async {}, onSync: () async {},
onDisconnect: () async {},
onLogout: () async {}, onLogout: () async {},
), ),
), ),
@ -49,7 +48,6 @@ void main() {
addTearDown(controllers.dispose); addTearDown(controllers.dispose);
final settings = SettingsSnapshot.defaults().copyWith( final settings = SettingsSnapshot.defaults().copyWith(
accountLocalMode: false,
accountBaseUrl: 'https://accounts.svc.plus', accountBaseUrl: 'https://accounts.svc.plus',
accountUsername: 'review@svc.plus', accountUsername: 'review@svc.plus',
acpBridgeServerModeConfig: AcpBridgeServerModeConfig.defaults() acpBridgeServerModeConfig: AcpBridgeServerModeConfig.defaults()
@ -105,7 +103,6 @@ void main() {
onVerifyMfa: () async {}, onVerifyMfa: () async {},
onCancelMfa: () async {}, onCancelMfa: () async {},
onSync: () async {}, onSync: () async {},
onDisconnect: () async {},
onLogout: () 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( test('syncAccountSettings pins the managed bridge cloud entry', () async {
'disconnectManagedAccountBase switches the snapshot to local mode', final storeRoot = await Directory.systemTemp.createTemp(
() async { 'xworkmate-account-managed-bridge-',
final storeRoot = await Directory.systemTemp.createTemp( );
'xworkmate-account-disconnect-', addTearDown(() async {
); if (await storeRoot.exists()) {
addTearDown(() async { await storeRoot.delete(recursive: true);
if (await storeRoot.exists()) { }
await storeRoot.delete(recursive: true); });
}
});
final store = SecureConfigStore( final store = SecureConfigStore(
secretRootPathResolver: () async => '${storeRoot.path}/secrets', secretRootPathResolver: () async => '${storeRoot.path}/secrets',
appDataRootPathResolver: () async => '${storeRoot.path}/app-data', appDataRootPathResolver: () async => '${storeRoot.path}/app-data',
supportRootPathResolver: () async => '${storeRoot.path}/support', supportRootPathResolver: () async => '${storeRoot.path}/support',
enableSecureStorage: false, enableSecureStorage: false,
); );
await store.initialize(); await store.initialize();
await store.saveSettingsSnapshot( await store.saveSettingsSnapshot(
SettingsSnapshot.defaults().copyWith( SettingsSnapshot.defaults().copyWith(
accountLocalMode: false, accountBaseUrl: 'https://accounts.svc.plus',
accountBaseUrl: 'https://accounts.svc.plus', accountUsername: 'review@svc.plus',
accountUsername: 'review@svc.plus', ),
acpBridgeServerModeConfig: AcpBridgeServerModeConfig.defaults() );
.copyWith( await store.saveAccountSessionToken('session-token');
cloudSynced: AcpBridgeServerModeConfig.defaults().cloudSynced await store.saveAccountManagedSecret(
.copyWith( target: kAccountManagedSecretTargetBridgeAuthToken,
accountIdentifier: 'review@svc.plus', value: 'bridge-token',
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 controller = SettingsController(store); final controller = SettingsController(store);
addTearDown(controller.dispose); addTearDown(controller.dispose);
await controller.initialize(); await controller.initialize();
await controller.disconnectManagedAccountBase(); final result = await controller.syncAccountSettings(
baseUrl: 'https://accounts.svc.plus',
);
expect(controller.snapshot.accountLocalMode, isTrue); expect(result.state, 'ready');
expect( expect(controller.accountSyncState, isNotNull);
controller expect(
.snapshot controller.accountSyncState!.syncedDefaults.bridgeServerUrl,
.acpBridgeServerModeConfig kManagedBridgeServerUrl,
.cloudSynced );
.accountBaseUrl, expect(
isEmpty, controller
); .snapshot
expect( .acpBridgeServerModeConfig
controller .cloudSynced
.snapshot .remoteServerSummary
.acpBridgeServerModeConfig .endpoint,
.cloudSynced kManagedBridgeServerUrl,
.accountIdentifier, );
isEmpty, });
);
expect(controller.accountSyncState, isNotNull);
expect(controller.accountSyncState!.syncState, 'disconnected');
expect(
controller.accountSyncState!.syncMessage,
'Using local connection settings',
);
expect(controller.accountSyncState!.profileScope, 'bridge');
},
);
test( test(
'recovers bridge sync state from cloud-synced snapshot when support state is missing', 'recovers bridge sync state from cloud-synced snapshot when support state is missing',
@ -159,7 +133,6 @@ void main() {
await store.initialize(); await store.initialize();
await store.saveSettingsSnapshot( await store.saveSettingsSnapshot(
SettingsSnapshot.defaults().copyWith( SettingsSnapshot.defaults().copyWith(
accountLocalMode: false,
acpBridgeServerModeConfig: AcpBridgeServerModeConfig.defaults() acpBridgeServerModeConfig: AcpBridgeServerModeConfig.defaults()
.copyWith( .copyWith(
cloudSynced: AcpBridgeServerModeConfig.defaults().cloudSynced 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);
},
);
}); });
} }