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:
parent
0c907a2a82
commit
d3da1505f6
@ -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.
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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 优化
|
||||
|
||||
@ -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=...`
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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,
|
||||
}) {
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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'], '离线');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@ -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'),
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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._(
|
||||
|
||||
@ -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,
|
||||
);
|
||||
|
||||
Loading…
Reference in New Issue
Block a user