diff --git a/README.md b/README.md index 858bba8..cc3ce8e 100644 --- a/README.md +++ b/README.md @@ -10,9 +10,10 @@ 2. `docs/architecture/repo_map_cn.md` 3. `docs/architecture/current_runtime_and_deploy_status_cn.md` 4. `docs/architecture/api_and_service_inventory_cn.md` -5. `docs/architecture/rbac_skill_regression_matrix_cn.md` -6. `docs/architecture/boss_server_connection_and_deploy_cn.md` -7. `prompts/codex_fullstack_build_and_deploy_prompt_cn.md` +5. `docs/architecture/enterprise_ai_ops_architecture_cn.md` +6. `docs/architecture/rbac_skill_regression_matrix_cn.md` +7. `docs/architecture/boss_server_connection_and_deploy_cn.md` +8. `prompts/codex_fullstack_build_and_deploy_prompt_cn.md` ## 当前有效目录 @@ -75,9 +76,12 @@ - 当前仓库已自带一个本地 OMX smoke runtime:`scripts/omx-team-smoke.mjs`。在还没有真实 `oh-my-codex` 可执行文件时,可以先用它验证 `OmxTeamBackendAdapter -> selector -> dispatch_execution -> 回写群聊账本` 这条链 - 当前仓库已自带一个本地 smoke runtime:`scripts/claw-runtime-smoke.mjs`。在还没有真实 `claw-code` 可执行文件时,可以先用它验证 `ClawBackendAdapter -> backendOverride -> 异步回流` 整条链 - 当前已新增“Boss 统一电脑控制中枢”第一批能力:主 Agent 已能把聊天输入区分为 `discussion / development / browser / desktop` 四类意图,并能把 `browser_control / desktop_control` 作为正式任务排入 `MasterAgentTask` 队列;本机 `local-agent` 已补上 `browser-control-task-runner / computer-use-task-runner` 外部 runtime 桥,可通过 `browserControl* / computerUse*` 配置接入真实 Browser Automation 与 Computer Use 执行器,未启用时会 fail closed,不再假装执行成功 +- 当前电脑控制中枢的生产范围先明确收敛为 `macOS`:意图路由会给 browser/desktop 控制任务写入 `controlPlatform=macos`,其中浏览器控制仍走 `openai-computer-use`,桌面 GUI 控制默认走 `codex-computer-use`,Codex Computer Use 不可用时再回退 `cua-driver-computer-use`;Windows 控制入口暂不参与当前运行链路,后续再单独做平台分支 - 当前 browser/desktop 控制结果已经会作为 `control_summary` 正式写回会话账本,并保留目标 URL / 应用名;Android 原生端会以单独控制结果卡片展示,便于把“执行什么”和“执行结果”与普通聊天正文区分开 -- 当前 `scripts/browser-control-smoke.mjs` 已经能对目标 URL 做一次真实最小探测:抓取页面标题并写回聊天结果;`scripts/computer-use-smoke.mjs` 已支持 macOS `osascript` 应用激活、引号文本输入、可选回车发送、`open -a` 兜底打开和执行 artifact 落盘,作为后续接完整 Computer Use 前的稳定过渡层 +- 当前 `scripts/browser-control-smoke.mjs` 已经能对目标 URL 做一次真实最小探测:抓取页面标题并写回聊天结果;桌面 GUI 控制默认先走 `scripts/codex-computer-use-runtime.mjs`,由 Codex App Server 发起 Codex Computer Use 执行;失败后自动回退 `scripts/cua-driver-computer-use-runtime.mjs`,通过外部 `cua-driver` 执行 `launch_app -> get_window_state -> 可选 type_text/press_key -> get_window_state` 闭环;`scripts/computer-use-smoke.mjs` 仍保留为旧兜底和回归资产 +- 受控 Mac 需要先安装并授权 `cua-driver`;Boss runtime 会优先搜索 `PATH`,再搜索 `~/.local/bin/cua-driver`、`/usr/local/bin/cua-driver`、`/opt/homebrew/bin/cua-driver` 和 `/Applications/CuaDriver.app/Contents/MacOS/cua-driver`;如果仍找不到,会明确返回 `CUA_DRIVER_COMMAND_NOT_FOUND`,不会伪装成执行成功 - 当前默认本机配置已把 `browserAutomation / computerUse` 两项能力直接上报为在线起步态,所以 Boss App 里这台 Mac 会显示“可做浏览器控制 / 桌面控制”;如果某条链路要临时收起,只需要改 `local-agent/config.cloud.json` +- 当前 `local-agent` 已新增 `Codex App Server` runner:boss-agent 默认打开 `codexAppServerEnabled`,通过 `codex app-server` stdio 接入 `conversation_reply / dispatch_execution`,失败时只在 turn 未启动前回退 `codex exec resume`,避免重复执行同一轮对话。设备 heartbeat 会单独上报 `codexAppServer` capability。 - `GET http://127.0.0.1:4317/api/v1/skills` 正常,已返回本机扫描到的 Codex Skill - `POST http://127.0.0.1:4317/api/v1/heartbeat` 正常,且会顺带触发 `thread-context` 上报 - `local-agent` 当前每 5 秒轮询一次本机 Skill lifecycle 请求;默认打开 `skillLifecycleEnabled=true`。远程 `install` 或带 `sourceUrl` 的更新必须命中 `skillLifecycleAllowedSources` 或 `skillLifecycleTrustedSources`,为空时只允许既有本地 Skill 的 `update / rollback / uninstall / version_lock`;请求携带 `checksum / expectedChecksum` 时会校验 `manifest.json` 或 `SKILL.md` 的 sha256,失败会清理半安装目录或尽量恢复备份。卸载 / 更新 / 回滚前会在 `skillsDir/.boss-skill-backups` 保留备份,卸载仍限制在 `skillsDir` 目录内,版本锁写入 `.boss-skill-locks.json` @@ -209,7 +213,7 @@ npm start - 登录页:[http://127.0.0.1:3000/auth/login](http://127.0.0.1:3000/auth/login) - 会话页:[http://127.0.0.1:3000/conversations](http://127.0.0.1:3000/conversations) - 设备页:[http://127.0.0.1:3000/devices](http://127.0.0.1:3000/devices) -- 平台总后台:[http://127.0.0.1:3000/admin](http://127.0.0.1:3000/admin),生产计划独立入口为 `https://admin.boss.hyzq.net` +- 平台总后台入口:[http://127.0.0.1:3000/enterprise-admin](http://127.0.0.1:3000/enterprise-admin),生产域名 `https://admin.boss.hyzq.net/` 根路径直接承载新独立 PC 后台;`/admin` 仅保留为跳转到根域的兼容入口 ## 设备端本地服务 @@ -246,8 +250,9 @@ open dist/boss-agent.app - `boss-agent.app` 是本机 `local-agent` 的 macOS WebView 外壳,默认打开 `http://127.0.0.1:4317/boss-agent` - 未绑定账号时会显示可扫码的 Boss APP 绑定二维码;已绑定后显示账号、API、服务器、授权、本机权限获取和本机 Skill 部署情况 -- 本机权限会区分两级判断:`辅助功能 / 屏幕录制 / 自动化控制` 只代表核心桌面控制能力;完整接管还需要按业务场景补齐全磁盘访问、输入监控、通知、麦克风、摄像头和本地网络等权限 -- 本机权限页提供“一次完整授权”入口;在 `boss-agent.app` 内点击时会先由应用本体触发辅助功能、屏幕录制、自动化、输入监控、通知、麦克风、摄像头和本地网络等原生权限预检,再打开对应 macOS 隐私设置页,确保权限列表里出现 `boss-agent` 供用户直接开启。授权完成后由系统持久保存,后续控制过程只静默校验并使用,不在任务执行中临时申请 +- boss-agent 已支持 Mac 端 OTA:打包脚本会发布 `public/downloads/boss-agent-mac-latest.zip` 与 `boss-agent-mac-latest.json`;本机 agent 通过 `/api/v1/boss-agent/ota/check` 检查更新,通过 `/api/v1/boss-agent/ota/apply` 下载、校验并拉起安装器。安装器会保留所有 `config*.json`,并优先沿用当前 LaunchAgent active config 或自定义设备配置,避免多台 Mac 覆盖安装时误切回默认设备身份。 +- 正式分发可设置 `BOSS_AGENT_CODESIGN_IDENTITY='Developer ID Application: ...'` 与 `BOSS_AGENT_NOTARIZE=1`,再用 `BOSS_AGENT_NOTARY_PROFILE` 或 Apple ID/team/password 环境变量走 `notarytool + stapler` 公证;未设置时仍保留本地开发签名 / ad-hoc 回退。 +- 本机权限按 Codex Computer Use 的最小权限模型收敛为 `辅助功能 + 屏幕录制` 两项;权限页会打开对应 macOS 隐私设置入口,授权完成后由系统持久保存,后续控制过程只静默校验并使用,不在任务执行中临时申请更多权限。 - 本机状态 JSON 可通过 `GET http://127.0.0.1:4317/api/v1/boss-agent/status` 查看,不会返回设备 token 明文 device-agent 当前职责: @@ -272,7 +277,7 @@ device-agent 当前职责: - Codex 项目/线程扫描当前已搬到 worker 线程执行,避免 `.codex/logs_1.sqlite` 和 `state_5.sqlite` 的同步扫描阻塞主线程 HTTP 响应 - 如果某个历史群聊里已经没有真实线程成员,当前不会再表现成“发了没反应”,而是会在群里追加一条 `system_notice`,提示用户先重新整理群成员 - 设备导入审核当前已经升级成 `local-agent -> codex exec -> complete` 的真实任务链;Web 和 Android 前台都会在 `pending_resolution` 阶段显示“主 Agent 审核中”并自动刷新,审核失败时保留当前勾选以便重新生成 -- 提供本地 `/boss-agent`、`/api/v1/boss-agent/status`、`/health`、`/api/v1/device`、`/api/v1/skills`、`/api/v1/heartbeat` +- 提供本地 `/boss-agent`、`/api/v1/boss-agent/status`、`/api/v1/boss-agent/ota/check`、`/api/v1/boss-agent/ota/apply`、`/health`、`/api/v1/device`、`/api/v1/skills`、`/api/v1/heartbeat` 当前常驻默认值: @@ -289,7 +294,7 @@ device-agent 当前职责: - APK 发布脚本:`scripts/publish-apk-to-public.sh` - `systemd` 配置:`deployment/systemd/boss-web.service` - `Caddy` 配置:`deployment/Caddyfile` -- 平台总后台域名解析:`admin.boss.hyzq.net` 需要在 DNSPod 添加 `A` 记录到 `106.53.170.158`,Caddy 已预留独立站点并把根路径跳到 `/admin` +- 平台总后台域名解析:`admin.boss.hyzq.net` 当前已解析到 `106.53.170.158`,Caddy 独立站点会把根路径内部 rewrite 到 `/admin-web/index.html`,浏览器地址栏保持 `https://admin.boss.hyzq.net/` - 服务器 Caddy 还有 `gptpluscontrol-boss-caddy-reconcile.timer` 周期性重写:如果改域名入口,必须同步更新 `/home/ubuntu/build/gptpluscontrol/deploy/server/caddy.boss_hyzq_net.gptpluscontrol.conf`,否则会再次生成重复站点块 - 邮件配置:`deployment/mail/` - Android 原生入口:`android/app/src/main/java/com/hyzq/boss/MainActivity.java` @@ -351,6 +356,7 @@ npm run aab:release - Web 生产启动、服务器 `systemd` 和部署构建当前都显式设置了 `BOSS_RUNTIME_ROOT`,避免 `process.cwd()` 在 standalone / 服务器构建阶段误把整个仓库根目录带进 tracing - `next.config.ts` 已显式排除 `deployment / docs / design / local-agent / prompts / scripts / android` 等非运行时目录,避免服务器端 standalone tracing 卷入运维资产导致构建失败 - 文件写入已经改成串行事务队列 + 原子写入 + `data/boss-state.json.bak` 备份恢复,`heartbeat` 和 APP 日志并发写不会再互相覆盖 +- 文件状态写入层已默认开启自动历史快照,按 `BOSS_STATE_AUTO_BACKUP_INTERVAL_MS` 节流生成 `data/backups/state-snapshot-*.json`,并按 `BOSS_STATE_AUTO_BACKUP_KEEP` 控制保留数量;最高管理员后台“备份与回退”页可创建手动快照、查看自动快照和恢复到指定快照 - 当前文件存储里已经包含: - `projects / messages / goals / versions` - `authAccounts / otaUpdates / otaUpdateLogs` diff --git a/android/app/src/main/java/com/hyzq/boss/BossApiClient.java b/android/app/src/main/java/com/hyzq/boss/BossApiClient.java index cd3aa43..a946bbc 100644 --- a/android/app/src/main/java/com/hyzq/boss/BossApiClient.java +++ b/android/app/src/main/java/com/hyzq/boss/BossApiClient.java @@ -673,9 +673,15 @@ public class BossApiClient { } public ApiResponse logout() throws IOException, JSONException { - ApiResponse response = request("POST", "/api/auth/logout", new JSONObject(), false); + try { + return request("POST", "/api/auth/logout", new JSONObject(), false); + } finally { + clearSession(); + } + } + + public void clearLocalAuthState() { clearSession(); - return response; } public String getAccountLabel() { @@ -1067,6 +1073,8 @@ public class BossApiClient { prefs.edit() .remove(KEY_SESSION_COOKIE) .remove(KEY_RESTORE_TOKEN) + .remove(KEY_ACCOUNT) + .remove(KEY_DISPLAY_NAME) .apply(); } diff --git a/android/app/src/main/java/com/hyzq/boss/BossUi.java b/android/app/src/main/java/com/hyzq/boss/BossUi.java index 0ecc546..ff7a51d 100644 --- a/android/app/src/main/java/com/hyzq/boss/BossUi.java +++ b/android/app/src/main/java/com/hyzq/boss/BossUi.java @@ -1227,6 +1227,16 @@ public final class BossUi { @Nullable JSONObject progress, @Nullable String meta ) { + String controlMode = progress == null ? "" : progress.optString("controlMode", "").trim(); + String runtimeKind = progress == null ? "" : progress.optString("runtimeKind", "").trim(); + boolean nativeRemoteControl = "native_remote_control".equals(controlMode) + || "browser-automation-runtime".equals(runtimeKind) + || "computer-use-runtime".equals(runtimeKind); + String titleText = progress == null ? "" : progress.optString("title", "").trim(); + if (TextUtils.isEmpty(titleText)) { + titleText = nativeRemoteControl ? "远程控制进度" : "进度"; + } + LinearLayout card = new LinearLayout(context); card.setOrientation(LinearLayout.VERTICAL); LinearLayout.LayoutParams params = new LinearLayout.LayoutParams( @@ -1250,7 +1260,7 @@ public final class BossUi { LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT )); - TextView title = sectionTitle(context, "进度"); + TextView title = sectionTitle(context, titleText); title.setLayoutParams(new LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f)); titleRow.addView(title); TextView pin = new TextView(context); @@ -1277,6 +1287,15 @@ public final class BossUi { } } + if (nativeRemoteControl) { + if (!TextUtils.isEmpty(meta)) { + TextView metaView = secondaryText(context, meta); + metaView.setPadding(0, dp(context, 10), 0, 0); + card.addView(metaView); + } + return card; + } + card.addView(divider(context)); card.addView(sectionTitle(context, "分支详情")); JSONObject branch = progress == null ? null : progress.optJSONObject("branch"); diff --git a/android/app/src/main/java/com/hyzq/boss/MainActivity.java b/android/app/src/main/java/com/hyzq/boss/MainActivity.java index af37dbd..bac1119 100644 --- a/android/app/src/main/java/com/hyzq/boss/MainActivity.java +++ b/android/app/src/main/java/com/hyzq/boss/MainActivity.java @@ -52,6 +52,7 @@ import java.util.function.Supplier; public class MainActivity extends AppCompatActivity { public static final String EXTRA_INITIAL_TAB = "initial_tab"; + public static final String EXTRA_FORCE_LOGOUT = "force_logout"; private static final int REQUEST_POST_NOTIFICATIONS = 2101; private static final String UI_PREFS = "boss_native_client"; private static final String KEY_LAST_ROOT_TAB = "last_root_tab"; @@ -169,6 +170,10 @@ public class MainActivity extends AppCompatActivity { bindViews(); bindActions(); configureBackNavigation(); + if (isForceLogoutIntent(getIntent())) { + forceLogoutToLoginPanel(); + return; + } applyInitialTab(getIntent()); bootstrapSession(); } @@ -195,6 +200,10 @@ public class MainActivity extends AppCompatActivity { protected void onNewIntent(Intent intent) { super.onNewIntent(intent); setIntent(intent); + if (isForceLogoutIntent(intent)) { + forceLogoutToLoginPanel(); + return; + } applyInitialTab(intent); if (contentPanel.getVisibility() == View.VISIBLE) { maybeApplyPreferredEntry(); @@ -202,6 +211,19 @@ public class MainActivity extends AppCompatActivity { } } + private boolean isForceLogoutIntent(@Nullable Intent intent) { + return intent != null && intent.getBooleanExtra(EXTRA_FORCE_LOGOUT, false); + } + + private void forceLogoutToLoginPanel() { + apiClient.clearLocalAuthState(); + sessionData = null; + conversationsData = null; + devicesData = null; + otaData = null; + showLogin("已退出登录。点击登录可重新进入系统。"); + } + private void configureBackNavigation() { getOnBackPressedDispatcher().addCallback(this, new OnBackPressedCallback(true) { @Override diff --git a/android/app/src/main/java/com/hyzq/boss/SecurityActivity.java b/android/app/src/main/java/com/hyzq/boss/SecurityActivity.java index 0cdb75b..b908cb4 100644 --- a/android/app/src/main/java/com/hyzq/boss/SecurityActivity.java +++ b/android/app/src/main/java/com/hyzq/boss/SecurityActivity.java @@ -134,6 +134,7 @@ public class SecurityActivity extends BossScreenActivity { showMessage("会话已撤销"); if (current) { Intent intent = new Intent(this, MainActivity.class); + intent.putExtra(MainActivity.EXTRA_FORCE_LOGOUT, true); intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP | Intent.FLAG_ACTIVITY_NEW_TASK); startActivity(intent); finish(); @@ -161,6 +162,7 @@ public class SecurityActivity extends BossScreenActivity { runOnUiThread(() -> { setRefreshing(false); Intent intent = new Intent(this, MainActivity.class); + intent.putExtra(MainActivity.EXTRA_FORCE_LOGOUT, true); intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP | Intent.FLAG_ACTIVITY_NEW_TASK); startActivity(intent); finish(); diff --git a/android/app/src/test/java/com/hyzq/boss/BossApiClientLogoutTest.java b/android/app/src/test/java/com/hyzq/boss/BossApiClientLogoutTest.java new file mode 100644 index 0000000..388cca3 --- /dev/null +++ b/android/app/src/test/java/com/hyzq/boss/BossApiClientLogoutTest.java @@ -0,0 +1,251 @@ +package com.hyzq.boss; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; + +import android.content.SharedPreferences; + +import org.json.JSONObject; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.annotation.Config; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.InputStream; +import java.io.OutputStream; +import java.io.IOException; +import java.net.HttpURLConnection; +import java.net.ProtocolException; +import java.net.URL; +import java.nio.charset.StandardCharsets; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; + +@RunWith(RobolectricTestRunner.class) +@Config(sdk = 34) +public class BossApiClientLogoutTest { + @Test + public void logoutClearsAllCachedIdentityHints() throws Exception { + InMemorySharedPreferences prefs = new InMemorySharedPreferences(); + prefs.edit().putString("session_cookie", "boss_session=session-token").apply(); + BossApiClient apiClient = new RecordingBossApiClient(prefs); + apiClient.rememberIdentity(new JSONObject() + .put("restoreToken", "restore-token") + .put("account", "honor_user") + .put("displayName", "荣耀测试账号")); + + BossApiClient.ApiResponse response = apiClient.logout(); + + assertEquals(200, response.statusCode); + assertFalse(prefs.contains("session_cookie")); + assertFalse(prefs.contains("restore_token")); + assertFalse(prefs.contains("account")); + assertFalse(prefs.contains("display_name")); + } + + @Test + public void logoutClearsLocalAuthEvenWhenServerRequestFails() throws Exception { + InMemorySharedPreferences prefs = new InMemorySharedPreferences(); + prefs.edit() + .putString("session_cookie", "boss_session=session-token") + .putString("restore_token", "restore-token") + .putString("account", "honor_user") + .putString("display_name", "荣耀测试账号") + .apply(); + BossApiClient apiClient = new FailingLogoutBossApiClient(prefs); + + try { + apiClient.logout(); + } catch (IOException expected) { + // Local logout state must still be cleared if the network request fails. + } + + assertFalse(prefs.contains("session_cookie")); + assertFalse(prefs.contains("restore_token")); + assertFalse(prefs.contains("account")); + assertFalse(prefs.contains("display_name")); + } + + private static final class RecordingBossApiClient extends BossApiClient { + RecordingBossApiClient(SharedPreferences prefs) { + super(prefs, "https://boss.hyzq.net"); + } + + @Override + HttpURLConnection openConnection(String path) throws java.io.IOException { + return new RecordingConnection(new URL("https://boss.hyzq.net" + path)); + } + } + + private static final class FailingLogoutBossApiClient extends BossApiClient { + FailingLogoutBossApiClient(SharedPreferences prefs) { + super(prefs, "https://boss.hyzq.net"); + } + + @Override + HttpURLConnection openConnection(String path) throws IOException { + throw new IOException("network down"); + } + } + + private static final class RecordingConnection extends HttpURLConnection { + private final ByteArrayOutputStream requestBody = new ByteArrayOutputStream(); + private String requestMethodValue = "GET"; + + RecordingConnection(URL url) { + super(url); + } + + @Override + public void disconnect() {} + + @Override + public boolean usingProxy() { + return false; + } + + @Override + public void connect() {} + + @Override + public void setRequestMethod(String method) throws ProtocolException { + requestMethodValue = method; + } + + @Override + public String getRequestMethod() { + return requestMethodValue; + } + + @Override + public int getResponseCode() { + return 200; + } + + @Override + public OutputStream getOutputStream() { + return requestBody; + } + + @Override + public Map> getHeaderFields() { + return Map.of("Set-Cookie", List.of("boss_session=; Max-Age=0; Path=/")); + } + + @Override + public InputStream getInputStream() { + return new ByteArrayInputStream("{\"ok\":true}".getBytes(StandardCharsets.UTF_8)); + } + } + + private static final class InMemorySharedPreferences implements SharedPreferences { + private final Map values = new HashMap<>(); + + @Override + public Map getAll() { + return Collections.unmodifiableMap(values); + } + + @Override + public String getString(String key, String defValue) { + return values.getOrDefault(key, defValue); + } + + @Override + public Set getStringSet(String key, Set defValues) { + throw new UnsupportedOperationException(); + } + + @Override + public int getInt(String key, int defValue) { + throw new UnsupportedOperationException(); + } + + @Override + public long getLong(String key, long defValue) { + throw new UnsupportedOperationException(); + } + + @Override + public float getFloat(String key, float defValue) { + throw new UnsupportedOperationException(); + } + + @Override + public boolean getBoolean(String key, boolean defValue) { + throw new UnsupportedOperationException(); + } + + @Override + public boolean contains(String key) { + return values.containsKey(key); + } + + @Override + public Editor edit() { + return new Editor() { + @Override + public Editor putString(String key, String value) { + values.put(key, value); + return this; + } + + @Override + public Editor remove(String key) { + values.remove(key); + return this; + } + + @Override + public Editor clear() { + values.clear(); + return this; + } + + @Override + public void apply() {} + + @Override + public boolean commit() { + return true; + } + + @Override + public Editor putStringSet(String key, Set values) { + throw new UnsupportedOperationException(); + } + + @Override + public Editor putInt(String key, int value) { + throw new UnsupportedOperationException(); + } + + @Override + public Editor putLong(String key, long value) { + throw new UnsupportedOperationException(); + } + + @Override + public Editor putFloat(String key, float value) { + throw new UnsupportedOperationException(); + } + + @Override + public Editor putBoolean(String key, boolean value) { + throw new UnsupportedOperationException(); + } + }; + } + + @Override + public void registerOnSharedPreferenceChangeListener(OnSharedPreferenceChangeListener listener) {} + + @Override + public void unregisterOnSharedPreferenceChangeListener(OnSharedPreferenceChangeListener listener) {} + } +} diff --git a/android/app/src/test/java/com/hyzq/boss/MainActivityBootstrapSessionTest.java b/android/app/src/test/java/com/hyzq/boss/MainActivityBootstrapSessionTest.java index 83327bb..c8cab43 100644 --- a/android/app/src/test/java/com/hyzq/boss/MainActivityBootstrapSessionTest.java +++ b/android/app/src/test/java/com/hyzq/boss/MainActivityBootstrapSessionTest.java @@ -5,6 +5,7 @@ import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertNotNull; import android.content.Context; +import android.content.Intent; import android.content.SharedPreferences; import android.view.View; @@ -67,6 +68,27 @@ public class MainActivityBootstrapSessionTest { assertEquals("krisolo", sessionData.optString("account", "")); } + @Test + public void forceLogoutIntentClearsExistingContentSessionAndShowsLogin() throws Exception { + TestRestoreBootstrapSessionMainActivity activity = + Robolectric.buildActivity(TestRestoreBootstrapSessionMainActivity.class).setup().get(); + + waitFor(() -> activity.apiClient.restoreCalls > 0 && activity.apiClient.homeCalls > 0); + + Intent intent = new Intent(activity, MainActivity.class); + intent.putExtra("force_logout", true); + activity.onNewIntent(intent); + Shadows.shadowOf(android.os.Looper.getMainLooper()).idleFor(Duration.ofMillis(200)); + + View loginPanel = activity.findViewById(R.id.login_panel); + View contentPanel = activity.findViewById(R.id.content_panel); + JSONObject sessionData = ReflectionHelpers.getField(activity, "sessionData"); + + assertEquals(View.VISIBLE, loginPanel.getVisibility()); + assertEquals(View.GONE, contentPanel.getVisibility()); + assertEquals(null, sessionData); + } + private static void waitFor(BooleanSupplier condition) { long deadline = System.currentTimeMillis() + 5_000L; while (System.currentTimeMillis() < deadline) { diff --git a/android/app/src/test/java/com/hyzq/boss/ProjectDetailActivityUiTest.java b/android/app/src/test/java/com/hyzq/boss/ProjectDetailActivityUiTest.java index d2e4fed..1c5ec7c 100644 --- a/android/app/src/test/java/com/hyzq/boss/ProjectDetailActivityUiTest.java +++ b/android/app/src/test/java/com/hyzq/boss/ProjectDetailActivityUiTest.java @@ -904,6 +904,53 @@ public class ProjectDetailActivityUiTest { assertTrue(viewTreeContainsText(messageView, "Mendel(explorer)")); } + @Test + public void nativeRemoteExecutionProgressDoesNotRenderCodexSections() throws Exception { + Intent intent = new Intent() + .putExtra(ProjectDetailActivity.EXTRA_PROJECT_ID, "native-remote") + .putExtra(ProjectDetailActivity.EXTRA_PROJECT_NAME, "MacBook Air 桌面控制"); + TestProjectDetailActivity activity = Robolectric + .buildActivity(TestProjectDetailActivity.class, intent) + .setup() + .get(); + + JSONObject message = new JSONObject() + .put("id", "native-progress-1") + .put("sender", "master") + .put("senderLabel", "主 Agent") + .put("body", "执行进度") + .put("kind", "execution_progress") + .put("sentAt", "2026-05-13T10:16:00+08:00") + .put("executionProgress", new JSONObject() + .put("title", "远程控制进度") + .put("controlMode", "native_remote_control") + .put("runtimeKind", "browser-automation-runtime") + .put("status", "running") + .put("steps", new JSONArray() + .put(new JSONObject().put("text", "接收远程控制指令").put("status", "done")) + .put(new JSONObject().put("text", "连接目标电脑").put("status", "running"))) + .put("branch", new JSONObject() + .put("githubCliStatus", "unavailable")) + .put("agents", new JSONArray() + .put(new JSONObject().put("name", "Mendel").put("role", "explorer")))); + + View messageView = ReflectionHelpers.callInstanceMethod( + activity, + "buildMessageView", + ReflectionHelpers.ClassParameter.from(JSONObject.class, message) + ); + + assertTrue(viewTreeContainsText(messageView, "远程控制进度")); + assertTrue(viewTreeContainsText(messageView, "接收远程控制指令")); + assertTrue(viewTreeContainsText(messageView, "连接目标电脑")); + assertFalse(viewTreeContainsText(messageView, "分支详情")); + assertFalse(viewTreeContainsText(messageView, "Git 操作")); + assertFalse(viewTreeContainsText(messageView, "GitHub CLI 不可用")); + assertFalse(viewTreeContainsText(messageView, "后台智能体")); + assertFalse(viewTreeContainsText(messageView, "Mendel(explorer)")); + assertFalse(viewTreeContainsText(messageView, "Codex")); + } + @Test public void completedReplyResponseRendersImmediatelyWithoutReloadingProjectDetail() throws Exception { Intent intent = new Intent() diff --git a/apps/boss-admin-web/src/App.vue b/apps/boss-admin-web/src/App.vue index 610f0a3..8f5327b 100644 --- a/apps/boss-admin-web/src/App.vue +++ b/apps/boss-admin-web/src/App.vue @@ -1,5 +1,5 @@