refactor(bridge): enforce remote bridge execution and remove local ACP fallbacks

- Prioritize remote endpoints for svc.plus accounts.
- Remove legacy local go-core execution logic.
- Remove legacy local codex app-server launch logic.
- Update endpoint resolution to support provider-specific paths.
This commit is contained in:
Haitao Pan 2026-04-21 12:35:52 +08:00
parent 0c907a2a82
commit d3da1505f6
20 changed files with 568 additions and 432 deletions

View File

@ -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.

View File

@ -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 <token>` 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

View File

@ -50,7 +50,7 @@ graph TD
### 3.1 统一鉴权
所有通过 `xworkmate-bridge.svc.plus` 域名访问的请求(除 Caddy 内部 handle 外)均由 Caddy 强制校验:
- **Header**: `Authorization: Bearer ***REMOVED-CREDENTIAL***`
- **Header**: `Authorization: Bearer <bridge-auth-token>`
- **未授权响应**: `401 Unauthorized`
### 3.2 SSE / WebSocket 优化

View File

@ -205,7 +205,7 @@ flutter test test/features/assistant_page_suite.dart
- `https://accounts.svc.plus`
- `review@svc.plus`
- `***REMOVED-CREDENTIAL***`
- `<review-account-password>`
- managed bridge origin: `https://xworkmate-bridge.svc.plus`
- `BRIDGE_AUTH_TOKEN=...`

View File

@ -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<void> refreshMultiAgentMounts({bool sync = false}) =>
AppControllerDesktopThreadSessions(this).refreshMultiAgentMounts(sync: sync);
AppControllerDesktopThreadSessions(
this,
).refreshMultiAgentMounts(sync: sync);
double get assistantSkillCount => 0; // Legacy
int get currentAssistantSkillCount => 0; // Legacy

View File

@ -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) {

View File

@ -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,

View File

@ -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();

View File

@ -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,
);
}

View File

@ -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<Process> Function(
String executable,
List<String> arguments, {
Map<String, String>? 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<String> chat({
@ -44,18 +16,8 @@ class ArisLlmChatClient {
}) {
return _callTool(
toolName: 'chat',
environment: <String, String>{
...Platform.environment,
'LLM_API_KEY': apiKey,
'LLM_BASE_URL': endpoint,
'LLM_MODEL': model,
'LLM_SERVER_NAME': 'xworkmate-aris-llm-chat',
},
arguments: <String, dynamic>{
'prompt': prompt,
'model': model,
if (systemPrompt.trim().isNotEmpty) 'system': systemPrompt.trim(),
},
environment: <String, String>{},
arguments: <String, dynamic>{},
);
}
@ -67,19 +29,8 @@ class ArisLlmChatClient {
}) {
return _callTool(
toolName: 'claude_review',
environment: <String, String>{
...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: <String, dynamic>{
'prompt': prompt,
if (model.trim().isNotEmpty) 'model': model.trim(),
if (systemPrompt.trim().isNotEmpty) 'system': systemPrompt.trim(),
if (tools.trim().isNotEmpty) 'tools': tools.trim(),
},
environment: <String, String>{},
arguments: <String, dynamic>{},
);
}
@ -88,146 +39,9 @@ class ArisLlmChatClient {
required Map<String, String> environment,
required Map<String, dynamic> 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<String>();
final errorBuffer = StringBuffer();
late final StreamSubscription<String> stdoutSubscription;
late final StreamSubscription<String> stderrSubscription;
late final StreamSubscription<int> exitSubscription;
stdoutSubscription = process.stdout
.transform(utf8.decoder)
.transform(const LineSplitter())
.listen((line) {
if (line.trim().isEmpty) {
return;
}
late final Map<String, dynamic> message;
try {
message = jsonDecode(line) as Map<String, dynamic>;
} 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<String, dynamic>() ??
const <String, dynamic>{};
final content =
(result['content'] as List?)
?.whereType<Map>()
.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<String, dynamic>();
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(<String, dynamic>{
'jsonrpc': '2.0',
'id': 1,
'method': 'initialize',
'params': <String, dynamic>{},
});
send(<String, dynamic>{
'jsonrpc': '2.0',
'method': 'notifications/initialized',
'params': <String, dynamic>{},
});
send(<String, dynamic>{
'jsonrpc': '2.0',
'id': 2,
'method': 'tools/call',
'params': <String, dynamic>{'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);
}
}
}
}

View File

@ -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<void> startStdio({
required String codexPath,
String? cwd,
@ -354,49 +354,9 @@ class CodexRuntime extends ChangeNotifier {
CodexApprovalPolicy approval = CodexApprovalPolicy.suggest,
List<String> 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

View File

@ -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,
}) {

View File

@ -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) {

View File

@ -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<bool> 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<GoCoreLaunch?> locate() async => null;
Future<GoCoreLaunch?> 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<bool> isAvailable() async => await locate() != null;
List<String> _candidateRoots() {
final roots = <String>{};
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<String> _ancestorPaths(Directory start) {
final ancestors = <String>[];
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<bool> _binaryExists(String command) async =>
(_binaryExistsResolver?.call(command)) ?? File(command).exists();
/// Always returns false as local execution is disabled.
Future<bool> isAvailable() async => false;
}

View File

@ -322,23 +322,26 @@ Future<AccountSyncResult> 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<void> 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) {

View File

@ -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>[
SingleAgentProvider.codex,
],
initialGatewayProviderCatalog: const <SingleAgentProvider>[
SingleAgentProvider.openclaw,
],
initialAvailableExecutionTargets: const <AssistantExecutionTarget>[
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'], '离线');
});
});
}

View File

@ -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: <String, String>{
'BRIDGE_SERVER_URL': capture.baseEndpoint.toString(),
'BRIDGE_AUTH_TOKEN': 'bridge-token',
},
environmentOverride: <String, String>{},
);
addTearDown(controller.dispose);
await controller.sessionsController.switchSession('session-1');
await _waitForRequest(capture, minimumCount: 1);
await Future<void>.delayed(const Duration(milliseconds: 200));
expect(controller.assistantProviderCatalog, isEmpty);
@ -298,11 +303,54 @@ void main() {
await Future<void>.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>[
SingleAgentProvider.codex,
],
initialGatewayProviderCatalog: const <SingleAgentProvider>[
SingleAgentProvider.openclaw,
],
initialAvailableExecutionTargets: const <AssistantExecutionTarget>[
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<StateError>().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: <String, String>{
'BRIDGE_SERVER_URL': capture.baseEndpoint.toString(),
'BRIDGE_AUTH_TOKEN': 'bridge-token',
},
environmentOverride: <String, String>{},
initialAvailableExecutionTargets: const <AssistantExecutionTarget>[
AssistantExecutionTarget.agent,
AssistantExecutionTarget.gateway,
@ -359,7 +413,7 @@ void main() {
await controller.setAssistantExecutionTarget(
AssistantExecutionTarget.gateway,
);
await _waitForRequest(capture, minimumCount: 2);
await Future<void>.delayed(const Duration(milliseconds: 200));
await expectLater(
controller.sendChatMessage('hi'),

View File

@ -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 <String, String>{
@ -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(

View File

@ -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: <String, String>{
'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 <String>[],
inlineAttachments: const <GatewayChatAttachmentPayload>[],
localAttachments: const <CollaborationAttachment>[],
agentId: '',
metadata: const <String, dynamic>{},
provider: provider,
);
}
Future<AppController> _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._(

View File

@ -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,
);