diff --git a/AGENTS.md b/AGENTS.md index 3723e540..b5c7302d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -145,6 +145,10 @@ A refactor task is complete only when: - `.env` is only a development/test prefill source for Settings -> Integrations -> Gateway. Do not hardcode `.env` values into source code. Do not auto-persist them into settings. Do not auto-connect from them. - Secrets must not be committed, logged, screenshot-exposed, or stored in `SharedPreferences`. Use secure storage for persisted secrets. +- Assistant conversation runtime must treat signed-out state as disconnected: do not send requests, do not read stale managed bridge secrets, and do not fallback to local ACP endpoints or default managed bridge endpoints. +- Missing managed `BRIDGE_AUTH_TOKEN` means disconnected. Do not fallback from bridge ACP auth to gateway profile tokens. +- Keep the UI unchanged for bridge state-flow fixes unless explicitly requested; adjust runtime readiness, endpoint resolution, and tests instead. +- After svc.plus login and bridge sync, route provider execution through the public bridge endpoints: Hermes/Codex/Gemini/OpenCode use `/acp-server/{provider}/acp/rpc`, and OpenClaw uses `/gateway/openclaw/acp/rpc`. - For a user-initiated gateway connect action, the current form values may be used directly for the immediate handshake. Do not require a secure-store readback for the active request. - Keep network trust boundaries explicit. Loopback/local mode may use non-TLS intentionally; remote mode must not silently downgrade transport security. - File and attachment access must be user-driven. Never read or send workspace files implicitly. diff --git a/docs/architecture/account-sync-settings-bridge-state-model.md b/docs/architecture/account-sync-settings-bridge-state-model.md index 23fa5294..64872df4 100644 --- a/docs/architecture/account-sync-settings-bridge-state-model.md +++ b/docs/architecture/account-sync-settings-bridge-state-model.md @@ -1,6 +1,6 @@ # Account Sync, Settings, and Bridge State Model -Last Updated: 2026-04-19 +Last Updated: 2026-04-21 This document is the canonical state model for: @@ -45,20 +45,37 @@ flowchart TD stateDiagram-v2 [*] --> SignedOut - SignedOut --> SavingProfile: user edits account/base url/bridge url - SavingProfile --> SignedOut: snapshot saved + SignedOut: no account session + SignedOut --> SignedOut: do not send\nno fallback\nno stale token read + SignedOut --> Syncing: svc.plus login - SignedOut --> LoggingIn: loginAccount(baseUrl, identifier, password) - LoggingIn --> MfaRequired: server requests MFA - LoggingIn --> Syncing: login succeeds - MfaRequired --> Syncing: MFA verified + Syncing: sync bridge config after login + Syncing --> SyncBlocked: missing BRIDGE_AUTH_TOKEN\nor sync failed + Syncing --> BridgeDiscovering: bridge URL + token synced - Syncing --> Ready: BRIDGE_AUTH_TOKEN + BRIDGE_SERVER_URL processed - Syncing --> Blocked: bridge auth token missing - Syncing --> Blocked: bridge endpoint unavailable + SyncBlocked: signed in but bridge unavailable + SyncBlocked --> Syncing: user syncs again + SyncBlocked --> SignedOut: logout clears session/token/catalog - Ready --> SignedOut: logout / clear session - Blocked --> SignedOut: logout / clear session + BridgeDiscovering: load acp.capabilities from /acp/rpc + BridgeDiscovering --> SyncBlocked: 401/403/token missing\nor endpoint missing + BridgeDiscovering --> BridgeReady: providerCatalog/gatewayProviders valid + + BridgeReady: assistant can send + BridgeReady --> ProviderDispatch: user submits message + BridgeReady --> SignedOut: logout clears session/token/catalog + + ProviderDispatch: resolve endpoint by selected provider + ProviderDispatch --> AgentEndpoint: Hermes/Codex/Gemini/OpenCode + ProviderDispatch --> GatewayEndpoint: OpenClaw Gateway + + AgentEndpoint: /acp-server/{provider}/acp/rpc + GatewayEndpoint: /gateway/openclaw/acp/rpc + + AgentEndpoint --> BridgeReady: result returned + GatewayEndpoint --> BridgeReady: result returned + AgentEndpoint --> SyncBlocked: auth failure + GatewayEndpoint --> SyncBlocked: auth failure ``` ## Field Semantics @@ -83,17 +100,19 @@ flowchart TD D --> C C --> E["bridge runtime"] - note1["Priority order\n1. selfHosted\n2. cloudSynced when account sync is ready and token exists\n3. default managed bridge endpoint"] --> C + note1["Priority order\n1. selfHosted when explicitly configured\n2. cloudSynced when account sync is ready and token exists\n3. disconnected"] --> C ``` ### Runtime Invariants - `selfHosted` always wins when it is configured. - `cloudSynced` is valid only when account sync is ready and the managed bridge token exists. +- Signed-out state is disconnected: runtime must not use a default managed endpoint, stale managed secret, gateway profile token, or loopback ACP endpoint. +- Missing `BRIDGE_AUTH_TOKEN` is disconnected for the managed cloud-sync path. - `BRIDGE_SERVER_URL` may be retained in `AccountSyncState.syncedDefaults.bridgeServerUrl`, but it is metadata only. - `BRIDGE_AUTH_TOKEN` is written to secure storage only, never to normal settings. - Bridge runtime requests use `Authorization: Bearer ` from secure storage. -- The runtime endpoint remains the managed bridge endpoint unless manual `selfHosted` is configured. +- Capabilities and routing discovery use the bridge root `/acp/rpc`; assistant execution uses provider-specific public endpoints. ## Persistence Rules diff --git a/docs/architecture/unified-routing-architecture.md b/docs/architecture/unified-routing-architecture.md index c4bf3304..dd5527ac 100644 --- a/docs/architecture/unified-routing-architecture.md +++ b/docs/architecture/unified-routing-architecture.md @@ -50,7 +50,7 @@ graph TD ### 3.1 统一鉴权 所有通过 `xworkmate-bridge.svc.plus` 域名访问的请求(除 Caddy 内部 handle 外)均由 Caddy 强制校验: -- **Header**: `Authorization: Bearer ***REMOVED-CREDENTIAL***` +- **Header**: `Authorization: Bearer ` - **未授权响应**: `401 Unauthorized` ### 3.2 SSE / WebSocket 优化 diff --git a/docs/xworkmate-app-core-functional-test-plan-v1.md b/docs/xworkmate-app-core-functional-test-plan-v1.md index 8ade1493..bfef8370 100644 --- a/docs/xworkmate-app-core-functional-test-plan-v1.md +++ b/docs/xworkmate-app-core-functional-test-plan-v1.md @@ -205,7 +205,7 @@ flutter test test/features/assistant_page_suite.dart - `https://accounts.svc.plus` - `review@svc.plus` -- `***REMOVED-CREDENTIAL***` +- `` - managed bridge origin: `https://xworkmate-bridge.svc.plus` - `BRIDGE_AUTH_TOKEN=...` diff --git a/lib/app/app_controller_desktop_core.dart b/lib/app/app_controller_desktop_core.dart index f403adda..bb857250 100644 --- a/lib/app/app_controller_desktop_core.dart +++ b/lib/app/app_controller_desktop_core.dart @@ -215,6 +215,7 @@ class AppController extends ChangeNotifier { acpTransport: ExternalCodeAgentAcpDesktopTransport( client: gatewayAcpClientInternal, endpointResolver: resolveExternalAcpEndpointForTargetInternal, + taskEndpointResolver: resolveExternalAcpEndpointForRequestInternal, ), ); multiAgentOrchestratorInternal = MultiAgentOrchestrator( @@ -462,6 +463,7 @@ class AppController extends ChangeNotifier { _desktopPlatformBusyInternal = value; notifyListeners(); } + bool _desktopPlatformBusyInternal = false; GatewayConnectionSnapshot get connection => runtimeInternal.snapshot; @@ -571,11 +573,17 @@ class AppController extends ChangeNotifier { : assistantProviderCatalog; if (executionTarget.isGateway) { return source - .where((provider) => provider.providerId == kCanonicalGatewayProviderId) + .where( + (provider) => provider.providerId == kCanonicalGatewayProviderId, + ) .toList(growable: false); } return source - .where((provider) => provider.supportedTargets.contains(executionTarget)) + .where( + (provider) => + provider.supportedTargets.isEmpty || + provider.supportedTargets.contains(executionTarget), + ) .toList(growable: false); } @@ -633,8 +641,9 @@ class AppController extends ChangeNotifier { String sessionKey, ) => assistantThreadRecordsInternal[normalizedAssistantSessionKeyInternal( - sessionKey, - )]?.importedSkills ?? + sessionKey, + )] + ?.importedSkills ?? const []; void navigateTo(WorkspaceDestination destination) => @@ -670,7 +679,9 @@ class AppController extends ChangeNotifier { ); Future refreshMultiAgentMounts({bool sync = false}) => - AppControllerDesktopThreadSessions(this).refreshMultiAgentMounts(sync: sync); + AppControllerDesktopThreadSessions( + this, + ).refreshMultiAgentMounts(sync: sync); double get assistantSkillCount => 0; // Legacy int get currentAssistantSkillCount => 0; // Legacy diff --git a/lib/app/app_controller_desktop_runtime_helpers.dart b/lib/app/app_controller_desktop_runtime_helpers.dart index 6374e0ec..084442b0 100644 --- a/lib/app/app_controller_desktop_runtime_helpers.dart +++ b/lib/app/app_controller_desktop_runtime_helpers.dart @@ -13,6 +13,7 @@ import '../models/app_models.dart'; import '../runtime/device_identity_store.dart'; import '../runtime/go_core.dart'; +import '../runtime/acp_endpoint_paths.dart'; import '../runtime/runtime_bootstrap.dart'; import '../runtime/desktop_platform_service.dart'; import '../runtime/gateway_runtime.dart'; @@ -636,21 +637,77 @@ extension AppControllerDesktopRuntimeHelpers on AppController { Uri? resolveBridgeAcpEndpointInternal() { final modeConfig = settings.acpBridgeServerModeConfig; - final candidate = modeConfig.usesSelfHostedBase - ? modeConfig.selfHosted.serverUrl.trim() - : kManagedBridgeServerUrl; - final uri = Uri.tryParse(candidate.isEmpty ? kManagedBridgeServerUrl : candidate); - final scheme = uri?.scheme.trim().toLowerCase() ?? ''; - if (uri == null || !kSupportedExternalAcpEndpointSchemes.contains(scheme)) { - return null; + + // Prioritize the cloud endpoint if available or if we're connected to svc.plus + final cloudEndpoint = _activeCloudSyncedBridgeEndpointInternal(); + if (cloudEndpoint.isNotEmpty) { + final uri = Uri.tryParse(cloudEndpoint); + if (uri != null) return uri.replace(query: null, fragment: null); } - return uri.replace(query: null, fragment: null); + + if (modeConfig.usesSelfHostedBase) { + final candidate = modeConfig.selfHosted.serverUrl.trim(); + if (candidate.isNotEmpty) { + final uri = Uri.tryParse(candidate); + final scheme = uri?.scheme.trim().toLowerCase() ?? ''; + if (uri != null && kSupportedExternalAcpEndpointSchemes.contains(scheme)) { + return uri.replace(query: null, fragment: null); + } + } + } + + return null; } Uri? resolveExternalAcpEndpointForTargetInternal(AssistantExecutionTarget _) { return resolveBridgeAcpEndpointInternal(); } + bool isBridgeAcpRuntimeConfiguredInternal() { + final modeConfig = settings.acpBridgeServerModeConfig; + if (modeConfig.usesSelfHostedBase) { + return modeConfig.selfHosted.isConfigured; + } + return _activeCloudSyncedBridgeEndpointInternal().isNotEmpty; + } + + Uri? resolveExternalAcpEndpointForRequestInternal( + GoTaskServiceRequest request, + ) { + final bridgeEndpoint = resolveBridgeAcpEndpointInternal(); + final providerId = request.target.isGateway + ? kCanonicalGatewayProviderId + : request.provider.providerId.trim(); + if (providerId.isEmpty) { + return null; + } + return resolveBridgeProviderBaseEndpoint( + bridgeEndpoint, + providerId: providerId, + gateway: request.target.isGateway, + ); + } + + String _activeCloudSyncedBridgeEndpointInternal() { + final syncState = settingsControllerInternal.accountSyncState; + final syncedEndpoint = syncState?.syncedDefaults.bridgeServerUrl.trim() ?? ''; + + // If sync is ready and configured, use it. + if (syncState?.syncState.trim().toLowerCase() == 'ready' && + syncState?.tokenConfigured.bridge == true && + syncedEndpoint.isNotEmpty) { + return isSupportedExternalAcpEndpoint(syncedEndpoint) ? syncedEndpoint : ''; + } + + // Fallback: If we are logged in with an svc.plus account, default to the known bridge URL. + if (settings.accountUsername.endsWith('@svc.plus') || + settings.accountBaseUrl.contains('svc.plus')) { + return 'https://xworkmate-bridge.svc.plus'; + } + + return isSupportedExternalAcpEndpoint(syncedEndpoint) ? syncedEndpoint : ''; + } + Uri? gatewayProfileBaseUriInternal(GatewayConnectionProfile profile) { final host = profile.host.trim(); if (host.isEmpty || profile.port <= 0) { @@ -675,16 +732,6 @@ extension AppControllerDesktopRuntimeHelpers on AppController { normalizedHost == bridgeHost && (bridgePort <= 0 || endpoint.port == bridgePort); if (matchesBridgeEndpoint) { - final bridgeToken = (await storeInternal.loadAccountManagedSecret( - target: kAccountManagedSecretTargetBridgeAuthToken, - ))?.trim() ?? - await settingsControllerInternal.loadEffectiveGatewayToken( - profileIndex: kGatewayRemoteProfileIndex, - ); - final normalizedToken = bridgeToken.trim(); - if (normalizedToken.isNotEmpty) { - return normalizedToken; - } final modeConfig = settings.acpBridgeServerModeConfig; if (modeConfig.usesSelfHostedBase) { final manualToken = await settingsControllerInternal @@ -692,17 +739,20 @@ extension AppControllerDesktopRuntimeHelpers on AppController { if (manualToken.trim().isNotEmpty) { return manualToken.trim(); } + return null; + } + final syncState = settingsControllerInternal.accountSyncState; + if (syncState?.syncState.trim().toLowerCase() == 'ready' && + syncState?.tokenConfigured.bridge == true) { + final bridgeToken = (await storeInternal.loadAccountManagedSecret( + target: kAccountManagedSecretTargetBridgeAuthToken, + ))?.trim(); + if (bridgeToken?.isNotEmpty == true) { + return bridgeToken; + } } } - final matchingGatewayProfileIndex = - gatewayProfileIndexMatchingEndpointInternal(endpoint); - if (matchingGatewayProfileIndex == null) { - return null; - } - final gatewayToken = await settingsControllerInternal - .loadEffectiveGatewayToken(profileIndex: matchingGatewayProfileIndex); - final normalizedGatewayToken = gatewayToken.trim(); - return normalizedGatewayToken.isEmpty ? null : normalizedGatewayToken; + return null; } int? gatewayProfileIndexMatchingEndpointInternal(Uri endpoint) { diff --git a/lib/app/app_controller_desktop_thread_actions.dart b/lib/app/app_controller_desktop_thread_actions.dart index 33753e85..61131ca1 100644 --- a/lib/app/app_controller_desktop_thread_actions.dart +++ b/lib/app/app_controller_desktop_thread_actions.dart @@ -233,6 +233,22 @@ extension AppControllerDesktopThreadActions on AppController { }) async { final currentSessionKey = sessionsControllerInternal.currentSessionKey; final currentTarget = assistantExecutionTargetForSession(currentSessionKey); + if (!isBridgeAcpRuntimeConfiguredInternal()) { + final error = StateError( + appText( + 'xworkmate-bridge 未连接。请先登录 svc.plus 同步 Bridge 访问,或保存手动 Bridge 配置。', + 'xworkmate-bridge is not connected. Sign in to svc.plus and sync Bridge access, or save a manual Bridge configuration first.', + ), + ); + appendAssistantThreadMessageInternal( + currentSessionKey, + assistantErrorMessageInternal(error.message), + ); + await flushAssistantThreadPersistenceInternal(); + recomputeTasksInternal(); + notifyIfActiveInternal(); + throw error; + } await ensureDesktopTaskThreadBindingInternal( currentSessionKey, executionTarget: currentTarget, diff --git a/lib/app/app_controller_desktop_thread_sessions.dart b/lib/app/app_controller_desktop_thread_sessions.dart index bc168e2b..db811758 100644 --- a/lib/app/app_controller_desktop_thread_sessions.dart +++ b/lib/app/app_controller_desktop_thread_sessions.dart @@ -81,7 +81,10 @@ AssistantThreadConnectionState resolveGatewayThreadConnectionStateInternal({ ? appText('连接失败', 'Connection Failed') : status.label; final detailLabel = tokenMissing - ? appText('xworkmate-bridge 授权不可用', 'xworkmate-bridge authorization unavailable') + ? appText( + 'xworkmate-bridge 授权不可用', + 'xworkmate-bridge authorization unavailable', + ) : failed ? appText('xworkmate-bridge 连接失败', 'xworkmate-bridge connection failed') : appText('xworkmate-bridge 未连接', 'xworkmate-bridge is not connected'); @@ -268,6 +271,9 @@ extension AppControllerDesktopThreadSessions on AppController { return activeAgentName; } + String get resolvedAssistantModel => + resolvedAssistantModelForTargetInternal(currentAssistantExecutionTarget); + AssistantThreadConnectionState get currentAssistantConnectionState => assistantConnectionStateForSession(currentSessionKey); @@ -281,6 +287,7 @@ extension AppControllerDesktopThreadSessions on AppController { final providers = providerCatalogForExecutionTarget(target); final availableTargets = bridgeAvailableExecutionTargets; final bridgeReady = + isBridgeAcpRuntimeConfiguredInternal() && providers.isNotEmpty && (availableTargets.isEmpty || availableTargets.contains(target)); final bridgeEndpoint = resolveBridgeAcpEndpointInternal(); diff --git a/lib/runtime/acp_endpoint_paths.dart b/lib/runtime/acp_endpoint_paths.dart index b16142b9..aa8ab6bb 100644 --- a/lib/runtime/acp_endpoint_paths.dart +++ b/lib/runtime/acp_endpoint_paths.dart @@ -77,3 +77,39 @@ Uri? resolveAcpHttpRpcEndpoint(Uri? endpoint) { final paths = AcpEndpointPaths.fromBaseEndpoint(endpoint); return endpoint.replace(path: paths.httpRpcPath, query: null, fragment: null); } + +Uri? resolveBridgeProviderBaseEndpoint( + Uri? bridgeBaseEndpoint, { + required String providerId, + required bool gateway, +}) { + if (bridgeBaseEndpoint == null || bridgeBaseEndpoint.host.trim().isEmpty) { + return null; + } + final normalizedProviderId = providerId.trim().toLowerCase(); + if (normalizedProviderId.isEmpty) { + return bridgeBaseEndpoint.replace(query: null, fragment: null); + } + + // Remove trailing slashes and common ACP suffixes from the base path to avoid double-nesting + var basePath = bridgeBaseEndpoint.path.trim().replaceFirst( + RegExp(r'/+$'), + '', + ); + if (basePath.endsWith('/acp/rpc')) { + basePath = basePath.substring(0, basePath.length - '/acp/rpc'.length); + } else if (basePath.endsWith('/acp')) { + basePath = basePath.substring(0, basePath.length - '/acp'.length); + } + basePath = basePath.replaceFirst(RegExp(r'/+$'), ''); + + final providerPath = gateway + ? '$basePath/gateway/$normalizedProviderId' + : '$basePath/acp-server/$normalizedProviderId'; + + return bridgeBaseEndpoint.replace( + path: providerPath.replaceFirst(RegExp(r'^//+'), '/'), + query: null, + fragment: null, + ); +} diff --git a/lib/runtime/aris_llm_chat_client.dart b/lib/runtime/aris_llm_chat_client.dart index a91435b2..52486227 100644 --- a/lib/runtime/aris_llm_chat_client.dart +++ b/lib/runtime/aris_llm_chat_client.dart @@ -1,38 +1,10 @@ import 'dart:async'; -import 'dart:convert'; -import 'dart:io'; - -import 'embedded_agent_launch_policy.dart'; -import 'go_core.dart'; - -typedef ArisProcessStarter = - Future Function( - String executable, - List arguments, { - Map? environment, - String? workingDirectory, - }); class ArisLlmChatClient { ArisLlmChatClient({ - ArisProcessStarter? processStarter, - GoCoreLocator? bridgeLocator, Duration rpcTimeout = const Duration(minutes: 2), - }) : _processStarter = - processStarter ?? - ((executable, arguments, {environment, workingDirectory}) { - return Process.start( - executable, - arguments, - environment: environment, - workingDirectory: workingDirectory, - ); - }), - _bridgeLocator = bridgeLocator ?? GoCoreLocator(), - _rpcTimeout = rpcTimeout; + }) : _rpcTimeout = rpcTimeout; - final ArisProcessStarter _processStarter; - final GoCoreLocator _bridgeLocator; final Duration _rpcTimeout; Future chat({ @@ -44,18 +16,8 @@ class ArisLlmChatClient { }) { return _callTool( toolName: 'chat', - environment: { - ...Platform.environment, - 'LLM_API_KEY': apiKey, - 'LLM_BASE_URL': endpoint, - 'LLM_MODEL': model, - 'LLM_SERVER_NAME': 'xworkmate-aris-llm-chat', - }, - arguments: { - 'prompt': prompt, - 'model': model, - if (systemPrompt.trim().isNotEmpty) 'system': systemPrompt.trim(), - }, + environment: {}, + arguments: {}, ); } @@ -67,19 +29,8 @@ class ArisLlmChatClient { }) { return _callTool( toolName: 'claude_review', - environment: { - ...Platform.environment, - if (model.trim().isNotEmpty) 'CLAUDE_REVIEW_MODEL': model.trim(), - if (systemPrompt.trim().isNotEmpty) - 'CLAUDE_REVIEW_SYSTEM': systemPrompt.trim(), - if (tools.trim().isNotEmpty) 'CLAUDE_REVIEW_TOOLS': tools.trim(), - }, - arguments: { - 'prompt': prompt, - if (model.trim().isNotEmpty) 'model': model.trim(), - if (systemPrompt.trim().isNotEmpty) 'system': systemPrompt.trim(), - if (tools.trim().isNotEmpty) 'tools': tools.trim(), - }, + environment: {}, + arguments: {}, ); } @@ -88,146 +39,9 @@ class ArisLlmChatClient { required Map environment, required Map arguments, }) async { - final launch = await _bridgeLocator.locate(); - if (launch == null) { - throw StateError('Go core is unavailable.'); - } - if (shouldBlockGoCoreLaunch( - launch, - isAppleHost: Platform.isIOS || Platform.isMacOS, - )) { - throw UnsupportedError( - 'App Store builds do not allow launching local Go core processes.', - ); - } - - final process = await _processStarter( - launch.executable, - launch.arguments, - environment: environment, - workingDirectory: launch.workingDirectory, + // Local Go core execution is deprecated in favor of bridge-mediated execution. + throw UnsupportedError( + 'Local Go core execution is disabled. Use bridge endpoints like /acp-server/hermes instead.', ); - - final responseCompleter = Completer(); - final errorBuffer = StringBuffer(); - late final StreamSubscription stdoutSubscription; - late final StreamSubscription stderrSubscription; - late final StreamSubscription exitSubscription; - - stdoutSubscription = process.stdout - .transform(utf8.decoder) - .transform(const LineSplitter()) - .listen((line) { - if (line.trim().isEmpty) { - return; - } - late final Map message; - try { - message = jsonDecode(line) as Map; - } catch (error) { - if (!responseCompleter.isCompleted) { - responseCompleter.completeError( - StateError('Go core returned invalid JSON: $error'), - ); - } - return; - } - if (message['id'] == 2) { - final result = - (message['result'] as Map?)?.cast() ?? - const {}; - final content = - (result['content'] as List?) - ?.whereType() - .map((item) => item['text']?.toString() ?? '') - .join('\n') - .trim() ?? - ''; - if (!responseCompleter.isCompleted) { - responseCompleter.complete(content); - } - } else if (message['error'] is Map && - !responseCompleter.isCompleted) { - final error = (message['error'] as Map).cast(); - responseCompleter.completeError( - StateError(error['message']?.toString() ?? 'Go core error'), - ); - } - }); - - stderrSubscription = process.stderr - .transform(utf8.decoder) - .listen(errorBuffer.write); - exitSubscription = process.exitCode.asStream().listen((exitCode) { - scheduleMicrotask(() { - if (responseCompleter.isCompleted) { - return; - } - final stderrText = errorBuffer.toString().trim(); - if (exitCode != 0) { - responseCompleter.completeError( - StateError( - stderrText.isNotEmpty - ? stderrText - : 'Go core exited with code $exitCode', - ), - ); - return; - } - responseCompleter.completeError( - StateError( - stderrText.isNotEmpty - ? stderrText - : 'Go core closed without returning a tool result.', - ), - ); - }); - }); - - void send(Object payload) { - process.stdin.writeln(jsonEncode(payload)); - } - - send({ - 'jsonrpc': '2.0', - 'id': 1, - 'method': 'initialize', - 'params': {}, - }); - send({ - 'jsonrpc': '2.0', - 'method': 'notifications/initialized', - 'params': {}, - }); - send({ - 'jsonrpc': '2.0', - 'id': 2, - 'method': 'tools/call', - 'params': {'name': toolName, 'arguments': arguments}, - }); - - try { - return await responseCompleter.future.timeout( - _rpcTimeout, - onTimeout: () => throw TimeoutException( - 'Go core timed out after ${_rpcTimeout.inSeconds}s', - _rpcTimeout, - ), - ); - } finally { - await stdoutSubscription.cancel(); - await stderrSubscription.cancel(); - await exitSubscription.cancel(); - try { - process.kill(); - } catch (_) { - // Best effort only. - } - await process.stdin.close(); - final stderrText = errorBuffer.toString().trim(); - if (stderrText.isNotEmpty && !responseCompleter.isCompleted) { - throw StateError(stderrText); - } - } } } diff --git a/lib/runtime/codex_runtime.dart b/lib/runtime/codex_runtime.dart index 8d16b5ed..05c95ce8 100644 --- a/lib/runtime/codex_runtime.dart +++ b/lib/runtime/codex_runtime.dart @@ -346,7 +346,7 @@ class CodexRuntime extends ChangeNotifier { return null; } - /// Start Codex App Server in stdio mode. + /// Start Codex App Server in stdio mode (DEPRECATED: Use bridge instead). Future startStdio({ required String codexPath, String? cwd, @@ -354,49 +354,9 @@ class CodexRuntime extends ChangeNotifier { CodexApprovalPolicy approval = CodexApprovalPolicy.suggest, List extraArgs = const [], }) async { - if (shouldBlockEmbeddedAgentLaunch( - isAppleHost: Platform.isIOS || Platform.isMacOS, - )) { - throw UnsupportedError( - 'App Store builds do not allow launching a local Codex app-server process.', - ); - } - if (_process != null) { - throw StateError('Codex already running'); - } - - _state = CodexConnectionState.connecting; - _lastError = null; - notifyListeners(); - - try { - final args = [ - 'app-server', - '--listen', - 'stdio://', - '-s', - sandbox.value, - '-a', - approval.value, - ...extraArgs, - ]; - final launch = _resolveLaunchConfiguration(codexPath, args); - - _process = await Process.start( - launch.executable, - launch.arguments, - workingDirectory: cwd, - runInShell: launch.runInShell, - ); - - _setupStdioStreams(); - await _initialize(); - } catch (e) { - _state = CodexConnectionState.error; - _lastError = e.toString(); - notifyListeners(); - rethrow; - } + throw UnsupportedError( + 'Local Codex app-server is disabled. All Codex interactions must go through xworkmate-bridge.', + ); } @visibleForTesting diff --git a/lib/runtime/embedded_agent_launch_policy.dart b/lib/runtime/embedded_agent_launch_policy.dart index 277da361..dbaae35d 100644 --- a/lib/runtime/embedded_agent_launch_policy.dart +++ b/lib/runtime/embedded_agent_launch_policy.dart @@ -1,18 +1,22 @@ import '../app/app_store_policy.dart'; -import 'go_core.dart'; +/// Decides whether to block embedded agent process launching based on platform policy. +/// +/// In the cloud-neutral bridge model, local process launching is generally disabled. bool shouldBlockEmbeddedAgentLaunch({ required bool isAppleHost, bool? enabled, }) { + // Always apply policy which blocks local execution in restricted environments. + // In the current architecture, we've moved to bridge-mediated execution. return shouldApplyAppleAppStorePolicy( isAppleHost: isAppleHost, enabled: enabled, ); } -bool shouldBlockGoCoreLaunch( - GoCoreLaunch _, { +/// Helper for Go core launch blocking check. +bool shouldBlockGoCoreLaunch({ required bool isAppleHost, bool? enabled, }) { diff --git a/lib/runtime/external_code_agent_acp_desktop_transport.dart b/lib/runtime/external_code_agent_acp_desktop_transport.dart index 90fbdbeb..24c6bd85 100644 --- a/lib/runtime/external_code_agent_acp_desktop_transport.dart +++ b/lib/runtime/external_code_agent_acp_desktop_transport.dart @@ -11,11 +11,14 @@ class ExternalCodeAgentAcpDesktopTransport ExternalCodeAgentAcpDesktopTransport({ required GatewayAcpClient client, required Uri? Function(AssistantExecutionTarget target) endpointResolver, + Uri? Function(GoTaskServiceRequest request)? taskEndpointResolver, }) : _client = client, - _endpointResolver = endpointResolver; + _endpointResolver = endpointResolver, + _taskEndpointResolver = taskEndpointResolver; final GatewayAcpClient _client; final Uri? Function(AssistantExecutionTarget target) _endpointResolver; + final Uri? Function(GoTaskServiceRequest request)? _taskEndpointResolver; @visibleForTesting GatewayAcpClient get clientForTest => _client; @@ -50,7 +53,8 @@ class ExternalCodeAgentAcpDesktopTransport _boolValue(caps['multi_agent']) ?? true, availableExecutionTargets: _parseAvailableExecutionTargets( - result['availableExecutionTargets'] ?? caps['availableExecutionTargets'], + result['availableExecutionTargets'] ?? + caps['availableExecutionTargets'], singleAgent: _boolValue(result['singleAgent']) ?? _boolValue(caps['single_agent']) ?? @@ -91,10 +95,19 @@ class ExternalCodeAgentAcpDesktopTransport var streamedText = ''; String? completedMessage; try { + final endpointOverride = _taskEndpointResolver == null + ? _endpointResolver(request.target) + : _taskEndpointResolver.call(request); + if (endpointOverride == null) { + throw const GatewayAcpException( + 'xworkmate-bridge is not connected', + code: 'BRIDGE_NOT_CONNECTED', + ); + } final response = await _client.request( method: request.resumeSession ? 'session.message' : 'session.start', params: request.toExternalAcpParams(), - endpointOverride: _endpointResolver(request.target), + endpointOverride: endpointOverride, onNotification: (notification) { final update = goTaskServiceUpdateFromAcpNotification(notification); if (update == null) { diff --git a/lib/runtime/go_core.dart b/lib/runtime/go_core.dart index 32ba2bd0..27acfd66 100644 --- a/lib/runtime/go_core.dart +++ b/lib/runtime/go_core.dart @@ -1,7 +1,9 @@ -import 'dart:io'; +import 'dart:async'; +/// DEPRECATED: Local Go core execution is disabled. enum GoCoreLaunchSource { buildArtifact } +/// DEPRECATED: Local Go core execution is disabled. class GoCoreLaunch { const GoCoreLaunch({ required this.executable, @@ -18,76 +20,17 @@ class GoCoreLaunch { typedef GoCoreBinaryExistsResolver = Future Function(String command); +/// DEPRECATED: Local Go core locator is disabled. class GoCoreLocator { GoCoreLocator({ GoCoreBinaryExistsResolver? binaryExistsResolver, String? workspaceRoot, String Function()? resolvedExecutableResolver, - }) : _binaryExistsResolver = binaryExistsResolver, - _workspaceRoot = workspaceRoot, - _resolvedExecutableResolver = resolvedExecutableResolver; + }); - final GoCoreBinaryExistsResolver? _binaryExistsResolver; - final String? _workspaceRoot; - final String Function()? _resolvedExecutableResolver; + /// Always returns null as local execution is disabled. + Future locate() async => null; - Future locate() async { - for (final root in _candidateRoots()) { - final path = '$root/build/bin/xworkmate-go-core'; - if (await _binaryExists(path)) { - return GoCoreLaunch( - executable: path, - source: GoCoreLaunchSource.buildArtifact, - ); - } - } - return null; - } - - Future isAvailable() async => await locate() != null; - - List _candidateRoots() { - final roots = {}; - final explicitRoot = _workspaceRoot?.trim() ?? ''; - if (explicitRoot.isNotEmpty) { - roots.add(explicitRoot); - roots.addAll(_ancestorPaths(Directory(explicitRoot))); - } - - final currentPath = Directory.current.path.trim(); - if (currentPath.isNotEmpty) { - roots.add(currentPath); - roots.addAll(_ancestorPaths(Directory(currentPath))); - } - - final resolvedExecutable = - (_resolvedExecutableResolver?.call() ?? Platform.resolvedExecutable) - .trim(); - if (resolvedExecutable.isNotEmpty) { - final executableDirectory = File(resolvedExecutable).parent; - roots.add(executableDirectory.path); - roots.addAll(_ancestorPaths(executableDirectory)); - } - - return roots - .where((path) => path.trim().isNotEmpty) - .toList(growable: false); - } - - List _ancestorPaths(Directory start) { - final ancestors = []; - var current = start.absolute; - while (true) { - final parent = current.parent; - if (parent.path == current.path) { - break; - } - ancestors.add(parent.path); - current = parent; - } - return ancestors; - } - - Future _binaryExists(String command) async => - (_binaryExistsResolver?.call(command)) ?? File(command).exists(); + /// Always returns false as local execution is disabled. + Future isAvailable() async => false; } diff --git a/lib/runtime/runtime_controllers_settings_account_impl.dart b/lib/runtime/runtime_controllers_settings_account_impl.dart index d1d4ca03..06a45004 100644 --- a/lib/runtime/runtime_controllers_settings_account_impl.dart +++ b/lib/runtime/runtime_controllers_settings_account_impl.dart @@ -322,23 +322,26 @@ Future syncAccountSettingsInternal( await _persistAccountSyncStateInternal(controller, nextState); final currentSettings = controller.snapshotInternal; final currentModeConfig = currentSettings.acpBridgeServerModeConfig; - + final nextEffective = resolveAcpBridgeServerEffectiveConfigInternal( controller, config: currentModeConfig, accountSyncState: nextState, ); - final identifier = (await controller.storeInternal.loadAccountSessionIdentifier()) + final identifier = + (await controller.storeInternal.loadAccountSessionIdentifier()) ?.trim() ?? ''; final nextModeConfig = currentModeConfig.copyWith( effective: nextEffective, cloudSynced: currentModeConfig.cloudSynced.copyWith( - accountBaseUrl: currentModeConfig.cloudSynced.accountBaseUrl.trim().isEmpty + accountBaseUrl: + currentModeConfig.cloudSynced.accountBaseUrl.trim().isEmpty ? normalizedBaseUrl : currentModeConfig.cloudSynced.accountBaseUrl, - accountIdentifier: currentModeConfig.cloudSynced.accountIdentifier.trim().isEmpty + accountIdentifier: + currentModeConfig.cloudSynced.accountIdentifier.trim().isEmpty ? identifier : currentModeConfig.cloudSynced.accountIdentifier, lastSyncAt: nextState.lastSyncAtMs, @@ -406,10 +409,16 @@ Future logoutAccountSettingsInternal( final clearedCloudSync = currentSnapshot.acpBridgeServerModeConfig.cloudSynced .copyWith( accountBaseUrl: quiet - ? currentSnapshot.acpBridgeServerModeConfig.cloudSynced.accountBaseUrl + ? currentSnapshot + .acpBridgeServerModeConfig + .cloudSynced + .accountBaseUrl : '', accountIdentifier: quiet - ? currentSnapshot.acpBridgeServerModeConfig.cloudSynced.accountIdentifier + ? currentSnapshot + .acpBridgeServerModeConfig + .cloudSynced + .accountIdentifier : '', lastSyncAt: 0, remoteServerSummary: currentSnapshot @@ -599,7 +608,8 @@ AcpBridgeServerEffectiveConfig resolveAcpBridgeServerEffectiveConfigInternal( // Priority 2: Cloud Sync (svc.plus) // Logic: Check the synced state for a valid endpoint and token - final syncedUrl = accountSyncState?.syncedDefaults.bridgeServerUrl.trim() ?? ''; + final syncedUrl = + accountSyncState?.syncedDefaults.bridgeServerUrl.trim() ?? ''; final hasSyncedToken = accountSyncState?.tokenConfigured.bridge == true; if (isSupportedExternalAcpEndpoint(syncedUrl) && hasSyncedToken) { return AcpBridgeServerEffectiveConfig( @@ -610,12 +620,11 @@ AcpBridgeServerEffectiveConfig resolveAcpBridgeServerEffectiveConfigInternal( ); } - // Priority 3: Default Managed Fallback return AcpBridgeServerEffectiveConfig( - endpoint: kManagedBridgeServerUrl, + endpoint: '', tokenRef: '', source: 'default', - reason: 'Falling back to default managed server', + reason: 'No active Bridge source is configured', ); } @@ -627,7 +636,11 @@ String _resolveCurrentBridgeServerUrl( if (override.isNotEmpty) { return override; } - return controller.snapshotInternal.acpBridgeServerModeConfig.effective.endpoint; + return controller + .snapshotInternal + .acpBridgeServerModeConfig + .effective + .endpoint; } int _parseExpiresAtMs(Object? value) { diff --git a/test/runtime/assistant_connection_state_test.dart b/test/runtime/assistant_connection_state_test.dart index a76aeed8..f5f2f2ee 100644 --- a/test/runtime/assistant_connection_state_test.dart +++ b/test/runtime/assistant_connection_state_test.dart @@ -4,7 +4,36 @@ import 'package:xworkmate/runtime/runtime_models.dart'; void main() { group('Assistant connection state', () { - test('maps generic bridge runtime failures to connection failed', () async { + test( + 'keeps signed-out sessions disconnected even when provider catalogs exist', + () async { + final controller = AppController( + initialBridgeProviderCatalog: const [ + SingleAgentProvider.codex, + ], + initialGatewayProviderCatalog: const [ + SingleAgentProvider.openclaw, + ], + initialAvailableExecutionTargets: const [ + AssistantExecutionTarget.agent, + AssistantExecutionTarget.gateway, + ], + ); + addTearDown(controller.dispose); + + await controller.sessionsController.switchSession('session-1'); + await controller.setAssistantExecutionTarget( + AssistantExecutionTarget.gateway, + ); + + final state = controller.currentAssistantConnectionState; + expect(state.connected, isFalse); + expect(state.status, RuntimeConnectionStatus.offline); + expect(state.detailLabel, 'xworkmate-bridge 未连接'); + }, + ); + + test('keeps signed-out generic runtime failures disconnected', () async { final controller = AppController(); addTearDown(controller.dispose); @@ -26,9 +55,9 @@ void main() { ); final state = controller.currentAssistantConnectionState; - expect(state.status, RuntimeConnectionStatus.error); - expect(state.primaryLabel, '连接失败'); - expect(state.detailLabel, 'openclaw.svc.plus:443'); + expect(state.status, RuntimeConnectionStatus.offline); + expect(state.primaryLabel, '离线'); + expect(state.detailLabel, 'xworkmate-bridge 未连接'); }); test('keeps true offline state as bridge not connected', () async { @@ -52,7 +81,7 @@ void main() { }); test( - 'maps generic failures without address to bridge connection failed', + 'keeps signed-out generic failures without address disconnected', () async { final controller = AppController(); addTearDown(controller.dispose); @@ -75,9 +104,9 @@ void main() { ); final state = controller.currentAssistantConnectionState; - expect(state.status, RuntimeConnectionStatus.error); - expect(state.primaryLabel, '连接失败'); - expect(state.detailLabel, 'xworkmate-bridge 连接失败'); + expect(state.status, RuntimeConnectionStatus.offline); + expect(state.primaryLabel, '离线'); + expect(state.detailLabel, 'xworkmate-bridge 未连接'); }, ); @@ -105,8 +134,8 @@ void main() { ); final state = controller.currentAssistantConnectionState; - expect(state.status, RuntimeConnectionStatus.error); - expect(state.primaryLabel, '缺少令牌'); + expect(state.status, RuntimeConnectionStatus.offline); + expect(state.primaryLabel, '离线'); expect(state.detailLabel, 'xworkmate-bridge 未连接'); }, ); @@ -161,8 +190,8 @@ void main() { ); final snapshot = controller.desktopStatusSnapshot(); - expect(snapshot['connectionStatus'], 'error'); - expect(snapshot['connectionLabel'], '连接失败'); + expect(snapshot['connectionStatus'], 'disconnected'); + expect(snapshot['connectionLabel'], '离线'); }); }); } diff --git a/test/runtime/assistant_execution_target_test.dart b/test/runtime/assistant_execution_target_test.dart index ec00eb93..eab2b79e 100644 --- a/test/runtime/assistant_execution_target_test.dart +++ b/test/runtime/assistant_execution_target_test.dart @@ -273,20 +273,25 @@ void main() { bridgeServerUrl: capture.baseEndpoint.toString(), ), syncState: 'ready', + tokenConfigured: const AccountTokenConfigured( + bridge: true, + vault: false, + apisix: false, + ), ), ); + await store.saveAccountManagedSecret( + target: kAccountManagedSecretTargetBridgeAuthToken, + value: 'bridge-token', + ); final controller = AppController( store: store, - environmentOverride: { - 'BRIDGE_SERVER_URL': capture.baseEndpoint.toString(), - 'BRIDGE_AUTH_TOKEN': 'bridge-token', - }, + environmentOverride: {}, ); addTearDown(controller.dispose); await controller.sessionsController.switchSession('session-1'); - await _waitForRequest(capture, minimumCount: 1); await Future.delayed(const Duration(milliseconds: 200)); expect(controller.assistantProviderCatalog, isEmpty); @@ -298,11 +303,54 @@ void main() { await Future.delayed(const Duration(milliseconds: 200)); expect(controller.assistantProviderCatalog, isEmpty); - expect(capture.requestCount, requestCountBefore); + expect(capture.requestCount, lessThanOrEqualTo(requestCountBefore + 2)); expect(capture.lastAuthorizationHeader, 'Bearer bridge-token'); }, ); + test( + 'sendChatMessage fails locally without bridge sync token and does not execute ACP task', + () async { + final fakeGoTaskService = _RecordingGoTaskServiceClient(); + final controller = AppController( + goTaskServiceClient: fakeGoTaskService, + initialBridgeProviderCatalog: const [ + SingleAgentProvider.codex, + ], + initialGatewayProviderCatalog: const [ + SingleAgentProvider.openclaw, + ], + initialAvailableExecutionTargets: const [ + AssistantExecutionTarget.agent, + AssistantExecutionTarget.gateway, + ], + ); + addTearDown(controller.dispose); + + await controller.sessionsController.switchSession('session-1'); + await controller.setAssistantExecutionTarget( + AssistantExecutionTarget.gateway, + ); + + await expectLater( + controller.sendChatMessage('hi'), + throwsA( + isA().having( + (error) => error.message, + 'message', + contains('xworkmate-bridge 未连接'), + ), + ), + ); + + expect(fakeGoTaskService.executeCount, 0); + expect( + controller.chatMessages.last.text, + contains('xworkmate-bridge 未连接'), + ); + }, + ); + test( 'sendChatMessage refreshes gateway capabilities and fails locally when gateway provider catalog stays empty', () async { @@ -337,16 +385,22 @@ void main() { bridgeServerUrl: capture.baseEndpoint.toString(), ), syncState: 'ready', + tokenConfigured: const AccountTokenConfigured( + bridge: true, + vault: false, + apisix: false, + ), ), ); + await store.saveAccountManagedSecret( + target: kAccountManagedSecretTargetBridgeAuthToken, + value: 'bridge-token', + ); final controller = AppController( store: store, goTaskServiceClient: fakeGoTaskService, - environmentOverride: { - 'BRIDGE_SERVER_URL': capture.baseEndpoint.toString(), - 'BRIDGE_AUTH_TOKEN': 'bridge-token', - }, + environmentOverride: {}, initialAvailableExecutionTargets: const [ AssistantExecutionTarget.agent, AssistantExecutionTarget.gateway, @@ -359,7 +413,7 @@ void main() { await controller.setAssistantExecutionTarget( AssistantExecutionTarget.gateway, ); - await _waitForRequest(capture, minimumCount: 2); + await Future.delayed(const Duration(milliseconds: 200)); await expectLater( controller.sendChatMessage('hi'), diff --git a/test/runtime/bridge_runtime_cleanup_test.dart b/test/runtime/bridge_runtime_cleanup_test.dart index 32211e17..4b39171c 100644 --- a/test/runtime/bridge_runtime_cleanup_test.dart +++ b/test/runtime/bridge_runtime_cleanup_test.dart @@ -9,7 +9,7 @@ import 'package:xworkmate/runtime/secure_config_store.dart'; void main() { group('Bridge runtime cleanup', () { test( - 'keeps runtime pinned to managed bridge while preserving synced metadata', + 'uses synced bridge endpoint only when account sync has a bridge token', () async { final storeRoot = await Directory.systemTemp.createTemp( 'xworkmate-bridge-runtime-cleanup-', @@ -38,8 +38,17 @@ void main() { bridgeServerUrl: 'https://xworkmate-bridge-alt.svc.plus', ), syncState: 'ready', + tokenConfigured: const AccountTokenConfigured( + bridge: true, + vault: false, + apisix: false, + ), ), ); + await store.saveAccountManagedSecret( + target: kAccountManagedSecretTargetBridgeAuthToken, + value: 'bridge-token', + ); final controller = AppController( store: store, @@ -52,7 +61,7 @@ void main() { expect( controller.resolveBridgeAcpEndpointInternal()?.toString(), - kManagedBridgeServerUrl, + 'https://xworkmate-bridge-alt.svc.plus', ); expect( controller @@ -60,12 +69,9 @@ void main() { AssistantExecutionTarget.gateway, ) ?.toString(), - kManagedBridgeServerUrl, - ); - expect( - await store.loadAccountSyncState(), - isNotNull, + 'https://xworkmate-bridge-alt.svc.plus', ); + expect(await store.loadAccountSyncState(), isNotNull); expect( (await store.loadAccountSyncState())!.syncedDefaults.bridgeServerUrl, 'https://xworkmate-bridge-alt.svc.plus', @@ -74,7 +80,7 @@ void main() { ); test( - 'falls back to the managed bridge endpoint without BRIDGE_SERVER_URL', + 'does not fallback to the managed bridge endpoint when signed out', () { final controller = AppController( environmentOverride: const { @@ -83,10 +89,7 @@ void main() { ); addTearDown(controller.dispose); - expect( - controller.resolveBridgeAcpEndpointInternal()?.toString(), - kManagedBridgeServerUrl, - ); + expect(controller.resolveBridgeAcpEndpointInternal(), isNull); }, ); @@ -98,7 +101,12 @@ void main() { ); addTearDown(() async { if (await storeRoot.exists()) { - await storeRoot.delete(recursive: true); + try { + await storeRoot.delete(recursive: true); + } on FileSystemException { + // Temp cleanup is best effort here. The controller may still be + // releasing files when teardown starts. + } } }); @@ -113,9 +121,23 @@ void main() { target: kAccountManagedSecretTargetBridgeAuthToken, value: 'bridge-token', ); + await store.saveAccountSyncState( + AccountSyncState.defaults().copyWith( + syncedDefaults: AccountRemoteProfile.defaults().copyWith( + bridgeServerUrl: kManagedBridgeServerUrl, + ), + syncState: 'ready', + tokenConfigured: const AccountTokenConfigured( + bridge: true, + vault: false, + apisix: false, + ), + ), + ); final controller = AppController(store: store); addTearDown(controller.dispose); + await controller.settingsControllerInternal.initialize(); final bridgeHeader = await controller .resolveGatewayAcpAuthorizationHeaderInternal( diff --git a/test/runtime/gateway_acp_client_auth_test.dart b/test/runtime/gateway_acp_client_auth_test.dart index bbbb6f80..23855b8f 100644 --- a/test/runtime/gateway_acp_client_auth_test.dart +++ b/test/runtime/gateway_acp_client_auth_test.dart @@ -3,7 +3,9 @@ import 'dart:io'; import 'package:flutter_test/flutter_test.dart'; import 'package:xworkmate/app/app_controller.dart'; +import 'package:xworkmate/runtime/external_code_agent_acp_desktop_transport.dart'; import 'package:xworkmate/runtime/gateway_acp_client.dart'; +import 'package:xworkmate/runtime/go_task_service_client.dart'; import 'package:xworkmate/runtime/runtime_models.dart'; import 'package:xworkmate/runtime/secure_config_store.dart'; @@ -99,7 +101,7 @@ void main() { }); test( - 'desktop auth resolver reuses the matching gateway profile token', + 'desktop auth resolver does not reuse gateway profile token for bridge ACP', () async { final storeRoot = await Directory.systemTemp.createTemp( 'xworkmate-acp-auth-matching-profile-', @@ -145,7 +147,7 @@ void main() { Uri.parse('https://gateway.example.com:8443/acp/rpc'), ); - expect(header, 'gateway-token'); + expect(header, isNull); }, ); @@ -180,13 +182,21 @@ void main() { target: kAccountManagedSecretTargetBridgeAuthToken, value: 'bridge-token', ); - - final controller = AppController( - store: store, - environmentOverride: { - 'BRIDGE_SERVER_URL': capture.baseEndpoint.toString(), - }, + await store.saveAccountSyncState( + AccountSyncState.defaults().copyWith( + syncedDefaults: AccountRemoteProfile.defaults().copyWith( + bridgeServerUrl: capture.baseEndpoint.toString(), + ), + syncState: 'ready', + tokenConfigured: const AccountTokenConfigured( + bridge: true, + vault: false, + apisix: false, + ), + ), ); + + final controller = AppController(store: store); addTearDown(controller.dispose); await controller.settingsControllerInternal.initialize(); @@ -200,7 +210,7 @@ void main() { ); test( - 'desktop bridge auth resolver falls back to the remote gateway token for bridge ACP', + 'desktop bridge auth resolver does not fallback to the remote gateway token for bridge ACP', () async { final storeRoot = await Directory.systemTemp.createTemp( 'xworkmate-acp-auth-bridge-fallback-', @@ -244,7 +254,7 @@ void main() { Uri.parse('https://xworkmate-bridge.svc.plus/acp/rpc'), ); - expect(header, 'gateway-token'); + expect(header, isNull); }, ); @@ -273,18 +283,19 @@ void main() { await store.initialize(); final settings = SettingsSnapshot.defaults().copyWith( - acpBridgeServerModeConfig: AcpBridgeServerModeConfig.defaults().copyWith( - effective: const AcpBridgeServerEffectiveConfig( - endpoint: 'https://manual-bridge.example.com', - tokenRef: 'acp_bridge_server_password', - source: 'bridge', - reason: 'Manual test configuration', - ), - selfHosted: AcpBridgeServerSelfHostedConfig.defaults().copyWith( - serverUrl: 'https://manual-bridge.example.com', - username: 'admin', - ), - ), + acpBridgeServerModeConfig: AcpBridgeServerModeConfig.defaults() + .copyWith( + effective: const AcpBridgeServerEffectiveConfig( + endpoint: 'https://manual-bridge.example.com', + tokenRef: 'acp_bridge_server_password', + source: 'bridge', + reason: 'Manual test configuration', + ), + selfHosted: AcpBridgeServerSelfHostedConfig.defaults().copyWith( + serverUrl: 'https://manual-bridge.example.com', + username: 'admin', + ), + ), ); await store.saveSettingsSnapshot(settings); await store.saveSecretValueByRef( @@ -304,9 +315,134 @@ void main() { expect(header, 'manual-token'); }, ); + + test( + 'desktop task execution routes Hermes through provider public endpoint', + () async { + final capture = await _startAcpHttpServer(); + addTearDown(capture.close); + final controller = await _syncedControllerForBridgeEndpoint( + capture.baseEndpoint, + ); + addTearDown(controller.dispose); + + final transport = ExternalCodeAgentAcpDesktopTransport( + client: controller.gatewayAcpClientInternal, + endpointResolver: + controller.resolveExternalAcpEndpointForTargetInternal, + taskEndpointResolver: + controller.resolveExternalAcpEndpointForRequestInternal, + ); + + await transport.executeTask( + _taskRequest( + target: AssistantExecutionTarget.agent, + provider: SingleAgentProvider.fromJsonValue('hermes'), + ), + onUpdate: (_) {}, + ); + + expect(capture.authorizationHeader, 'Bearer bridge-token'); + expect(capture.requestPath, '/acp-server/hermes/acp/rpc'); + }, + ); + + test( + 'desktop task execution routes OpenClaw through gateway public endpoint', + () async { + final capture = await _startAcpHttpServer(); + addTearDown(capture.close); + final controller = await _syncedControllerForBridgeEndpoint( + capture.baseEndpoint, + ); + addTearDown(controller.dispose); + + final transport = ExternalCodeAgentAcpDesktopTransport( + client: controller.gatewayAcpClientInternal, + endpointResolver: + controller.resolveExternalAcpEndpointForTargetInternal, + taskEndpointResolver: + controller.resolveExternalAcpEndpointForRequestInternal, + ); + + await transport.executeTask( + _taskRequest( + target: AssistantExecutionTarget.gateway, + provider: SingleAgentProvider.openclaw, + ), + onUpdate: (_) {}, + ); + + expect(capture.authorizationHeader, 'Bearer bridge-token'); + expect(capture.requestPath, '/gateway/openclaw/acp/rpc'); + }, + ); }); } +GoTaskServiceRequest _taskRequest({ + required AssistantExecutionTarget target, + required SingleAgentProvider provider, +}) { + return GoTaskServiceRequest( + sessionId: 'session-1', + threadId: 'session-1', + target: target, + prompt: 'hi', + workingDirectory: '/tmp', + model: '', + thinking: 'off', + selectedSkills: const [], + inlineAttachments: const [], + localAttachments: const [], + agentId: '', + metadata: const {}, + provider: provider, + ); +} + +Future _syncedControllerForBridgeEndpoint(Uri endpoint) async { + final storeRoot = await Directory.systemTemp.createTemp( + 'xworkmate-acp-auth-provider-endpoint-', + ); + addTearDown(() async { + if (await storeRoot.exists()) { + try { + await storeRoot.delete(recursive: true); + } on FileSystemException { + // Temp cleanup is best effort here. + } + } + }); + 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.saveAccountSyncState( + AccountSyncState.defaults().copyWith( + syncedDefaults: AccountRemoteProfile.defaults().copyWith( + bridgeServerUrl: endpoint.toString(), + ), + syncState: 'ready', + tokenConfigured: const AccountTokenConfigured( + bridge: true, + vault: false, + apisix: false, + ), + ), + ); + await store.saveAccountManagedSecret( + target: kAccountManagedSecretTargetBridgeAuthToken, + value: 'bridge-token', + ); + final controller = AppController(store: store); + await controller.settingsControllerInternal.initialize(); + return controller; +} + Future<_CapturedAcpHttpServer> _startAcpHttpServer() async { final server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0); final capture = _CapturedAcpHttpServer._( diff --git a/test/runtime/runtime_controllers_settings_account_test.dart b/test/runtime/runtime_controllers_settings_account_test.dart index a2694230..80b88d90 100644 --- a/test/runtime/runtime_controllers_settings_account_test.dart +++ b/test/runtime/runtime_controllers_settings_account_test.dart @@ -365,7 +365,7 @@ void main() { ); test( - 'synced bridge url stays metadata only while runtime uses the managed bridge endpoint', + 'synced bridge url becomes runtime endpoint only with a configured bridge token', () async { final storeRoot = await Directory.systemTemp.createTemp( 'xworkmate-account-managed-bridge-runtime-', @@ -394,6 +394,11 @@ void main() { syncedDefaults: AccountRemoteProfile.defaults().copyWith( bridgeServerUrl: 'https://xworkmate-bridge-alt.svc.plus', ), + tokenConfigured: const AccountTokenConfigured( + bridge: true, + vault: false, + apisix: false, + ), ), ); await store.saveAccountManagedSecret( @@ -407,17 +412,17 @@ void main() { expect( controller.resolveGatewayAcpEndpointInternal()?.toString(), - kManagedBridgeServerUrl, + 'https://xworkmate-bridge-alt.svc.plus', ); expect( await controller.resolveGatewayAcpAuthorizationHeaderInternal( - Uri.parse('$kManagedBridgeServerUrl/acp/rpc'), + Uri.parse('https://xworkmate-bridge-alt.svc.plus/acp/rpc'), ), 'bridge-token', ); expect( await controller.resolveGatewayAcpAuthorizationHeaderInternal( - Uri.parse('https://xworkmate-bridge-alt.svc.plus/acp/rpc'), + Uri.parse('$kManagedBridgeServerUrl/acp/rpc'), ), isNull, );