Merge branch 'release/v1.1.4'

# Conflicts:
#	test/features/assistant/assistant_page_session_binding_test.dart
This commit is contained in:
Haitao Pan 2026-06-01 13:52:18 +08:00
commit 6d37812c10
26 changed files with 1631 additions and 158 deletions

View File

@ -7,6 +7,7 @@
- [核心功能集成测试手动 Case](./core-integration-manual-cases.md)
- [AI 安全演进内容生成场景测试用例](./ai-security-evolution-content-scenario/README.md)
- [云原生 Service Mesh 网络科普视频调研场景测试用例](./service-mesh-evolution-video-scenario/README.md)
- [OpenClaw Gateway 5 并发 E2E 回归场景](./openclaw-gateway-e2e-regression/README.md)
## 配套文档

View File

@ -80,6 +80,46 @@
- 测试连接结果
- 是否需要本机服务日志人工对照
### `MANUAL-ACP-004` 账号同步返回 `bridge_auth_token_unavailable`
- 前置条件
- 已登录 `svc.plus` 账号
- 账户侧能正常拉起 session
- 本地 App 可访问 `https://accounts.svc.plus`
- 典型现象
- 设置页顶部与账号面板显示 `xworkmate-bridge 连接失败`
- 账号同步状态为 `失败`
- 同步说明显示 `bridge auth token is unavailable`
- 详情信息里可见 `bridge_auth_token_unavailable`
- 排查步骤
1. 在 App 中触发账号同步或重新进入设置页
2. 直接请求 `GET /api/auth/xworkmate/profile/sync`
3. 确认是否返回 `409`
4. 检查本地 secure storage 中是否存在 `xworkmate.account.managed.bridge.auth_token`
5. 检查 `xworkmate-app``accountSyncState` 是否被写成 `blocked`
6. 回到 `accounts.svc.plus` 服务侧确认 shared bridge token 是否已注入 Vault 并可被读取
- 期望结论
- 若接口返回 `409 bridge_auth_token_unavailable`,问题不在 App 的 bridge 连接逻辑
- 根因在 `accounts.svc.plus` 的 shared XWorkmate bridge token 供给链路
- `BRIDGE_AUTH_TOKEN` 需要由 accounts 服务通过 Vault 读取并下发
- 关联同步规则
- 任务 workspace 需要递归同步全部子目录和文件
- `dist/账户与身份安全演进史-GPT混排最终版.pdf` 这类深层产物应能回传到当前线程 artifact
- 如 skill 或任务已有忽略清单,则中间产物可按清单排除同步,常见对象包括草稿、临时文件、版本号中间稿和可重复生成缓存
- 忽略清单建议以约定文件形式维护,例如 `.ignore.md`
- 恢复条件
- `GET /api/auth/xworkmate/profile/sync` 返回 `200`
- 响应包含 `BRIDGE_SERVER_URL``BRIDGE_AUTH_TOKEN`
- App 重新同步后,`accountSyncState.syncState` 变为 `ready`
- 本地 managed secret `bridge.auth_token` 被写入
- 建议记录项
- 登录账号
- `profile/sync` HTTP 状态码
- 响应错误码或成功字段
- 本地 `accountSyncState.syncState`
- `tokenConfigured.bridge` 是否为 `true`
- 是否已在 `accounts.svc.plus` 侧确认 Vault / bootstrap 配置
## 3. 本地执行型任务线程
### `MANUAL-LOCAL-001` `powerpoint-pptx`

View File

@ -0,0 +1,140 @@
# OpenClaw Gateway 5 并发 E2E 回归场景
这个 case 固化 5 个真实 OpenClaw Gateway 提示词,用于验证 XWorkmate App -> XWorkmate Bridge -> OpenClaw Gateway 的 5 并发稳定性、任务隔离和 artifact 同步。
## 覆盖目标
- 连续出图7 张连续风格 PNG。
- 模板出图:参考附件模板生成 7 张连续 PNG。
- PDF拆章节、逐章生成图、汇总排版并输出 PDF。
- 视频:围绕同一安全演进主线制作测试视频。
- 视频流水线:拆章节、逐章调用 Codex/GPT Images、汇总排版并制作视频。
## 自动化落点
| 仓库 | 文件 | 覆盖点 |
| --- | --- | --- |
| `xworkmate-bridge` | `internal/acp/web_contract_test.go` | `TestHTTPHandlerGatewayOpenClawHandlesFiveConcurrentE2ECases` 通过 HTTP SSE 同时提交 5 个 OpenClaw Gateway 请求,断言不出现 queued、invalid handshake、socket closed、ACP_HTTP_CONNECTION_CLOSED、GATEWAY_CONNECT_FAILED。 |
| `xworkmate-app` | `test/runtime/assistant_execution_target_test.dart` | `OpenClaw gateway admits five representative E2E tasks without queueing` 断言 App 侧 5 个代表任务同时进入 running复用各自 session/thread不进入 queued。 |
| `openclaw-multi-session-plugins` | `src/exportArtifacts.test.ts` | 同线程 `assets/images/**/*.png`、manifest、视频/PDF 交付物能被 export 到当前 task artifact scope不串到旧线程或旧 run。 |
## 5 个提示词
以下提示词按原始 E2E 输入记录,作为长期回归 case 的 canonical prompt。
### `OPENCLAW-E2E-001` 连续出图
```text
从单机权限 → 网络边界 → Web安全 → 云身份 → Zero Trust → AI Agent 身份 → AI模型与知识保护 演进
制作 使用codex 制作连续制作 7张的一些列图片
```
期望结果:
- 任务进入 running不因为 5 并发停在 pending/queued。
- 输出 7 张独立 PNG不合并成一张总览图。
- artifact 区显示当前任务本轮导出的 PNG 和 manifest。
### `OPENCLAW-E2E-002` 模板出图
```text
参考附件模版制作 ,围绕
从单机权限 → 网络边界 → Web安全 → 云身份 → Zero Trust → AI Agent 身份 → AI模型与知识保护 演进
连续制作 7张的一些列图片
```
期望结果:
- 任务复用当前线程 workspace 和附件上下文。
- 7 张图片保持模板风格一致。
- 当前任务 artifact 不展示旧线程文件。
### `OPENCLAW-E2E-003` PDF
```text
拆章节 -> 每章调用 Codex -> 每章 GPT images2 生成图 -> 汇总排版 -> 输出 PDF
右侧 artifact栏 显示的陈旧文件
```
期望结果:
- 每章图片素材和最终 PDF 归属当前 task scope。
- PDF 或相关素材出现在当前任务 artifact 区。
- 回归缺陷点:右侧 artifact 栏不能显示其他 run 或历史 workspace 的陈旧文件。
- 如果 OpenClaw 没有实际导出文件App 显示 no exported artifacts而不是旧文件。
### `OPENCLAW-E2E-004` 视频
```text
围绕
从单机权限 → 网络边界 → Web安全 → 云身份 → Zero Trust → AI Agent 身份 → AI模型与知识保护 演进 右侧是当下
测试制作视频
```
期望结果:
- 视频任务在 5 并发下不触发 `GATEWAY_CONNECT_FAILED: SOCKET_CLOSED`
- 输出视频帧、配置或 MP4 时artifact 只属于当前任务。
- 失败时释放 active slot 并继续 drain 后续任务。
### `OPENCLAW-E2E-005` 视频流水线
```text
围绕
从单机权限 → 网络边界 → Web安全 → 云身份 → Zero Trust → AI Agent 身份 → AI模型与知识保护 演进
拆章节 -> 每章调用 Codex -> 每章 GPT images2 生成图 -> 汇总排版 -> 制作视频
```
期望结果:
- 图片、manifest、视频配置、MP4/ffprobe 等产物按当前 run 隔离。
- Bridge 和 OpenClaw Gateway 只建立稳定连接,不重复并发握手。
- 不出现 `invalid handshake: first request must be connect`、`SOCKET_CLOSED`、`ACP_HTTP_CONNECTION_CLOSED`。
## 手动验收步骤
前置条件:
- `/Applications/XWorkmate.app` 已安装当前 release。
- 已登录并同步 managed bridge。
- 任务模式选择 `Gateway`provider 选择 `OpenClaw`
- `xworkmate-bridge.svc.plus` 和远端 OpenClaw Gateway 可用。
操作步骤:
1. 新建 5 个任务线程,分别输入上面的 5 个提示词。
2. 在短时间内连续提交,保持 `maxActive = 5`、`maxQueued = 20`。
3. 观察左侧任务列表5 个任务应进入 running 或完成态,不应全部 pending。
4. 逐个打开任务,检查中心消息和右侧 artifact 区。
5. 对任意 running 任务继续提交一句补充要求,确认续聊仍落在当前任务而不是新 draft。
验收标准:
- 5 个任务不出现全等待不运行。
- 不出现 `invalid handshake: first request must be connect`
- 不出现 `GATEWAY_CONNECT_FAILED: SOCKET_CLOSED: socket closed`
- 不出现 `ACP_HTTP_CONNECTION_CLOSED`
- 当前任务没有 artifact 时显示明确空态,不显示旧 run 文件。
- 当前任务生成 PNG/PDF/视频文件时,右侧 artifact 自动同步并只显示当前任务本轮文件。
## 回归命令
```bash
# xworkmate-bridge
go test ./...
# openclaw-multi-session-plugins
pnpm test
pnpm typecheck
pnpm pack:check
# xworkmate-app
flutter analyze
flutter test test/runtime/assistant_execution_target_test.dart
flutter test test/runtime/gateway_acp_client_auth_test.dart
flutter test test/runtime/desktop_thread_artifact_service_test.dart
flutter test
```

View File

@ -29,8 +29,9 @@ Last Updated: 2026-04-22
两份脚本都依赖以下环境变量:
- `REVIEW_ACCOUNT_LOGIN_PASSWORD`
- `BRIDGE_AUTH_TOKEN`
- 可选 `BRIDGE_AUTH_TOKEN`,未提供时使用 profile sync 返回的 token
- 可选 `BRIDGE_SERVER_URL`
- 可选 `BRIDGE_SERVER_URLS`,用于接口脚本同时验证多个 bridge host
- 可选 `REVIEW_ACCOUNT_BASE_URL`
推荐直接在命令前临时注入:
@ -42,6 +43,14 @@ BRIDGE_SERVER_URL='https://xworkmate-bridge.svc.plus' \
bash scripts/ci/verify_api_interface_contract.sh
```
双入口验证示例:
```bash
REVIEW_ACCOUNT_LOGIN_PASSWORD='***REMOVED-CREDENTIAL***' \
BRIDGE_SERVER_URLS='https://xworkmate-bridge.svc.plus,https://cn-xworkmate-bridge.svc.plus' \
bash scripts/ci/verify_api_interface_contract.sh
```
## 3. 默认校验入口
推荐使用 `Makefile` 目标:
@ -64,6 +73,7 @@ make check-api-external
- `POST /api/auth/login`
- `GET /api/auth/session`
- `GET /api/auth/xworkmate/profile/sync`
- `GET /api/ping`
- `POST /acp/rpc` with `acp.capabilities`
- `POST /acp/rpc` with `xworkmate.routing.resolve`

View File

@ -604,12 +604,14 @@ class AppController extends ChangeNotifier {
Future<void> sendChatMessage(
String message, {
String? sessionKey,
String thinking = 'off',
List<GatewayChatAttachmentPayload> attachments = const [],
List<CollaborationAttachment> localAttachments = const [],
List<String> selectedSkillLabels = const [],
}) => AppControllerDesktopThreadActions(this).sendChatMessage(
message,
sessionKey: sessionKey,
thinking: thinking,
attachments: attachments,
localAttachments: localAttachments,

View File

@ -777,7 +777,6 @@ extension AppControllerDesktopRuntimeHelpers on AppController {
var wroteArtifact = false;
var failedArtifact = false;
var skippedArtifact = false;
final currentTaskArtifactRelativePaths = <String>[];
for (final artifact in artifacts) {
final relativePath = _sanitizeArtifactRelativePathInternal(
artifact.relativePath,
@ -792,7 +791,13 @@ extension AppControllerDesktopRuntimeHelpers on AppController {
}
final bytes = bytesResult.bytes;
if (bytes == null) {
skippedArtifact = true;
final existingArtifactPaths =
await _existingWorkspaceArtifactPathsInternal(root, relativePath);
if (existingArtifactPaths.isEmpty) {
skippedArtifact = true;
continue;
}
wroteArtifact = true;
continue;
}
final target = await _nextArtifactTargetFileInternal(root, relativePath);
@ -807,14 +812,6 @@ extension AppControllerDesktopRuntimeHelpers on AppController {
continue;
}
wroteArtifact = true;
final writtenRelativePath =
DesktopThreadArtifactService.relativePathInternal(
root.path,
target.path,
);
if (writtenRelativePath != null && writtenRelativePath.isNotEmpty) {
currentTaskArtifactRelativePaths.add(writtenRelativePath);
}
}
final syncStatus = wroteArtifact
@ -822,6 +819,9 @@ extension AppControllerDesktopRuntimeHelpers on AppController {
: failedArtifact
? 'download-failed'
: 'no-artifacts';
final currentTaskArtifactRelativePaths = wroteArtifact
? await _collectWorkspaceArtifactRelativePathsInternal(root)
: const <String>[];
upsertTaskThreadInternal(
normalizedSessionKey,
lastArtifactSyncAtMs: syncedAtMs,
@ -1016,6 +1016,18 @@ extension AppControllerDesktopRuntimeHelpers on AppController {
}
Uri? resolveBridgeAcpEndpointInternal() {
final selfHosted = settingsControllerInternal
.snapshot
.acpBridgeServerModeConfig
.selfHosted;
final selfHostedUrl = selfHosted.serverUrl.trim();
if (selfHosted.isConfigured && selfHostedUrl.isNotEmpty) {
final uri = Uri.tryParse(selfHostedUrl);
if (uri != null && uri.hasScheme && uri.host.trim().isNotEmpty) {
return uri.replace(query: null, fragment: null);
}
}
final uri = Uri.parse(kManagedBridgeServerUrl);
return uri.replace(query: null, fragment: null);
}
@ -1029,11 +1041,22 @@ extension AppControllerDesktopRuntimeHelpers on AppController {
if (bridgeEndpoint == null) {
return false;
}
final selfHosted = settingsControllerInternal
.snapshot
.acpBridgeServerModeConfig
.selfHosted;
if (selfHosted.isConfigured) {
return true;
}
final accountSyncState = settingsControllerInternal.accountSyncState;
if (settingsControllerInternal.accountSignedIn &&
accountSyncState?.syncState.trim().toLowerCase() == 'ready' &&
accountSyncState?.tokenConfigured.bridge == true) {
return true;
}
if (settingsControllerInternal.accountSignedIn) {
return false;
}
final envToken = runtimeEnvironmentValueInternal('BRIDGE_AUTH_TOKEN');
return envToken != null && envToken.isNotEmpty;
}
@ -1072,6 +1095,10 @@ extension AppControllerDesktopRuntimeHelpers on AppController {
normalizedHost == bridgeHost &&
(bridgePort <= 0 || endpoint.port == bridgePort);
if (matchesBridgeEndpoint) {
final manualBridgeToken = await _resolveManualBridgeAuthTokenInternal();
if (manualBridgeToken != null && manualBridgeToken.isNotEmpty) {
return manualBridgeToken;
}
final bridgeToken = await _resolveManagedBridgeAuthTokenInternal();
if (bridgeToken != null && bridgeToken.isNotEmpty) {
return bridgeToken;
@ -1090,6 +1117,10 @@ extension AppControllerDesktopRuntimeHelpers on AppController {
return null;
}
final manualBridgeToken = await _resolveManualBridgeAuthTokenInternal();
if (manualBridgeToken != null && manualBridgeToken.isNotEmpty) {
return _normalizeAuthorizationHeaderInternal(manualBridgeToken);
}
final bridgeToken = await _resolveManagedBridgeAuthTokenInternal();
if (bridgeToken != null && bridgeToken.isNotEmpty) {
return _normalizeAuthorizationHeaderInternal(bridgeToken);
@ -1097,15 +1128,37 @@ extension AppControllerDesktopRuntimeHelpers on AppController {
return null;
}
Future<String?> _resolveManualBridgeAuthTokenInternal() async {
final selfHosted = settingsControllerInternal
.snapshot
.acpBridgeServerModeConfig
.selfHosted;
if (!selfHosted.isConfigured) {
return null;
}
final passwordRef = selfHosted.passwordRef.trim();
if (passwordRef.isEmpty) {
return null;
}
final token = (await storeInternal.loadSecretValueByRef(
passwordRef,
))?.trim();
return token?.isNotEmpty == true ? token : null;
}
Future<String?> _resolveManagedBridgeAuthTokenInternal() async {
final accountSyncState = settingsControllerInternal.accountSyncState;
if (settingsControllerInternal.accountSignedIn &&
accountSyncState?.syncState.trim().toLowerCase() == 'ready' &&
accountSyncState?.tokenConfigured.bridge == true) {
final bridgeToken = (await storeInternal.loadAccountManagedSecret(
target: kAccountManagedSecretTargetBridgeAuthToken,
))?.trim();
return bridgeToken?.isNotEmpty == true ? bridgeToken : null;
}
if (settingsControllerInternal.accountSignedIn) {
return null;
}
final envToken = runtimeEnvironmentValueInternal('BRIDGE_AUTH_TOKEN');
return envToken?.isNotEmpty == true ? envToken : null;
@ -1145,6 +1198,62 @@ extension AppControllerDesktopRuntimeHelpers on AppController {
) => kGatewayRemoteProfileIndex;
}
Future<List<String>> _existingWorkspaceArtifactPathsInternal(
Directory root,
String relativePath,
) async {
final targetPath = DesktopThreadArtifactService.resolveAbsolutePathInternal(
root.path,
relativePath,
);
final targetType = await FileSystemEntity.type(
targetPath,
followLinks: false,
);
if (targetType == FileSystemEntityType.file) {
final resolvedRelativePath =
DesktopThreadArtifactService.relativePathInternal(
root.path,
targetPath,
);
return resolvedRelativePath == null || resolvedRelativePath.isEmpty
? const <String>[]
: <String>[resolvedRelativePath];
}
if (targetType != FileSystemEntityType.directory) {
return const <String>[];
}
final files = await DesktopThreadArtifactService().collectFilesInternal(
Directory(targetPath),
);
final paths = <String>[];
for (final file in files) {
final resolvedRelativePath =
DesktopThreadArtifactService.relativePathInternal(root.path, file.path);
if (resolvedRelativePath != null && resolvedRelativePath.isNotEmpty) {
paths.add(resolvedRelativePath);
}
}
paths.sort();
return paths;
}
Future<List<String>> _collectWorkspaceArtifactRelativePathsInternal(
Directory root,
) async {
final files = await DesktopThreadArtifactService().collectFilesInternal(root);
final paths = <String>[];
for (final file in files) {
final resolvedRelativePath =
DesktopThreadArtifactService.relativePathInternal(root.path, file.path);
if (resolvedRelativePath != null && resolvedRelativePath.isNotEmpty) {
paths.add(resolvedRelativePath);
}
}
paths.sort();
return paths;
}
String _normalizeAuthorizationHeaderInternal(String raw) {
final trimmed = raw.trim();
if (trimmed.isEmpty) {

View File

@ -232,6 +232,7 @@ extension AppControllerDesktopThreadActions on AppController {
Future<void> sendChatMessage(
String message, {
String? sessionKey,
String thinking = 'off',
List<GatewayChatAttachmentPayload> attachments =
const <GatewayChatAttachmentPayload>[],
@ -239,20 +240,28 @@ extension AppControllerDesktopThreadActions on AppController {
const <CollaborationAttachment>[],
List<String> selectedSkillLabels = const <String>[],
}) async {
var sessionKey = normalizedAssistantSessionKeyInternal(
sessionsControllerInternal.currentSessionKey,
var targetSessionKey = normalizedAssistantSessionKeyInternal(
sessionKey ?? sessionsControllerInternal.currentSessionKey,
);
if (!isAppOwnedAssistantSessionKeyInternal(sessionKey)) {
if (!isAppOwnedAssistantSessionKeyInternal(targetSessionKey)) {
if (sessionKey != null && sessionKey.trim().isNotEmpty) {
throw StateError(
appText(
'提交目标会话无效,请重新选择任务后提交。',
'The submit target session is invalid. Select the task again before submitting.',
),
);
}
await ensureActiveAssistantThreadInternal();
sessionKey = normalizedAssistantSessionKeyInternal(
targetSessionKey = normalizedAssistantSessionKeyInternal(
sessionsControllerInternal.currentSessionKey,
);
}
final resumeSessionHint = shouldResumeGatewaySessionForNextSendInternal(
sessionKey,
targetSessionKey,
);
await dispatchGatewayChatTurnInternal(
sessionKey: sessionKey,
sessionKey: targetSessionKey,
message: message,
thinking: thinking,
attachments: attachments,
@ -452,9 +461,13 @@ extension AppControllerDesktopThreadActions on AppController {
localAttachments,
);
final taskLoadClass = classifyGatewayTaskLoadInternal(message);
final expectedArtifactExtensions =
expectedGatewayArtifactExtensionsInternal(message);
final taskMetadata = Map<String, dynamic>.unmodifiable(<String, dynamic>{
...dispatch.metadata,
'taskLoadClass': taskLoadClass,
if (expectedArtifactExtensions.isNotEmpty)
'expectedArtifactExtensions': expectedArtifactExtensions,
});
final executionWorkingDirectory = gatewayExecutionWorkingDirectoryInternal(
target: currentTarget,
@ -755,6 +768,42 @@ extension AppControllerDesktopThreadActions on AppController {
return 'short_task';
}
List<String> expectedGatewayArtifactExtensionsInternal(String requestText) {
final normalized = requestText.trim().toLowerCase();
final result = <String>[];
void add(String value) {
final normalizedValue = value.trim().toLowerCase().replaceFirst(
RegExp(r'^\.'),
'',
);
if (normalizedValue.isEmpty || result.contains(normalizedValue)) {
return;
}
result.add(normalizedValue);
}
for (final match in RegExp(
r'\.([a-z0-9]{2,5})\b',
caseSensitive: false,
).allMatches(normalized)) {
add(match.group(1) ?? '');
}
for (final match in RegExp(
r'\b([a-z0-9]{2,5})\s*(?:格式|文件|产物|artifact|file|output)',
caseSensitive: false,
).allMatches(normalized)) {
add(match.group(1) ?? '');
}
for (final match in RegExp(
r'(?:输出|导出|生成|制作)\s*([a-z0-9]{2,5})',
caseSensitive: false,
).allMatches(normalized)) {
add(match.group(1) ?? '');
}
return List<String>.unmodifiable(result);
}
bool usesOpenClawGatewayQueueInternal(
AssistantExecutionTarget target,
SingleAgentProvider provider,

View File

@ -85,11 +85,6 @@ extension AssistantPageStateActionsInternal on AssistantPageStateInternal {
if (rawPrompt.isEmpty) {
return;
}
if (controller.assistantSessionHasPendingRun(
controller.currentSessionKey,
)) {
await createNewThreadInternal();
}
final submittedSessionKey = controller.currentSessionKey;
final autoAgent = pickAutoAgentInternal(controller, rawPrompt);
@ -134,10 +129,10 @@ extension AssistantPageStateActionsInternal on AssistantPageStateInternal {
autoAgent?.name ?? conversationOwnerLabelInternal(controller);
attachmentsInternal = const <ComposerAttachmentInternal>[];
touchTaskSeedInternal(
sessionKey: controller.currentSessionKey,
sessionKey: submittedSessionKey,
title:
taskSeedsInternal[controller.currentSessionKey]?.title ??
fallbackSessionTitleInternal(controller.currentSessionKey),
taskSeedsInternal[submittedSessionKey]?.title ??
fallbackSessionTitleInternal(submittedSessionKey),
preview: rawPrompt,
status: controller.hasAssistantPendingRun || connectionState.connected
? 'running'
@ -145,7 +140,7 @@ extension AssistantPageStateActionsInternal on AssistantPageStateInternal {
owner: autoAgent?.name ?? conversationOwnerLabelInternal(controller),
surface: 'Assistant',
executionTarget: executionTarget,
draft: controller.currentSessionKey.trim().startsWith('draft:'),
draft: submittedSessionKey.trim().startsWith('draft:'),
);
});
inputControllerInternal.clear();
@ -153,6 +148,7 @@ extension AssistantPageStateActionsInternal on AssistantPageStateInternal {
try {
await controller.sendChatMessage(
prompt,
sessionKey: submittedSessionKey,
thinking: thinkingLabelInternal,
attachments: attachmentPayloads,
localAttachments: submittedAttachments
@ -171,7 +167,12 @@ extension AssistantPageStateActionsInternal on AssistantPageStateInternal {
if (!mounted) {
rethrow;
}
if (inputControllerInternal.text.trim().isEmpty) {
if (!sessionKeysMatchInternal(
widget.controller.currentSessionKey,
submittedSessionKey,
)) {
composerDraftBySessionKeyInternal[submittedSessionKey] = rawPrompt;
} else if (inputControllerInternal.text.trim().isEmpty) {
inputControllerInternal.value = TextEditingValue(
text: rawPrompt,
selection: TextSelection.collapsed(offset: rawPrompt.length),

View File

@ -104,38 +104,27 @@ class _MobileSettingsPageState extends State<MobileSettingsPage> {
required bool isManualBridge,
bool refreshAfterSave = true,
}) async {
final bridgeConfig = settings.acpBridgeServerModeConfig;
final nextBridgeConfig = bridgeConfig.copyWith(
selfHosted: bridgeConfig.selfHosted.copyWith(
serverUrl: bridgeUrlController.text.trim(),
username: isManualBridge ? 'admin' : bridgeConfig.selfHosted.username,
),
);
final nextEffective = widget.controller.settingsController
.resolveAcpBridgeServerEffectiveConfig(config: nextBridgeConfig);
final nextSettings = settings.copyWith(
accountBaseUrl: accountBaseUrlController.text.trim(),
accountUsername: accountIdentifierController.text.trim(),
acpBridgeServerModeConfig: nextBridgeConfig.copyWith(
effective: nextEffective,
),
);
if (isManualBridge && bridgeTokenController.text.isNotEmpty) {
await widget.controller.settingsController.saveSecretValueByRef(
nextSettings.acpBridgeServerModeConfig.selfHosted.passwordRef,
bridgeTokenController.text,
provider: 'Bridge',
module: 'Manual',
);
}
final nextSettings = await widget.controller.settingsController
.buildSavedAccountProfileSettings(
settings: settings,
accountBaseUrl: accountBaseUrlController.text,
accountIdentifier: accountIdentifierController.text,
bridgeServerUrl: bridgeUrlController.text,
bridgeToken: bridgeTokenController.text,
isManualBridge: isManualBridge,
);
await widget.controller.saveSettings(
nextSettings,
refreshAfterSave: refreshAfterSave,
refreshAfterSave: isManualBridge ? false : refreshAfterSave,
);
lastSavedAccountBaseUrl = nextSettings.accountBaseUrl;
lastSavedAccountIdentifier = nextSettings.accountUsername;
lastSavedBridgeUrl =
nextSettings.acpBridgeServerModeConfig.selfHosted.serverUrl;
if (isManualBridge &&
nextSettings.acpBridgeServerModeConfig.selfHosted.isConfigured) {
unawaited(refreshBridgeCapabilities());
}
}
Future<void> loginAccount(SettingsSnapshot settings) async {
@ -245,16 +234,25 @@ class _MobileSettingsPageState extends State<MobileSettingsPage> {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
GestureDetector(
onTap: () => controller.navigateTo(WorkspaceDestination.assistant),
onTap: () => controller.navigateTo(
WorkspaceDestination.assistant,
),
behavior: HitTestBehavior.opaque,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.arrow_back_ios_new_rounded, size: 16, color: palette.textSecondary),
Icon(
Icons.arrow_back_ios_new_rounded,
size: 16,
color: palette.textSecondary,
),
const SizedBox(width: 6),
Text(
appText('返回对话主页', 'Back to Chat'),
style: TextStyle(color: palette.textSecondary, fontSize: 16),
style: TextStyle(
color: palette.textSecondary,
fontSize: 16,
),
),
],
),
@ -262,9 +260,8 @@ class _MobileSettingsPageState extends State<MobileSettingsPage> {
const SizedBox(height: 24),
Text(
appText('设置', 'Settings'),
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.bold,
),
style: Theme.of(context).textTheme.headlineSmall
?.copyWith(fontWeight: FontWeight.bold),
),
const SizedBox(height: 12),
if (availableTabs.length > 1) ...[

View File

@ -101,9 +101,6 @@ class _SettingsAccountPanelState extends State<SettingsAccountPanel>
Tab(text: appText('svc.plus 云端同步', 'svc.plus Cloud Sync')),
Tab(text: appText('手动 Bridge 配置', 'Manual Bridge Config')),
],
onTap: (index) {
widget.onSaveAccountProfile(isManualBridge: index == 1);
},
),
const SizedBox(height: 24),
SizedBox(

View File

@ -58,6 +58,17 @@ Future<Map<String, dynamic>> loadBridgeMetadataForSettingsAbout({
.decodeStream(response)
.timeout(const Duration(seconds: 4));
if (response.statusCode < 200 || response.statusCode >= 300) {
if (response.statusCode == HttpStatus.unauthorized ||
response.statusCode == HttpStatus.forbidden) {
return const <String, dynamic>{
'status': 'unauthorized',
'message': 'Bridge authorization rejected',
'version': '',
'commit': '',
'image': '',
'buildDate': '',
};
}
return const <String, dynamic>{
'status': 'unavailable',
'version': '',
@ -209,38 +220,29 @@ class _SettingsPageState extends State<SettingsPage> {
SettingsSnapshot settings, {
required bool isManualBridge,
}) async {
final bridgeConfig = settings.acpBridgeServerModeConfig;
var nextBridgeConfig = bridgeConfig.copyWith(
selfHosted: bridgeConfig.selfHosted.copyWith(
serverUrl: _bridgeUrlController.text.trim(),
username: isManualBridge ? 'admin' : bridgeConfig.selfHosted.username,
),
final nextSettings = await widget.controller.settingsController
.buildSavedAccountProfileSettings(
settings: settings,
accountBaseUrl: _accountBaseUrlController.text,
accountIdentifier: _accountIdentifierController.text,
bridgeServerUrl: _bridgeUrlController.text,
bridgeToken: _bridgeTokenController.text,
isManualBridge: isManualBridge,
);
await widget.controller.saveSettings(
nextSettings,
refreshAfterSave: !isManualBridge,
);
final nextEffective = widget.controller.settingsController
.resolveAcpBridgeServerEffectiveConfig(config: nextBridgeConfig);
final nextSettings = settings.copyWith(
accountBaseUrl: _accountBaseUrlController.text.trim(),
accountUsername: _accountIdentifierController.text.trim(),
acpBridgeServerModeConfig: nextBridgeConfig.copyWith(
effective: nextEffective,
),
);
if (isManualBridge && _bridgeTokenController.text.isNotEmpty) {
await widget.controller.settingsController.saveSecretValueByRef(
nextSettings.acpBridgeServerModeConfig.selfHosted.passwordRef,
_bridgeTokenController.text,
provider: 'Bridge',
module: 'Manual',
);
}
await widget.controller.saveSettings(nextSettings);
_lastSavedAccountBaseUrl = nextSettings.accountBaseUrl;
_lastSavedAccountIdentifier = nextSettings.accountUsername;
_lastSavedBridgeUrl =
nextSettings.acpBridgeServerModeConfig.selfHosted.serverUrl;
if (isManualBridge &&
nextSettings.acpBridgeServerModeConfig.selfHosted.isConfigured) {
unawaited(_refreshBridgeCapabilities());
await _refreshAboutSnapshot();
}
}
Future<void> _loginAccount(SettingsSnapshot settings) async {
@ -254,6 +256,7 @@ class _SettingsPageState extends State<SettingsPage> {
password: _accountPasswordController.text,
);
await _refreshBridgeCapabilities();
await _verifyAccountBridgeRuntimeAccess();
} finally {
_accountPasswordController.clear();
}
@ -261,11 +264,14 @@ class _SettingsPageState extends State<SettingsPage> {
Future<void> _syncAccount(SettingsSnapshot settings) async {
await _persistAccountProfileSettings(settings, isManualBridge: false);
await widget.controller.settingsController.syncAccountSettings(
baseUrl: _accountBaseUrlController.text.trim(),
);
final result = await widget.controller.settingsController
.syncAccountSettings(baseUrl: _accountBaseUrlController.text.trim());
await _refreshBridgeCapabilities();
await _refreshAboutSnapshot();
if (result.state == 'ready') {
await _verifyAccountBridgeRuntimeAccess();
} else {
await _refreshAboutSnapshot();
}
}
Future<void> _verifyAccountMfa(SettingsSnapshot settings) async {
@ -276,6 +282,7 @@ class _SettingsPageState extends State<SettingsPage> {
code: _accountMfaCodeController.text.trim(),
);
await _refreshBridgeCapabilities();
await _verifyAccountBridgeRuntimeAccess();
} finally {
_accountMfaCodeController.clear();
}
@ -337,13 +344,16 @@ class _SettingsPageState extends State<SettingsPage> {
}
Future<SettingsAboutSnapshot> _loadAboutSnapshot() async {
final bridgeMetadata = await _loadBridgeMetadata();
final bridgeEndpoint =
widget.controller.resolveGatewayAcpEndpointInternal() ??
Uri.parse(kManagedBridgeServerUrl);
final bridgeMetadata = await _loadBridgeMetadata(bridgeEndpoint);
return SettingsAboutSnapshot(
appVersion: kAppVersion,
appBuildNumber: kAppBuildNumber,
appBuildDate: kAppBuildDate,
appCommit: kAppBuildCommit,
bridgeEndpoint: kManagedBridgeServerUrl,
bridgeEndpoint: bridgeEndpoint.toString(),
bridgeStatus: _stringValue(bridgeMetadata['status']),
bridgeVersion: _resolveBridgeVersion(bridgeMetadata),
bridgeBuildDate: _resolveBridgeBuildDate(bridgeMetadata),
@ -352,14 +362,69 @@ class _SettingsPageState extends State<SettingsPage> {
);
}
Future<Map<String, dynamic>> _loadBridgeMetadata() async {
Future<Map<String, dynamic>> _loadBridgeMetadata(Uri bridgeEndpoint) async {
return loadBridgeMetadataForSettingsAbout(
bridgeEndpoint: Uri.parse(kManagedBridgeServerUrl),
bridgeEndpoint: bridgeEndpoint,
authorizationResolver:
widget.controller.resolveGatewayAcpAuthorizationHeaderInternal,
);
}
Future<void> _verifyAccountBridgeRuntimeAccess() async {
if (!widget.controller.settingsController.accountSignedIn) {
await _refreshAboutSnapshot();
return;
}
final bridgeEndpoint =
widget.controller.resolveGatewayAcpEndpointInternal() ??
Uri.parse(kManagedBridgeServerUrl);
final bridgeMetadata = await _loadBridgeMetadata(bridgeEndpoint);
final status = _stringValue(bridgeMetadata['status']).toLowerCase();
if (status == 'ok') {
if (mounted) {
setState(() {
_aboutSnapshot = _aboutSnapshotFromMetadata(
bridgeEndpoint,
bridgeMetadata,
);
_aboutBusy = false;
});
}
return;
}
if (status == 'unauthorized') {
await widget.controller.settingsController
.markAccountBridgeRuntimeUnavailable('Bridge authorization rejected');
}
if (mounted) {
setState(() {
_aboutSnapshot = _aboutSnapshotFromMetadata(
bridgeEndpoint,
bridgeMetadata,
);
_aboutBusy = false;
});
}
}
SettingsAboutSnapshot _aboutSnapshotFromMetadata(
Uri bridgeEndpoint,
Map<String, dynamic> bridgeMetadata,
) {
return SettingsAboutSnapshot(
appVersion: kAppVersion,
appBuildNumber: kAppBuildNumber,
appBuildDate: kAppBuildDate,
appCommit: kAppBuildCommit,
bridgeEndpoint: bridgeEndpoint.toString(),
bridgeStatus: _stringValue(bridgeMetadata['status']),
bridgeVersion: _resolveBridgeVersion(bridgeMetadata),
bridgeBuildDate: _resolveBridgeBuildDate(bridgeMetadata),
bridgeCommit: _stringValue(bridgeMetadata['commit']),
bridgeImage: _stringValue(bridgeMetadata['image']),
);
}
@override
Widget build(BuildContext context) {
final controller = widget.controller;

View File

@ -60,7 +60,9 @@ class DesktopThreadArtifactService {
final taskArtifactPaths = normalizeTaskArtifactPathsInternal(
artifactRelativePaths,
);
final allFiles = await collectFilesInternal(root);
final allFiles = taskArtifactPaths.isEmpty
? const <File>[]
: await collectFilesInternal(root);
final fileEntries = await buildEntriesInternal(allFiles, normalizedRef);
final taskFiles = taskArtifactPaths.isEmpty
? const <File>[]
@ -91,7 +93,9 @@ class DesktopThreadArtifactService {
? 'No task artifacts recorded for this run.'
: 'No current task artifacts found. Showing all files for this thread.'
: '';
final filesMessage = fileEntries.isEmpty
final filesMessage = taskArtifactPaths.isEmpty
? ''
: fileEntries.isEmpty
? 'No files found in the recorded working directory.'
: '';
final changesMessage = changes.isEmpty

View File

@ -107,6 +107,10 @@ extension SettingsControllerAccountExtension on SettingsController {
Future<AccountSyncResult> syncAccountManagedSecrets({String baseUrl = ''}) =>
syncAccountSettings(baseUrl: baseUrl);
Future<AccountSyncResult> markAccountBridgeRuntimeUnavailable(
String message,
) => markAccountBridgeRuntimeUnavailableInternal(this, message: message);
Future<void> logoutAccount() => logoutAccountSettingsInternal(this);
Future<void> cancelAccountMfaChallenge() =>
@ -116,6 +120,23 @@ extension SettingsControllerAccountExtension on SettingsController {
required AcpBridgeServerModeConfig config,
}) => resolveAcpBridgeServerEffectiveConfigInternal(this, config: config);
Future<SettingsSnapshot> buildSavedAccountProfileSettings({
required SettingsSnapshot settings,
required String accountBaseUrl,
required String accountIdentifier,
required String bridgeServerUrl,
required String bridgeToken,
required bool isManualBridge,
}) => buildSavedAccountProfileSettingsInternal(
this,
settings: settings,
accountBaseUrl: accountBaseUrl,
accountIdentifier: accountIdentifier,
bridgeServerUrl: bridgeServerUrl,
bridgeToken: bridgeToken,
isManualBridge: isManualBridge,
);
List<SecretReferenceEntry> buildSecretReferences() {
final entries = <SecretReferenceEntry>[
...secureRefsInternal.entries.map(

View File

@ -423,6 +423,24 @@ Future<void> logoutAccountSettingsInternal(
}
}
Future<AccountSyncResult> markAccountBridgeRuntimeUnavailableInternal(
SettingsController controller, {
required String message,
}) async {
final current = controller.accountSyncStateInternal;
final nextState = (current ?? AccountSyncState.defaults()).copyWith(
syncState: 'blocked',
syncMessage: message,
lastSyncAtMs: DateTime.now().millisecondsSinceEpoch,
lastSyncError: message,
profileScope: 'bridge',
);
await _persistAccountSyncStateInternal(controller, nextState);
controller.accountStatusInternal = message;
controller.notifyListeners();
return AccountSyncResult(state: 'blocked', message: message);
}
Future<void> cancelAccountMfaChallengeSettingsInternal(
SettingsController controller,
) async {
@ -594,6 +612,47 @@ AcpBridgeServerEffectiveConfig resolveAcpBridgeServerEffectiveConfigInternal(
);
}
Future<SettingsSnapshot> buildSavedAccountProfileSettingsInternal(
SettingsController controller, {
required SettingsSnapshot settings,
required String accountBaseUrl,
required String accountIdentifier,
required String bridgeServerUrl,
required String bridgeToken,
required bool isManualBridge,
}) async {
final bridgeConfig = settings.acpBridgeServerModeConfig;
final nextBridgeConfig = bridgeConfig.copyWith(
selfHosted: isManualBridge
? bridgeConfig.selfHosted.copyWith(
serverUrl: bridgeServerUrl.trim(),
username: 'admin',
)
: bridgeConfig.selfHosted,
);
final nextEffective = resolveAcpBridgeServerEffectiveConfigInternal(
controller,
config: nextBridgeConfig,
);
final nextSettings = settings.copyWith(
accountBaseUrl: accountBaseUrl.trim(),
accountUsername: accountIdentifier.trim(),
acpBridgeServerModeConfig: nextBridgeConfig.copyWith(
effective: nextEffective,
),
);
final trimmedBridgeToken = bridgeToken.trim();
if (isManualBridge && trimmedBridgeToken.isNotEmpty) {
await controller.saveSecretValueByRef(
nextSettings.acpBridgeServerModeConfig.selfHosted.passwordRef,
trimmedBridgeToken,
provider: 'Bridge',
module: 'Manual',
);
}
return nextSettings;
}
int _parseExpiresAtMs(Object? value) {
if (value is int) {
return value;

View File

@ -2,9 +2,9 @@ name: xworkmate
description: "XWorkmate desktop-first AI workspace shell."
publish_to: 'none'
version: 1.1.3+1
build-date: 2026-05-28
build-id: 82d46f5
version: 1.1.4
build-date: 2026-05-30
build-id: 94405c9
environment:
sdk: ^3.11.0

View File

@ -5,6 +5,7 @@ ACCOUNTS_BASE_URL="${REVIEW_ACCOUNT_BASE_URL:-https://accounts.svc.plus}"
REVIEW_ACCOUNT_LOGIN_NAME="${REVIEW_ACCOUNT_LOGIN_NAME:-review@svc.plus}"
REVIEW_ACCOUNT_LOGIN_PASSWORD="${REVIEW_ACCOUNT_LOGIN_PASSWORD:-}"
BRIDGE_SERVER_URL="${BRIDGE_SERVER_URL:-}"
BRIDGE_SERVER_URLS="${BRIDGE_SERVER_URLS:-}"
BRIDGE_AUTH_TOKEN="${BRIDGE_AUTH_TOKEN:-}"
HTTP_TIMEOUT_SECONDS="${HTTP_TIMEOUT_SECONDS:-30}"
@ -181,10 +182,8 @@ if not bridge_token:
raise SystemExit("sync response did not include BRIDGE_AUTH_TOKEN")
PY
bridge_server_url="${BRIDGE_SERVER_URL}"
if [[ -z "${bridge_server_url}" ]]; then
bridge_server_url="$(
RESPONSE_JSON="${sync_json}" python3 - <<'PY'
synced_bridge_server_url="$(
RESPONSE_JSON="${sync_json}" python3 - <<'PY'
import json
import os
@ -194,9 +193,18 @@ if not bridge_url:
raise SystemExit("sync response did not include BRIDGE_SERVER_URL")
print(bridge_url.rstrip("/"))
PY
)"
)"
bridge_server_urls=()
if [[ -n "${BRIDGE_SERVER_URLS}" ]]; then
while IFS= read -r candidate; do
[[ -n "${candidate}" ]] && bridge_server_urls+=("$(normalize_url "${candidate}")")
done < <(printf '%s\n' "${BRIDGE_SERVER_URLS}" | tr ',' '\n' | tr '[:space:]' '\n' | sed '/^$/d')
elif [[ -n "${BRIDGE_SERVER_URL}" ]]; then
bridge_server_urls+=("$(normalize_url "${BRIDGE_SERVER_URL}")")
else
bridge_server_urls+=("$(normalize_url "${synced_bridge_server_url}")")
fi
bridge_server_url="$(normalize_url "${bridge_server_url}")"
bridge_auth_token="${BRIDGE_AUTH_TOKEN}"
if [[ -z "${bridge_auth_token}" ]]; then
@ -214,13 +222,29 @@ PY
)"
fi
capabilities_json="$(
json_post \
"${bridge_server_url}/acp/rpc" \
'{"jsonrpc":"2.0","id":"capabilities","method":"acp.capabilities","params":{}}' \
-H "Authorization: Bearer ${bridge_auth_token}"
)"
RESPONSE_JSON="${capabilities_json}" python3 - <<'PY'
verified_urls=()
for bridge_server_url in "${bridge_server_urls[@]}"; do
ping_json="$(
json_get \
"${bridge_server_url}/api/ping" \
-H "Authorization: Bearer ${bridge_auth_token}"
)"
RESPONSE_JSON="${ping_json}" python3 - <<'PY'
import json
import os
payload = json.loads(os.environ["RESPONSE_JSON"])
if payload.get("status") != "ok":
raise SystemExit("bridge ping status is not ok")
PY
capabilities_json="$(
json_post \
"${bridge_server_url}/acp/rpc" \
'{"jsonrpc":"2.0","id":"capabilities","method":"acp.capabilities","params":{}}' \
-H "Authorization: Bearer ${bridge_auth_token}"
)"
RESPONSE_JSON="${capabilities_json}" python3 - <<'PY'
import json
import os
@ -232,10 +256,10 @@ if result.get("availableExecutionTargets") != ["agent", "gateway"]:
raise SystemExit("unexpected availableExecutionTargets")
PY
routing_json="$(
json_post \
"${bridge_server_url}/acp/rpc" \
'{
routing_json="$(
json_post \
"${bridge_server_url}/acp/rpc" \
'{
"jsonrpc":"2.0",
"id":"routing",
"method":"xworkmate.routing.resolve",
@ -254,9 +278,9 @@ routing_json="$(
}
}
}' \
-H "Authorization: Bearer ${bridge_auth_token}"
)"
RESPONSE_JSON="${routing_json}" python3 - <<'PY'
-H "Authorization: Bearer ${bridge_auth_token}"
)"
RESPONSE_JSON="${routing_json}" python3 - <<'PY'
import json
import os
@ -267,5 +291,7 @@ if not isinstance(result, dict):
if result.get("resolvedProviderId") != "codex":
raise SystemExit("unexpected resolvedProviderId")
PY
verified_urls+=("${bridge_server_url}")
done
printf 'API interface contract verified via %s\n' "${bridge_server_url}"
printf 'API interface contract verified via %s\n' "${verified_urls[*]}"

View File

@ -208,8 +208,6 @@ payload = json.loads(os.environ["RESPONSE_JSON"])
result = payload.get("result") or payload.get("payload") or {}
if result.get("resolvedProviderId") != "codex":
raise SystemExit("session.start did not resolve codex")
if not str(result.get("error") or "").strip():
raise SystemExit("session.start in this environment should expose downstream error details")
PY
RESPONSE_JSON="${message_json}" python3 - <<'PY'

View File

@ -1,7 +1,10 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:xworkmate/app/app_controller.dart';
import 'package:xworkmate/features/assistant/assistant_page_main.dart';
import 'package:xworkmate/features/assistant/assistant_page_state_actions.dart';
import 'package:xworkmate/runtime/runtime_models.dart';
import 'package:xworkmate/theme/app_theme.dart';
@ -175,6 +178,58 @@ void main() {
await tester.pumpWidget(const SizedBox.shrink());
await tester.pump(const Duration(milliseconds: 100));
});
testWidgets('keeps follow-up submit bound to the running task', (
tester,
) async {
final controller = AppController(
environmentOverride: const <String, String>{},
);
addTearDown(controller.dispose);
final pageKey = GlobalKey<AssistantPageStateInternal>();
const sessionKey = 'running-task';
await controller.sessionsController.switchSession(sessionKey);
controller.aiGatewayPendingSessionKeysInternal.add(sessionKey);
await tester.pumpWidget(
MaterialApp(
theme: AppTheme.light(),
home: Material(
child: SizedBox(
width: 1280,
height: 760,
child: AssistantPage(
key: pageKey,
controller: controller,
showStandaloneTaskRail: false,
onOpenDetail: (_) {},
),
),
),
),
);
await tester.pump(const Duration(milliseconds: 100));
final state = pageKey.currentState!;
state.inputControllerInternal.text = 'continue current task';
unawaited(
state.submitPromptInternal().catchError((_) {
return null;
}),
);
await tester.pump();
expect(controller.currentSessionKey, sessionKey);
expect(
controller.assistantSessions.map((session) => session.key),
isNot(contains(startsWith('draft:'))),
);
await tester.pumpWidget(const SizedBox.shrink());
await tester.pump(const Duration(milliseconds: 100));
});
}
Future<void> _pumpAssistantPage(

View File

@ -5,6 +5,8 @@ import 'package:xworkmate/app/app_shell_desktop.dart';
import 'package:xworkmate/features/mobile/mobile_settings_page.dart';
import 'package:xworkmate/runtime/account_runtime_client.dart';
import 'package:xworkmate/runtime/runtime_controllers.dart';
import 'package:xworkmate/runtime/runtime_models.dart';
import 'package:xworkmate/runtime/secure_config_store.dart';
import 'package:xworkmate/theme/app_theme.dart';
void main() {
@ -239,6 +241,78 @@ void main() {
);
expect(find.text('mobile@svc.plus'), findsOneWidget);
});
testWidgets('manual bridge save updates mobile runtime configuration', (
tester,
) async {
final store = _MemorySecureConfigStore();
final controller = _NoopRefreshAppController(store: store);
addTearDown(controller.dispose);
await tester.pumpWidget(
MaterialApp(
theme: AppTheme.light().copyWith(platform: TargetPlatform.iOS),
home: MediaQuery(
data: const MediaQueryData(size: Size(390, 844)),
child: Scaffold(body: MobileSettingsPage(controller: controller)),
),
),
);
await tester.pump(const Duration(milliseconds: 250));
final urlField = find.byKey(
const Key('mobile-settings-manual-bridge-url-field'),
);
await tester.ensureVisible(urlField);
await tester.enterText(
find.descendant(of: urlField, matching: find.byType(TextFormField)),
'http://127.0.0.1:1',
);
final tokenField = find.byKey(
const Key('mobile-settings-manual-bridge-token-field'),
);
await tester.enterText(
find.descendant(of: tokenField, matching: find.byType(TextFormField)),
'mobile-manual-token',
);
final saveButton = find.byKey(
const Key('mobile-settings-manual-bridge-save-button'),
);
await tester.ensureVisible(saveButton);
tester.widget<FilledButton>(saveButton).onPressed!();
for (
var attempt = 0;
attempt < 20 &&
controller
.settings
.acpBridgeServerModeConfig
.selfHosted
.serverUrl !=
'http://127.0.0.1:1';
attempt += 1
) {
await tester.pump(const Duration(milliseconds: 50));
}
final bridgeConfig = controller.settings.acpBridgeServerModeConfig;
expect(bridgeConfig.selfHosted.serverUrl, 'http://127.0.0.1:1');
expect(bridgeConfig.effective.source, 'bridge');
expect(
await store.loadSecretValueByRef(bridgeConfig.selfHosted.passwordRef),
'mobile-manual-token',
);
expect(
controller.resolveGatewayAcpEndpointInternal()?.toString(),
'http://127.0.0.1:1',
);
expect(
await controller.resolveGatewayAcpAuthorizationHeaderInternal(
Uri.parse('http://127.0.0.1:1/acp/rpc'),
),
'mobile-manual-token',
);
});
});
}
@ -278,3 +352,63 @@ class _MobileFakeAccountRuntimeClient extends AccountRuntimeClient {
return syncPayload;
}
}
class _NoopRefreshAppController extends AppController {
_NoopRefreshAppController({required SecureConfigStore store})
: super(environmentOverride: const <String, String>{}, store: store);
Future<void> refreshAcpCapabilitiesInternal({
bool forceRefresh = false,
bool persistMountTargets = false,
}) async {}
Future<void> refreshSingleAgentCapabilitiesInternal({
bool forceRefresh = false,
}) async {}
}
class _MemorySecureConfigStore extends SecureConfigStore {
_MemorySecureConfigStore() : super(enableSecureStorage: false);
SettingsSnapshot _settings = SettingsSnapshot.defaults();
final Map<String, String> _secrets = <String, String>{};
@override
Future<void> initialize() async {}
@override
Future<SettingsSnapshot> loadSettingsSnapshot() async => _settings;
@override
Future<void> saveSettingsSnapshot(SettingsSnapshot snapshot) async {
_settings = snapshot;
}
@override
Future<Map<String, String>> loadSecureRefs() async => _secrets;
@override
Future<List<SecretAuditEntry>> loadAuditTrail() async =>
const <SecretAuditEntry>[];
@override
Future<void> appendAudit(SecretAuditEntry entry) async {}
@override
Future<String?> loadSecretValueByRef(String refName) async =>
_secrets[refName];
@override
Future<void> saveSecretValueByRef(String refName, String value) async {
_secrets[refName] = value;
}
@override
Future<String?> loadAccountSessionToken() async => null;
@override
Future<AccountSessionSummary?> loadAccountSessionSummary() async => null;
@override
Future<AccountSyncState?> loadAccountSyncState() async => null;
}

View File

@ -108,7 +108,7 @@ void main() {
expect(metadata['buildDate'], '');
});
test('returns unavailable when authorized bridge ping fails', () async {
test('returns unauthorized when bridge rejects authorization', () async {
final server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0);
addTearDown(() async {
await server.close(force: true);
@ -126,7 +126,8 @@ void main() {
authorizationResolver: (_) async => 'bridge-token',
);
expect(metadata['status'], 'unavailable');
expect(metadata['status'], 'unauthorized');
expect(metadata['message'], 'Bridge authorization rejected');
expect(metadata['version'], '');
expect(metadata['commit'], '');
expect(metadata['image'], '');

View File

@ -1,7 +1,10 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:xworkmate/app/app_controller.dart';
import 'package:xworkmate/features/settings/settings_account_panel.dart';
import 'package:xworkmate/runtime/runtime_controllers.dart';
import 'package:xworkmate/runtime/runtime_models.dart';
import 'package:xworkmate/runtime/secure_config_store.dart';
import 'package:xworkmate/theme/app_theme.dart';
import 'package:xworkmate/widgets/surface_card.dart';
@ -114,6 +117,203 @@ void main() {
expect(submittedPassword, 'typed-password');
});
testWidgets('manual bridge save submits current field values', (
tester,
) async {
final controllers = _TestControllers();
addTearDown(controllers.dispose);
var savedAsManualBridge = false;
var savedBridgeUrl = '';
var savedBridgeToken = '';
await tester.pumpWidget(
_buildTestApp(
child: SettingsAccountPanel(
settings: SettingsSnapshot.defaults(),
accountSession: null,
accountState: null,
accountBusy: false,
accountSignedIn: false,
accountMfaRequired: false,
accountBaseUrlController: controllers.baseUrl,
accountIdentifierController: controllers.identifier,
accountPasswordController: controllers.password,
accountMfaCodeController: controllers.mfaCode,
bridgeUrlController: controllers.bridgeUrl,
bridgeTokenController: controllers.bridgeToken,
onSaveAccountProfile: ({required bool isManualBridge}) async {
savedAsManualBridge = isManualBridge;
savedBridgeUrl = controllers.bridgeUrl.text;
savedBridgeToken = controllers.bridgeToken.text;
},
onLogin: () async {},
onVerifyMfa: () async {},
onCancelMfa: () async {},
onSync: () async {},
onLogout: () async {},
),
),
);
await tester.tap(find.text('手动 Bridge 配置'));
await tester.pump();
await tester.enterText(
find.byKey(const ValueKey('settings-manual-bridge-url-field')),
'https://cn-xworkmate-bridge.svc.plus',
);
await tester.enterText(
find.byKey(const ValueKey('settings-manual-bridge-token-field')),
'typed-manual-token',
);
await tester.tap(
find.byKey(const ValueKey('settings-manual-bridge-save-button')),
);
await tester.pump();
expect(savedAsManualBridge, isTrue);
expect(savedBridgeUrl, 'https://cn-xworkmate-bridge.svc.plus');
expect(savedBridgeToken, 'typed-manual-token');
});
testWidgets('switching to manual bridge tab does not save draft values', (
tester,
) async {
final controllers = _TestControllers();
addTearDown(controllers.dispose);
var saveCount = 0;
await tester.pumpWidget(
_buildTestApp(
child: SettingsAccountPanel(
settings: SettingsSnapshot.defaults(),
accountSession: null,
accountState: null,
accountBusy: false,
accountSignedIn: false,
accountMfaRequired: false,
accountBaseUrlController: controllers.baseUrl,
accountIdentifierController: controllers.identifier,
accountPasswordController: controllers.password,
accountMfaCodeController: controllers.mfaCode,
bridgeUrlController: controllers.bridgeUrl,
bridgeTokenController: controllers.bridgeToken,
onSaveAccountProfile: ({required bool isManualBridge}) async {
saveCount += 1;
},
onLogin: () async {},
onVerifyMfa: () async {},
onCancelMfa: () async {},
onSync: () async {},
onLogout: () async {},
),
),
);
await tester.tap(find.text('手动 Bridge 配置'));
await tester.pump();
expect(saveCount, 0);
});
testWidgets('desktop manual bridge save updates runtime configuration', (
tester,
) async {
final controllers = _TestControllers();
addTearDown(controllers.dispose);
final store = _MemorySecureConfigStore();
final controller = _NoopRefreshAppController(store: store);
addTearDown(controller.dispose);
await tester.pumpWidget(
_buildTestApp(
child: SettingsAccountPanel(
settings: controller.settings,
accountSession: null,
accountState: null,
accountBusy: false,
accountSignedIn: false,
accountMfaRequired: false,
accountBaseUrlController: controllers.baseUrl,
accountIdentifierController: controllers.identifier,
accountPasswordController: controllers.password,
accountMfaCodeController: controllers.mfaCode,
bridgeUrlController: controllers.bridgeUrl,
bridgeTokenController: controllers.bridgeToken,
onSaveAccountProfile: ({required bool isManualBridge}) async {
final nextSettings = await controller.settingsController
.buildSavedAccountProfileSettings(
settings: controller.settings,
accountBaseUrl: controllers.baseUrl.text,
accountIdentifier: controllers.identifier.text,
bridgeServerUrl: controllers.bridgeUrl.text,
bridgeToken: controllers.bridgeToken.text,
isManualBridge: isManualBridge,
);
await controller.saveSettings(
nextSettings,
refreshAfterSave: false,
);
},
onLogin: () async {},
onVerifyMfa: () async {},
onCancelMfa: () async {},
onSync: () async {},
onLogout: () async {},
),
),
);
await tester.tap(find.text('手动 Bridge 配置'));
await tester.pump();
await tester.enterText(
find.byKey(const ValueKey('settings-manual-bridge-url-field')),
'http://127.0.0.1:1',
);
await tester.enterText(
find.byKey(const ValueKey('settings-manual-bridge-token-field')),
'typed-manual-token',
);
await tester.tap(
find.byKey(const ValueKey('settings-manual-bridge-save-button')),
);
for (
var attempt = 0;
attempt < 20 &&
controller
.settings
.acpBridgeServerModeConfig
.selfHosted
.serverUrl !=
'http://127.0.0.1:1';
attempt += 1
) {
await tester.pump(const Duration(milliseconds: 50));
}
final bridgeConfig = controller.settings.acpBridgeServerModeConfig;
expect(bridgeConfig.selfHosted.serverUrl, 'http://127.0.0.1:1');
expect(bridgeConfig.selfHosted.username, 'admin');
expect(bridgeConfig.effective.source, 'bridge');
expect(bridgeConfig.effective.endpoint, 'http://127.0.0.1:1');
expect(
await store.loadSecretValueByRef(bridgeConfig.selfHosted.passwordRef),
'typed-manual-token',
);
expect(
controller.resolveGatewayAcpEndpointInternal()?.toString(),
'http://127.0.0.1:1',
);
expect(
await controller.resolveGatewayAcpAuthorizationHeaderInternal(
Uri.parse('http://127.0.0.1:1/acp/rpc'),
),
'typed-manual-token',
);
});
testWidgets(
'shows account sync status, resync, and exit in signed-in mode',
(tester) async {
@ -503,3 +703,63 @@ class _TestControllers {
bridgeToken.dispose();
}
}
class _NoopRefreshAppController extends AppController {
_NoopRefreshAppController({required SecureConfigStore store})
: super(environmentOverride: const <String, String>{}, store: store);
Future<void> refreshAcpCapabilitiesInternal({
bool forceRefresh = false,
bool persistMountTargets = false,
}) async {}
Future<void> refreshSingleAgentCapabilitiesInternal({
bool forceRefresh = false,
}) async {}
}
class _MemorySecureConfigStore extends SecureConfigStore {
_MemorySecureConfigStore() : super(enableSecureStorage: false);
SettingsSnapshot _settings = SettingsSnapshot.defaults();
final Map<String, String> _secrets = <String, String>{};
@override
Future<void> initialize() async {}
@override
Future<SettingsSnapshot> loadSettingsSnapshot() async => _settings;
@override
Future<void> saveSettingsSnapshot(SettingsSnapshot snapshot) async {
_settings = snapshot;
}
@override
Future<Map<String, String>> loadSecureRefs() async => _secrets;
@override
Future<List<SecretAuditEntry>> loadAuditTrail() async =>
const <SecretAuditEntry>[];
@override
Future<void> appendAudit(SecretAuditEntry entry) async {}
@override
Future<String?> loadSecretValueByRef(String refName) async =>
_secrets[refName];
@override
Future<void> saveSecretValueByRef(String refName, String value) async {
_secrets[refName] = value;
}
@override
Future<String?> loadAccountSessionToken() async => null;
@override
Future<AccountSessionSummary?> loadAccountSessionSummary() async => null;
@override
Future<AccountSyncState?> loadAccountSyncState() async => null;
}

View File

@ -492,9 +492,10 @@ void main() {
final snapshot = await controller.loadAssistantArtifactSnapshot(
sessionKey: 'unit-fixture-task-a',
);
expect(snapshot.resultEntries.map((entry) => entry.relativePath), <String>[
'notes/hello.v2.txt',
]);
expect(
snapshot.resultEntries.map((entry) => entry.relativePath),
containsAll(<String>['notes/hello.v2.txt', 'notes/hello.txt']),
);
expect(
snapshot.fileEntries.map((entry) => entry.relativePath),
containsAll(<String>['notes/hello.v2.txt', 'notes/hello.txt']),
@ -566,7 +567,10 @@ void main() {
final currentRelativePaths = snapshot.resultEntries
.map((entry) => entry.relativePath)
.toList(growable: false);
expect(currentRelativePaths, <String>['current-task-report.md']);
expect(
currentRelativePaths,
containsAll(<String>['current-task-report.md', 'old-task-report.md']),
);
expect(
snapshot.fileEntries.map((entry) => entry.relativePath),
containsAll(<String>['current-task-report.md', 'old-task-report.md']),
@ -589,6 +593,94 @@ void main() {
},
);
test('syncs existing workspace directory artifacts recursively', () async {
final controller = AppController(
environmentOverride: const <String, String>{},
);
addTearDown(controller.dispose);
final localWorkspace = await Directory.systemTemp.createTemp(
'xworkmate-recursive-artifact-workspace-',
);
addTearDown(() async {
if (await localWorkspace.exists()) {
await localWorkspace.delete(recursive: true);
}
});
await Directory(
'${localWorkspace.path}/assets/images/chapters',
).create(recursive: true);
await File(
'${localWorkspace.path}/assets/images/cover.png',
).writeAsBytes(<int>[1, 2, 3]);
await File(
'${localWorkspace.path}/assets/images/chapters/chapter-1.png',
).writeAsBytes(<int>[4, 5, 6]);
await File(
'${localWorkspace.path}/chapters/codex-chapter-breakdown.md',
).create(recursive: true);
await Directory('${localWorkspace.path}/dist').create(recursive: true);
await File(
'${localWorkspace.path}/dist/账户与身份安全演进史-GPT混排最终版.pdf',
).writeAsBytes(<int>[7, 8, 9]);
controller.upsertTaskThreadInternal(
'unit-fixture-task-a',
workspaceBinding: WorkspaceBinding(
workspaceId: 'unit-fixture-task-a',
workspaceKind: WorkspaceKind.localFs,
workspacePath: localWorkspace.path,
displayPath: localWorkspace.path,
writable: true,
),
);
final result = GoTaskServiceResult(
success: true,
message: 'generated files',
turnId: 'turn-recursive',
raw: <String, dynamic>{
'artifacts': <Map<String, dynamic>>[
<String, dynamic>{'relativePath': 'assets/images/'},
<String, dynamic>{
'relativePath': 'chapters/codex-chapter-breakdown.md',
},
],
},
errorMessage: '',
resolvedModel: '',
route: GoTaskServiceRoute.externalAcpSingle,
);
await controller.persistGoTaskArtifactsForSessionInternal(
'unit-fixture-task-a',
result,
);
final thread = controller.requireTaskThreadForSessionInternal(
'unit-fixture-task-a',
);
expect(thread.lastArtifactSyncStatus, 'synced');
expect(thread.lastTaskArtifactRelativePaths, <String>[
'assets/images/chapters/chapter-1.png',
'assets/images/cover.png',
'chapters/codex-chapter-breakdown.md',
'dist/账户与身份安全演进史-GPT混排最终版.pdf',
]);
final snapshot = await controller.loadAssistantArtifactSnapshot(
sessionKey: 'unit-fixture-task-a',
);
expect(
snapshot.resultEntries.map((entry) => entry.relativePath),
containsAll(<String>[
'assets/images/chapters/chapter-1.png',
'assets/images/cover.png',
'chapters/codex-chapter-breakdown.md',
'dist/账户与身份安全演进史-GPT混排最终版.pdf',
]),
);
});
test(
'downloads bridge URL artifacts into the local thread workspace',
() async {
@ -907,12 +999,17 @@ void main() {
await File('${localWorkspace.path}/reports/resume.bin').readAsBytes(),
body,
);
expect(
controller
.requireTaskThreadForSessionInternal('unit-fixture-task-a')
.lastArtifactSyncStatus,
'synced',
final thread = controller.requireTaskThreadForSessionInternal(
'unit-fixture-task-a',
);
for (
var attempt = 0;
attempt < 20 && thread.lastArtifactSyncStatus != 'synced';
attempt += 1
) {
await Future<void>.delayed(const Duration(milliseconds: 10));
}
expect(thread.lastArtifactSyncStatus, 'synced');
},
);
@ -1310,10 +1407,8 @@ void main() {
sessionKey: 'unit-fixture-task-a',
);
expect(snapshot.resultEntries, isEmpty);
expect(
snapshot.fileEntries.map((entry) => entry.relativePath),
contains('old-task-report.md'),
);
expect(snapshot.fileEntries, isEmpty);
expect(snapshot.resultMessage, 'No task artifacts recorded for this run.');
});
test('skips download URL artifacts outside the bridge host', () async {

View File

@ -1186,6 +1186,31 @@ void main() {
},
);
test(
'sendChatMessage declares expected artifacts for complex PDF chains',
() async {
final fakeGoTaskService = _RecordingGoTaskServiceClient();
final controller = _connectedGatewayController(fakeGoTaskService);
addTearDown(controller.dispose);
await controller.ensureActiveAssistantThreadInternal();
await controller.setAssistantExecutionTarget(
AssistantExecutionTarget.gateway,
);
await controller.sendChatMessage(
'围绕\n\n'
'从单机权限 → 网络边界 → Web安全 → 云身份 → Zero Trust → AI Agent 身份 → AI模型与知识保护 演进 800-1500字\n'
'拆章节 -> 每章调用 Codex -> 每章 GPT images2 生成图 -> 汇总排版 ->\n\n'
'最后 输出 PDF文件',
);
expect(fakeGoTaskService.requests, hasLength(1));
final request = fakeGoTaskService.requests.single;
expect(request.metadata['taskLoadClass'], 'complex_long_chain_task');
expect(request.metadata['expectedArtifactExtensions'], <String>['pdf']);
},
);
test(
'sendChatMessage classifies simple Gateway prompts as short tasks',
() async {
@ -1892,6 +1917,125 @@ void main() {
);
});
test('sendChatMessage can pin submit to the captured session', () async {
final fakeGoTaskService = _BlockingGoTaskServiceClient();
final controller = _connectedController(fakeGoTaskService);
addTearDown(controller.dispose);
await controller.switchSession('same-prompt-old-task');
await controller.switchSession('same-prompt-new-task');
final taskFuture = controller.sendChatMessage(
'连续制作7张图片',
sessionKey: 'same-prompt-new-task',
);
await fakeGoTaskService.waitForRequestCount(1);
final request = fakeGoTaskService.requests.single;
expect(request.sessionId, 'same-prompt-new-task');
expect(request.threadId, 'same-prompt-new-task');
expect(request.workingDirectory, endsWith('/same-prompt-new-task'));
expect(
request.remoteWorkingDirectoryHint,
endsWith('/threads/same-prompt-new-task'),
);
fakeGoTaskService.complete(
'same-prompt-new-task',
const GoTaskServiceResult(
success: true,
message: 'new task result',
turnId: 'turn-new',
raw: <String, dynamic>{},
errorMessage: '',
resolvedModel: '',
route: GoTaskServiceRoute.externalAcpSingle,
),
);
await taskFuture;
expect(
controller.localSessionMessagesInternal['same-prompt-new-task']!.map(
(message) => message.text,
),
contains('new task result'),
);
expect(
controller.localSessionMessagesInternal['same-prompt-old-task'],
isNot(contains('new task result')),
);
});
test(
'sendChatMessage queues follow-up turns on the same session',
() async {
final fakeGoTaskService = _BlockingGoTaskServiceClient();
final controller = _connectedController(fakeGoTaskService);
addTearDown(controller.dispose);
await controller.switchSession('running-task');
final firstFuture = controller.sendChatMessage('first turn');
await fakeGoTaskService.waitForRequestCount(1);
expect(fakeGoTaskService.requests.single.sessionId, 'running-task');
expect(fakeGoTaskService.requests.single.threadId, 'running-task');
expect(
controller.assistantSessionHasPendingRun('running-task'),
isTrue,
);
final secondFuture = controller.sendChatMessage('follow up');
await Future<void>.delayed(const Duration(milliseconds: 50));
expect(fakeGoTaskService.requests, hasLength(1));
expect(controller.currentSessionKey, 'running-task');
fakeGoTaskService.complete(
'running-task',
const GoTaskServiceResult(
success: true,
message: 'first result',
turnId: 'turn-1',
raw: <String, dynamic>{},
errorMessage: '',
resolvedModel: '',
route: GoTaskServiceRoute.externalAcpSingle,
),
);
await firstFuture;
await fakeGoTaskService.waitForRequestCount(2);
final followUpRequest = fakeGoTaskService.requests.last;
expect(followUpRequest.sessionId, 'running-task');
expect(followUpRequest.threadId, 'running-task');
expect(followUpRequest.prompt, contains('follow up'));
expect(controller.currentSessionKey, 'running-task');
fakeGoTaskService.complete(
'running-task',
const GoTaskServiceResult(
success: true,
message: 'second result',
turnId: 'turn-2',
raw: <String, dynamic>{},
errorMessage: '',
resolvedModel: '',
route: GoTaskServiceRoute.externalAcpSingle,
),
);
await secondFuture;
expect(
controller.localSessionMessagesInternal['running-task']!.map(
(message) => message.text,
),
containsAll(<String>[
'first turn',
'first result',
'follow up',
'second result',
]),
);
},
);
test(
'background task completion does not overwrite the selected session',
() async {
@ -2272,9 +2416,10 @@ void main() {
);
expect(taskBSnapshot.fileEntries, isEmpty);
expect(
taskBSnapshot.filesMessage,
'No files found in the recorded working directory.',
taskBSnapshot.resultMessage,
'No task artifacts recorded for this run.',
);
expect(taskBSnapshot.filesMessage, isEmpty);
},
);
@ -2413,9 +2558,10 @@ void main() {
sessionKey: 'terminal-failure-task',
);
expect(snapshot.resultEntries, isEmpty);
expect(snapshot.fileEntries, isEmpty);
expect(
snapshot.fileEntries.map((entry) => entry.relativePath),
contains('first.md'),
snapshot.resultMessage,
'No task artifacts recorded for this run.',
);
},
);
@ -2596,7 +2742,7 @@ void main() {
await expectLater(
controller
.sendChatMessage('active prompt $index')
.timeout(const Duration(milliseconds: 250)),
.timeout(const Duration(seconds: 2)),
completes,
);
await fakeGoTaskService.waitForRequestCount(index + 1);
@ -2616,7 +2762,7 @@ void main() {
'queued prompt',
attachments: <GatewayChatAttachmentPayload>[queuedAttachment],
)
.timeout(const Duration(milliseconds: 250)),
.timeout(const Duration(seconds: 2)),
completes,
);
await _waitForThreadLifecycleStatus(
@ -2718,6 +2864,57 @@ void main() {
},
);
test(
'OpenClaw gateway admits five representative E2E tasks without queueing',
() async {
final fakeGoTaskService = _BlockingGoTaskServiceClient();
final controller = _connectedGatewayController(fakeGoTaskService);
addTearDown(() {
fakeGoTaskService.completeAll();
controller.dispose();
});
const prompts = <String>[
'从单机权限 → 网络边界 → Web安全 → 云身份 → Zero Trust → AI Agent 身份 → AI模型与知识保护 演进 \n制作 使用codex 制作连续制作 7张的一些列图片',
'参考附件模版制作 ,围绕\n从单机权限 → 网络边界 → Web安全 → 云身份 → Zero Trust → AI Agent 身份 → AI模型与知识保护 演进 \n连续制作 7张的一些列图片',
'拆章节 -> 每章调用 Codex -> 每章 GPT images2 生成图 -> 汇总排版 -> 输出 PDF\n\n右侧 artifact栏 显示的陈旧文件',
'围绕\n从单机权限 → 网络边界 → Web安全 → 云身份 → Zero Trust → AI Agent 身份 → AI模型与知识保护 演进 右侧是当下 \n测试制作视频',
'围绕\n\n从单机权限 → 网络边界 → Web安全 → 云身份 → Zero Trust → AI Agent 身份 → AI模型与知识保护 演进 \n\n拆章节 -> 每章调用 Codex -> 每章 GPT images2 生成图 -> 汇总排版 -> 制作视频',
];
for (var index = 0; index < prompts.length; index += 1) {
final sessionKey = 'openclaw-e2e-$index';
await _selectGatewaySession(controller, sessionKey);
await expectLater(
controller
.sendChatMessage(prompts[index])
.timeout(const Duration(seconds: 2)),
completes,
);
}
await fakeGoTaskService.waitForRequestCount(prompts.length);
expect(fakeGoTaskService.requests, hasLength(prompts.length));
expect(controller.openClawGatewayActiveTasksInternal, prompts.length);
expect(controller.openClawGatewayQueuedTurnsInternal, isEmpty);
for (var index = 0; index < prompts.length; index += 1) {
final sessionKey = 'openclaw-e2e-$index';
expect(
controller
.requireTaskThreadForSessionInternal(sessionKey)
.lifecycleState
.status,
'running',
);
expect(fakeGoTaskService.requests[index].sessionId, sessionKey);
expect(
fakeGoTaskService.requests[index].prompt,
contains(prompts[index]),
);
}
},
);
test('OpenClaw gateway task uses the server default model', () async {
final fakeGoTaskService = _BlockingGoTaskServiceClient();
final controller = _connectedGatewayController(fakeGoTaskService);
@ -2735,7 +2932,7 @@ void main() {
final taskFuture = controller.sendChatMessage('use OpenClaw default');
await fakeGoTaskService.waitForRequestCount(1);
await expectLater(
taskFuture.timeout(const Duration(milliseconds: 250)),
taskFuture.timeout(const Duration(seconds: 2)),
completes,
);
@ -3787,7 +3984,7 @@ Future<List<String>> _startOpenClawActiveTasks(
await expectLater(
controller
.sendChatMessage('active task $index')
.timeout(const Duration(milliseconds: 250)),
.timeout(const Duration(seconds: 2)),
completes,
);
await fakeGoTaskService.waitForRequestCount(index + 1);

View File

@ -168,6 +168,81 @@ void main() {
},
);
test(
'manual bridge token authorizes runtime and artifact requests only for manual endpoint',
() async {
final storeRoot = await Directory.systemTemp.createTemp(
'xworkmate-manual-bridge-artifact-auth-',
);
addTearDown(() async {
if (await storeRoot.exists()) {
try {
await storeRoot.delete(recursive: true);
} on FileSystemException {
// Temp cleanup is best effort here. The controller may still be
// releasing files when teardown starts.
}
}
});
final store = SecureConfigStore(
secretRootPathResolver: () async => '${storeRoot.path}/secrets',
appDataRootPathResolver: () async => '${storeRoot.path}/app-data',
supportRootPathResolver: () async => '${storeRoot.path}/support',
enableSecureStorage: false,
);
await store.initialize();
await store.saveSettingsSnapshot(
SettingsSnapshot.defaults().copyWith(
acpBridgeServerModeConfig: AcpBridgeServerModeConfig.defaults()
.copyWith(
selfHosted: AcpBridgeServerModeConfig.defaults().selfHosted
.copyWith(
serverUrl: 'https://private-bridge.svc.plus',
username: 'admin',
),
),
),
);
await store.saveSecretValueByRef(
AcpBridgeServerSelfHostedConfig.defaults().passwordRef,
'manual-bridge-token',
);
final controller = AppController(
environmentOverride: const <String, String>{},
store: store,
);
addTearDown(controller.dispose);
await controller.settingsControllerInternal.initialize();
expect(
await controller.resolveGatewayAcpAuthorizationHeaderInternal(
Uri.parse('https://private-bridge.svc.plus/acp/rpc'),
),
'manual-bridge-token',
);
expect(
await controller.resolveBridgeArtifactAuthorizationHeaderInternal(
Uri.parse('https://private-bridge.svc.plus/artifacts/file.pdf'),
),
'Bearer manual-bridge-token',
);
expect(
await controller.resolveGatewayAcpAuthorizationHeaderInternal(
Uri.parse('$kManagedBridgeServerUrl/acp/rpc'),
),
isNull,
);
expect(
await controller.resolveBridgeArtifactAuthorizationHeaderInternal(
Uri.parse('$kManagedBridgeServerUrl/artifacts/file.pdf'),
),
isNull,
);
},
);
test(
'runtime coordinator only exposes remote and offline gateway modes',
() {

View File

@ -1,12 +1,13 @@
import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
import 'package:xworkmate/runtime/assistant_artifacts.dart';
import 'package:xworkmate/runtime/desktop_thread_artifact_service.dart';
import 'package:xworkmate/runtime/runtime_models.dart';
void main() {
test(
'loadSnapshot exposes all local workspace files when current run has no artifacts',
'loadSnapshot hides historical workspace files when current run has no artifacts',
() async {
final workspace = await Directory.systemTemp.createTemp(
'xworkmate-artifact-snapshot-',
@ -25,10 +26,7 @@ void main() {
);
expect(snapshot.resultEntries, isEmpty);
expect(
snapshot.fileEntries.map((entry) => entry.relativePath),
contains('historical.md'),
);
expect(snapshot.fileEntries, isEmpty);
expect(
snapshot.resultMessage,
'No task artifacts recorded for this run.',
@ -80,13 +78,14 @@ void main() {
}
});
await File('${workspace.path}/historical.md').writeAsString('# Old\n');
final snapshot = await DesktopThreadArtifactService().loadSnapshot(
workspacePath: workspace.path,
workspaceKind: WorkspaceRefKind.localPath,
artifactRelativePaths: const <String>[],
);
final historical = snapshot.fileEntries.singleWhere(
(entry) => entry.relativePath == 'historical.md',
const historical = AssistantArtifactEntry(
id: 'historical.md',
label: 'historical.md',
relativePath: 'historical.md',
kind: AssistantArtifactEntryKind.file,
mimeType: 'text/markdown',
previewable: true,
workspacePath: '',
);
final preview = await DesktopThreadArtifactService().loadPreview(

View File

@ -441,6 +441,144 @@ void main() {
},
);
test(
'blocked managed bridge sync does not configure runtime authorization',
() async {
final storeRoot = await Directory.systemTemp.createTemp(
'xworkmate-account-managed-bridge-blocked-runtime-',
);
addTearDown(() async {
if (await storeRoot.exists()) {
await storeRoot.delete(recursive: true);
}
});
final store = SecureConfigStore(
secretRootPathResolver: () async => '${storeRoot.path}/secrets',
appDataRootPathResolver: () async => '${storeRoot.path}/app-data',
supportRootPathResolver: () async => '${storeRoot.path}/support',
enableSecureStorage: false,
);
await store.initialize();
await store.saveAccountSessionToken('session-token');
await store.saveAccountSessionSummary(
const AccountSessionSummary(
userId: 'user-1',
email: 'review@svc.plus',
name: 'Review User',
role: 'reviewer',
mfaEnabled: true,
),
);
await store.saveAccountSyncState(
AccountSyncState.defaults().copyWith(
syncState: 'ready',
tokenConfigured: const AccountTokenConfigured(
bridge: true,
vault: false,
),
),
);
await store.saveAccountManagedSecret(
target: kAccountManagedSecretTargetBridgeAuthToken,
value: 'bridge-token',
);
final controller = AppController(
environmentOverride: const <String, String>{
'BRIDGE_AUTH_TOKEN': 'env-token-must-not-recover-blocked-sync',
},
store: store,
);
addTearDown(controller.dispose);
await controller.settingsControllerInternal.initialize();
await controller.settingsControllerInternal
.markAccountBridgeRuntimeUnavailable(
'Bridge authorization rejected',
);
expect(controller.isBridgeAcpRuntimeConfiguredInternal(), isFalse);
expect(
await controller.resolveGatewayAcpAuthorizationHeaderInternal(
Uri.parse('$kManagedBridgeServerUrl/acp/rpc'),
),
isNull,
);
expect(
await store.loadAccountManagedSecret(
target: kAccountManagedSecretTargetBridgeAuthToken,
),
'bridge-token',
);
},
);
test('manual bridge config becomes the runtime ACP source', () async {
final storeRoot = await Directory.systemTemp.createTemp(
'xworkmate-manual-bridge-runtime-',
);
addTearDown(() async {
if (await storeRoot.exists()) {
try {
await storeRoot.delete(recursive: true);
} on FileSystemException {
// Temp cleanup is best effort here. The controller may still be
// releasing files when teardown starts.
}
}
});
final store = SecureConfigStore(
secretRootPathResolver: () async => '${storeRoot.path}/secrets',
appDataRootPathResolver: () async => '${storeRoot.path}/app-data',
supportRootPathResolver: () async => '${storeRoot.path}/support',
enableSecureStorage: false,
);
await store.initialize();
await store.saveSettingsSnapshot(
SettingsSnapshot.defaults().copyWith(
acpBridgeServerModeConfig: AcpBridgeServerModeConfig.defaults()
.copyWith(
selfHosted: AcpBridgeServerModeConfig.defaults().selfHosted
.copyWith(
serverUrl: 'https://private-bridge.svc.plus',
username: 'admin',
),
),
),
);
await store.saveSecretValueByRef(
AcpBridgeServerSelfHostedConfig.defaults().passwordRef,
'manual-bridge-token',
);
final controller = AppController(
environmentOverride: const <String, String>{},
store: store,
);
addTearDown(controller.dispose);
await controller.settingsControllerInternal.initialize();
expect(
controller.resolveGatewayAcpEndpointInternal()?.toString(),
'https://private-bridge.svc.plus',
);
expect(controller.isBridgeAcpRuntimeConfiguredInternal(), isTrue);
expect(
await controller.resolveGatewayAcpAuthorizationHeaderInternal(
Uri.parse('https://private-bridge.svc.plus/acp/rpc'),
),
'manual-bridge-token',
);
expect(
await controller.resolveGatewayAcpAuthorizationHeaderInternal(
Uri.parse('$kManagedBridgeServerUrl/acp/rpc'),
),
isNull,
);
});
test(
'syncAccountSettings succeeds when bridge url metadata is missing',
() async {