Remove managed local bridge mode
This commit is contained in:
parent
c1d9b64a2c
commit
1c539d437f
@ -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.
|
||||
|
||||
@ -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.
|
||||
|
||||
|
||||
@ -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>`.
|
||||
|
||||
@ -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 模式允许非 TLS,remote 模式不允许静默降级
|
||||
### `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 分类:
|
||||
- 鉴权失败
|
||||
- 空响应
|
||||
|
||||
@ -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 内状态。
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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,
|
||||
) {
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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 ||
|
||||
|
||||
@ -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')
|
||||
|
||||
@ -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')),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@ -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,
|
||||
),
|
||||
),
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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() =>
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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';
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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'),
|
||||
};
|
||||
|
||||
|
||||
@ -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 {},
|
||||
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@ -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 {},
|
||||
),
|
||||
),
|
||||
|
||||
43
test/runtime/bridge_runtime_cleanup_test.dart
Normal file
43
test/runtime/bridge_runtime_cleanup_test.dart
Normal 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],
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
@ -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);
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user