feat: harden enterprise control plane

This commit is contained in:
AI Bot
2026-05-17 02:20:08 +08:00
parent 67511c31f4
commit e1aed590f8
112 changed files with 10977 additions and 2004 deletions

View File

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

View File

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

View File

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

View File

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

View File

@@ -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<String, List<String>> 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<String, String> values = new HashMap<>();
@Override
public Map<String, ?> getAll() {
return Collections.unmodifiableMap(values);
}
@Override
public String getString(String key, String defValue) {
return values.getOrDefault(key, defValue);
}
@Override
public Set<String> getStringSet(String key, Set<String> 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<String> 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) {}
}
}

View File

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

View File

@@ -904,6 +904,53 @@ public class ProjectDetailActivityUiTest {
assertTrue(viewTreeContainsText(messageView, "Mendelexplorer"));
}
@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, "Mendelexplorer"));
assertFalse(viewTreeContainsText(messageView, "Codex"));
}
@Test
public void completedReplyResponseRendersImmediatelyWithoutReloadingProjectDetail() throws Exception {
Intent intent = new Intent()