feat: harden enterprise control plane
This commit is contained in:
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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) {}
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user