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

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

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

File diff suppressed because it is too large Load Diff

View File

@@ -6,7 +6,30 @@ export interface BossAdminMenuItem {
export interface BossAdminBackofficePayload {
ok: boolean;
surface: "platform" | "enterprise";
currentCompany: Record<string, unknown> | null;
menuTree: BossAdminMenuItem[];
insights: {
onboardingSteps: string[];
serviceStatuses: Array<Record<string, unknown>>;
openingPreview: Array<Record<string, unknown>>;
deliveryChecklist: Array<Record<string, unknown>>;
recentCompanies: Array<Record<string, unknown>>;
customerHealthRows: Array<Record<string, unknown>>;
riskAggregates: Array<Record<string, unknown>>;
customerFollowups: Array<Record<string, unknown>>;
enterpriseGoals: Array<Record<string, unknown>>;
organizationUnits: string[];
departmentProgress: Array<Record<string, unknown>>;
masterAgentSummary: string[];
permissionHighlights: string[];
agentFlowSteps: string[];
skillUsageAudit: Array<Record<string, unknown>>;
recoveryActions: string[];
backupStatus: Record<string, unknown>;
capabilitySummary: Record<string, number>;
surface: "platform" | "enterprise";
};
workbench: {
summary: Record<string, number>;
companies: Array<Record<string, unknown>>;
@@ -36,6 +59,34 @@ export interface BossAdminBackofficePayload {
yudaoMapping: Record<string, string>;
}
export interface BossAdminBackupSnapshot {
snapshotId: string;
fileName: string;
absolutePath: string;
bytes: number;
sha256: string;
createdAt: string;
actorAccount?: string;
reason?: string;
schemaVersion?: number;
}
export interface BossAdminBackupStatus {
mode: "file";
backupDir: string;
stateFile: string;
restorePointCount: number;
lastBackupAt?: string;
status: "ready" | "empty" | "error";
detail?: string;
}
export interface BossAdminBackupsPayload {
ok: boolean;
status: BossAdminBackupStatus;
snapshots: BossAdminBackupSnapshot[];
}
async function requestJson<T>(url: string, init: RequestInit = {}): Promise<T> {
const response = await fetch(url, {
credentials: "include",
@@ -57,8 +108,8 @@ async function requestJson<T>(url: string, init: RequestInit = {}): Promise<T> {
return response.json();
}
export async function fetchBossAdminBackoffice(): Promise<BossAdminBackofficePayload> {
return requestJson<BossAdminBackofficePayload>("/api/v1/admin/backoffice");
export async function fetchBossAdminBackoffice(scope: "platform" | "enterprise" = "platform"): Promise<BossAdminBackofficePayload> {
return requestJson<BossAdminBackofficePayload>(`/api/v1/admin/backoffice?scope=${scope}`);
}
export async function postAdminAccess(payload: Record<string, unknown>) {
@@ -81,3 +132,27 @@ export async function postSkillLifecycleRequest(payload: Record<string, unknown>
body: JSON.stringify(payload),
});
}
export async function fetchAdminBackups(): Promise<BossAdminBackupsPayload> {
return requestJson<BossAdminBackupsPayload>("/api/v1/admin/backups");
}
export async function createAdminBackup(reason: string) {
return requestJson<Record<string, unknown>>("/api/v1/admin/backups", {
method: "POST",
body: JSON.stringify({
action: "create_snapshot",
reason,
}),
});
}
export async function restoreAdminBackup(snapshotId: string) {
return requestJson<Record<string, unknown>>("/api/v1/admin/backups", {
method: "POST",
body: JSON.stringify({
action: "restore_snapshot",
snapshotId,
}),
});
}

View File

@@ -8,62 +8,109 @@
body {
margin: 0;
min-width: 1280px;
min-width: 1360px;
min-height: 100vh;
background:
radial-gradient(circle at top left, rgba(16, 185, 129, 0.16), transparent 30%),
linear-gradient(135deg, #f7fbf8 0%, #eef4ef 55%, #e8f1ed 100%);
linear-gradient(135deg, #f7fbf8 0%, #eef4ef 54%, #e6f0ec 100%);
}
.boss-admin-shell {
display: grid;
grid-template-columns: 280px 1fr;
grid-template-columns: 300px 1fr;
min-height: 100vh;
}
.boss-admin-sidebar {
padding: 28px 20px;
background: rgba(255, 255, 255, 0.78);
background: rgba(255, 255, 255, 0.82);
border-right: 1px solid rgba(16, 32, 24, 0.08);
backdrop-filter: blur(20px);
backdrop-filter: blur(22px);
}
.boss-admin-brand {
display: flex;
gap: 14px;
align-items: center;
margin-bottom: 32px;
margin-bottom: 22px;
}
.boss-admin-brand-mark {
display: grid;
place-items: center;
width: 44px;
height: 44px;
width: 46px;
height: 46px;
color: white;
font-weight: 800;
font-weight: 900;
background: #10b981;
border-radius: 16px;
border-radius: 17px;
box-shadow: 0 14px 28px rgba(16, 185, 129, 0.24);
}
.boss-admin-brand h1 {
margin: 0;
font-size: 20px;
letter-spacing: -0.02em;
}
.boss-admin-brand p,
.boss-admin-eyebrow {
margin: 0;
color: #6b766f;
color: #68766e;
font-size: 13px;
}
.boss-admin-surface-switch {
display: grid;
gap: 10px;
margin-bottom: 24px;
}
.boss-admin-surface-card {
display: grid;
gap: 4px;
width: 100%;
padding: 14px;
color: #526158;
font: inherit;
text-align: left;
cursor: pointer;
background: rgba(247, 250, 248, 0.78);
border: 1px solid rgba(16, 32, 24, 0.08);
border-radius: 18px;
}
.boss-admin-surface-card span {
font-size: 15px;
font-weight: 800;
}
.boss-admin-surface-card small {
color: #7b8780;
font-size: 12px;
}
.boss-admin-surface-card.active {
color: #0b6b4c;
background: #e5f8ef;
border-color: rgba(16, 185, 129, 0.36);
box-shadow: 0 12px 30px rgba(16, 185, 129, 0.12);
}
.boss-admin-menu {
display: grid;
gap: 8px;
}
.boss-admin-visually-hidden {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
}
.boss-admin-menu-item {
display: flex;
gap: 12px;
@@ -93,12 +140,14 @@ body {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 20px;
margin-bottom: 22px;
}
.boss-admin-header h2 {
margin: 4px 0 0;
font-size: 28px;
margin: 5px 0 0;
font-size: 30px;
line-height: 1.15;
letter-spacing: -0.03em;
}
.boss-admin-header-actions {
@@ -121,47 +170,106 @@ body {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.boss-admin-section-title {
grid-column: 1 / -1;
color: #25342b;
font-size: 18px;
font-weight: 700;
}
.boss-admin-hero {
grid-column: 1 / -1;
overflow: hidden;
background:
linear-gradient(135deg, rgba(16, 185, 129, 0.16), rgba(255, 255, 255, 0.92)),
linear-gradient(135deg, rgba(16, 185, 129, 0.16), rgba(255, 255, 255, 0.94)),
white;
}
.boss-admin-hero h3 {
margin: 8px 0 20px;
font-size: 24px;
max-width: 820px;
margin: 8px 0 22px;
font-size: 25px;
line-height: 1.25;
letter-spacing: -0.03em;
}
.boss-admin-metrics {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
grid-template-columns: repeat(5, minmax(0, 1fr));
gap: 14px;
}
.boss-admin-metrics .ant-statistic {
.boss-admin-metrics.compact {
grid-template-columns: repeat(4, minmax(0, 1fr));
margin-top: 18px;
}
.boss-admin-metric {
display: grid;
gap: 8px;
padding: 18px;
background: rgba(255, 255, 255, 0.76);
background: rgba(255, 255, 255, 0.78);
border: 1px solid rgba(16, 32, 24, 0.08);
border-radius: 18px;
}
.boss-admin-metric span {
color: #6a766f;
font-size: 13px;
}
.boss-admin-metric strong {
color: #102018;
font-size: 30px;
line-height: 1;
}
.boss-admin-metric.green strong {
color: #10b981;
}
.boss-admin-metric.red strong {
color: #f04452;
}
.boss-admin-metric.orange strong {
color: #f97316;
}
.boss-admin-form {
max-width: 520px;
max-width: 540px;
}
.boss-admin-form-gap {
margin-top: 10px;
}
.boss-admin-steps {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 12px;
}
.boss-admin-step {
display: flex;
gap: 10px;
align-items: center;
padding: 14px;
color: #51625a;
background: rgba(255, 255, 255, 0.76);
border: 1px solid rgba(16, 32, 24, 0.08);
border-radius: 16px;
}
.boss-admin-step span {
display: grid;
place-items: center;
width: 26px;
height: 26px;
color: #0f7a55;
background: #dbf7ea;
border-radius: 50%;
}
.boss-admin-step.active {
color: #0b6b4c;
border-color: rgba(16, 185, 129, 0.34);
box-shadow: 0 10px 24px rgba(16, 185, 129, 0.12);
}
.boss-admin-action-strip {
display: grid;
grid-template-columns: 220px 320px 1fr;
@@ -169,6 +277,123 @@ body {
margin-bottom: 16px;
}
.boss-admin-status-list,
.boss-admin-check-list,
.boss-admin-goal-list {
display: grid;
gap: 12px;
}
.boss-admin-status-row,
.boss-admin-check-row {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 14px;
background: #f7faf8;
border: 1px solid rgba(16, 32, 24, 0.06);
border-radius: 14px;
}
.boss-admin-check-row {
justify-content: flex-start;
gap: 12px;
}
.boss-admin-check-row span {
display: grid;
place-items: center;
width: 24px;
height: 24px;
color: #f97316;
background: #fff7ed;
border-radius: 50%;
}
.boss-admin-check-row span.done {
color: #10b981;
background: #dcfce7;
}
.boss-admin-goal-row {
display: grid;
gap: 8px;
padding: 14px;
background: #f7faf8;
border-radius: 14px;
}
.boss-admin-goal-row > div {
display: flex;
justify-content: space-between;
}
.boss-admin-org-grid,
.boss-admin-capability-grid,
.boss-admin-recovery-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
.boss-admin-org-node,
.boss-admin-recovery-card,
.boss-admin-capability-grid > div {
padding: 16px;
color: #18352a;
font-weight: 800;
background: #f7faf8;
border: 1px solid rgba(16, 32, 24, 0.06);
border-radius: 16px;
}
.boss-admin-capability-grid span {
display: block;
color: #68766e;
font-size: 12px;
font-weight: 600;
}
.boss-admin-capability-grid strong {
color: #10b981;
font-size: 28px;
}
.boss-admin-permission-list {
display: flex;
flex-wrap: wrap;
gap: 10px;
}
.boss-admin-flow {
display: flex;
flex-wrap: wrap;
gap: 10px;
align-items: center;
}
.boss-admin-flow-node {
padding: 14px 16px;
color: #0b6b4c;
font-weight: 800;
background: #e5f8ef;
border-radius: 16px;
}
.boss-admin-flow-arrow {
color: #8b9790;
font-size: 20px;
}
.boss-admin-backup-status {
margin-top: 16px;
}
.ant-card {
border-radius: 22px;
box-shadow: 0 18px 50px rgba(16, 32, 24, 0.07);
}
.ant-table-wrapper .ant-table {
border-radius: 16px;
}

View File

@@ -13,7 +13,11 @@ boss.hyzq.net {
admin.boss.hyzq.net {
encode zstd gzip
redir / /admin 308
@adminRoot path /
handle @adminRoot {
rewrite * /admin-web/index.html
reverse_proxy 127.0.0.1:3000
}
reverse_proxy 127.0.0.1:3000
}

View File

@@ -15,7 +15,7 @@
<array>
<string>/bin/zsh</string>
<string>-lc</string>
<string>cd /Users/kris/code/boss &amp;&amp; node scripts/codex-desktop-refresh-bridge-daemon.mjs</string>
<string>cd __BOSS_AGENT_ROOT__ &amp;&amp; node scripts/codex-desktop-refresh-bridge-daemon.mjs</string>
</array>
<key>RunAtLoad</key>
<true/>

View File

@@ -8,7 +8,7 @@
<array>
<string>/bin/zsh</string>
<string>-lc</string>
<string>cd /Users/kris/code/boss &amp;&amp; ./scripts/start-local-agent.sh __BOSS_AGENT_CONFIG__</string>
<string>cd __BOSS_AGENT_ROOT__ &amp;&amp; ./scripts/start-local-agent.sh __BOSS_AGENT_CONFIG__</string>
</array>
<key>RunAtLoad</key>
<true/>

View File

@@ -26,14 +26,14 @@
边界:
- 现有 `/admin` 不删除,继续作为主站内 fallback
- `/admin` UI 已删除,`/admin` 仅保留为跳转到根路径 `/` 的兼容入口;生产域名 `https://admin.boss.hyzq.net/` 直接承载新独立 PC 后台
- 独立后台只消费 Admin BFF不直接读取 `boss-state.json`
- 独立后台当前复用 Boss Cookie 登录态,后续再绑定 `admin.boss.hyzq.net` 的独立部署。
- `/api/v1/admin/backoffice` 仍只允许 `highest_admin`,并过滤 `passwordHash``mfaSecret` 和 session token。
## 当前落地范围
- 新增 `/admin` 页面。
- `/admin` 页面收敛为兼容跳转,不再承载旧 Next 管理 UI
- 新增 `/api/v1/admin/overview` 聚合接口。
- 新增 `/api/v1/admin/backoffice` 独立企业后台聚合接口。
- 新增 `/api/v1/admin/risks/actions` 风险处理动作接口。
@@ -122,8 +122,8 @@
## 权限边界
- `/admin` 页面要求登录
-`highest_admin` 只看到“仅最高管理员可用”提示。
- `/admin` 页面直接跳转根路径 `/`,生产根路径由 `admin.boss.hyzq.net` 站点内部 rewrite 到独立后台静态入口
-`highest_admin` 访问 `/enterprise-admin`只看到“仅最高管理员可用”提示。
- `/api/v1/admin/overview` 未登录返回 `401`,非最高管理员返回 `403`
- `/api/v1/admin/risks/actions` 未登录返回 `401`,非最高管理员返回 `403`
- `/api/v1/admin/risks/scan` 未登录返回 `401`,非最高管理员返回 `403`

View File

@@ -19,12 +19,13 @@
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. `docs/architecture/wechat_project_conversation_mapping_cn.md`
8. `docs/architecture/thread_context_budget_and_handoff_protocol_cn.md`
9. `docs/architecture/dependency_security_audit_cn.md`
10. `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. `docs/architecture/wechat_project_conversation_mapping_cn.md`
9. `docs/architecture/thread_context_budget_and_handoff_protocol_cn.md`
10. `docs/architecture/dependency_security_audit_cn.md`
11. `prompts/codex_fullstack_build_and_deploy_prompt_cn.md`
## 3. 当前有效实现边界
@@ -42,6 +43,7 @@
- `src/lib/boss-storage-server-file.ts`:服务器文件存储上传 / 读取
- `src/lib/boss-storage-aliyun-oss.ts`:阿里 OSS 私有桶上传 / 签名下载
- `src/lib/boss-ota.ts`APK OTA 产物定位与元数据读取
- `src/lib/boss-agent-ota.ts`boss-agent macOS 运行包 OTA 产物定位与元数据读取
- `src/lib/boss-projections.ts`:当前聚合 BFF 投影视图
- `src/components/app-runtime.tsx`APP 日志桥、SSE 刷新和 Skill 面板
- `local-agent/server.mjs`:设备端心跳和 thread-context 上报服务
@@ -105,6 +107,7 @@
- `GET /api/v1/app-logs` 正常,可按登录态分页读取 APP 日志
- `POST /api/v1/projects/master-agent/messages` 正常,已验证通过 `local-agent -> codex exec -> complete` 返回真实主 Agent 回复
- `GET /api/v1/user/ota/package` 正常,当前会返回最新 APK
- `GET /api/v1/boss-agent/ota``GET /api/v1/boss-agent/ota/package` 已接入 boss-agent Mac 端 OTA要求设备 token打包脚本会发布 `boss-agent-mac-latest.zip/json`
- `npm run apk:release` 正常,已能输出 signed release APK
- 当前原生 Android 页面已覆盖会话、设备、我的三栏和主要二级页,不再依赖 WebView 承载业务页面
- 本地 `device-agent` 正常
@@ -149,6 +152,10 @@
- 当前设备导入 `review` 已经会留下 `device_import_resolution` master task 轨迹,但决议内容仍是服务端 heuristic 版,尚未真正交给 `local-agent -> codex exec`
- Web 和原生 Android 当前都已经接上“新设备导入草稿 -> 勾选 -> 决议预览 -> 应用导入”的前台页面;已绑定生产设备继续保留 heartbeat 自动导入链路
- 原生首页的刷新失败策略当前已改成按当前 tab 独立判错,不会再因为 `设备 / 设置 / OTA` 的旁路请求失败把会话页刷新一并判成失败
- 当前量产方向已经明确为“Boss 企业控制面 + 可插拔执行协议”:多租户、权限、审批、审计、备份、回退和 Skill 治理由 Boss 承担Codex App Server / Codex MCP / Codex CLI / Computer Use / 业务系统 API 都作为 provider 接入;详见 `docs/architecture/enterprise_ai_ops_architecture_cn.md`
- 当前 Codex App Server 已完成第一批接入boss-agent 默认开启 `local-agent/codex-app-server-runner.mjs` 作为 Codex 绑定入口,优先走 `codex app-server` stdioturn 启动前失败才回退 CLIturn 启动后不重复执行;桌面远程控制默认先走 `codex-computer-use`,失败后回退 `cua-driver-computer-use`
- 当前 boss-agent 已支持 Mac OTA`local-agent/boss-agent-ota-runner.mjs` 默认开启,每 5 分钟检查服务端最新包;状态页可手动检查或下载并安装,安装时保留原绑定配置,只更新版本号和本机 runtime 路径。最新验证版本为 `20260516221619`,已在 MacBook Air `macbook-air` 上确认 OTA 下载校验、暂存、覆盖安装后不会误切到默认 `config.cloud.json`。正式分发脚本已预留 Developer ID 公证路径:`BOSS_AGENT_NOTARIZE=1` 配合 notary profile 或 Apple ID 凭据。
- 当前量产治理已补设备撤权和任务可靠性底座:`revoke_device` 会清空设备 token、标记离线并阻断 heartbeat / 任务认领 / Skill 同步 / 日志上报 / boss-agent OTA`MasterAgentTask` claim 会记录 attempt 和 lease运行中任务可按租约重试超过上限转 `timed_out`,用户或管理员可通过 cancel 接口转 `canceled` 且迟到 complete 不覆盖终态。
- 当前群聊 `dispatch_execution` 完成回写已补幂等,重复完成不会再向群聊重复追加结果
- 当前已支持微信式消息转发:长按消息可直接 `转发 / 多选 / 复制 / 删除`,单条消息转发显示为普通转发消息,多条消息转发显示为聊天记录卡片
- 当前已支持聊天附件主链:原生聊天框左侧 `+` 会打开底部抽屉,支持图片 / 视频 / 文件发送;图片 / PDF / 文本默认自动进入主 Agent 附件分析,视频 / Office / 大文件默认手动触发
@@ -159,7 +166,7 @@
- `版本迭代记录` 只读,由主 Agent 汇总
- `我的` 根页当前保留 `账号与安全 / 设置 / 运维与修复 / AI 账号 / 附件与存储 / Telegram 接入 / 技能 / 关于`,其中 `用户与权限` 仅最高管理员可见
- `我的 > 账号与安全` 已支持查看和撤销登录会话;最高管理员可管理全部活跃会话,子账号只能管理自己的会话
- `我的 > 用户与权限` 与 Web `/me/access` 共用 `/api/v1/admin/access`,可创建子账号、分配设备 / 项目 / Skill 权限,并查看同名 Skill 跨设备聚合PC `/admin` 已补公司停用、CSV/文本批量导入预览、重置密码、子账号 MFA、风险 SLA 通知派发、风险时间线和后台审计来源字段
- `我的 > 用户与权限` 与 Web `/me/access` 共用 `/api/v1/admin/access`,可创建子账号、分配设备 / 项目 / Skill 权限,并查看同名 Skill 跨设备聚合PC 总后台已收敛到 `https://admin.boss.hyzq.net/` 根路径,`/admin` 仅保留跳转兼容
- 多用户 / RBAC / Skill / 主 Agent 权限和多设备控制的集中状态、回归矩阵与缺口清单见 `docs/architecture/rbac_skill_regression_matrix_cn.md`
- `我的 > 主 Agent 提示词 / 记忆` 当前可编辑管理员全局主提示词、用户主提示词、当前对话附加提示词,以及用户通用记忆 / 项目记忆
- `我的 > AI 账号` 必须可查看和切换 `主 GPT / 备用 GPT / API 容灾`
@@ -232,7 +239,7 @@ npm run apk:debug
- OTA 版本中心、检查更新、执行升级和 APK 包下载已接通,但当前仍是文件型状态驱动的 MVP
- APP 实时日志同步、主 Agent 日志镜像、SSE 自动刷新和 Skill 同步页已经接通;日志检索已有基础分页,风险 SLA 通知账本已接入,外部通知渠道仍未做
- 设备导入主链当前已经具备后端闭环和 Web/Android 前台接线;主 Agent 理解同步已经避免未接管状态下主动问线程,后续重点是继续细化导入筛选规则和用户主动同步体验
- 数据库尚未替代文件存储;当前已补 `BOSS_STATE_STORE=postgres` 单行 JSONB 适配层、schema 和 `scripts/boss-state-store-maintenance.mjs` 备份 / 迁移 / 回滚工具,但生产仍默认文件状态
- 数据库尚未替代文件存储;当前已补 `BOSS_STATE_STORE=postgres` 单行 JSONB 适配层、schema 和 `scripts/boss-state-store-maintenance.mjs` schema 校验 / 文件备份 / dry-run 迁移 / PostgreSQL 备份导出 / 备份恢复 / 文件回滚工具但生产仍默认文件状态。PostgreSQL 路径必须显式设置 `BOSS_STATE_STORE=postgres`,真实连接 / 写入还必须设置 `BOSS_DATABASE_URL`。最高管理员后台已新增 `GET/POST /api/v1/admin/backups` 文件状态快照能力,可手动创建、列出和恢复快照,恢复前会自动生成 pre-restore 快照;文件状态写入层已默认开启自动 `auto:writeState` 历史快照
- 域名入口的代理 / 分裂 DNS 结构仍未完全摸清
- 当前只支持服务器文件存储和阿里 OSS尚未接更多对象存储或更丰富的附件详情页
- 认证已有真实 session、restore token 轮换、单会话撤销、CSRF 基础防护和 MFA 开关,但还没有企业 SSO / IdP

View File

@@ -25,7 +25,7 @@
- 构建脚本:`npm run admin:web:build`
- 数据入口:`GET /api/v1/admin/backoffice`
- 登录态:复用 `boss_session` HttpOnly Cookie
- 当前定位:平台侧 To B 总后台面向公司、账号、设备、项目、Skill、风险与审计治理现有 `/admin` 继续作为主站内 fallback
- 当前定位:平台侧 To B 总后台面向公司、账号、设备、项目、Skill、风险与审计治理生产入口为 `https://admin.boss.hyzq.net/` 根路径Caddy 内部 rewrite 到 `/admin-web/index.html`,旧 `/admin` UI 已移除,仅作为跳转到根域的兼容入口
### 1.3 boss-android-native
@@ -117,6 +117,8 @@
- 当前 `RemoteRuntimeAdapter` 还负责拦截固定模式的线程内部环境提示;命中后会直接改写成失败,避免把只读/cwd 这类脏文本写进聊天记录
- 当前普通单线程 `conversation_reply` 在真正执行 `codex exec resume` 前,会先把 Boss 用户消息镜像进目标 Codex Desktop rollout定位优先走 `state_5.sqlite`,不可用时回退扫描 `~/.codex/sessions`,并按 `sourceMessageId` 去重
- 当前 Codex Desktop 同步新增常驻刷新桥:`scripts/codex-desktop-refresh-bridge-daemon.mjs` 通过 launchd 监听 `127.0.0.1:4318`,暴露 `POST /api/v1/codex-desktop/refresh``GET /api/v1/codex-desktop/events``GET /api/v1/codex-desktop/events/recent``GET /api/v1/codex-desktop/capabilities``local-agent` 会优先调用 refresh endpoint失败时回退到 `scripts/codex-desktop-refresh-hint.mjs` 命令式刷新。SSE 事件只包含线程引用、消息 ID、状态、deep link 等安全元数据,不包含用户正文或内部 prompt`scripts/codex-desktop-event-consumer.mjs` 可作为 Desktop 插件/IPC 接入前的订阅 smoke`scripts/codex-desktop-integration-probe.mjs` 负责只读探测 Codex.app 能力
- 当前新增 Codex App Server runner`local-agent/codex-app-server-runner.mjs`。boss-agent 默认配置 `codexAppServerEnabled=true`,会接管 `conversation_reply / dispatch_execution`;它通过 stdio 启动 `codex app-server`,执行 `initialize -> thread/resume|thread/start -> turn/start`,并把 `item/agentMessage/delta``item/completed` 归一成 Boss 任务回复。turn 启动前失败可回退 CLIturn 启动后失败不回退,避免重复执行。
- 当前 boss-agent Mac OTA 已接入:`local-agent/boss-agent-ota-runner.mjs` 会用设备 token 调 Boss 服务端 `/api/v1/boss-agent/ota` 检查最新 Mac 运行包,`/api/v1/boss-agent/ota/apply` 会下载 `boss-agent-mac-latest.zip`、校验 sha256、暂存安装 wrapper并拉起本机安装器安装脚本会保留绑定配置并只更新版本号与本机 runtime 路径。安装器会优先沿用当前 LaunchAgent active config并保留所有 `config*.json`,避免多电脑场景中误绑定到默认设备配置。当前最新验证包为 `20260516221619`;构建脚本支持 `BOSS_AGENT_NOTARIZE=1` 的 Developer ID 公证路径。
- 当前 `local-agent` 还新增了两条统一电脑控制 runtime
- `local-agent/browser-control-task-runner.mjs`
- `local-agent/computer-use-task-runner.mjs`
@@ -125,27 +127,33 @@
- 相关配置项:
- `browserControlEnabled / browserControlCommand / browserControlArgs / browserControlWorkdir / browserControlTimeoutMs`
- `computerUseEnabled / computerUseCommand / computerUseArgs / computerUseWorkdir / computerUseTimeoutMs`
- 当前仓库已自带最小 smoke runtime
- `codexAppServerEnabled / codexAppServerCommand / codexAppServerArgs / codexAppServerWorkdir / codexAppServerTimeoutMs / codexAppServerFallbackToCli`
- `codexComputerUseEnabled / codexComputerUseCommand / codexComputerUseArgs / codexComputerUseWorkdir / codexComputerUseTimeoutMs / codexComputerUseFallbackToCua`
- 当前仓库已自带 browser smoke runtime、desktop Cua runtime 和旧 desktop smoke 兜底:
- `scripts/browser-control-smoke.mjs`
- `scripts/codex-computer-use-runtime.mjs`
- `scripts/cua-driver-computer-use-runtime.mjs`
- `scripts/computer-use-smoke.mjs`
- `scripts/browser-control-smoke.mjs` 当前已支持两段式最小真实动作:
- 能从目标 URL 拉取 HTML 标题并回写到 `replyBody / executionSummary`
- 在显式配置 opener 命令时可实际执行打开 URL
- `scripts/computer-use-smoke.mjs` 当前已支持识别常见桌面应用名macOS 下默认用 `osascript` 激活目标应用,并支持把用户请求中的引号文本输入到当前前台应用、按需回车发送;同时保留 `open -a` 兜底,并会落盘结构化 artifact便于后续真实 Computer Use runtime 复用同一回写协议
- `config.example.json / config.cloud.json` 现默认把这两条 smoke runtime 作为 browser/desktop 控制的推荐起步配置
- `scripts/codex-computer-use-runtime.mjs` 当前通过 `codex app-server` 发起 Codex Computer Use 桌面控制,是 boss-agent 的默认桌面控制入口;失败时由 `local-agent/computer-use-task-runner.mjs` 自动回退 CUA
- `scripts/cua-driver-computer-use-runtime.mjs` 当前通过外部 `cua-driver` 执行 macOS 桌面 GUI 控制:先 `launch_app`,再按返回窗口做 `get_window_state`,需要写入文本时调用 `type_text` 并再次观测;发送、提交、删除、支付等高风险动作默认返回 `needs_user_action`,不静默下发
- `scripts/computer-use-smoke.mjs` 当前已支持识别常见桌面应用名macOS 下默认用 `osascript` 激活目标应用,并支持把用户请求中的引号文本输入到当前前台应用、按需回车发送;它保留为旧兜底和回归资产
- `config.example.json / config.cloud.json` 现默认把 browser smoke runtime 和 desktop Cua runtime 作为 browser/desktop 控制的推荐起步配置
- `config.example.json / config.cloud.json` 现同时默认把 `browserAutomationConnected / computerUseConnected` 置为 `true`,让前台设备详情默认按“这台 Mac 已具备浏览器控制 / 桌面控制能力”展示
- 这两条 smoke runtime 当前还会返回结构化字段:
- browser`targetUrl / artifacts`
- desktop`targetApp / typedText / artifacts`
- 这样前台与后续真实 runtime 可以共用同一套结果形态,而不需要等接入 Playwright / Computer Use 后再改返回协议
- heartbeat 的 `browserAutomation / computerUse` 能力上报会同时参考静态 connected 标记和 runtime 配置状态
- heartbeat 的 `browserAutomation / computerUse` 能力上报会同时参考静态 connected 标记和 runtime 配置状态`codexAppServer` 能力上报会参考 feature flag 与 app-server 命令可执行性
### 1.5 Caddy
- 作用:反向代理和 HTTPS 自动续签
- 服务器服务名:`caddy.service`
- 配置文件:`deployment/Caddyfile`
- 当前站点:`boss.hyzq.net` 服务客户 Web / App API`admin.boss.hyzq.net` 服务平台总后台。独立后台第一批仍未替换线上 `/admin`,后续部署完成后再把该域名切到 `apps/boss-admin-web` 静态产物
- 当前站点:`boss.hyzq.net` 服务客户 Web / App API`admin.boss.hyzq.net` 根路径内部 rewrite 到 `/admin-web/index.html`,浏览器地址栏保持 `https://admin.boss.hyzq.net/`,作为平台级 To B 独立后台入口;旧 `/admin` 页面不再渲染旧 UI只做兼容跳转到根路径
### 1.6 boss-server-debug skill
@@ -267,6 +275,7 @@
- `reclaim_account`:离职回收,停用账号、撤销活跃会话并清理设备 / 项目 / Skill 授权
- `upsert_account`:创建或更新子账号
- `set_account_status`:启用或停用子账号;停用时撤销该账号当前活跃会话,且禁止停用最高管理员账号
- `revoke_device`:吊销指定设备,立即清空设备 token、标记离线、写入 `device.revoked` 审计;旧 token 后续不能 heartbeat、领任务、同步 Skill、上传日志或拉取 boss-agent OTA
- `grant_device`:授予设备权限
- `grant_project`:授予项目权限
- `grant_skill`:授予 Skill 权限
@@ -300,7 +309,19 @@
- `resourceGroups`设备、项目线程、Skill 聚合目录和授权记录
- `audit`:风险、通知、风险时间线和 `permissionAuditLogs`
- `yudaoMapping`Boss 账本字段到后台概念的映射,用于后续数据库化或模块拆分
- 当前定位:供 `apps/boss-admin-web` 消费;现有 `/admin` 仍继续使用 `/api/v1/admin/overview` `/api/v1/admin/access`
- 当前定位:供 `https://admin.boss.hyzq.net/ -> apps/boss-admin-web` 消费; `/admin` UI 已下线,不再消费 `/api/v1/admin/overview`旧数据 provider
#### `GET/POST /api/v1/admin/backups`
- 用途:最高管理员做文件状态快照、查看可回退点和执行状态回退
- 权限:仅 `highest_admin`
- `GET` 返回:
- `status`:当前文件状态路径、备份目录、最近快照时间、可回退点数量和校验状态
- `snapshots[]`:快照 ID、创建时间、创建人、备注、大小、sha256 和 schema 版本
- `POST` 输入:
- `action=create_snapshot`:创建当前 `boss-state` 快照,可带 `reason`
- `action=restore_snapshot`:恢复到指定 `snapshotId`
- 当前行为:恢复前会自动创建 `pre-restore:<snapshotId>` 快照,避免误操作后无法回滚;文件状态写入层默认按 `BOSS_STATE_AUTO_BACKUP_INTERVAL_MS` 自动创建 `auto:writeState` 快照,并按 `BOSS_STATE_AUTO_BACKUP_KEEP` 保留;独立 PC 管理后台的“备份与回退”页已接入创建、刷新和恢复动作。
#### `POST /api/v1/admin/risks/scan`
@@ -308,6 +329,7 @@
- 权限:仅 `highest_admin`
- 当前行为:
- 扫描未关闭的 `opsFaults``threadContextAlerts`
- 同步检查运行态异常:在线设备 `Computer Use` 不可用会补 `BOSS.COMPUTER_USE.UNAVAILABLE` 运维故障,`boss-agent OTA` 失败日志会补 `BOSS_AGENT.OTA.FAILED` 运维故障
-`slaDueAt` 已早于当前时间时,写入 `adminNotifications[]`
- 同一个 `riskId` 只生成一条 `risk_sla_overdue` 通知,重复扫描不会重复膨胀账本
- 生成新通知时发布 `project.context_risk.updated`
@@ -553,6 +575,10 @@
- 更新设备状态
-`pairingCode` 合法,则 claim 设备绑定草稿并返回 token
- 若携带 `projectCandidates[]`,则会同步生成或刷新对应设备的 `deviceImportDraft`
- 当前保护:
- 已存在设备必须携带有效 `token` 或未过期 enrollment 的 `pairingCode`
- 未准备 enrollment 的新 `deviceId` 不能通过心跳自注册
- 已吊销设备返回 `DEVICE_REVOKED`,不会更新 `lastSeenAt / status / projects / projectCandidates`
#### `POST /api/projects/[projectId]/goals/[goalId]/toggle`
@@ -1113,6 +1139,20 @@
- 当前归档:发布脚本还会额外保留 `public/downloads/boss-android-v{versionName}-{flavor}.apk`
- 当前保护:要求有效 `boss_session`
#### `GET /api/v1/boss-agent/ota`
- 用途:被控电脑上的 boss-agent 用设备 token 检查 Mac agent 运行包 OTA
- 输入:`deviceId``currentVersion`
- 返回:`hasUpdate` 与最新 `boss-agent-mac-latest.zip` 的版本、大小、sha256、下载地址
- 当前保护:要求 `x-boss-device-token`
#### `GET /api/v1/boss-agent/ota/package`
- 用途:下载当前已发布的最新 boss-agent macOS 运行包
- 当前来源:`public/downloads/boss-agent-mac-latest.zip`
- 当前元数据:`public/downloads/boss-agent-mac-latest.json`
- 当前保护:要求 `x-boss-device-token``deviceId`
#### `GET /api/v1/ops/summary`
- 用途:读取运维 `fault / repair ticket / verification` 聚合数据
@@ -1148,6 +1188,7 @@
- 用途:由 local-agent 认领分配给本机的主 Agent 任务
- 当前保护:要求 `x-boss-device-token` 或匹配登录会话
- 当前可靠性claim 会写入 `attemptCount / maxAttempts / claimedAt / lastClaimedAt / leaseExpiresAt`;运行中任务租约过期后可重试认领,超过最大次数会转为 `timed_out` 并更新进度卡;被吊销设备不能继续认领
#### `POST /api/v1/master-agent/tasks/[taskId]/complete`
@@ -1168,8 +1209,15 @@
- `taskType=dispatch_execution` 时,会把线程原始结果镜像回群聊,再追加一条主 Agent 汇总,并更新对应执行单状态
- `failed` 时写入 relay 失败消息,并更新 AI 账号健康状态
- 如果任务带有 `externalReplyTarget.provider=telegram`,完成后会尝试调用 Telegram Bot API 把 `replyBody` 回推到原始聊天
- 终态任务 `completed / failed / timed_out / canceled` 的迟到重复 complete 会直接返回当前任务,不再覆盖终态或重复写消息
- 对群聊分发推荐失败的情况,消息入口当前会额外写入一条 `system_notice`,把“没有真实线程”或“成员引用失效”明确回显给用户
#### `POST /api/v1/master-agent/tasks/[taskId]/cancel`
- 用途:取消仍在 `queued / running / needs_user_action` 的主 Agent 任务
- 权限:任务请求账号、`highest_admin`,或具备目标设备 `device.manage` 的账号
- 当前行为:任务转为 `canceled`,写入 `canceledAt / canceledBy / cancelReason`,清除租约;如果之后设备端迟到回写成功,服务端不会覆盖取消终态
#### `GET /api/v1/integrations/telegram`
- 用途:读取 Telegram Bot 接入配置
@@ -1402,6 +1450,15 @@
- `data/boss-state.json`
状态存储默认仍走文件模式。PostgreSQL 仅作为显式量产切换路径存在:必须设置 `BOSS_STATE_STORE=postgres`,涉及真实连接 / 写入的维护命令还必须设置 `BOSS_DATABASE_URL``scripts/boss-state-store-maintenance.mjs` 当前支持:
- `validate-schema`:校验 `scripts/postgres-state-schema.sql` 是否包含 `boss_state_snapshots``snapshot_key` 主键、`state JSONB` 和更新时间索引
- `backup-file / export-file`:在文件模式下导出当前状态文件备份
- `migrate-file-to-postgres --dry-run`:只校验文件状态和 schema不连接数据库正式迁移会 upsert 到 `boss_state_snapshots`
- `export-postgres-backup`:从 PostgreSQL 导出带元数据和 sha256 的 JSON 备份包
- `restore-postgres-backup`:把备份包或原始状态 JSON 恢复回 PostgreSQL可先用 `--dry-run` 验证
- `rollback-postgres-to-file`:把 PostgreSQL 当前快照回写到文件,用于数据库切换失败后的文件模式回退
状态文件当前带有迁移前置元数据:
- `schemaVersion`:当前 BossState schema 版本

View File

@@ -1,10 +1,26 @@
# Codex Server 协议与 Boss 执行进度卡接入记录
更新时间:`2026-05-08`
更新时间:`2026-05-16`
## 1. Codex 最新开放协议结论
当前可作为 Boss 稳定集成入口的是 Codex CLI MCP server
2026-05-16 的最新架构判断Boss 后续优先围绕 Codex App Server 做深度接入,但当前生产链路仍保留 `codex exec resume``codex mcp-server` 作为兼容 provider 候选。
Codex App Server 是更适合 Boss 长期接入的协议层,因为它面向富客户端和产品级集成,覆盖:
- authentication
- conversation history
- approvals
- streamed agent events
- Thread / Turn / Item
- model/list、skills/list、plugin/list、app/list
- command execution、file change、tool input、MCP tool-call approvals
Boss 不能直接把 App Server 原始 Thread / Turn / Item 字段写进业务层。当前第一批已经新增 `local-agent/codex-app-server-runner.mjs`,把 App Server 的 `thread/resume | thread/start -> turn/start -> item/agentMessage/delta -> turn/completed` 映射成 Boss 的普通任务完成回写;下一批再继续把 Approval、Skill、file changes 和更细粒度 progress 归一化为 Boss 自有的 `execution_progress / approval_card / change_set / risk_event / skill_capability`
官方文档入口:`https://developers.openai.com/codex/app-server`
当前仍可作为 Boss 兼容集成入口的是 Codex CLI MCP server
- 启动命令:`codex mcp-server`
- Inspector 调试:`npx @modelcontextprotocol/inspector codex mcp-server`
@@ -16,10 +32,12 @@
本机当前检测结果:
- 本机 `codex --version``codex-cli 0.114.0`
- npm 最新稳定包:`@openai/codex 0.129.0`
- npm alpha`0.130.0-alpha.5`
- 本机 `0.114.0` 已支持 `codex mcp-server --help`,但落后于当前 `0.129.0` 的 app-server / protocol 拆分、ThreadStore、MCP turn metadata、plugin sharing 等新能力
- 本机 `codex --version``codex-cli 0.131.0-alpha.9`
- 本机 `codex app-server --help` 已可用;本机 help 当前显示 `--listen` 支持 `stdio://``unix://``unix://PATH``off`
- 官方文档仍提到 WebSocket 传输,但标注 experimental / unsupportedBoss 当前只把 `stdio` 作为默认接入,后续如要接 WebSocket 必须先做协议快照和灰度开关
- Boss 第一批只用 App Server 做任务级 provider不直接复用 ChatGPT Mobile 到 Codex App 的官方 relay官方移动控制链路仍属于 ChatGPT App 与 Codex App 同账号/工作区之间的产品能力,不是第三方 Boss 可以稳定依赖的私有通道
下一轮再核对版本时,不要只看 npm 包版本号;必须同时读取 App Server schema / TypeScript 定义,并把 protocol snapshot 保存到 `docs/protocol-snapshots/codex-app-server/<version>/`
## 2. Boss 当前采用的接入策略
@@ -76,8 +94,13 @@ APP 展示结构对齐截图:
- Boss 消息账本新增 `execution_progress`
- Android 原生聊天页新增结构化进度卡
- local-agent 完成回写会补 Git diff、GitHub CLI 状态和产物名
- `local-agent` 新增 `Codex App Server` runnerboss-agent 默认打开;`conversation_reply / dispatch_execution` 会先尝试 App Server任务尚未真正启动 turn 时允许回退 CLIturn 已启动后不再重复下发,避免双写同一线程
- `local-agent` 新增 `Codex Computer Use -> CUA Driver` 桌面控制级 fallback远程控制这台电脑时默认先通过 Codex Computer Use 执行,失败后再走 Boss 既有 CUA Driver runtime
- `device-heartbeat` 设备能力新增 `codexAppServer`,用于前台和后台知道该设备是否具备 App Server provider
后续建议按两步继续:
1. 新增 `CodexMcpBackendAdapter`:让 `codex mcp-server` 成为 `ExecutionBackend` 的可选实现,先 feature flag 默认关闭,保留 `codex exec resume` 作为生产主链
2. 增加任务级 live progress API`POST /api/v1/master-agent/tasks/[taskId]/progress`,让本地 agent 在执行中也能实时刷新进度卡,而不是只在 claim / complete 两个节点更新
1. 把当前 runner 提升为完整 `CodexAppServerBackendAdapter`:继续补 Approval / file change / skill / app / browser / computer-use 事件映射,但保持 feature flag 默认关闭
2. 新增 `CodexMcpBackendAdapter`:让 `codex mcp-server` 成为 `ExecutionBackend` 的兼容实现,用于 App Server 不可用或只需要轻量会话时
3. 增加任务级 live progress API`POST /api/v1/master-agent/tasks/[taskId]/progress`,让本地 agent 在执行中也能实时刷新进度卡,而不是只在 claim / complete 两个节点更新。
4. 建立协议快照目录和兼容测试:每次 Codex 协议升级时生成 schema、跑映射测试、灰度打开新 capability避免把某个 Codex 版本写死到 APP 或后台。

View File

@@ -1,6 +1,6 @@
# Boss 当前运行与部署状态
更新时间:`2026-04-27`
更新时间:`2026-05-16`
## 1. 本地状态
@@ -26,14 +26,17 @@
- 独立企业后台 BFF`GET http://127.0.0.1:3000/api/v1/admin/backoffice`
- 管理后台授权接口:`GET/POST http://127.0.0.1:3000/api/v1/admin/access`
- 管理后台风险 SLA 扫描接口:`POST http://127.0.0.1:3000/api/v1/admin/risks/scan`
- 管理后台状态备份与回退接口:`GET/POST http://127.0.0.1:3000/api/v1/admin/backups`,仅 `highest_admin` 可用;支持创建状态快照、列出快照和恢复到指定快照,恢复前会自动创建 pre-restore 快照。文件状态写入层已默认开启自动快照,可用 `BOSS_STATE_AUTO_BACKUP_INTERVAL_MS``BOSS_STATE_AUTO_BACKUP_KEEP` 调整频率与保留数量
- OTA 包下载接口:`GET http://127.0.0.1:3000/api/v1/user/ota/package`
- boss-agent Mac OTA 接口:`GET http://127.0.0.1:3000/api/v1/boss-agent/ota?deviceId=...&currentVersion=...``GET http://127.0.0.1:3000/api/v1/boss-agent/ota/package`
- 本地 agent 健康检查:`http://127.0.0.1:4317/health`。当前这台开发机的 `launchd` 常驻已经恢复,`/health` 可在数十毫秒内返回,并且在手动 heartbeat 执行期间也不会再被 Codex 线程扫描卡死
- 本地 Skill 扫描接口:`http://127.0.0.1:4317/api/v1/skills`
- 本地 agent 手动 heartbeat`POST http://127.0.0.1:4317/api/v1/heartbeat`
- `launchd` 已安装:`~/Library/LaunchAgents/com.hyzq.boss.local-agent.plist`
- 当前执行底座抽象层已落地在 `src/lib/execution/`,并已补齐 `ExecutionBackend / PromptAssembler / PermissionPolicy / RemoteRuntimeAdapter / OrchestrationBackend` 默认实现
- 当前生产主链仍然沿用 `local-agent -> codex exec resume -> /api/v1/master-agent/tasks/[taskId]/complete`,执行底座重构以“先抽象、不改行为”为准
- 当前 Codex server 调研结论已记录在 `docs/architecture/codex_server_progress_card_cn.md`官方稳定入口是 `codex mcp-server` 的 MCP 协议,本机 `codex-cli 0.114.0` 已支持该命令但落后于 npm 最新 `0.129.0`Boss 当前先保留 `codex exec resume` 主链,并新增 `execution_progress` 结构化进度卡作为 APP 可见执行态
- 当前 Codex server 调研结论已记录在 `docs/architecture/codex_server_progress_card_cn.md`长期优先方向更新为 `Codex App Server -> CodexMcpBackendAdapter -> codex exec resume` 的分层 provider 策略;当前 boss-agent 默认打开 `Codex App Server` runner 作为 Codex 绑定入口,Boss 保留 `codex exec resume` 兜底,并继续用 `execution_progress` 结构化进度卡作为 APP 可见执行态
- 当前量产 B+ 架构开发文档已新增:`docs/architecture/enterprise_ai_ops_architecture_cn.md`。该文档把 PPT 中的主 Agent / 业务 Agent / 老板端 / 经理端 / 员工端 / 治理层 / 系统层 / 设备层 / 执行层 / 接入层整理成后续产品架构约束并明确数据库备份、业务回退、Codex 协议扩展和 Skill 治理方向;它是规划文档,不代表当前全部已落地
- 当前 `claw-code` 已以最小 `ClawBackendAdapter` 形式接入执行底座,但默认关闭;只有显式配置 `BOSS_CLAW_*` 且可用性探测通过时,`master-agent` 当前对话中才会出现并允许选择 `claw-runtime`
- 当前已新增最小 `Telegram Gateway`Boss 当前可直接暴露 Telegram webhook把 Telegram 私聊或受控群聊文本桥接进 `master-agent` 或按群 / Topic 路由到指定 Boss 项目,并在主 Agent 异步任务完成后自动回推 Telegram配置入口已接到 Web `/me/telegram` 和原生 Android `我的 > Telegram 接入`
- 如果历史上已经保存过 `backendOverride=claw-runtime`,但当前 `Claw Runtime` 不可用,运行时会自动回退到默认后端,并在 Web/Android 前台给出明确原因
@@ -47,10 +50,15 @@
- 当前 `conversation_reply / dispatch_execution` 的线程执行结果会先经过 `RemoteRuntimeAdapter` 标准化;如果线程返回的是固定模式的内部环境提示(如“当前会话环境只读 / cwd …”),会直接转成失败,不再把原文写回会话消息
- 当前设备模型已支持同一台 Mac / Windows 同时接入 Codex `GUI + CLI` 双能力Web / Android 设备详情页都会展示两种能力状态,并允许切换默认执行模式
- 当前同项目 `GUI / CLI` 并行写入风险已接入项目/文件夹级冲突控制:默认阻断,用户只能对当前异常项目/文件夹选择 `禁止 / 允许本次 / 永久放行`
- 当前已补上“Boss 统一电脑控制中枢”第二批本地 runtime主 Agent 已能把聊天请求识别为 `discussion_only / project_development / browser_control / desktop_control``browser_control / desktop_control` 已能作为正式 `MasterAgentTask` 入队,并返回 `executionMode / riskLevel / requiresConfirmation` 元数据给前台;本机 `local-agent` 现已把 `browser-control-task-runner.mjs / computer-use-task-runner.mjs` 升级成外部 runtime 桥,并默认带上 `scripts/browser-control-smoke.mjs / scripts/computer-use-smoke.mjs` 作为 smoke 执行器,后续只需要替换配置就能接真实 browser automation 与 computer use runtime
- 当前已补上“Boss 统一电脑控制中枢”第二批本地 runtime主 Agent 已能把聊天请求识别为 `discussion_only / project_development / browser_control / desktop_control``browser_control / desktop_control` 已能作为正式 `MasterAgentTask` 入队,并返回 `executionMode / riskLevel / requiresConfirmation` 元数据给前台;本机 `local-agent` 现已把 `browser-control-task-runner.mjs / computer-use-task-runner.mjs` 升级成外部 runtime 桥,并默认带上 `scripts/browser-control-smoke.mjs / scripts/cua-driver-computer-use-runtime.mjs` 作为 browser / desktop 起步执行器
- 当前这条电脑控制链先只按 macOS 交付:`browser_control / desktop_control` 任务会写入 `controlPlatform=macos``computerUseProvider`,其中浏览器控制默认 `openai-computer-use`,桌面 GUI 控制默认 `codex-computer-use``local-agent` 会先调用 Codex Computer Use失败后自动回退 `cua-driver-computer-use``local-agent` 下发 runtime stdin 时也会携带同一组字段,桌面 dialog guard 只保留 macOS adapterWindows 分支不进入当前生产链路
- 当前这两条控制链的 `control_summary` 已能回写结构化目标信息browser 会保留 `targetUrl`desktop 会保留 `targetApp`Android 聊天窗口会在控制结果卡片里直接显示执行目标
- 当前 `scripts/browser-control-smoke.mjs` 已提升到“最小真实浏览器探测”:如果目标 URL 可访问,会抓取页面 `<title>` 并回写结果;`scripts/computer-use-smoke.mjs` 也已升级为 macOS 默认 `osascript` 激活应用、引号文本输入、按需回车发送、`open -a` 兜底和 artifact 回写,因此 Boss App 里的 browser/desktop 控制消息都已开始返回真实执行结果而不是固定 smoke 文案
- 当前本机 `local-agent` 默认 heartbeat 已把 `browserAutomation / computerUse` 两项能力视为“已接通起步版 runtime”因此 Boss 前台设备能力会直接显示这两条链路在线;如果后续需要临时关闭,可在 `local-agent/config.cloud.json` 里单独下掉对应 connected 标记或 runtime 命令
- 当前 `scripts/browser-control-smoke.mjs` 已提升到“最小真实浏览器探测”:如果目标 URL 可访问,会抓取页面 `<title>` 并回写结果;`scripts/codex-computer-use-runtime.mjs` 会通过 Codex App Server 发起 Codex Computer Use 执行;`scripts/cua-driver-computer-use-runtime.mjs` 作为 fallback 接入 `cua-driver` 的 macOS 窗口级控制能力,默认执行 `launch_app -> get_window_state`,并支持安全范围内的引号文本写入;涉及发送、提交、删除、支付等动作时默认返回确认卡,不直接执行高风险提交
- 当前 boss-agent 已补 Mac OTA`scripts/package-boss-agent-mac-runtime.sh` 会生成 `dist/boss-agent-mac-runtime-{version}.zip`,并同步发布 `public/downloads/boss-agent-mac-latest.zip/json`;本机 `local-agent` 默认每 5 分钟检查一次,可在 boss-agent 状态页手动“检查更新 / 下载并安装”。安装采用“下载校验 -> 写入暂存 wrapper -> 拉起 install.command”的安全路径失败不会覆盖当前运行版本。正式分发脚本已支持 `BOSS_AGENT_NOTARIZE=1 + BOSS_AGENT_NOTARY_PROFILE` 的 Developer ID 公证路径,本地开发默认仍可 ad-hoc / Apple Development 签名。
- 当前最新 boss-agent Mac 包版本为 `20260516221619`,已部署到 `https://boss.hyzq.net/api/v1/boss-agent/ota` 并在局域网 MacBook Air `macbook-air` 上完成真实 OTA 下载、sha256 校验、暂存、覆盖安装和 up-to-date 检查:安装后 `config.installed.json` 仍保持 `deviceId=macbook-air`、账号 `krisolo`、版本 `20260516221619``launchd` 状态为 running。
- 当前安装器已做多电脑绑定保护:`install.command` 会保留所有 `config*.json` 并优先沿用当前 launchd active config底层 `scripts/install-local-launchagent.sh` 在无显式参数时也会优先读取现有 LaunchAgent 的配置路径,再回退自定义设备配置,避免多台 Mac 重装/OTA 时误切到默认 `config.cloud.json`
- 当前 Cua runtime 已补上 launchd 友好的可执行文件发现:除 `PATH` 外会主动查找 `~/.local/bin/cua-driver``/Applications/CuaDriver.app/Contents/MacOS/cua-driver`;如果 `launch_app` 对已运行 App 返回 not found会兜底走 `list_apps -> list_windows -> get_window_state` 复用现有窗口
- 当前本机 `local-agent` 默认 heartbeat 已把 `browserAutomation / computerUse` 两项能力视为“已接通起步版 runtime”因此 Boss 前台设备能力会直接显示这两条链路在线;`codexAppServer` 能力只有在显式打开 App Server runner 且本机 `codex` 命令可执行时才会上报在线;如果后续需要临时关闭,可在 `local-agent/config.cloud.json` 里单独下掉对应 connected 标记或 runtime 命令
本地已知运行方式:
@@ -101,16 +109,17 @@ cd /Users/kris/code/boss
- `npm start`、服务器 `systemd` 与远端 `npm run build` 当前都显式设置了 `BOSS_RUNTIME_ROOT`,避免 `process.cwd()` 在 standalone / 服务器构建阶段误扫描整个仓库
- `next.config.ts` 当前已把 `deployment / docs / design / local-agent / prompts / scripts / android` 等目录排除出 standalone tracing服务器端构建不会再把非运行时资产卷进 `.next/standalone`
- `data/boss-state.json` 的写入已经改成串行事务队列、原子替换和 `.bak` 备份恢复,`heartbeat` 与 APP 日志并发写入已复核通过
- `data/boss-state.json` 当前额外具备自动历史快照:每次写入后按 `BOSS_STATE_AUTO_BACKUP_INTERVAL_MS` 节流写入 `data/backups/state-snapshot-*.json`,元数据标记 `actorAccount=system / reason=auto:writeState`,管理后台可直接作为回退点查看和恢复
- `BossState` 当前新增 `schemaVersion / migratedAt` 元数据和 `migrateBossState` 迁移入口;读取旧的无版本状态时会补齐当前 schema并规范化 `accountDeviceGrants / accountProjectGrants / accountSkillGrants / skillLifecycleRequests / permissionAuditLogs`
- 这只是正式数据库迁移前置层,当前生产读写仍然是 `data/boss-state.json`,尚未完成 PostgreSQL / Redis / 其他 DB 落地
- 当前登录成功后会写入 `boss_session` Cookie`会话 / 设备 / 我的 / 线程` 页面以及主要 `/api/v1/*` 路由都要求有效会话
- 当前 `boss_session` 默认保持 30 天,`Set-Cookie` 已验证为 `Max-Age=2592000`
- 原生 Android 客户端当前会把登录返回的 `boss_session / restore token / account` 落到 `SharedPreferences`,并在 APP 启动时通过 `/api/auth/restore` 自动补回会话;已本地验证“登录 -> 取 restore token -> restore 接口恢复”链路
- 当前多用户 / RBAC 第一阶段已落地:状态文件新增 `accountDeviceGrants / accountProjectGrants / accountSkillGrants / skillCatalog / skillLifecycleRequests / permissionAuditLogs`,非最高管理员访问 `devices / conversations / projects / messages / device skills / state` 时都会先走 `src/lib/boss-permissions.ts` 和 session-aware projections 过滤
- 当前最高管理员授权管理接口已落地:`GET/POST /api/v1/admin/access` 可以查看脱敏账号、公司、设备、项目、Skill、授权、权限模板和审计日志并支持公司管理、公司启用/停用、账号/设备归属、批量导入预览、批量导入子账号、重置子账号密码、离职回收、创建/更新子账号、启用/停用子账号、授予设备/项目/Skill 权限、套用权限模板、撤销授权;停用公司会禁用该租户普通子账号并撤销会话,停用 / 回收 / 重置账号也会撤销该账号当前活跃会话,普通账号访问返回 `403`
- 当前 To B 管理后台第一版可操作面已经落地Web `/admin``highest_admin` 可进,包含 `总览 / 账号与授权 / Skill 治理` 三个页签;总览使用 `/api/v1/admin/overview`,账号与授权复用 `/api/v1/admin/access`Skill 治理复用 `/api/v1/admin/skills/requests`;公司聚合优先使用显式 `adminCompanies`,未绑定时才回退账号域名
- 当前企业级后台独立化第一批已开始落地:新增 `apps/boss-admin-web` 作为 Vue + Vite + Ant Design Vue 独立 PC 后台骨架,新增 `/api/v1/admin/backoffice` 作为 YuDao/Vben 风格 BFF现有 `/admin` 暂保留为主站 fallback`admin.boss.hyzq.net` 后续再切到独立后台静态产物
- 当前后台风险处理接口已落地:`POST /api/v1/admin/risks/actions``highest_admin` 可用,支持对 `ops_fault` 指派负责人、设置 SLA、确认、关闭、创建或复用修复工单`thread_context_alert` 指派负责人、设置 SLA、确认和关闭`POST /api/v1/admin/risks/scan` 会扫描超时 SLA 并幂等写入 `adminNotifications`,管理后台总览会展示开放风险通知;不支持的风险类型会明确返回 `RISK_ACTION_UNSUPPORTED`
- 当前最高管理员授权管理接口已落地:`GET/POST /api/v1/admin/access` 可以查看脱敏账号、公司、设备、项目、Skill、授权、权限模板和审计日志并支持公司管理、公司启用/停用、账号/设备归属、设备吊销、批量导入预览、批量导入子账号、重置子账号密码、离职回收、创建/更新子账号、启用/停用子账号、授予设备/项目/Skill 权限、套用权限模板、撤销授权;停用公司会禁用该租户普通子账号并撤销会话,停用 / 回收 / 重置账号也会撤销该账号当前活跃会话,吊销设备会清空设备 token、置离线并阻断 heartbeat / 任务认领 / Skill 同步 / 日志上报 / boss-agent OTA普通账号访问返回 `403`
- 当前旧 Web `/admin` 管理 UI 已下线:`src/components/admin/boss-admin-app.tsx` 和旧 data provider 已移除,`/admin` 现在只做兼容跳转到根路径 `/`
- 当前企业级后台独立化第一批已部署到云:`apps/boss-admin-web` 作为 Vue + Vite + Ant Design Vue 独立 PC 后台,静态产物位于 `/admin-web/index.html``admin.boss.hyzq.net` 根路径由 Caddy 内部 rewrite 到该静态入口,不再跳转到 `/enterprise-admin`
- 当前后台风险处理接口已落地:`POST /api/v1/admin/risks/actions``highest_admin` 可用,支持对 `ops_fault` 指派负责人、设置 SLA、确认、关闭、创建或复用修复工单`thread_context_alert` 指派负责人、设置 SLA、确认和关闭`POST /api/v1/admin/risks/scan` 会扫描超时 SLA 并幂等写入 `adminNotifications`并会把 Computer Use 不可用、boss-agent OTA 失败等运行态异常补成可治理 `opsFaults`管理后台总览会展示开放风险通知;不支持的风险类型会明确返回 `RISK_ACTION_UNSUPPORTED`
- 当前权限审计查询第一版已落地:`GET /api/v1/audits/permission-logs``highest_admin` 可读,支持按 `action / actorAccount / targetAccount / deviceId / projectId / skillId / cursor / limit` 查询 `permissionAuditLogs`并实时返回短时间大量授权、Skill lifecycle 失败、过期授权仍存在、admin route 拒绝访问等 deterministic 风险摘要;后台 mutation 审计已支持 `ipAddress / userAgent / requestId / beforeJson / afterJson`其中重置密码会记录安全化前后快照Web `/me/ops/audit` 会向最高管理员展示最近权限审计和风险摘要
- 当前 Skill 远程治理第一版可执行链路已落地:`GET/POST /api/v1/admin/skills/requests` 仅允许 `highest_admin` 创建和查看 `install / update / uninstall / rollback / version_lock` 请求;设备端通过 `/api/v1/devices/[deviceId]/skill-requests/claim``/complete` 认领回写local-agent 默认每 5 秒执行本机 Skill 安装 / 更新 / 卸载 / 回滚 / 版本锁,并同步最新 Skill 清单。远程安装或带 `sourceUrl` 的更新必须命中本机 `skillLifecycleAllowedSources``skillLifecycleTrustedSources`;配置为空时不允许远程新来源安装,但保留既有本地 Skill 的更新 / 回滚 / 卸载 / 版本锁。携带 `checksum / expectedChecksum` 的请求会校验 `manifest.json``SKILL.md` 的 sha256更新 / 卸载 / 回滚前会写入 `skillsDir/.boss-skill-backups` 并在失败时尽量恢复
- 当前授权管理前台已接入Web `/me/access` 与原生 Android `我的 > 用户与权限` 仅最高管理员可见,可创建子账号、授权设备/项目/Skill、套用 `只读观察员 / 项目开发者 / 设备操作者` 模板、查看同名 Skill 跨设备聚合并撤销单条授权
@@ -237,14 +246,16 @@ cd /Users/kris/code/boss
- 当前 `local-agent` 已能回写带 `dispatchExecutionId / targetProjectId / targetThreadId / rawThreadReply` 的任务完成载荷,群聊分发执行结果不再只停留在主 Agent 队列
- 当前 `local-agent``conversation_reply` 任务会优先使用 `codex exec resume <targetCodexThreadRef>`,只有缺失真实线程引用时才退回 `--ephemeral`
- 当前已绑定真实 `codexThreadRef` 的普通单线程聊天,会在 `local-agent` 执行 `codex exec resume` 前,先把 Boss 用户消息镜像写入对应 Codex Desktop rollout这样 APP 发起的消息也能进入桌面版同一线程历史,并按 `sourceMessageId` 去重。rollout 定位优先使用 `state_5.sqlite`,状态库不可用或索引缺失时回退扫描 `~/.codex/sessions`;写入后会尽量刷新 `threads.updated_at / updated_at_ms / has_user_event`,再通过 `codex://threads/{threadId}` 深链提示桌面版打开目标线程
- 当前 `local-agent` 已新增 `Codex App Server` providerboss-agent 默认配置 `codexAppServerEnabled=true``conversation_reply / dispatch_execution` 会先通过 `codex app-server` 的 stdio JSON-RPC 恢复或创建线程,再下发 `turn/start` 并收集流式 agent 回复;如果 App Server 在 turn 启动前失败,默认允许回退到 `codex exec resume`,如果 turn 已经启动则不再回退,避免同一轮用户消息被重复执行。桌面控制另有 `codexComputerUseEnabled=true`,默认先走 Codex Computer Use再回退 CUA Driver。
- 当前 `local-agent``dispatch_execution` 任务会按 `orchestrationBackendId` 分流:默认走 `codex exec resume`;当任务显式选择 `omx-team` 且本机 `omxEnabled + omxCommand/omxArgs` 可用时,会改走 `OMX Team Runtime` JSON 协议执行并回写 `rawThreadReply / replyBody`
- 当前 `local-agent` 会在 Codex 任务完成时回传 `executionProgress`:服务端把同一任务的进度卡从 queued / running 更新到 completed / failedAndroid 原生聊天页会显示“进度 / 分支详情 / 生成结果 / 后台智能体”,其中 Git diff、GitHub CLI 可用性和产物名由本地 agent 补齐
- 当前 `MasterAgentTask` 已具备服务端租约和取消基础状态机claim 会写入 `attemptCount / maxAttempts / leaseExpiresAt`,运行中任务租约过期后可被重新认领,超过重试上限会转 `timed_out``POST /api/v1/master-agent/tasks/[taskId]/cancel` 会把任务转 `canceled`,迟到的成功 complete 不会覆盖终态
- 当前 `local-agent``browser_control / desktop_control` 已从占位骨架升级成外部 runtime 桥:当本机配置了 `browserControlEnabled + browserControlCommand``computerUseEnabled + computerUseCommand` 时,会把标准化 JSON 请求透传给外部进程,并解析单行 JSON 结果;未启用时会 fail closed返回明确的 runtime disabled 错误,不再假装执行成功
- 远程电脑控制链路当前已有可复用压测基线:`npm run stress:remote-control` 可按参数压测 `local-agent -> MasterAgentTask -> browser_control / desktop_control runtime -> complete 回写` 全链路;`npm run stress:remote-control:ci` 固定 120 条链路任务和 360 条 runtime 并发任务,并用 p95 延迟预算判断是否退化。压测报告可通过 `--report-json=PATH` 落盘,便于后续接入真实 macOS AX / Windows UIA helper 后复用同一套稳定性判断。
- 当前历史脏群如果不再包含真实线程成员,群聊消息不会再表现成“无响应”;服务端会在群内追加明确 `system_notice`,提示先重新添加线程成员
- 当前设备导入决议已经升级成真正通过 `local-agent -> codex exec -> /complete` 回写的主 Agent 决议链Web 和 Android 前台都会在 `pending_resolution` 阶段显示审核任务状态,并在任务完成后自动刷新出正式导入建议
- 当前 `local-agent` 已改成先启动本地 `4317` 健康监听,再异步跑首次 heartbeat 和 task poll避免控制面短时阻塞时本地健康探针不可用
- 当前 heartbeat 上报 `browserAutomation / computerUse` 能力时,不再只看静态 `browserAutomationConnected / computerUseConnected` 布尔值;如果本机已经配置可执行的 browser/computer runtime,也会自动把对应能力标记成 connected
- 当前 heartbeat 上报 `browserAutomation / computerUse / codexAppServer` 能力时,不再只看静态 connected 布尔值browser/computer 会参考 runtime 配置状态Codex App Server 会参考 `codexAppServerEnabled` 和本机 app-server 命令可执行性
- Codex 项目/线程扫描当前已搬到 worker 线程执行,避免 `.codex/logs_1.sqlite``state_5.sqlite` 的同步扫描阻塞主线程健康接口
- 当前 `local-agent` 的任务完成回写已通过 `RemoteRuntimeAdapter` 标准化,`conversation_reply / dispatch_execution` 的完成载荷会先做统一归一化,再进入主 Agent 完成路由
- 原生 Android 当前对 `master-agent` 聊天不再依赖长时间同步等待;发送后会先显示“主 Agent 思考中”,右上角改成微信式 `...` 菜单,菜单项包含 `模型 / 推理强度 / 会话信息 / 刷新`
@@ -275,7 +286,7 @@ cd /Users/kris/code/boss
- `boss-web` 当前通过 `npm start` 启动
- 实际监听端口为 `3000`
- `boss-web.service` 显式设置了 `BOSS_STATE_FILE=/opt/boss/data/boss-state.json`
- `Caddy` 反代 `127.0.0.1:3000``boss.hyzq.net` 服务客户 Web / App API`admin.boss.hyzq.net` 作为平台总后台独立 PC 入口并把根路径跳转到 `/admin`
- `Caddy` 反代 `127.0.0.1:3000``boss.hyzq.net` 服务客户 Web / App API`admin.boss.hyzq.net` 作为平台级 To B 独立后台入口并把根路径内部 rewrite 到 `/admin-web/index.html`
- 服务器上存在 `gptpluscontrol-boss-caddy-reconcile.timer`,会周期性用 `/home/ubuntu/build/gptpluscontrol/deploy/server/caddy.boss_hyzq_net.gptpluscontrol.conf` 重写 `/etc/caddy/Caddyfile``/opt/boss/deployment/Caddyfile`;以后改 Caddy 入口必须同步更新这份 canonical否则会重新生成重复站点块并导致 Caddy reload 失败
- `Postfix` 监听 `25 / 465 / 587`
- `Dovecot` 监听 `993`
@@ -296,12 +307,12 @@ cd /Users/kris/code/boss
- 服务器本机 `dig +short boss.hyzq.net` 返回 `106.53.170.158`
- 服务器本机访问 `http://boss.hyzq.net` 会被 `308` 跳转到 `https://boss.hyzq.net`
- 服务器本机执行 `curl --resolve boss.hyzq.net:443:127.0.0.1 https://boss.hyzq.net -I` 返回 `307` 并跳转到 `/auth/login`
- 当前 `admin.boss.hyzq.net` 用于平台总后台,应用根路由会在该 host 下把已登录用户送到 `/admin`,未登录用户送到 `/auth/login`
- 当前 `admin.boss.hyzq.net` 用于平台级 To B 独立后台入口,站点根路径直接承载新 PC 后台;`/admin` 不再渲染旧 UI只保留跳转到根路径的兼容入口
同时也确认了这些事实:
- 当前本机网络 `dig +short boss.hyzq.net` 仍返回 `198.18.1.188`
- 当前本机网络 `dig +short admin.boss.hyzq.net` 暂无 A 记录;需要在 DNSPod 增加 `admin -> 106.53.170.158`
- 当前本机网络 `dig +short admin.boss.hyzq.net` 已返回 `106.53.170.158`
- 当前本机网络 `curl -I http://boss.hyzq.net` 返回 `308`
- 当前本机网络 `curl -I https://boss.hyzq.net` 返回 `HTTP/2 307`,并跳转到 `/auth/login`
- 当前本机网络 `curl https://boss.hyzq.net/api/health` 返回 `{"ok":true,"service":"boss-web",...}`
@@ -346,7 +357,7 @@ cd /Users/kris/code/boss
- Android 本地 Gradle 验证当前必须串行执行;如果并发跑 `testDebugUnitTest / compileDebugJavaWithJavac / assembleDebug`,会导致中间产物互踩并出现假失败
- 聊天附件当前已经支持真实上传、消息落账本、受保护下载和原生打开;默认后端为服务器文件存储,可按用户切到阿里 OSS 私有桶
- 企业认证默认值已收紧:`POST /api/auth/login` 默认不再允许临时免验证登录,只有显式设置 `BOSS_AUTH_AUTO_LOGIN=1/true/yes` 才会开启开发兜底。
- 状态存储现在通过 `src/lib/boss-state-store.ts` 抽象,默认继续使用 `data/boss-state.json`;设置 `BOSS_STATE_STORE=postgres` 必须同时配置 `BOSS_DATABASE_URL`schema 见 `scripts/postgres-state-schema.sql`
- 状态存储现在通过 `src/lib/boss-state-store.ts` 抽象,默认继续使用 `data/boss-state.json`只有显式设置 `BOSS_STATE_STORE=postgres` 才会进入 PostgreSQL 路径,真实连接 / 写入还必须同时配置 `BOSS_DATABASE_URL`schema 见 `scripts/postgres-state-schema.sql`,生产切换前需先跑 `validate-schema`、文件备份、`migrate-file-to-postgres --dry-run`、PostgreSQL 备份导出和恢复演练
- 认证已补 CSRF 基础防护、restore token 轮换、账号锁定和子账号 MFA 开关;后续仍可继续补更完整的企业 IdP / SSO
- 邮件对外正式投递仍缺少 DNS / 信誉相关的最终收口,例如 SPF、DKIM、DMARC、MX 与退信策略
- 外部真实邮箱的 end-to-end 收件链路还没有在生产账号上完成最终验收
@@ -363,7 +374,10 @@ curl -sS http://127.0.0.1:3000/api/v1/conversations
curl -sS http://127.0.0.1:3000/api/v1/projects/master-agent
curl -sS http://127.0.0.1:3000/api/v1/devices/mac-studio/skills
node scripts/boss-state-store-maintenance.mjs backup-file --dry-run
node scripts/boss-state-store-maintenance.mjs migrate-file-to-postgres --dry-run
node scripts/boss-state-store-maintenance.mjs validate-schema
BOSS_STATE_STORE=postgres BOSS_DATABASE_URL="$BOSS_DATABASE_URL" node scripts/boss-state-store-maintenance.mjs migrate-file-to-postgres --dry-run
BOSS_STATE_STORE=postgres BOSS_DATABASE_URL="$BOSS_DATABASE_URL" node scripts/boss-state-store-maintenance.mjs export-postgres-backup --output /tmp/boss-postgres-backup.json --dry-run
BOSS_STATE_STORE=postgres BOSS_DATABASE_URL="$BOSS_DATABASE_URL" node scripts/boss-state-store-maintenance.mjs restore-postgres-backup --input data/boss-state.json --dry-run
curl -I http://127.0.0.1:3000/api/v1/user/ota/package
curl -sS http://127.0.0.1:4317/health
curl -sS http://127.0.0.1:4317/api/v1/skills

View File

@@ -0,0 +1,463 @@
# Boss 企业 AI 运营中枢量产架构开发文档
更新时间:`2026-05-17`
## 1. 文档定位
这份文档把 `outputs/boss-product-intro-image2-full-raster.pptx` 里的产品架构、此前确认的量产 B+ 方案、以及 Codex App Server 最新开放协议思路统一成后续开发约束。
当前结论Boss 不能只做一个“手机控制 Codex”的工具而要升级成企业级 AI 运营中枢。Boss 负责组织、权限、任务、审计、数据安全、回退和跨设备协作Codex、Computer Use、Skill、业务系统和第三方 Agent 都只是可替换执行能力。
本文件描述的是量产目标架构,不代表当前所有能力都已经落地。当前运行真相仍以 `docs/architecture/current_runtime_and_deploy_status_cn.md` 为准。
## 2. 来源材料
- 产品 PPT`outputs/boss-product-intro-image2-full-raster.pptx`
- PPT 抽图校对目录:`outputs/pptx-architecture-read/slides`
- Codex App Server 官方文档:`https://developers.openai.com/codex/app-server`
- 当前 Boss 运行文档:`docs/architecture/current_runtime_and_deploy_status_cn.md`
- 当前 API 与服务清单:`docs/architecture/api_and_service_inventory_cn.md`
## 3. 产品总目标
Boss 的产品目标是把主 Agent、业务 Agent、组织角色、真实电脑、企业系统和 Skill 连接成可执行的企业管理系统。
PPT 中的核心判断需要进入产品开发主线:
- AI 已经能对话,但企业执行还没有被完整接管。
- 真正的成本不在模型本身,而在重复沟通、人工汇总、跨系统搬运和不可追踪的执行过程。
- Boss 的价值不是再做一个聊天机器人,而是让经营目标变成可拆解、可审批、可执行、可追踪、可复盘、可回退的闭环。
- 企业级 AI 必须先可控,再谈自动化。
## 4. 采用方案 BBoss 企业控制面 + 可插拔执行协议
量产版本默认采用方案 B。
方案 B 的定义:
- Boss 是企业级控制面和数据事实源。
- Codex App Server、Codex MCP、Codex CLI、Computer Use、CUA Driver、Browser Automation、业务系统 API、Skill Runtime 都作为执行 provider 接入。
- 所有 provider 的原始事件必须先归一化为 Boss 自有事件和消息模型,再进入 APP、Web 管理后台、审计日志和回退系统。
- UI、权限、审计、备份、任务 SLA、风险处置和企业账号体系不能直接依赖某一个 provider 的私有字段。
选择方案 B 的原因:
- 企业客户最关心的是权限、审批、审计、稳定性、数据边界和可回退,不是某个单一执行引擎的能力展示。
- Codex 协议未来会持续变化Boss 必须通过适配层快速跟进,而不是把协议字段写死到业务模型里。
- Boss 还需要支持我们自研的 Computer Use、未来的企业系统 API、Telegram/飞书/微信入口、Skill 分发和多租户后台,单独围绕 Codex 建模会限制长期扩展。
## 5. 方案 C 的优劣势
方案 C 指把 Codex App Server 作为更核心的数据和执行事实源,让 Boss 尽量贴近 Codex 原生 Thread、Turn、Item、Approval、Skill、Plugin 和 App Server event。
优势:
- 最接近 Codex 原生体验线程、实时事件、审批、Skill、命令执行和文件变更可以更快跟随官方能力。
- APP 与 Codex 桌面端的同线程实时同步理论上更顺,重复实现更少。
- Codex 新增功能时Boss 可以更快暴露给用户。
劣势:
- 业务核心会强绑定 Codex 协议,协议变更会直接冲击 Boss 的权限、审计、回退和消息账本。
- 企业级多租户、子账号授权、跨公司隔离、平台总后台、Skill 分配和数据留存不能完全交给 Codex 原生模型。
- 非 Codex 执行能力会变成二等能力,比如自研 Computer Use、业务系统 API、Telegram/飞书入口和后续企业 OA 集成。
- 数据自动备份和业务级回退会受限于 Codex 本地会话存储语义,不能满足 To B 生产级治理。
最终策略:
- 不采用方案 C 作为总架构。
- 在执行层内部吸收方案 C 的优点,优先新增 `CodexAppServerBackendAdapter`
- Boss 数据模型保持独立Codex App Server 只作为强执行 provider 和实时事件来源。
## 6. 多层级协作关系
PPT 第 3、4、5、8、9 页确定的协作链路必须成为产品模型的骨架。
### 6.1 组织层级
| 层级 | 角色 | 主要职责 | 可见范围 |
| --- | --- | --- | --- |
| 平台最高管理员 | Boss 平台运营方 | 创建企业、开通老板账号、查看全局风险、处理服务异常、管理套餐和授权 | 全平台治理数据,不默认看企业业务内容明细 |
| 企业老板端 | 企业超级管理员 | 看全局目标、成本、现金流、风险和最终结果;通过主 Agent 问询公司执行状态 | 本企业全局 |
| 经理端 | 部门或项目负责人 | 接收目标、拆解任务、审批异常、追踪团队进度、协调资源 | 授权团队、部门、项目 |
| 员工端 | 具体执行人员 | 处理具体任务、补充判断、上传材料、确认结果 | 自己任务和授权上下文 |
| 系统端 | Boss 控制面 | 维护账号、设备、权限、SOP、审批、审计、日志、备份和回退 | 按租户和权限隔离 |
### 6.2 Agent 层级
| 层级 | Agent | 职责 |
| --- | --- | --- |
| 调度层 | 主 Agent | 理解目标、判断权限、拆解任务、分配资源、汇总结果、协调多线程/多设备/多业务 Agent |
| 执行层 | 业务 Agent | 按 SOP 执行业务流程如销售、客服、财务、HR、项目、行政、运维 |
| 设备层 | 本地 Agent | 接入真实电脑、Codex、Computer Use、浏览器、文件、系统权限和本地 Skill |
| Provider 层 | 执行 provider | Codex App Server、Codex CLI/MCP、CUA Driver、Browser Automation、业务系统 API、Skill Runtime |
### 6.3 主 Agent 与业务 Agent 分工
主 Agent 负责“为什么做、谁来做、能不能做”:
- 理解业务目标和约束条件。
- 拆解任务、里程碑和依赖关系。
- 判断账号、设备、项目、Skill 和数据访问权限。
- 选择合适业务 Agent、设备 Agent 或执行 provider。
- 在关键节点请求用户、经理或审批人确认。
- 汇总执行结果、风险、阻塞和下一步动作。
业务 Agent 负责“按 SOP 把事情做完”:
- 销售 Agent线索跟进、商机管理、合同执行、回款跟踪。
- 客服 Agent客户咨询、工单处理、服务跟进、满意度管理。
- 财务 Agent费用报销、预算管理、账务处理、财务分析。
- HR Agent招聘管理、入职管理、考勤管理、绩效管理。
- 项目 Agent项目计划、进度跟踪、风险管理、交付验收。
- 行政 Agent采购管理、资产管理、会议管理、用品管理。
- 运维 Agent巡检、告警、故障复盘和修复建议。
## 7. 标准执行闭环
所有企业动作都应尽量落到同一条闭环:
1. 用户提出经营目标或执行请求。
2. 主 Agent 将自然语言目标拆成任务、里程碑、依赖和风险。
3. 系统检查账号、设备、项目、Skill、SOP 和数据权限。
4. 经理或授权人确认边界、资源、预算和高风险动作。
5. 业务 Agent 或设备 Agent 按 SOP 执行。
6. 员工只处理例外、补充材料、做判断和确认结果。
7. 系统回写过程记录、权限记录、结果记录和异常记录。
8. 主 Agent 形成复盘、版本记录、项目目标更新和下一步建议。
这条闭环必须沉淀四类记录:
- 权限记录:谁在何时对哪些内容拥有什么操作权限。
- 过程记录:任务流转、操作步骤、审批意见和执行过程。
- 结果记录:关键输出、指标达成、交付物和数据回写。
- 异常记录:异常识别、处理过程、原因分析和改进措施。
## 8. 治理能力必须前置
PPT 第 8 页强调“AI 执行必须先可控,再谈自动化”。量产版本的功能优先级必须体现这一点。
| 治理能力 | 产品要求 |
| --- | --- |
| RBAC 角色权限 | 老板、经理、员工、子账号、设备、项目、Skill 和数据权限必须可裁剪 |
| 审批与确认 | 高风险任务必须进入人工确认,不允许 AI 越权操作 |
| 审计日志 | 记录任务来源、执行过程、结果回写、异常原因和审批链 |
| 账号与设备治理 | 管理 AI 账号、真实电脑、本地 Agent、Skill 能力和授权状态 |
| 数据边界 | 按企业、部门、项目、账号隔离上下文,越权数据不展示 |
| SLA 与风险处置 | 异常任务、离线设备、执行失败、超时任务必须进入风险台 |
## 9. 量产数据安全与自动备份机制
当前文件型 `data/boss-state.json` 只能支撑 MVP量产必须迁移到数据库和可回退的数据架构。
### 9.1 数据事实源
量产推荐:
- PostgreSQL 作为主业务库。
- 对象存储保存附件、截图、执行产物、日志归档和备份包。
- Redis 或队列系统只做缓存、锁和异步任务,不作为最终事实源。
- 每一个重要状态变化都写入 append-only 事件账本。
核心原则:
- 普通业务表保存当前状态。
- 事件账本保存“发生过什么”。
- 审计表保存“谁允许了什么”。
- 快照表保存“某个时刻可以回到哪里”。
### 9.2 自动备份
必须具备:
- PostgreSQL WAL 归档和定时全量备份。
- 每日全量备份、小时级增量备份、关键变更前即时快照。
- 跨区域或独立对象存储备份。
- 备份加密、备份校验和定期恢复演练。
- 按企业租户拆分可导出数据包,便于企业级交付和迁移。
建议目标:
- RPO普通业务不超过 15 分钟,关键企业客户可配置到 5 分钟。
- RTO普通故障 1 小时内恢复,关键演示或生产客户 15 分钟内恢复核心链路。
### 9.3 业务级回退
量产回退不能只依赖数据库备份。必须提供业务级回退能力:
| 场景 | 回退方式 |
| --- | --- |
| 消息误删 | 软删除 + 会话级恢复 |
| 项目目标或版本记录误改 | 版本化保存 + 一键恢复上一版 |
| 权限误授权 | 授权变更事件可撤销,撤销后同步清理会话和任务上下文 |
| Skill 安装或升级失败 | 安装前备份、版本锁、回滚到上一可用版本 |
| 主 Agent 错误接管 | 关闭接管、取消 queued/running 主动任务、恢复原线程控制权 |
| Codex 开发任务误操作 | 执行前 checkpoint、Git 分支隔离、diff 审批、必要时 revert |
| Computer Use 错误点击 | 高风险动作前确认、动作录像/截图留档、支持人工中止 |
| 企业配置误改 | 配置快照 + 审计原因 + 指定时间点恢复 |
## 10. Codex 协议扩展策略
Codex App Server 官方文档明确了它面向深度产品集成,包含 authentication、conversation history、approvals 和 streamed agent events。它当前基于 JSON-RPC 形态暴露 Thread、Turn、Item、Approval、Skill、Plugin、App、MCP、文件系统和模型列表等能力。
Boss 后续要把 Codex App Server 当作优先升级方向,但不能把业务模型绑死在它的字段上。
### 10.1 Provider 抽象
新增或强化统一执行 provider 接口:
```text
Boss Task
-> ExecutionBackendSelector
-> CodexAppServerBackendAdapter
-> CodexMcpBackendAdapter
-> CodexCliExecBackendAdapter
-> NativeComputerUseBackendAdapter
-> BrowserAutomationBackendAdapter
-> BusinessSystemApiBackendAdapter
```
每个 provider 必须声明:
- `providerId`
- `protocolVersion`
- `capabilities`
- `riskPolicy`
- `approvalModes`
- `streamingModes`
- `rollbackSupport`
- `healthCheck`
- `fallbackProviderId`
### 10.2 事件归一化
Codex App Server 的原始事件不能直接进入前台 UI。必须先归一化为 Boss 事件:
| Codex 原始概念 | Boss 归一化概念 |
| --- | --- |
| Thread | ProjectConversation / CodexThreadRef |
| Turn | ExecutionTurn / UserRequest |
| Item | ExecutionItem / MessageSegment / ProgressStep |
| Approval Request | ApprovalCard |
| Command Execution | ExecutionStep |
| File Change | ChangeSet |
| Skill | SkillCapability |
| Plugin/App | ExternalCapability |
| Error | RiskEvent / TaskFailure |
### 10.3 版本兼容机制
每次 Codex 协议升级时必须走以下流程:
1. 拉取或生成当前 Codex App Server TypeScript / JSON Schema。
2. 保存到 `docs/protocol-snapshots/codex-app-server/<version>/`
3. 自动生成 provider capability manifest。
4. 跑协议兼容测试,确认 Thread、Turn、Item、Approval、Skill、Plugin 和 model/list 是否仍能映射。
5. 新能力先挂 feature flag不直接进入生产默认链路。
6. 对关键企业租户先做灰度,再全量开启。
### 10.4 禁止写死的内容
以下内容不得写死进业务逻辑或 UI
- Codex CLI 输出 envelope。
- Codex Desktop 私有数据库字段。
- 某一个 Codex 版本的 stderr/stdout 文案。
- 某一个 App Server event 的完整原始字段。
- 本地线程文件路径。
- Codex 具体模型列表。
- Skill 的本地绝对路径。
允许写死的是 Boss 自己的领域模型、权限模型、审计模型和回退模型。
## 11. 进度卡与实时协作
PPT 强调从目标到结果的可追踪闭环Codex App Server 又提供 streamed agent events。Boss 的聊天窗口必须把“正在做什么”表达为结构化进度,而不是刷屏输出过程噪音。
建议统一进度卡结构:
- 进度:计划、执行中、完成、失败、等待审批。
- 分支详情Git 分支、diff 摘要、测试状态、生成产物。
- 生成结果文档、APK、图片、代码文件、报告。
- 后台智能体:主 Agent、业务 Agent、Codex、explorer、Computer Use provider。
- 风险:权限不足、设备离线、测试失败、审批等待、回退点。
执行过程中的低价值输出默认折叠,只显示最终结果和关键节点。用户需要查看细节时再展开过程日志。
## 12. Skill 治理与共享
Skill 是 Boss 企业扩展性的关键能力,但不能只做本机目录同步。
量产 Skill 治理需要支持:
- 平台级 Skill 市场。
- 企业级私有 Skill 仓库。
- 按公司、部门、账号、设备、项目授权 Skill。
- Skill 安装、升级、回滚、版本锁。
- Skill 依赖、来源、checksum、签名和安全等级。
- Skill 使用审计和失败率统计。
- 多电脑共享 Skill但执行时仍按设备本地能力和权限裁剪。
主 Agent 在选择 Skill 时,只能看到当前用户、当前企业、当前设备和当前项目被授权的 Skill。
## 13. 当前落地进度与量产剩余清单
本节用于承接当前开发状态。这里的“已落地”只表示代码和本地回归已具备,不代表已经完成生产灰度、客户验收或长期稳定性验证。
### 13.1 已落地的量产底座
| 方向 | 当前状态 | 关键文件 / 接口 |
| --- | --- | --- |
| 多租户与 RBAC | 已具备最高管理员、企业管理员、成员账号、公司归属、设备 / 项目 / Skill 授权和审计日志 | `src/lib/boss-permissions.ts``GET/POST /api/v1/admin/access` |
| 设备撤权 | 已支持 `revoke_device`:清空设备 token、置离线、写 `device.revoked` 审计,并阻断 heartbeat、任务认领、Skill 同步、日志上报、boss-agent OTA | `src/lib/boss-data.ts``src/app/api/device-heartbeat/route.ts``src/app/api/v1/admin/access/route.ts` |
| 设备心跳安全 | 已禁止无 token 的已存在设备续命,禁止未准备 enrollment 的新设备自注册,吊销设备不会刷新 `lastSeenAt / status / projects / projectCandidates` | `POST /api/device-heartbeat` |
| 主 Agent 任务租约 | 已支持 `attemptCount / maxAttempts / leaseExpiresAt`,运行中任务租约过期可重试认领,超过上限转 `timed_out` | `claimNextMasterAgentTask()``POST /api/v1/master-agent/tasks/claim` |
| 主 Agent 任务取消 | 已支持取消 `queued / running / needs_user_action`,写 `canceledAt / canceledBy / cancelReason`,迟到 complete 不覆盖终态 | `POST /api/v1/master-agent/tasks/[taskId]/cancel` |
| Codex 桌面同步 | 已支持 APP 用户消息镜像到 Codex Desktop rollout并通过本机刷新桥提示桌面端感知更新 | `local-agent`、Codex Desktop Refresh Bridge |
| Codex App Server 接入 | 已有第一批 provider runnerturn 启动前失败可回退 CLIturn 启动后不重复执行 | `local-agent/codex-app-server-runner.mjs` |
| 电脑控制 provider | macOS 链路优先 Codex Computer Use失败后回退 CUA Driverbrowser / desktop 任务统一走 `MasterAgentTask` 和进度卡 | `local-agent/computer-use-task-runner.mjs``scripts/codex-computer-use-runtime.mjs``scripts/cua-driver-computer-use-runtime.mjs` |
| 状态快照与回退 | 文件状态已有自动快照、手动创建快照和恢复前 pre-restore 快照 | `src/lib/boss-state-backups.ts``GET/POST /api/v1/admin/backups` |
| PostgreSQL 切换前置 | 已有 Postgres JSONB store、schema 校验、dry-run 迁移、Postgres 备份导出和恢复脚本 | `src/lib/boss-state-store.ts``scripts/boss-state-store-maintenance.mjs` |
| boss-agent Mac OTA | 已支持 Mac agent 包检查、下载、校验和覆盖安装,并保留绑定配置 | `src/lib/boss-agent-ota.ts``local-agent/boss-agent-ota-runner.mjs` |
| Skill 生命周期治理 | 已支持 install / update / uninstall / rollback / version_lock 请求、设备端 claim / complete、source allowlist、checksum、更新前备份和失败恢复 | `GET/POST /api/v1/admin/skills/requests``skill-requests/claim` |
### 13.2 量产 P0上线前必须补齐
P0 的定义:不补齐会影响企业客户数据安全、权限边界、稳定演示或生产事故恢复。
1. 数据库正式切换:把 `BOSS_STATE_STORE=postgres` 从可选适配层推进到生产主路径,补正式 PostgreSQL 表拆分、迁移脚本、灰度开关和回滚剧本。
2. 事件账本:新增 append-only event ledger覆盖账号、设备、权限、任务、审批、Skill、备份、Computer Use 动作和主 Agent 接管。
3. 备份恢复演练自动化:把文件快照和 Postgres 备份纳入后台“恢复演练”流程,记录演练时间、耗时、校验结果和负责人。
4. 任务调度服务化:把 `MasterAgentTask` 从状态文件轮询升级为数据库任务队列或可靠队列,支持 lease、retry、cancel、dead-letter 和 worker 心跳。
5. 企业 SSO / IdP补 OIDC/SAML 之一支持企业管理员配置登录策略、MFA 强制、离职回收和会话全量吊销。
6. 设备绑定与正版授权boss-agent 绑定二维码、授权到期、许可证校验、设备换绑、离线宽限期和吊销恢复流程需要闭环。
7. 审计不可抵赖:关键操作需要稳定审计 ID、操作者、来源 IP、UA、前后快照、关联任务和导出能力。
8. 高风险审批Computer Use、代码提交、部署、权限变更、批量 Skill 下发必须进入审批卡,不允许只靠主 Agent 自行判断。
9. 生产监控与告警:补服务端 metrics、错误率、任务积压、设备离线、API 失败、OTA 失败、备份失败和客户维度 SLA 告警。
10. 客户数据隔离验收:对所有 Web / APP / API 投影视图做租户隔离回归确保子账号看不到未授权设备、项目、线程、Skill、日志和附件。
### 13.3 量产 P1首批企业客户试点必须补齐
P1 的定义:不影响最小上线,但会影响试点客户规模化复制、运营效率和长期留存。
1. 管理后台企业化:平台总后台和企业自管后台进一步拆清,补租户套餐、授权席位、设备额度、用量统计、客户健康分和客户成功视图。
2. Skill 市场:支持平台 Skill、企业私有 Skill、版本签名、依赖扫描、灰度发布、回滚统计和失败率看板。
3. 业务 Agent 目录把销售、客服、财务、HR、项目、行政、运维 Agent 做成可配置目录,并绑定 SOP、权限和数据源。
4. 进度卡增强:把 `execution_progress` 扩展为统一 task timeline支持步骤、截图、文件变更、测试结果、审批记录和可展开过程日志。
5. Codex 协议快照:建立 `docs/protocol-snapshots/codex-app-server/`,自动比较 protocol version、capabilities、model/list、approval、skill、plugin 变化。
6. 多入口一致会话Telegram 已有最小链路,后续补飞书、企业微信或微信生态入口,并统一权限、审计、通知和会话状态。
7. 企业知识库与长期记忆:把项目目标、版本记录、任务结果、回退点、客户 SOP 和主 Agent 记忆纳入统一版本化记录。
8. Computer Use 证据链:关键桌面动作需要截图 / 屏幕录制 / AX tree 摘要 / 操作序列留档,便于复盘和客户信任。
### 13.4 当前验证基线
截至本次文档更新,以下本地验证命令作为当前量产底座的最小回归基线:
```bash
npx tsx --test tests/device-revocation-auth.test.ts tests/master-agent-task-reliability.test.ts tests/device-import-draft.test.ts tests/ai-account-validation.test.ts tests/device-import-candidate-id-regression.test.ts tests/master-agent-task-claim-route.test.ts tests/device-execution-conflict.test.ts tests/browser-desktop-control-summary-message.test.ts tests/skill-lifecycle-route.test.ts tests/state-store-maintenance-script.test.ts tests/boss-state-store.test.ts
npm run lint
npm run build
```
本次验证结果49 项核心测试通过,`npm run lint` 通过,`npm run build` 通过。
## 14. 90 天量产试点路径
PPT 第 10 页给出的 90 天路径进入后续 To B 交付方法论。
### 阶段 10-30 天
目标:选择 1-2 个高频流程,搭好企业账号、权限、设备和数据接入基础。
交付:
- 梳理老板、经理、员工权限。
- 选择高频、规则清晰、跨系统搬运多的流程。
- 接入关键系统和数据,如 CRM、ERP、财务、OA、知识库。
- 接入真实电脑和本地 Agent。
### 阶段 231-60 天
目标:部署主 Agent 和 2-3 个业务 Agent跑真实任务。
交付:
- 主 Agent 统筹,业务 Agent 执行具体流程。
- 建立审批、审计、异常处理和权限边界。
- 跑通真实任务并持续优化 SOP。
- 形成可复制配置模板。
### 阶段 361-90 天
目标:接入经营看板,评估效率和复制条件。
交付:
- 实时展示任务进度、效率指标和业务价值。
- 评估从发起到结果的整体响应时间。
- 评估人工汇总减少、流程稳定性和复制条件。
- 输出下一部门复制方案。
成功标准:
- 可持续执行。
- 可审批。
- 可追踪。
- 可复制。
## 15. 产品开发优先级
第一优先级:稳定和治理。
- 数据库正式替换文件状态。
- append-only 事件账本。
- 自动备份和恢复演练。
- 业务级回退。
- 主 Agent 任务取消、接管关闭和主动任务清理。当前已有任务 cancel / timed_out 底座,仍需数据库队列化和 dead-letter。
- 设备离线、执行失败、审批超时进入风险台。
第二优先级Codex App Server 深度接入。
- App Server provider adapter。当前已有第一批 runner仍需协议快照、能力清单和 streamed events 完整映射。
- Thread / Turn / Item 映射。
- Approval card 映射。
- streamed events 进入进度卡。
- skills/list、model/list、plugin/list 进入能力清单。
- 协议快照和兼容测试。
第三优先级:企业协作网络。
- 老板端、经理端、员工端权限视图。
- 业务 Agent 目录和 SOP。
- 部门/项目维度执行闭环。
- Skill 企业分配和跨设备同步。
- 平台总后台风险与客户成功视图。
第四优先级:前瞻扩展。
- Telegram、飞书、微信、Web、APP 多入口一致会话。
- 自研 Computer Use 与 Codex Computer Use 并存。
- 多 provider 智能路由。
- 企业知识库和长期记忆。
- 自动复盘、流程优化和策略建议。
## 16. 非协商性原则
- 不允许把系统提示词、内部 prompt、设备 token、API key、工作目录调度说明写进用户可见消息。
- 不允许主 Agent 在用户取消接管后继续主动向线程发起任务。
- 不允许没有审计记录的高风险操作。
- 不允许没有回退点的批量权限、Skill、数据或代码变更。
- 不允许把 Codex 当前某个版本的协议字段直接作为 Boss 业务事实源。
- 不允许把过程噪音当作未读消息。
- 不允许让企业子账号看到未授权设备、项目、Skill 或线程上下文。
## 17. 下一步落地清单
1. 输出 PostgreSQL 正式 schema 设计账号、企业、设备、项目、线程、消息、任务、审批、事件账本、审计、备份、Skill。
2.`MasterAgentTask` 调度从文件状态轮询迁到可靠队列或数据库任务表,并保留现有 lease / retry / cancel 语义。
3. 新增 `docs/protocol-snapshots/codex-app-server/` 目录规范和兼容测试。
4.`execution_progress` 进度卡扩展成统一 task timeline。
5. 把项目目标、版本记录、任务结果和回退点纳入同一套版本化记录。
6. 为平台总后台增加恢复演练、租户风险、设备离线、主 Agent 失败和任务积压看板。
7. 为 Skill 治理增加签名、依赖扫描、灰度发布和失败率统计。
8. 为 boss-agent 增加企业正版授权、授权到期提醒、离线宽限和设备换绑流程。

View File

@@ -4,7 +4,7 @@
## 背景
Boss 需要从“客户也能用的 Web 页面”升级为平台侧 To B 总后台。这个后台用于平台运营人员管理公司、老板账号、子账号、电脑节点、Skill 授权、风险告警和审计记录。现有 `/admin` 已能展示核心数据,但运行在 Next 主站内,信息架构不够像成熟企业后台,后续不适合承载更复杂的租户、权限和治理能力。
Boss 需要从“客户也能用的 Web 页面”升级为平台侧 To B 总后台。这个后台用于平台运营人员管理公司、老板账号、子账号、电脑节点、Skill 授权、风险告警和审计记录。 `/admin` 曾经承载核心数据展示,但运行在 Next 主站内,信息架构不够像成熟企业后台,后续不适合承载更复杂的租户、权限和治理能力;当前已收敛为 `/enterprise-admin` 独立后台
调研 `YunaiV/yudao-cloud` 后,结论是:不直接引入它的 Spring Cloud 微服务后端;借鉴它的租户、用户、角色、菜单、日志、工作台和独立前端思路。前端形态参考 YuDao 的 Vben/Vue 管理后台,数据仍由 Boss 现有状态账本和 Admin BFF 提供。
@@ -14,7 +14,7 @@ Boss 需要从“客户也能用的 Web 页面”升级为平台侧 To B 总后
- 新增独立 PC 后台工程 `apps/boss-admin-web`,使用 Vue + Vite + Ant Design Vue。
- 新增 `/api/v1/admin/backoffice` 聚合接口,输出 YuDao 风格的菜单、工作台、租户、账号、角色权限、资源授权、风险和审计数据。
- 保留现有 `/admin`,作为 Boss 主站内 fallback不和独立后台互相替代
- 独立后台成为唯一 PC 管理 UI`/admin` 仅作为兼容跳转入口,不再保留旧 Next UI
- 后台权限继续只允许 `highest_admin` 访问不暴露密码哈希、MFA 密钥和会话令牌。
- 新后台先复用 Boss Cookie 登录态,后续再接独立域名 `admin.boss.hyzq.net`
@@ -33,7 +33,7 @@ flowchart LR
B --> C["boss-state.json\n当前状态账本"]
B --> D["buildAdminOverview\n现有后台聚合"]
B --> E["BOSS_PERMISSION_TEMPLATES\n权限模板"]
F["现有 /admin\nNext fallback"] --> G["/api/v1/admin/overview"]
F["/admin\n兼容跳转"] --> A
```
`apps/boss-admin-web` 是独立前端工程。它只消费 BFF不直接读取本地文件也不复制业务规则。`/api/v1/admin/backoffice` 是企业后台的新契约层,负责把 Boss 当前状态翻译为更稳定的后台管理模型。

View File

@@ -0,0 +1,293 @@
import { spawn } from "node:child_process";
import { createHash } from "node:crypto";
import { chmod, mkdir, rm, writeFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
function nonEmpty(value) {
const text = String(value ?? "").trim();
return text || undefined;
}
function boolConfig(value, defaultValue) {
if (value === undefined || value === null || value === "") return defaultValue;
return value !== false && value !== "false" && value !== "0";
}
function positiveNumber(value, defaultValue) {
const number = Number(value);
return Number.isFinite(number) && number > 0 ? number : defaultValue;
}
function safeFileName(value, fallback = "boss-agent-mac-latest.zip") {
const base = path.basename(String(value ?? "").trim());
return base && base !== "." && base !== "/" ? base : fallback;
}
function resolveControlPlaneUrl(config) {
return String(config.controlPlaneUrl ?? "https://boss.hyzq.net").replace(/\/$/, "");
}
function resolveDownloadUrl(controlPlaneUrl, downloadUrl, deviceId) {
const url = new URL(downloadUrl || "/api/v1/boss-agent/ota/package", controlPlaneUrl);
if (deviceId && !url.searchParams.get("deviceId")) {
url.searchParams.set("deviceId", deviceId);
}
return url;
}
function deviceHeaders(config, runtime) {
const token = nonEmpty(runtime?.issuedToken) ?? nonEmpty(config.token);
return {
...(token ? { "x-boss-device-token": token } : {}),
...(nonEmpty(config.deviceId) ? { "x-boss-device-id": nonEmpty(config.deviceId) } : {}),
};
}
function compareVersions(left, right) {
const lhs = String(left ?? "").trim();
const rhs = String(right ?? "").trim();
if (!rhs) return false;
return lhs !== rhs;
}
function nowIso() {
return new Date().toISOString();
}
function writeRuntimeStatus(runtime, status) {
if (runtime) {
runtime.lastBossAgentOtaStatus = {
...status,
checkedAt: nowIso(),
};
}
return runtime?.lastBossAgentOtaStatus ?? status;
}
export function getBossAgentOtaRunnerConfig(env = process.env, config = {}) {
const enabled = boolConfig(config.bossAgentOtaEnabled ?? env.BOSS_AGENT_OTA_ENABLED, true);
const currentVersion =
nonEmpty(config.bossAgentVersion) ??
nonEmpty(env.BOSS_AGENT_VERSION) ??
"dev";
const installRoot = path.resolve(
nonEmpty(config.bossAgentInstallRoot) ??
nonEmpty(env.BOSS_AGENT_INSTALL_ROOT) ??
path.join(os.homedir(), "boss-agent", "current"),
);
const downloadDir = path.resolve(
nonEmpty(config.bossAgentOtaDownloadDir) ??
nonEmpty(env.BOSS_AGENT_OTA_DOWNLOAD_DIR) ??
path.join(os.homedir(), "boss-agent", "updates"),
);
const checkIntervalMs = positiveNumber(
config.bossAgentOtaCheckIntervalMs ?? env.BOSS_AGENT_OTA_CHECK_INTERVAL_MS,
300_000,
);
const autoInstall = boolConfig(config.bossAgentOtaAutoInstall ?? env.BOSS_AGENT_OTA_AUTO_INSTALL, false);
const launchInstallerCommand =
nonEmpty(config.bossAgentOtaLaunchInstallerCommand) ??
nonEmpty(env.BOSS_AGENT_OTA_LAUNCH_INSTALLER_COMMAND) ??
(process.platform === "darwin" ? "open" : "");
const launchInstallerArgs = Array.isArray(config.bossAgentOtaLaunchInstallerArgs)
? config.bossAgentOtaLaunchInstallerArgs.map(String)
: [];
return {
enabled,
currentVersion,
installRoot,
downloadDir,
checkIntervalMs,
autoInstall,
launchInstallerCommand,
launchInstallerArgs,
};
}
export async function checkBossAgentOtaUpdate(config = {}, runtime = {}) {
const runnerConfig = getBossAgentOtaRunnerConfig(process.env, config);
if (!runnerConfig.enabled) {
return writeRuntimeStatus(runtime, {
enabled: false,
currentVersion: runnerConfig.currentVersion,
hasUpdate: false,
latest: null,
message: "BOSS_AGENT_OTA_DISABLED",
});
}
const controlPlaneUrl = resolveControlPlaneUrl(config);
const url = new URL("/api/v1/boss-agent/ota", controlPlaneUrl);
url.searchParams.set("deviceId", nonEmpty(config.deviceId) ?? "");
url.searchParams.set("currentVersion", runnerConfig.currentVersion);
try {
const response = await fetch(url, {
method: "GET",
headers: deviceHeaders(config, runtime),
});
const payload = await response.json().catch(() => null);
if (!response.ok || !payload?.ok) {
throw new Error(payload?.message ?? `BOSS_AGENT_OTA_CHECK_FAILED:${response.status}`);
}
const latest = payload.latest ?? null;
const hasUpdate = Boolean(
latest &&
compareVersions(runnerConfig.currentVersion, latest.version) &&
payload.hasUpdate !== false,
);
return writeRuntimeStatus(runtime, {
enabled: true,
currentVersion: runnerConfig.currentVersion,
hasUpdate,
latest,
message: hasUpdate ? "BOSS_AGENT_OTA_AVAILABLE" : "BOSS_AGENT_OTA_UP_TO_DATE",
});
} catch (error) {
return writeRuntimeStatus(runtime, {
enabled: true,
currentVersion: runnerConfig.currentVersion,
hasUpdate: false,
latest: null,
error: error instanceof Error ? error.message : String(error),
message: "BOSS_AGENT_OTA_CHECK_ERROR",
});
}
}
async function downloadArchive(config, runtime, latest, runnerConfig) {
const controlPlaneUrl = resolveControlPlaneUrl(config);
const url = resolveDownloadUrl(controlPlaneUrl, latest.downloadUrl, nonEmpty(config.deviceId));
const response = await fetch(url, {
method: "GET",
headers: deviceHeaders(config, runtime),
});
if (!response.ok) {
throw new Error(`BOSS_AGENT_OTA_DOWNLOAD_FAILED:${response.status}`);
}
const buffer = Buffer.from(await response.arrayBuffer());
const actualSha256 = createHash("sha256").update(buffer).digest("hex");
const expectedSha256 = nonEmpty(latest.sha256);
if (expectedSha256 && actualSha256.toLowerCase() !== expectedSha256.toLowerCase()) {
throw new Error("BOSS_AGENT_OTA_CHECKSUM_MISMATCH");
}
const version = nonEmpty(latest.version) ?? "latest";
const stageDir = path.join(runnerConfig.downloadDir, version);
await rm(stageDir, { recursive: true, force: true });
await mkdir(stageDir, { recursive: true });
const archivePath = path.join(stageDir, safeFileName(latest.fileName));
await writeFile(archivePath, buffer);
return {
version,
stageDir,
archivePath,
sha256: actualSha256,
sizeBytes: buffer.length,
};
}
async function writeInstallerWrapper(downloaded, runnerConfig) {
const installCommandPath = path.join(downloaded.stageDir, "install.command");
const extractDir = path.join(downloaded.stageDir, "extracted");
const script = `#!/bin/zsh
set -euo pipefail
ARCHIVE=${JSON.stringify(downloaded.archivePath)}
EXTRACT_DIR=${JSON.stringify(extractDir)}
INSTALL_ROOT=${JSON.stringify(runnerConfig.installRoot)}
rm -rf "$EXTRACT_DIR"
mkdir -p "$EXTRACT_DIR"
ditto -x -k "$ARCHIVE" "$EXTRACT_DIR"
PACKAGE_DIR="$(find "$EXTRACT_DIR" -maxdepth 1 -type d -name 'boss-agent-mac-runtime-*' | head -n 1)"
if [[ -z "$PACKAGE_DIR" || ! -x "$PACKAGE_DIR/install.command" ]]; then
echo "boss-agent OTA package is invalid: install.command not found" >&2
exit 1
fi
BOSS_AGENT_INSTALL_ROOT="$INSTALL_ROOT" "$PACKAGE_DIR/install.command"
`;
await writeFile(installCommandPath, script, "utf8");
await chmod(installCommandPath, 0o755);
return installCommandPath;
}
function launchInstaller(command, args, installCommandPath) {
return new Promise((resolve) => {
if (!command) {
resolve({ ok: false, error: "BOSS_AGENT_OTA_LAUNCH_COMMAND_MISSING" });
return;
}
const child = spawn(command, [...args, installCommandPath], {
detached: true,
stdio: "ignore",
});
child.on("error", (error) => {
resolve({ ok: false, error: error.message });
});
child.on("spawn", () => {
child.unref();
resolve({ ok: true });
});
});
}
export async function applyBossAgentOtaUpdate(config = {}, runtime = {}, options = {}) {
const runnerConfig = getBossAgentOtaRunnerConfig(process.env, config);
if (!runnerConfig.enabled) {
const result = {
status: "failed",
error: "BOSS_AGENT_OTA_DISABLED",
completedAt: nowIso(),
};
if (runtime) runtime.lastBossAgentOtaApply = result;
return result;
}
try {
const status = await checkBossAgentOtaUpdate(config, runtime);
if (!status.hasUpdate || !status.latest) {
const result = {
status: "skipped",
reason: "BOSS_AGENT_OTA_UP_TO_DATE",
currentVersion: runnerConfig.currentVersion,
completedAt: nowIso(),
};
if (runtime) runtime.lastBossAgentOtaApply = result;
return result;
}
const downloaded = await downloadArchive(config, runtime, status.latest, runnerConfig);
const installCommandPath = await writeInstallerWrapper(downloaded, runnerConfig);
const shouldLaunch = options.launchInstaller ?? runnerConfig.autoInstall;
const launch = shouldLaunch
? await launchInstaller(
runnerConfig.launchInstallerCommand,
runnerConfig.launchInstallerArgs,
installCommandPath,
)
: { ok: false, skipped: true };
const result = {
status: shouldLaunch && launch.ok ? "installer_launched" : "staged",
version: downloaded.version,
archivePath: downloaded.archivePath,
stageDir: downloaded.stageDir,
installCommandPath,
sha256: downloaded.sha256,
sizeBytes: downloaded.sizeBytes,
launch,
completedAt: nowIso(),
};
if (runtime) runtime.lastBossAgentOtaApply = result;
return result;
} catch (error) {
const result = {
status: "failed",
error: error instanceof Error ? error.message : String(error),
completedAt: nowIso(),
};
if (runtime) runtime.lastBossAgentOtaApply = result;
return result;
}
}

View File

@@ -166,6 +166,76 @@ function resolveLicense(config, bound) {
};
}
function resolveCodexBinding(config) {
const appServerEnabled = config.codexAppServerEnabled === true;
const codexComputerUseEnabled = config.codexComputerUseEnabled === true;
const command = nonEmpty(config.codexAppServerCommand) ?? "codex";
const defaultDesktopProvider = codexComputerUseEnabled
? "codex-computer-use"
: "cua-driver-computer-use";
const bindingStatus = appServerEnabled || codexComputerUseEnabled ? "connected" : "not_configured";
return {
bindingStatus,
statusLabel: bindingStatus === "connected" ? "已默认绑定" : "未默认绑定",
command,
appServerEnabled,
computerUseEnabled: codexComputerUseEnabled,
defaultDesktopProvider,
desktopProviderLabel:
defaultDesktopProvider === "codex-computer-use"
? "Codex Computer Use"
: "Boss CUA Driver",
fallbackProvider: "cua-driver-computer-use",
fallbackLabel: "Boss CUA Driver",
summary:
defaultDesktopProvider === "codex-computer-use"
? "远程控制默认走 Codex Computer Use失败后回退 Boss CUA Driver。"
: "远程控制默认走 Boss CUA Driver。",
};
}
function resolveAgentOta(config, runtime) {
const enabledValue = config.bossAgentOtaEnabled;
const enabled = enabledValue === undefined ? true : enabledValue !== false && enabledValue !== "false";
const currentVersion = nonEmpty(config.bossAgentVersion) ?? "dev";
const lastStatus = runtime.lastBossAgentOtaStatus && typeof runtime.lastBossAgentOtaStatus === "object"
? runtime.lastBossAgentOtaStatus
: {};
const lastApply = runtime.lastBossAgentOtaApply && typeof runtime.lastBossAgentOtaApply === "object"
? runtime.lastBossAgentOtaApply
: {};
const latest = lastStatus.latest && typeof lastStatus.latest === "object" ? lastStatus.latest : null;
const hasUpdate = enabled && lastStatus.hasUpdate === true && Boolean(latest);
const latestVersion = nonEmpty(latest?.version) ?? "";
const applyStatus = nonEmpty(lastApply.status) ?? "";
return {
enabled,
currentVersion,
hasUpdate,
latestVersion,
latestFileName: nonEmpty(latest?.fileName) ?? "",
latestUpdatedAt: nonEmpty(latest?.updatedAt) ?? "",
lastCheckedAt: nonEmpty(lastStatus.checkedAt) ?? "",
lastApplyStatus: applyStatus,
lastApplyAt: nonEmpty(lastApply.completedAt) ?? "",
statusLabel: !enabled
? "未启用"
: hasUpdate
? "发现新版本"
: lastStatus.error
? "检查失败"
: "当前版本",
detail: !enabled
? "boss-agent OTA 已关闭"
: hasUpdate
? `最新:${latestVersion || "未知版本"}`
: lastStatus.error
? String(lastStatus.error)
: `当前:${currentVersion}`,
};
}
function permissionItems(defs, permissions) {
return defs.map((item) => ({
...item,
@@ -363,6 +433,8 @@ export function buildBossAgentStatus(config = {}, runtime = {}, options = {}) {
lastHeartbeatStatus: runtime.lastHeartbeatStatus ?? null,
},
api: resolveApiUsage(config),
codex: resolveCodexBinding(config),
agentOta: resolveAgentOta(config, runtime),
license: resolveLicense(config, bound),
permissions: {
summary: permissionReadiness.summary,
@@ -543,6 +615,16 @@ function renderOverviewTab(status, { bound, heroTitle, heroSubtitle, qrBlock })
<div class="metric-value">${escapeHtml(status.api.primary)}</div>
<div class="metric-detail">备用 API${escapeHtml(status.api.backup)}</div>
</div>
<div class="card metric">
<div class="metric-title">Codex 默认接管</div>
<div class="metric-value">${escapeHtml(status.codex.desktopProviderLabel)}</div>
<div class="metric-detail">${escapeHtml(status.codex.summary)}</div>
</div>
<div class="card metric">
<div class="metric-title">boss-agent OTA</div>
<div class="metric-value">${escapeHtml(status.agentOta.statusLabel)}</div>
<div class="metric-detail">${escapeHtml(status.agentOta.detail)}</div>
</div>
<div class="card metric">
<div class="metric-title">服务器连接</div>
<div class="metric-value">${escapeHtml(status.server.ok ? "正常" : "异常")}</div>
@@ -562,6 +644,21 @@ function renderOverviewTab(status, { bound, heroTitle, heroSubtitle, qrBlock })
<div class="row"><span class="label">授权到期</span><span class="value">${escapeHtml(status.license.expiresAtLabel)}</span></div>
<div class="row"><span class="label">权限范围</span><span class="value">${escapeHtml(status.license.scope)}</span></div>
</div>
</section>
<section class="card panel">
<h2>boss-agent OTA</h2>
<div class="rows">
<div class="row"><span class="label">当前版本</span><span class="value">${escapeHtml(status.agentOta.currentVersion)}</span></div>
<div class="row"><span class="label">最新版本</span><span class="value">${escapeHtml(status.agentOta.latestVersion || "未发现新版本")}</span></div>
<div class="row"><span class="label">升级状态</span><span class="value">${escapeHtml(status.agentOta.statusLabel)}</span></div>
<div class="row"><span class="label">最近安装</span><span class="value">${escapeHtml(status.agentOta.lastApplyStatus || "暂无")}</span></div>
</div>
<div class="button-row ota-actions">
<form action="/api/v1/boss-agent/ota/check" method="get"><button class="button secondary" type="submit">检查更新</button></form>
<form action="/api/v1/boss-agent/ota/apply" method="post"><button class="button" type="submit">下载并安装</button></form>
</div>
<div class="hint">Mac 端 OTA 会先下载并校验安装包,再拉起本机安装器;原配置会被保留,失败不会覆盖当前运行版本。</div>
</section>`;
}
@@ -780,13 +877,16 @@ function renderBossAgentHtmlBase(status, options = {}) {
font-weight: 800;
font-size: 14px;
}
.button.secondary { background: #eef3ef; color: #263128; }
form { margin: 0; }
.ota-actions { margin-top: 18px; }
.timer { color: var(--muted); font-size: 13px; }
.panel { padding: 22px; }
.rows { display: grid; gap: 14px; margin-top: 18px; }
.row, .permission-row, .skill-row, .setup-action { display: flex; justify-content: space-between; gap: 18px; align-items: center; }
.label, .muted { color: var(--muted); font-size: 13px; }
.value { font-weight: 750; text-align: right; }
.cards { display: grid; grid-template-columns: repeat(4, 1fr); gap: 14px; margin-bottom: 18px; }
.cards { display: grid; grid-template-columns: repeat(6, 1fr); gap: 14px; margin-bottom: 18px; }
.metric { padding: 18px; min-height: 126px; }
.metric-title { color: var(--muted); font-size: 13px; margin-bottom: 16px; }
.metric-value { font-size: 20px; font-weight: 850; letter-spacing: -.03em; line-height: 1.35; }

View File

@@ -17,6 +17,17 @@ function parseTimeoutMs(value) {
return Number.isFinite(parsed) && parsed > 0 ? parsed : 45000;
}
function normalizeControlPlatform(value) {
const platform = String(value || "").trim().toLowerCase();
if (!platform || platform === "macos") return "macos";
throw new Error("UNSUPPORTED_CONTROL_PLATFORM");
}
function normalizeComputerUseProvider(value) {
const provider = String(value || "").trim();
return provider === "openai-computer-use" ? provider : "openai-computer-use";
}
function pickConfigValue(config, key, fallback) {
if (config && config[key] !== undefined && config[key] !== null && `${config[key]}`.trim() !== "") {
return config[key];
@@ -85,6 +96,8 @@ export function buildBrowserControlTaskExecution(config, task) {
}
const cwd = config.cwd || process.cwd();
const controlPlatform = normalizeControlPlatform(task?.controlPlatform);
const computerUseProvider = normalizeComputerUseProvider(task?.computerUseProvider);
return {
command: config.command,
args: resolveCommandArgs(config.command, config.args || [], cwd),
@@ -94,6 +107,8 @@ export function buildBrowserControlTaskExecution(config, task) {
requestKind: "browser_control",
requestId: String(task?.taskId || "").trim(),
objective: String(task?.requestText || task?.executionPrompt || "").trim(),
platform: controlPlatform,
provider: computerUseProvider,
context: {
projectId: String(task?.projectId || "").trim() || undefined,
threadId: String(task?.threadId || task?.targetThreadId || "").trim() || undefined,
@@ -101,6 +116,8 @@ export function buildBrowserControlTaskExecution(config, task) {
requestedAt: String(task?.requestedAt || "").trim() || undefined,
confirmationScopeKey: String(task?.confirmationScopeKey || "").trim() || undefined,
riskLevel: String(task?.riskLevel || "").trim() || undefined,
controlPlatform,
computerUseProvider,
},
},
};

View File

@@ -0,0 +1,360 @@
import { spawn } from "node:child_process";
import readline from "node:readline";
import { resolve } from "node:path";
function trimToDefined(value) {
const trimmed = String(value ?? "").trim();
return trimmed ? trimmed : undefined;
}
function boolFromEnv(value) {
const normalized = trimToDefined(value)?.toLowerCase();
return normalized === "1" || normalized === "true" || normalized === "yes";
}
function listFromEnv(value) {
if (!value) return undefined;
try {
const parsed = JSON.parse(String(value));
if (Array.isArray(parsed)) {
return parsed.map((item) => String(item));
}
} catch {
// Fall through to whitespace splitting.
}
return String(value).split(/\s+/).filter(Boolean);
}
function resolveTaskThreadRef(task) {
return trimToDefined(task?.targetCodexThreadRef || task?.targetThreadId);
}
function resolveTaskCwd(config, task) {
return resolve(
trimToDefined(task?.targetCodexFolderRef) ||
trimToDefined(config.masterAgentWorkdir) ||
process.cwd(),
);
}
function resolvePrompt(task) {
return String(task?.executionPrompt || task?.requestText || "").trim();
}
function normalizeTimeoutMs(value) {
const numeric = Number(value);
return Number.isFinite(numeric) && numeric > 0 ? Math.floor(numeric) : 120_000;
}
export function getCodexAppServerRunnerConfig(env = process.env, config = {}) {
const argsFromConfig = Array.isArray(config.codexAppServerArgs)
? config.codexAppServerArgs.map((item) => String(item))
: undefined;
const args =
argsFromConfig ??
listFromEnv(env.BOSS_CODEX_APP_SERVER_ARGS) ??
["app-server"];
return {
enabled:
config.codexAppServerEnabled === true ||
boolFromEnv(env.BOSS_CODEX_APP_SERVER_ENABLED),
command:
trimToDefined(config.codexAppServerCommand) ||
trimToDefined(env.BOSS_CODEX_APP_SERVER_COMMAND) ||
"codex",
args,
cwd:
trimToDefined(config.codexAppServerWorkdir) ||
trimToDefined(env.BOSS_CODEX_APP_SERVER_WORKDIR) ||
trimToDefined(config.masterAgentWorkdir) ||
process.cwd(),
timeoutMs: normalizeTimeoutMs(
config.codexAppServerTimeoutMs ?? env.BOSS_CODEX_APP_SERVER_TIMEOUT_MS,
),
clientName:
trimToDefined(config.codexAppServerClientName) ||
trimToDefined(env.BOSS_CODEX_APP_SERVER_CLIENT_NAME) ||
"boss_local_agent",
clientTitle:
trimToDefined(config.codexAppServerClientTitle) ||
trimToDefined(env.BOSS_CODEX_APP_SERVER_CLIENT_TITLE) ||
"Boss Local Agent",
clientVersion:
trimToDefined(config.codexAppServerClientVersion) ||
trimToDefined(env.BOSS_CODEX_APP_SERVER_CLIENT_VERSION) ||
"0.1.0",
model: trimToDefined(config.masterAgentModel || env.BOSS_MASTER_AGENT_MODEL),
sandbox: trimToDefined(config.masterAgentSandbox),
transport: "stdio",
};
}
export function shouldUseCodexAppServerTaskRunner(runnerConfig, task) {
if (!runnerConfig?.enabled || !runnerConfig.command) {
return false;
}
const taskType = trimToDefined(task?.taskType);
if (taskType !== "conversation_reply" && taskType !== "dispatch_execution") {
return false;
}
return Boolean(resolvePrompt(task));
}
function extractAgentTextFromContent(value) {
if (typeof value === "string") {
return value;
}
if (Array.isArray(value)) {
return value.map(extractAgentTextFromContent).filter(Boolean).join("");
}
if (!value || typeof value !== "object") {
return "";
}
return (
extractAgentTextFromContent(value.text) ||
extractAgentTextFromContent(value.outputText) ||
extractAgentTextFromContent(value.content) ||
extractAgentTextFromContent(value.delta)
);
}
function extractAgentDelta(params) {
if (!params || typeof params !== "object") {
return "";
}
return (
extractAgentTextFromContent(params.delta) ||
extractAgentTextFromContent(params.text) ||
extractAgentTextFromContent(params.messageDelta) ||
extractAgentTextFromContent(params.item?.delta)
);
}
function extractCompletedAgentMessage(params) {
const item = params?.item;
if (!item || typeof item !== "object") {
return "";
}
const itemType = String(item.type || item.kind || "");
if (!/agent|assistant/i.test(itemType)) {
return "";
}
return (
extractAgentTextFromContent(item.text) ||
extractAgentTextFromContent(item.content) ||
extractAgentTextFromContent(item.message) ||
extractAgentTextFromContent(item.output)
);
}
function createFailure(error, extra = {}) {
return {
status: "failed",
errorMessage: error instanceof Error ? error.message : String(error),
transport: "stdio",
...extra,
};
}
export async function executeCodexAppServerTask(runnerConfig, task) {
if (!shouldUseCodexAppServerTaskRunner(runnerConfig, task)) {
return createFailure("CODEX_APP_SERVER_DISABLED", { canFallbackToCli: true });
}
const cwd = resolveTaskCwd(runnerConfig, task);
const targetThreadRef = resolveTaskThreadRef(task);
const prompt = resolvePrompt(task);
const child = spawn(runnerConfig.command, runnerConfig.args, {
cwd: runnerConfig.cwd || cwd,
env: process.env,
stdio: ["pipe", "pipe", "pipe"],
});
let closed = false;
let stderr = "";
let nextId = 1;
let activeTurnStarted = false;
let turnSettled = false;
let replyBody = "";
let completedMessageText = "";
const pending = new Map();
let resolveTurnCompleted;
let rejectTurnCompleted;
const turnCompleted = new Promise((resolveTurn, rejectTurn) => {
resolveTurnCompleted = (value) => {
turnSettled = true;
resolveTurn(value);
};
rejectTurnCompleted = (error) => {
turnSettled = true;
rejectTurn(error);
};
});
const timeout = setTimeout(() => {
rejectTurnCompleted(new Error("CODEX_APP_SERVER_TIMEOUT"));
for (const { reject } of pending.values()) {
reject(new Error("CODEX_APP_SERVER_TIMEOUT"));
}
pending.clear();
if (!child.killed) {
child.kill("SIGKILL");
}
}, runnerConfig.timeoutMs);
const rl = readline.createInterface({ input: child.stdout });
const cleanup = () => {
clearTimeout(timeout);
rl.close();
if (!child.killed) {
child.kill("SIGTERM");
}
};
const request = (method, params = {}) => {
if (closed) {
return Promise.reject(new Error("CODEX_APP_SERVER_CLOSED"));
}
const id = nextId++;
const message = { method, id, params };
return new Promise((resolveRequest, rejectRequest) => {
pending.set(id, { resolve: resolveRequest, reject: rejectRequest });
child.stdin.write(`${JSON.stringify(message)}\n`, (error) => {
if (error) {
pending.delete(id);
rejectRequest(error);
}
});
});
};
const notify = (method, params = {}) => {
if (closed) return;
child.stdin.write(`${JSON.stringify({ method, params })}\n`);
};
child.stderr.on("data", (chunk) => {
stderr += String(chunk);
});
child.on("error", (error) => {
closed = true;
for (const { reject } of pending.values()) {
reject(error);
}
pending.clear();
rejectTurnCompleted(error);
});
child.on("close", (code) => {
closed = true;
const error = new Error(
stderr.trim() || `CODEX_APP_SERVER_EXITED:${code ?? "unknown"}`,
);
for (const { reject } of pending.values()) {
reject(error);
}
pending.clear();
if (code !== 0 || (activeTurnStarted && !turnSettled)) {
rejectTurnCompleted(error);
}
});
rl.on("line", (line) => {
if (!line.trim()) return;
let message;
try {
message = JSON.parse(line);
} catch {
return;
}
if (Object.hasOwn(message, "id")) {
const pendingRequest = pending.get(message.id);
if (pendingRequest) {
pending.delete(message.id);
if (message.error) {
pendingRequest.reject(new Error(message.error.message || JSON.stringify(message.error)));
} else {
pendingRequest.resolve(message.result ?? {});
}
}
return;
}
if (message.method === "item/agentMessage/delta") {
replyBody += extractAgentDelta(message.params);
return;
}
if (message.method === "item/completed") {
completedMessageText += extractCompletedAgentMessage(message.params);
return;
}
if (message.method === "turn/completed") {
const status = message.params?.turn?.status ?? message.params?.status ?? "completed";
if (status === "completed") {
resolveTurnCompleted(message.params ?? {});
} else {
rejectTurnCompleted(new Error(`CODEX_APP_SERVER_TURN_${String(status).toUpperCase()}`));
}
}
});
try {
await request("initialize", {
clientInfo: {
name: runnerConfig.clientName,
title: runnerConfig.clientTitle,
version: runnerConfig.clientVersion,
},
});
notify("initialized", {});
const threadResult = targetThreadRef
? await request("thread/resume", {
threadId: targetThreadRef,
model: runnerConfig.model,
})
: await request("thread/start", {
model: runnerConfig.model,
cwd,
sandbox: runnerConfig.sandbox,
serviceName: runnerConfig.clientName,
});
const threadId = trimToDefined(threadResult?.thread?.id) || targetThreadRef;
if (!threadId) {
throw new Error("CODEX_APP_SERVER_THREAD_ID_MISSING");
}
await request("turn/start", {
threadId,
input: [{ type: "text", text: prompt }],
cwd,
model: runnerConfig.model,
});
activeTurnStarted = true;
await turnCompleted;
const normalizedReply = (replyBody || completedMessageText).trim();
return {
status: "completed",
replyBody: normalizedReply,
threadId,
cwd,
transport: "stdio",
canFallbackToCli: false,
};
} catch (error) {
return createFailure(error, {
stderr: stderr.trim(),
cwd,
threadId: targetThreadRef,
canFallbackToCli: !activeTurnStarted,
});
} finally {
cleanup();
}
}

View File

@@ -24,6 +24,33 @@ function parseTimeoutMs(value) {
return Number.isFinite(parsed) && parsed > 0 ? parsed : 45000;
}
function normalizeControlPlatform(value) {
const platform = String(value || "").trim().toLowerCase();
if (!platform || platform === "macos") return "macos";
throw new Error("UNSUPPORTED_CONTROL_PLATFORM");
}
function normalizeComputerUseProvider(value) {
const provider = String(value || "").trim();
return provider === "boss-native-computer-use" ||
provider === "codex-computer-use" ||
provider === "cua-driver-computer-use" ||
provider === "openai-computer-use"
? provider
: "codex-computer-use";
}
function normalizeMacDialogGuardPlatformAdapters(value) {
const adapters = Array.isArray(value) ? value : [];
const macAdapters = adapters
.map((item) => String(item).trim())
.filter((item) => {
const normalized = item.toLowerCase();
return normalized === "darwin" || normalized === "macos";
});
return macAdapters.length > 0 ? macAdapters : ["darwin"];
}
function pickConfigValue(config, key, fallback) {
if (config && config[key] !== undefined && config[key] !== null && `${config[key]}`.trim() !== "") {
return config[key];
@@ -83,12 +110,32 @@ export function getComputerUseTaskRunnerConfig(env = process.env, config = {}) {
const dialogGuardMacActionArgs = Array.isArray(config?.dialogGuardMacActionArgs)
? config.dialogGuardMacActionArgs.map((item) => String(item)).filter(Boolean)
: parseArgs(pickConfigValue(config, "dialogGuardMacActionArgs", env.BOSS_MAC_DIALOG_GUARD_ACTION_ARGS));
const dialogGuardWindowsActionCommand = String(
pickConfigValue(config, "dialogGuardWindowsActionCommand", env.BOSS_WINDOWS_DIALOG_GUARD_ACTION_COMMAND) || "",
const cuaDriverCommand = String(
pickConfigValue(config, "cuaDriverCommand", env.BOSS_CUA_DRIVER_COMMAND) || "",
).trim();
const dialogGuardWindowsActionArgs = Array.isArray(config?.dialogGuardWindowsActionArgs)
? config.dialogGuardWindowsActionArgs.map((item) => String(item)).filter(Boolean)
: parseArgs(pickConfigValue(config, "dialogGuardWindowsActionArgs", env.BOSS_WINDOWS_DIALOG_GUARD_ACTION_ARGS));
const cuaDriverArgs = Array.isArray(config?.cuaDriverArgs)
? config.cuaDriverArgs.map((item) => String(item)).filter(Boolean)
: parseArgs(pickConfigValue(config, "cuaDriverArgs", env.BOSS_CUA_DRIVER_ARGS));
const cuaDriverTimeoutMs = parseTimeoutMs(pickConfigValue(config, "cuaDriverTimeoutMs", env.BOSS_CUA_DRIVER_TIMEOUT_MS));
const codexComputerUseEnabled = parseBoolean(
pickConfigValue(config, "codexComputerUseEnabled", env.BOSS_CODEX_COMPUTER_USE_ENABLED),
);
const codexComputerUseCommand = String(
pickConfigValue(config, "codexComputerUseCommand", env.BOSS_CODEX_COMPUTER_USE_COMMAND) || "",
).trim();
const codexComputerUseArgs = Array.isArray(config?.codexComputerUseArgs)
? config.codexComputerUseArgs.map((item) => String(item)).filter(Boolean)
: parseArgs(pickConfigValue(config, "codexComputerUseArgs", env.BOSS_CODEX_COMPUTER_USE_ARGS));
const codexComputerUseWorkdir = String(
pickConfigValue(config, "codexComputerUseWorkdir", env.BOSS_CODEX_COMPUTER_USE_WORKDIR) || "",
).trim();
const codexComputerUseTimeoutMs = parseTimeoutMs(
pickConfigValue(config, "codexComputerUseTimeoutMs", env.BOSS_CODEX_COMPUTER_USE_TIMEOUT_MS),
);
const codexComputerUseFallbackToCua =
pickConfigValue(config, "codexComputerUseFallbackToCua", env.BOSS_CODEX_COMPUTER_USE_FALLBACK_TO_CUA) === undefined
? true
: parseBoolean(pickConfigValue(config, "codexComputerUseFallbackToCua", env.BOSS_CODEX_COMPUTER_USE_FALLBACK_TO_CUA));
return {
enabled,
command,
@@ -97,11 +144,18 @@ export function getComputerUseTaskRunnerConfig(env = process.env, config = {}) {
timeoutMs,
dialogGuardEnabled,
dialogGuardConsentRequired,
dialogGuardPlatformAdapters,
dialogGuardPlatformAdapters: normalizeMacDialogGuardPlatformAdapters(dialogGuardPlatformAdapters),
dialogGuardMacActionCommand,
dialogGuardMacActionArgs,
dialogGuardWindowsActionCommand,
dialogGuardWindowsActionArgs,
cuaDriverCommand,
cuaDriverArgs,
cuaDriverTimeoutMs,
codexComputerUseEnabled,
codexComputerUseCommand,
codexComputerUseArgs,
codexComputerUseWorkdir,
codexComputerUseTimeoutMs,
codexComputerUseFallbackToCua,
};
}
@@ -118,6 +172,9 @@ export function buildComputerUseTaskExecution(config, task) {
}
const cwd = config.cwd || process.cwd();
const controlPlatform = normalizeControlPlatform(task?.controlPlatform);
const computerUseProvider = normalizeComputerUseProvider(task?.computerUseProvider);
const dialogGuardPlatformAdapters = normalizeMacDialogGuardPlatformAdapters(config.dialogGuardPlatformAdapters);
return {
command: config.command,
args: resolveCommandArgs(config.command, config.args || [], cwd),
@@ -126,18 +183,21 @@ export function buildComputerUseTaskExecution(config, task) {
env: {
BOSS_DIALOG_GUARD_ENABLED: config.dialogGuardEnabled ? "true" : "false",
BOSS_DIALOG_GUARD_CONSENT_REQUIRED: config.dialogGuardConsentRequired ? "true" : "false",
BOSS_DIALOG_GUARD_PLATFORM_ADAPTERS: Array.isArray(config.dialogGuardPlatformAdapters)
? config.dialogGuardPlatformAdapters.join(",")
: "",
BOSS_DIALOG_GUARD_PLATFORM_ADAPTERS: dialogGuardPlatformAdapters.join(","),
BOSS_MAC_DIALOG_GUARD_ACTION_COMMAND: config.dialogGuardMacActionCommand || "",
BOSS_MAC_DIALOG_GUARD_ACTION_ARGS_JSON: JSON.stringify(config.dialogGuardMacActionArgs || []),
BOSS_WINDOWS_DIALOG_GUARD_ACTION_COMMAND: config.dialogGuardWindowsActionCommand || "",
BOSS_WINDOWS_DIALOG_GUARD_ACTION_ARGS_JSON: JSON.stringify(config.dialogGuardWindowsActionArgs || []),
BOSS_CUA_DRIVER_COMMAND: config.cuaDriverCommand || "",
BOSS_CUA_DRIVER_ARGS_JSON: JSON.stringify(config.cuaDriverArgs || []),
BOSS_CUA_DRIVER_TIMEOUT_MS: String(config.cuaDriverTimeoutMs || 45000),
BOSS_CONTROL_PLATFORM: controlPlatform,
BOSS_COMPUTER_USE_PROVIDER: computerUseProvider,
},
stdinPayload: {
requestKind: "desktop_control",
requestId: String(task?.taskId || "").trim(),
objective: String(task?.requestText || task?.executionPrompt || "").trim(),
platform: controlPlatform,
provider: computerUseProvider,
context: {
projectId: String(task?.projectId || "").trim() || undefined,
threadId: String(task?.threadId || task?.targetThreadId || "").trim() || undefined,
@@ -145,6 +205,47 @@ export function buildComputerUseTaskExecution(config, task) {
requestedAt: String(task?.requestedAt || "").trim() || undefined,
confirmationScopeKey: String(task?.confirmationScopeKey || "").trim() || undefined,
riskLevel: String(task?.riskLevel || "").trim() || undefined,
controlPlatform,
computerUseProvider,
},
},
};
}
function buildCodexComputerUseTaskExecution(config, task) {
if (!config?.codexComputerUseEnabled) {
throw new Error("CODEX_COMPUTER_USE_RUNTIME_DISABLED");
}
if (!config?.codexComputerUseCommand) {
throw new Error("CODEX_COMPUTER_USE_COMMAND_REQUIRED");
}
const cwd = config.codexComputerUseWorkdir || config.cwd || process.cwd();
const controlPlatform = normalizeControlPlatform(task?.controlPlatform);
return {
command: config.codexComputerUseCommand,
args: resolveCommandArgs(config.codexComputerUseCommand, config.codexComputerUseArgs || [], cwd),
cwd,
timeoutMs: config.codexComputerUseTimeoutMs || 45000,
env: {
BOSS_CONTROL_PLATFORM: controlPlatform,
BOSS_COMPUTER_USE_PROVIDER: "codex-computer-use",
},
stdinPayload: {
requestKind: "desktop_control",
requestId: String(task?.taskId || "").trim(),
objective: String(task?.requestText || task?.executionPrompt || "").trim(),
platform: controlPlatform,
provider: "codex-computer-use",
context: {
projectId: String(task?.projectId || "").trim() || undefined,
threadId: String(task?.threadId || task?.targetThreadId || "").trim() || undefined,
requestedBy: String(task?.requestedByAccount || task?.requestedBy || "").trim() || undefined,
requestedAt: String(task?.requestedAt || "").trim() || undefined,
confirmationScopeKey: String(task?.confirmationScopeKey || "").trim() || undefined,
riskLevel: String(task?.riskLevel || "").trim() || undefined,
controlPlatform,
computerUseProvider: "codex-computer-use",
},
},
};
@@ -201,6 +302,13 @@ export function parseComputerUseTaskResult(rawOutput) {
status: "completed",
requestId: typeof parsed.requestId === "string" ? parsed.requestId.trim() || undefined : undefined,
replyBody,
computerUseProvider:
parsed.computerUseProvider === "boss-native-computer-use" ||
parsed.computerUseProvider === "codex-computer-use" ||
parsed.computerUseProvider === "cua-driver-computer-use" ||
parsed.computerUseProvider === "openai-computer-use"
? parsed.computerUseProvider
: undefined,
targetApp:
typeof parsed.targetApp === "string" && parsed.targetApp.trim()
? parsed.targetApp.trim()
@@ -212,16 +320,25 @@ export function parseComputerUseTaskResult(rawOutput) {
};
}
export async function executeComputerUseTask(task, config = {}) {
const runnerConfig = getComputerUseTaskRunnerConfig(process.env, config);
if (!runnerConfig.enabled) {
return {
status: "failed",
errorMessage: "COMPUTER_USE_RUNTIME_DISABLED",
};
}
function shouldTryCodexComputerUse(runnerConfig, task) {
const provider = normalizeComputerUseProvider(task?.computerUseProvider);
return (
runnerConfig.codexComputerUseEnabled === true &&
Boolean(runnerConfig.codexComputerUseCommand) &&
(provider === "codex-computer-use" || provider === "openai-computer-use")
);
}
const execution = buildComputerUseTaskExecution(runnerConfig, task);
function withComputerUseProvider(result, provider) {
return result && typeof result === "object" && !Array.isArray(result)
? {
...result,
computerUseProvider: result.computerUseProvider || provider,
}
: result;
}
function executeComputerUseRuntime(execution) {
return new Promise((resolve, reject) => {
const child = spawn(execution.command, execution.args, {
cwd: execution.cwd,
@@ -273,3 +390,51 @@ export async function executeComputerUseTask(task, config = {}) {
child.stdin.end();
});
}
export async function executeComputerUseTask(task, config = {}) {
const runnerConfig = getComputerUseTaskRunnerConfig(process.env, config);
if (!runnerConfig.enabled && !shouldTryCodexComputerUse(runnerConfig, task)) {
return {
status: "failed",
errorMessage: "COMPUTER_USE_RUNTIME_DISABLED",
};
}
if (shouldTryCodexComputerUse(runnerConfig, task)) {
try {
const codexResult = await executeComputerUseRuntime(buildCodexComputerUseTaskExecution(runnerConfig, task));
if (codexResult.status !== "failed") {
return withComputerUseProvider(codexResult, "codex-computer-use");
}
if (!runnerConfig.codexComputerUseFallbackToCua) {
return withComputerUseProvider(codexResult, "codex-computer-use");
}
} catch (error) {
if (!runnerConfig.codexComputerUseFallbackToCua) {
return {
status: "failed",
errorMessage: error instanceof Error ? error.message : String(error),
computerUseProvider: "codex-computer-use",
};
}
}
}
if (!runnerConfig.enabled) {
return {
status: "failed",
errorMessage: "COMPUTER_USE_RUNTIME_DISABLED",
};
}
const fallbackTask = {
...task,
computerUseProvider:
normalizeComputerUseProvider(task?.computerUseProvider) === "codex-computer-use"
? "cua-driver-computer-use"
: task?.computerUseProvider,
};
const execution = buildComputerUseTaskExecution(runnerConfig, fallbackTask);
const result = await executeComputerUseRuntime(execution);
return withComputerUseProvider(result, normalizeComputerUseProvider(fallbackTask.computerUseProvider));
}

View File

@@ -8,12 +8,34 @@
"skillLifecycleTimeoutMs": 120000,
"skillLifecycleAllowedSources": [],
"skillLifecycleTrustedSources": {},
"bossAgentOtaEnabled": true,
"bossAgentVersion": "dev",
"bossAgentInstallRoot": "/Users/kris/boss-agent/current",
"bossAgentOtaDownloadDir": "/Users/kris/boss-agent/updates",
"bossAgentOtaCheckIntervalMs": 300000,
"bossAgentOtaAutoInstall": false,
"controlPlaneUrl": "https://boss.hyzq.net",
"skillsDir": "/Users/kris/.codex/skills",
"masterAgentEnabled": true,
"masterAgentWorkdir": "/Users/kris/code/boss",
"masterAgentSandbox": "workspace-write",
"masterAgentModel": "gpt-5.4",
"codexAppServerEnabled": true,
"codexAppServerCommand": "codex",
"codexAppServerArgs": [
"app-server"
],
"codexAppServerWorkdir": "/Users/kris/code/boss",
"codexAppServerTimeoutMs": 120000,
"codexAppServerFallbackToCli": true,
"codexComputerUseEnabled": true,
"codexComputerUseCommand": "node",
"codexComputerUseArgs": [
"scripts/codex-computer-use-runtime.mjs"
],
"codexComputerUseWorkdir": "/Users/kris/code/boss",
"codexComputerUseTimeoutMs": 120000,
"codexComputerUseFallbackToCua": true,
"browserAutomationConnected": true,
"computerUseConnected": true,
"browserControlEnabled": true,
@@ -26,10 +48,13 @@
"computerUseEnabled": true,
"computerUseCommand": "node",
"computerUseArgs": [
"scripts/computer-use-smoke.mjs"
"scripts/cua-driver-computer-use-runtime.mjs"
],
"computerUseWorkdir": "/Users/kris/code/boss",
"computerUseTimeoutMs": 45000,
"cuaDriverCommand": "cua-driver",
"cuaDriverArgs": [],
"cuaDriverTimeoutMs": 45000,
"dialogGuardEnabled": true,
"dialogGuardConsentRequired": true,
"dialogGuardPlatformAdapters": [

View File

@@ -8,6 +8,12 @@
"skillLifecycleTimeoutMs": 120000,
"skillLifecycleAllowedSources": [],
"skillLifecycleTrustedSources": {},
"bossAgentOtaEnabled": true,
"bossAgentVersion": "dev",
"bossAgentInstallRoot": "/Users/kris/boss-agent/current",
"bossAgentOtaDownloadDir": "/Users/kris/boss-agent/updates",
"bossAgentOtaCheckIntervalMs": 300000,
"bossAgentOtaAutoInstall": false,
"controlPlaneUrl": "http://127.0.0.1:3000",
"skillsDir": "/Users/kris/.codex/skills",
"masterAgentEnabled": true,
@@ -16,6 +22,22 @@
"masterAgentModel": "gpt-5.4",
"preferredExecutionMode": "cli",
"guiConnected": false,
"codexAppServerEnabled": true,
"codexAppServerCommand": "codex",
"codexAppServerArgs": [
"app-server"
],
"codexAppServerWorkdir": "/Users/kris/code/boss",
"codexAppServerTimeoutMs": 120000,
"codexAppServerFallbackToCli": true,
"codexComputerUseEnabled": true,
"codexComputerUseCommand": "node",
"codexComputerUseArgs": [
"scripts/codex-computer-use-runtime.mjs"
],
"codexComputerUseWorkdir": "/Users/kris/code/boss",
"codexComputerUseTimeoutMs": 120000,
"codexComputerUseFallbackToCua": true,
"browserAutomationConnected": true,
"computerUseConnected": true,
"browserControlEnabled": true,
@@ -28,10 +50,13 @@
"computerUseEnabled": true,
"computerUseCommand": "node",
"computerUseArgs": [
"scripts/computer-use-smoke.mjs"
"scripts/cua-driver-computer-use-runtime.mjs"
],
"computerUseWorkdir": "/Users/kris/code/boss",
"computerUseTimeoutMs": 45000,
"cuaDriverCommand": "cua-driver",
"cuaDriverArgs": [],
"cuaDriverTimeoutMs": 45000,
"dialogGuardEnabled": true,
"dialogGuardConsentRequired": true,
"dialogGuardPlatformAdapters": [

View File

@@ -4,9 +4,14 @@ import { spawn } from "node:child_process";
import { createServer } from "node:http";
import { access, readFile, readdir, rm } from "node:fs/promises";
import os from "node:os";
import { join, resolve } from "node:path";
import { delimiter, isAbsolute, join, resolve } from "node:path";
import { discoverCodexProjectCandidatesInWorker } from "./codex-session-discovery.mjs";
import { prepareCodexTaskExecution } from "./codex-task-runner.mjs";
import {
executeCodexAppServerTask,
getCodexAppServerRunnerConfig,
shouldUseCodexAppServerTaskRunner,
} from "./codex-app-server-runner.mjs";
import { appendBossUserMessageToCodexThreadRollout } from "./codex-thread-rollout-writer.mjs";
import {
executeOmxTeamTask,
@@ -30,6 +35,11 @@ import {
executeSkillLifecycleRequest,
getSkillLifecycleRunnerConfig,
} from "./skill-lifecycle-runner.mjs";
import {
applyBossAgentOtaUpdate,
checkBossAgentOtaUpdate,
getBossAgentOtaRunnerConfig,
} from "./boss-agent-ota-runner.mjs";
import {
sanitizeSensitiveTaskFailureDetailForLog,
sanitizeSensitiveTaskFailureDetailForTransport,
@@ -119,6 +129,9 @@ async function postHeartbeat(config, runtime, heartbeatProjects) {
: undefined;
const browserControlRuntime = getBrowserControlTaskRunnerConfig(process.env, config);
const computerUseRuntime = getComputerUseTaskRunnerConfig(process.env, config);
const codexAppServerRuntime = getCodexAppServerRunnerConfig(process.env, config);
const computerUseConnected = await resolveComputerUseCapabilityConnected(config, computerUseRuntime);
const codexAppServerConnected = await resolveCodexAppServerCapabilityConnected(codexAppServerRuntime);
const guiConnected =
config.guiConnected === true ||
(config.guiConnected !== false && heartbeatProjects.guiConnected === true);
@@ -152,7 +165,12 @@ async function postHeartbeat(config, runtime, heartbeatProjects) {
lastActiveProjectId: "",
},
computerUse: {
connected: Boolean(config.computerUseConnected) || Boolean(computerUseRuntime.enabled && computerUseRuntime.command),
connected: computerUseConnected,
lastSeenAt: now,
lastActiveProjectId: "",
},
codexAppServer: {
connected: codexAppServerConnected,
lastSeenAt: now,
lastActiveProjectId: "",
},
@@ -179,6 +197,64 @@ async function postHeartbeat(config, runtime, heartbeatProjects) {
};
}
function isCuaDriverRuntime(runtime) {
return (
Array.isArray(runtime?.args) &&
runtime.args.some((item) => String(item).includes("cua-driver-computer-use-runtime.mjs"))
);
}
async function canExecuteCommand(command, cwd) {
const normalizedCommand = String(command || "").trim();
if (!normalizedCommand) return false;
const commandHasPath = normalizedCommand.includes("/") || isAbsolute(normalizedCommand);
const pathCandidates = commandHasPath
? [isAbsolute(normalizedCommand) ? normalizedCommand : resolve(cwd || process.cwd(), normalizedCommand)]
: String(process.env.PATH || "")
.split(delimiter)
.filter(Boolean)
.map((item) => join(item, normalizedCommand));
const candidatePaths = [
...pathCandidates,
commandHasPath ? undefined : join(os.homedir(), ".local", "bin", normalizedCommand),
commandHasPath ? undefined : join("/usr/local/bin", normalizedCommand),
commandHasPath ? undefined : join("/opt/homebrew/bin", normalizedCommand),
normalizedCommand === "cua-driver" ? "/Applications/CuaDriver.app/Contents/MacOS/cua-driver" : undefined,
].filter(Boolean);
for (const candidate of candidatePaths) {
try {
await access(candidate);
return true;
} catch {
// Try the next PATH entry.
}
}
return false;
}
async function resolveComputerUseCapabilityConnected(config, computerUseRuntime) {
if (!computerUseRuntime?.enabled || !computerUseRuntime?.command) {
return false;
}
if (!isCuaDriverRuntime(computerUseRuntime)) {
return Boolean(config.computerUseConnected) || Boolean(computerUseRuntime.command);
}
const driverCommand = computerUseRuntime.cuaDriverCommand || "cua-driver";
return canExecuteCommand(driverCommand, computerUseRuntime.cwd || process.cwd());
}
async function resolveCodexAppServerCapabilityConnected(codexAppServerRuntime) {
if (!codexAppServerRuntime?.enabled || !codexAppServerRuntime.command) {
return false;
}
return canExecuteCommand(codexAppServerRuntime.command, codexAppServerRuntime.cwd || process.cwd());
}
function deviceTokenHeaders(config, runtime) {
const token = runtime.issuedToken ?? config.token;
return token ? { "x-boss-device-token": token } : {};
@@ -331,6 +407,12 @@ async function postAppLog(config, runtime, payload) {
}
async function claimMasterAgentTask(config, runtime) {
const configuredWaitMs = Number(
config.masterAgentClaimWaitMs ?? config.masterAgentLongPollMs ?? 25_000,
);
const waitMs = Number.isFinite(configuredWaitMs)
? Math.max(0, Math.min(30_000, Math.floor(configuredWaitMs)))
: 25_000;
const response = await fetch(
`${config.controlPlaneUrl.replace(/\/$/, "")}/api/v1/master-agent/tasks/claim`,
{
@@ -339,7 +421,7 @@ async function claimMasterAgentTask(config, runtime) {
"Content-Type": "application/json",
...deviceTokenHeaders(config, runtime),
},
body: JSON.stringify({ deviceId: config.deviceId }),
body: JSON.stringify({ deviceId: config.deviceId, waitMs }),
},
);
@@ -575,6 +657,7 @@ async function runMasterAgentTask(config, runtime, task) {
replyBody: computerUseResult.replyBody,
dispatchExecutionCompletion: {
targetApp: computerUseResult.targetApp,
computerUseProvider: computerUseResult.computerUseProvider,
},
};
}
@@ -593,6 +676,50 @@ async function runMasterAgentTask(config, runtime, task) {
};
}
const codexAppServerRunner = getCodexAppServerRunnerConfig(process.env, config);
if (shouldUseCodexAppServerTaskRunner(codexAppServerRunner, task)) {
const appServerResult = await executeCodexAppServerTask(codexAppServerRunner, task);
if (appServerResult.status === "completed") {
const executionProgress = await collectLocalExecutionProgress(
appServerResult.cwd || task.targetCodexFolderRef || config.masterAgentWorkdir || process.cwd(),
appServerResult.replyBody,
);
try {
if (task.targetCodexThreadRef || task.targetThreadId) {
await executeCodexDesktopRefreshBridge(
{
targetThreadRef: task.targetCodexThreadRef || task.targetThreadId,
sourceMessageId: task.sourceMessageId || task.requestMessageId,
threadTouchStatus: "app_server_turn_started",
},
config,
);
}
} catch {
// Desktop refresh is only a visibility hint; app-server already owns the thread turn.
}
return {
replyBody: appServerResult.replyBody,
executionProgress,
dispatchExecutionCompletion:
task.taskType === "dispatch_execution"
? parseDispatchExecutionCompletion(appServerResult.replyBody)
: null,
};
}
if (appServerResult.canFallbackToCli !== true || config.codexAppServerFallbackToCli === false) {
throw new Error(appServerResult.errorMessage || "CODEX_APP_SERVER_EXECUTION_FAILED");
}
await postAppLog(config, runtime, {
projectId: task.projectId,
level: "warn",
category: "local_agent.codex_app_server_fallback",
message: "Codex App Server 本轮不可用,已回退到 CLI resume。",
detail: appServerResult.errorMessage,
mirrorToMaster: false,
});
}
const codexPreparation = await prepareCodexTaskExecution(config, task, outputFile);
if (!codexPreparation.ok) {
throw new Error(codexPreparation.error.message);
@@ -724,6 +851,7 @@ async function runMasterAgentTask(config, runtime, task) {
targetThreadId: task.targetThreadId,
targetUrl: dispatchExecutionCompletion?.targetUrl,
targetApp: dispatchExecutionCompletion?.targetApp,
computerUseProvider: dispatchExecutionCompletion?.computerUseProvider,
rawThreadReply: dispatchExecutionCompletion?.rawThreadReply,
executionProgress,
}),
@@ -893,6 +1021,38 @@ async function pollSkillLifecycleRequests(config, runtime) {
}
}
async function checkBossAgentOta(config, runtime) {
const runnerConfig = getBossAgentOtaRunnerConfig(process.env, config);
if (!runnerConfig.enabled || runtime.bossAgentOtaBusy) {
return;
}
runtime.bossAgentOtaBusy = true;
try {
const status = await checkBossAgentOtaUpdate(config, runtime);
if (status?.hasUpdate) {
await postAppLog(config, runtime, {
level: "info",
category: "local_agent.boss_agent_ota_available",
message: `boss-agent 发现可用更新:${status.latest?.version ?? "未知版本"}`,
detail: status.latest?.fileName,
mirrorToMaster: false,
});
}
} catch (error) {
runtime.lastBossAgentOtaStatus = {
enabled: true,
currentVersion: getBossAgentOtaRunnerConfig(process.env, config).currentVersion,
hasUpdate: false,
latest: null,
checkedAt: new Date().toISOString(),
error: error instanceof Error ? error.message : String(error),
};
} finally {
runtime.bossAgentOtaBusy = false;
}
}
const configPath = process.argv[2];
if (!configPath) {
console.error("Usage: node local-agent/server.mjs <config.json>");
@@ -920,6 +1080,9 @@ const runtime = {
skillLifecycleBusy: false,
activeSkillLifecycleRequest: null,
lastSkillLifecyclePoll: null,
bossAgentOtaBusy: false,
lastBossAgentOtaStatus: null,
lastBossAgentOtaApply: null,
lastProjectDiscoveryAt: null,
lastProjectDiscoveryOk: false,
lastProjectDiscoverySummary: null,
@@ -1006,6 +1169,9 @@ const masterTaskPoll = createSerializedRunner(async () => {
const skillLifecyclePoll = createSerializedRunner(async () => {
await pollSkillLifecycleRequests(config, runtime);
});
const bossAgentOtaPoll = createSerializedRunner(async () => {
await checkBossAgentOta(config, runtime);
});
const server = createServer(async (request, response) => {
const requestUrl = new URL(request.url || "/", `http://${config.bindHost || "127.0.0.1"}`);
@@ -1036,6 +1202,44 @@ const server = createServer(async (request, response) => {
return;
}
if (requestUrl.pathname === "/api/v1/boss-agent/ota/check") {
const status = await checkBossAgentOtaUpdate(config, runtime);
const wantsJson = String(request.headers.accept || "").includes("application/json");
if (!wantsJson) {
response.writeHead(302, { Location: "/boss-agent?tab=overview" });
response.end();
return;
}
response.writeHead(200, { "Content-Type": "application/json" });
response.end(JSON.stringify({ ok: true, status }));
return;
}
if (requestUrl.pathname === "/api/v1/boss-agent/ota/apply" && request.method === "POST") {
const launchInstaller = requestUrl.searchParams.get("launch") !== "0";
const result = await applyBossAgentOtaUpdate(config, runtime, { launchInstaller });
await postAppLog(config, runtime, {
level: result.status === "failed" ? "error" : "info",
category: result.status === "failed"
? "local_agent.boss_agent_ota_failed"
: "local_agent.boss_agent_ota_applied",
message: result.status === "failed"
? "boss-agent OTA 更新失败"
: `boss-agent OTA 已${result.status === "installer_launched" ? "拉起安装器" : "下载暂存"}`,
detail: result.version ?? result.error ?? result.archivePath,
mirrorToMaster: result.status === "failed",
});
const wantsJson = String(request.headers.accept || "").includes("application/json");
if (!wantsJson) {
response.writeHead(302, { Location: "/boss-agent?tab=overview" });
response.end();
return;
}
response.writeHead(result.status === "failed" ? 400 : 200, { "Content-Type": "application/json" });
response.end(JSON.stringify({ ok: result.status !== "failed", result }));
return;
}
if (requestUrl.pathname === "/api/v1/boss-agent/permissions/open") {
const target = requestUrl.searchParams.get("target") || "core";
const returnTab = normalizeBossAgentTab(requestUrl.searchParams.get("returnTab") ?? "permissions");
@@ -1114,6 +1318,7 @@ void (async () => {
await heartbeat();
await masterTaskPoll();
await skillLifecyclePoll();
await bossAgentOtaPoll();
})();
setInterval(() => {
@@ -1127,3 +1332,7 @@ setInterval(() => {
setInterval(() => {
void skillLifecyclePoll();
}, config.skillLifecyclePollIntervalMs ?? 5000);
setInterval(() => {
void bossAgentOtaPoll();
}, getBossAgentOtaRunnerConfig(process.env, config).checkIntervalMs);

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -4,8 +4,8 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Boss 企业后台</title>
<script type="module" crossorigin src="/admin-web/assets/index-BLn7d5Tl.js"></script>
<link rel="stylesheet" crossorigin href="/admin-web/assets/index-D3NRMWfZ.css">
<script type="module" crossorigin src="/admin-web/assets/index-D8-R1LUW.js"></script>
<link rel="stylesheet" crossorigin href="/admin-web/assets/index-BVg8rLlq.css">
</head>
<body>
<div id="app"></div>

View File

@@ -0,0 +1,10 @@
{
"packageType": "boss_agent_macos",
"version": "20260516221619",
"fileName": "boss-agent-mac-latest.zip",
"archiveFileName": "boss-agent-mac-runtime-20260516221619.zip",
"sizeBytes": 976369,
"sha256": "926838f336903a0042287407ea68c900b00e3ed8c852f4f652d1f5aadb2816c8",
"updatedAt": "2026-05-16T14:16:29.418Z",
"downloadUrl": "/api/v1/boss-agent/ota/package"
}

Binary file not shown.

View File

@@ -1,10 +1,10 @@
{
"fileName": "boss-android-v2.5.11-debug.apk",
"fileName": "boss-android-v2.5.11-release.apk",
"urlPath": "/api/v1/user/ota/package",
"sizeBytes": 5191391,
"updatedAt": "2026-05-08T23:31:03Z",
"sha256": "33e3a5f7101b4f3a516cf95a07c6dd1143e0dca49d87a541b3524a20c2a89e77",
"sizeBytes": 3425840,
"updatedAt": "2026-05-16T06:41:29Z",
"sha256": "354b2c5f62273851abcc00a25719e09aacc31bf10c781d6c3a5e1c57933ea7ba",
"versionName": "2.5.11",
"versionCode": 24,
"buildFlavor": "debug"
"buildFlavor": "release"
}

View File

@@ -1,5 +1,5 @@
#!/usr/bin/env node
import { Client } from "pg";
import { createHash } from "node:crypto";
import fs from "node:fs/promises";
import path from "node:path";
import process from "node:process";
@@ -7,6 +7,8 @@ import process from "node:process";
const snapshotKey = process.env.BOSS_STATE_POSTGRES_KEY?.trim() || "default";
const defaultStateFile = process.env.BOSS_STATE_FILE || path.join(process.cwd(), "data", "boss-state.json");
const defaultBackupDir = process.env.BOSS_STATE_BACKUP_DIR || path.join(path.dirname(defaultStateFile), "backups");
const defaultSchemaFile = path.join(process.cwd(), "scripts", "postgres-state-schema.sql");
const postgresTable = "boss_state_snapshots";
function usage() {
return [
@@ -14,13 +16,16 @@ function usage() {
"",
"Commands:",
" describe",
" validate-schema [--schema <file>]",
" backup-file --input <file> [--output <file>] [--dry-run]",
" export-file --input <file> --output <file> [--dry-run]",
" migrate-file-to-postgres --input <file> [--dry-run]",
" export-postgres-backup --output <file> [--dry-run]",
" restore-postgres-backup --input <file> [--dry-run]",
" rollback-postgres-to-file --output <file> [--dry-run]",
"",
"Environment:",
" BOSS_STATE_FILE, BOSS_DATABASE_URL, BOSS_STATE_POSTGRES_KEY, BOSS_STATE_BACKUP_DIR",
" BOSS_STATE_FILE, BOSS_STATE_STORE, BOSS_DATABASE_URL, BOSS_STATE_POSTGRES_KEY, BOSS_STATE_BACKUP_DIR",
].join("\n");
}
@@ -43,6 +48,11 @@ function parseArgs(argv) {
index += 1;
continue;
}
if (item === "--schema") {
options.schema = items[index + 1];
index += 1;
continue;
}
throw new Error(`UNKNOWN_OPTION:${item}`);
}
return options;
@@ -56,6 +66,30 @@ function timestampSegment() {
return new Date().toISOString().replace(/[:.]/g, "-");
}
function sha256(text) {
return createHash("sha256").update(text).digest("hex");
}
function postgresModeEnabled() {
return process.env.BOSS_STATE_STORE?.trim().toLowerCase() === "postgres";
}
function postgresConfigured() {
return Boolean(process.env.BOSS_DATABASE_URL?.trim());
}
function requirePostgresMode() {
if (!postgresModeEnabled()) {
throw new Error("BOSS_STATE_STORE_POSTGRES_REQUIRED");
}
}
function requirePostgresDatabaseUrl() {
if (!postgresConfigured()) {
throw new Error("BOSS_DATABASE_URL_REQUIRED");
}
}
function validateStateText(text, source) {
const parsed = JSON.parse(text);
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
@@ -70,6 +104,37 @@ async function readStateText(filePath) {
return text;
}
function validatePostgresSchemaText(text, source) {
const compact = text.replace(/\s+/g, " ").toLowerCase();
const required = [
[/create table if not exists boss_state_snapshots/, "table"],
[/snapshot_key\s+text\s+primary key/, "snapshot_key_primary_key"],
[/state\s+jsonb\s+not null/, "state_jsonb"],
[/created_at\s+timestamptz\s+not null\s+default now\(\)/, "created_at"],
[/updated_at\s+timestamptz\s+not null\s+default now\(\)/, "updated_at"],
[/create index if not exists boss_state_snapshots_updated_at_idx/, "updated_at_index"],
];
const missing = required.filter(([pattern]) => !pattern.test(compact)).map(([, name]) => name);
if (missing.length > 0) {
throw new Error(`POSTGRES_SCHEMA_INVALID:${source}:${missing.join(",")}`);
}
return {
ok: true,
source,
table: postgresTable,
sha256: sha256(text),
};
}
async function validatePostgresSchema(options) {
const schema = path.resolve(options.schema || defaultSchemaFile);
const text = await fs.readFile(schema, "utf8");
return {
action: "validate-schema",
...validatePostgresSchemaText(text, schema),
};
}
async function ensurePostgresSchema(client) {
await client.query(`
CREATE TABLE IF NOT EXISTS boss_state_snapshots (
@@ -79,13 +144,17 @@ async function ensurePostgresSchema(client) {
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
)
`);
await client.query(`
CREATE INDEX IF NOT EXISTS boss_state_snapshots_updated_at_idx
ON boss_state_snapshots (updated_at DESC)
`);
}
async function withPostgres(handler) {
const connectionString = process.env.BOSS_DATABASE_URL?.trim();
if (!connectionString) {
throw new Error("BOSS_DATABASE_URL_REQUIRED");
}
requirePostgresMode();
requirePostgresDatabaseUrl();
const connectionString = process.env.BOSS_DATABASE_URL.trim();
const { Client } = await import("pg");
const client = new Client({ connectionString });
await client.connect();
try {
@@ -135,8 +204,11 @@ async function exportFile(options) {
}
async function migrateFileToPostgres(options) {
requirePostgresMode();
requirePostgresDatabaseUrl();
const source = path.resolve(options.input || defaultStateFile);
const text = await readStateText(source);
const schema = await validatePostgresSchema({ schema: options.schema });
if (!options.dryRun) {
await withPostgres(async (client) => {
await ensurePostgresSchema(client);
@@ -157,11 +229,18 @@ async function migrateFileToPostgres(options) {
dryRun: options.dryRun,
source,
snapshotKey,
postgresConfigured: postgresConfigured(),
wouldConnect: !options.dryRun,
schemaValid: schema.ok,
schemaSha256: schema.sha256,
bytes: Buffer.byteLength(text),
stateSha256: sha256(text),
};
}
async function rollbackPostgresToFile(options) {
requirePostgresMode();
requirePostgresDatabaseUrl();
const output = path.resolve(options.output || defaultStateFile);
if (options.dryRun) {
return {
@@ -170,6 +249,8 @@ async function rollbackPostgresToFile(options) {
dryRun: true,
output,
snapshotKey,
postgresConfigured: postgresConfigured(),
wouldConnect: false,
};
}
const text = await withPostgres(async (client) => {
@@ -190,6 +271,115 @@ async function rollbackPostgresToFile(options) {
output,
snapshotKey,
bytes: Buffer.byteLength(text),
stateSha256: sha256(text),
};
}
function normalizeBackupPayload(text, source) {
const parsed = validateStateText(text, source);
if (parsed.metadata?.format === "boss-state-postgres-backup/v1" && parsed.state) {
return {
metadata: parsed.metadata,
state: parsed.state,
stateText: JSON.stringify(parsed.state, null, 2),
bundled: true,
};
}
return {
metadata: null,
state: parsed,
stateText: JSON.stringify(parsed, null, 2),
bundled: false,
};
}
async function exportPostgresBackup(options) {
requirePostgresMode();
const output = path.resolve(options.output || path.join(defaultBackupDir, `boss-postgres-state-${timestampSegment()}.json`));
if (options.dryRun) {
requirePostgresDatabaseUrl();
return {
ok: true,
action: "export-postgres-backup",
dryRun: true,
output,
snapshotKey,
postgresConfigured: true,
wouldConnect: false,
};
}
const stateText = await withPostgres(async (client) => {
await ensurePostgresSchema(client);
const result = await client.query("SELECT state FROM boss_state_snapshots WHERE snapshot_key = $1", [snapshotKey]);
const state = result.rows[0]?.state;
if (!state) {
throw new Error("BOSS_POSTGRES_STATE_NOT_FOUND");
}
return JSON.stringify(state, null, 2);
});
const state = validateStateText(stateText, `${postgresTable}:${snapshotKey}`);
const backup = {
metadata: {
format: "boss-state-postgres-backup/v1",
exportedAt: new Date().toISOString(),
snapshotKey,
table: postgresTable,
stateSha256: sha256(stateText),
stateBytes: Buffer.byteLength(stateText),
},
state,
};
const backupText = `${JSON.stringify(backup, null, 2)}\n`;
await fs.mkdir(path.dirname(output), { recursive: true });
await fs.writeFile(output, backupText, "utf8");
return {
ok: true,
action: "export-postgres-backup",
dryRun: false,
output,
snapshotKey,
bytes: Buffer.byteLength(backupText),
stateSha256: backup.metadata.stateSha256,
};
}
async function restorePostgresBackup(options) {
requirePostgresMode();
requirePostgresDatabaseUrl();
if (!options.input) {
throw new Error("INPUT_REQUIRED");
}
const source = path.resolve(options.input);
const text = await fs.readFile(source, "utf8");
const backup = normalizeBackupPayload(text, source);
validateStateText(backup.stateText, source);
if (!options.dryRun) {
await withPostgres(async (client) => {
await ensurePostgresSchema(client);
await client.query(
`
INSERT INTO boss_state_snapshots (snapshot_key, state, updated_at)
VALUES ($1, $2::jsonb, now())
ON CONFLICT (snapshot_key)
DO UPDATE SET state = EXCLUDED.state, updated_at = now()
`,
[snapshotKey, backup.stateText],
);
});
}
return {
ok: true,
action: "restore-postgres-backup",
dryRun: options.dryRun,
source,
snapshotKey,
postgresConfigured: postgresConfigured(),
wouldConnect: !options.dryRun,
bundled: backup.bundled,
bytes: Buffer.byteLength(backup.stateText),
stateSha256: sha256(backup.stateText),
};
}
@@ -200,14 +390,17 @@ async function main() {
jsonOut({
ok: true,
action: "describe",
mode: process.env.BOSS_STATE_STORE === "postgres" ? "postgres" : "file",
mode: postgresModeEnabled() ? "postgres" : "file",
stateFile: path.resolve(defaultStateFile),
backupDir: path.resolve(defaultBackupDir),
postgresConfigured: Boolean(process.env.BOSS_DATABASE_URL?.trim()),
postgresTable: "boss_state_snapshots",
postgresConfigured: postgresConfigured(),
postgresTable,
snapshotKey,
});
return;
case "validate-schema":
jsonOut(await validatePostgresSchema(options));
return;
case "backup-file":
jsonOut(await backupFile(options));
return;
@@ -217,6 +410,12 @@ async function main() {
case "migrate-file-to-postgres":
jsonOut(await migrateFileToPostgres(options));
return;
case "export-postgres-backup":
jsonOut(await exportPostgresBackup(options));
return;
case "restore-postgres-backup":
jsonOut(await restorePostgresBackup(options));
return;
case "rollback-postgres-to-file":
jsonOut(await rollbackPostgresToFile(options));
return;

View File

@@ -39,8 +39,53 @@ function normalizePayload(raw) {
}
function extractTargetUrl(objective) {
const match = String(objective || "").match(/https?:\/\/[^\s、)]+/i);
return match?.[0] || undefined;
const text = String(objective || "");
const fullUrl = text.match(/https?:\/\/[^\s、)]+/i)?.[0];
if (fullUrl) {
return fullUrl;
}
const bareDomain = text.match(
/(?:访问|打开|进入|看一下|visit|open)?\s*((?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,}(?:\/[^\s、)]*)?)/i,
)?.[1];
return bareDomain ? `https://${bareDomain}` : undefined;
}
function cleanupSearchQuery(value) {
return String(value || "")
.replace(/打开\s*(?:youtube|油管)/gi, " ")
.replace(/(?:youtube|油管)/gi, " ")
.replace(/用浏览器打开/gi, " ")
.replace(/打开浏览器/gi, " ")
.replace(/找一个|找一下|搜索|搜一下|搜|播放/gi, " ")
.replace(/的\s*mv/gi, " MV")
.replace(/mv/gi, " MV")
.replace(/[,。;;、]/g, " ")
.replace(/\s+/g, " ")
.trim();
}
function deriveYouTubeSearchUrl(objective) {
const text = String(objective || "").trim();
if (!/(youtube|油管)/i.test(text)) {
return undefined;
}
const queryPatterns = [
/(?:找一个|找一下|搜索|搜一下|搜|播放)\s*([^,。;;]+)/i,
/(?:youtube|油管)\s*([^,。;;]+)/i,
];
for (const pattern of queryPatterns) {
const query = cleanupSearchQuery(text.match(pattern)?.[1]);
if (query) {
return `https://www.youtube.com/results?search_query=${encodeURIComponent(query)}`;
}
}
const fallbackQuery = cleanupSearchQuery(text);
return fallbackQuery
? `https://www.youtube.com/results?search_query=${encodeURIComponent(fallbackQuery)}`
: "https://www.youtube.com";
}
async function writeArtifact(payload) {
@@ -84,6 +129,55 @@ function parseArgsJson(value) {
}
}
function detectRequestedBrowserApp(objective) {
const text = String(objective || "").toLowerCase();
if (text.includes("chrome") || text.includes("谷歌浏览器")) {
return "Google Chrome";
}
if (text.includes("safari")) {
return "Safari";
}
return undefined;
}
function resolveBrowserOpenArgs(command, objective) {
const configured =
parseArgsJson(process.env.BOSS_BROWSER_OPEN_ARGS_JSON) ??
parseArgs(process.env.BOSS_BROWSER_OPEN_ARGS);
if (configured.length > 0) {
return configured;
}
const requestedApp = detectRequestedBrowserApp(objective);
const commandName = path.basename(command || "").toLowerCase();
return requestedApp && commandName === "open" ? ["-a", requestedApp] : [];
}
function escapeAppleScriptString(value) {
return String(value || "").replace(/\\/g, "\\\\").replace(/"/g, '\\"');
}
async function openTargetUrl(targetUrl, objective) {
const commandOverride = String(process.env.BOSS_BROWSER_OPEN_COMMAND || "").trim();
const command = commandOverride || "open";
const requestedApp = detectRequestedBrowserApp(objective);
if (!commandOverride && process.platform === "darwin" && requestedApp) {
const app = escapeAppleScriptString(requestedApp);
const url = escapeAppleScriptString(targetUrl);
const script = [
`tell application "${app}" to activate`,
`tell application "${app}" to open location "${url}"`,
].join("\n");
await runCommand("osascript", ["-e", script]);
return "osascript_open_url_executed";
}
const prefixArgs = resolveBrowserOpenArgs(command, objective);
await runCommand(command, [...prefixArgs, targetUrl]);
return "open_url_executed";
}
function getBrowserAutomationMode() {
const raw = String(process.env.BOSS_BROWSER_AUTOMATION_MODE || "").trim().toLowerCase();
if (raw === "off" || raw === "fetch" || raw === "playwright" || raw === "auto") {
@@ -92,6 +186,11 @@ function getBrowserAutomationMode() {
return "auto";
}
function shouldOpenVisibleBrowserAfterAutomation() {
const raw = String(process.env.BOSS_BROWSER_VISIBLE_OPEN_AFTER_AUTOMATION || "").trim().toLowerCase();
return !["0", "false", "off", "no"].includes(raw);
}
function resolveCodexHome() {
return String(process.env.CODEX_HOME || "").trim() || path.join(process.env.HOME || "", ".codex");
}
@@ -195,7 +294,7 @@ const objective =
typeof payload.objective === "string" && payload.objective.trim()
? payload.objective.trim()
: "浏览器控制 smoke 链路正常";
const targetUrl = extractTargetUrl(objective);
const targetUrl = extractTargetUrl(objective) || deriveYouTubeSearchUrl(objective);
const riskLevel =
typeof payload.context?.riskLevel === "string" && payload.context.riskLevel.trim()
? payload.context.riskLevel.trim()
@@ -209,23 +308,27 @@ const automationMode =
? "playwright"
: "fetch"
: configuredAutomationMode;
const automatedTitle =
targetUrl && !dryRun && automationMode === "playwright"
? await runBrowserAutomation(targetUrl, currentRequestId)
: undefined;
let automationError;
let automatedTitle;
if (targetUrl && !dryRun && automationMode === "playwright") {
try {
automatedTitle = await runBrowserAutomation(targetUrl, currentRequestId);
} catch (error) {
automationError = error instanceof Error ? error.message : String(error);
}
}
const pageTitle =
automatedTitle ||
(automationMode !== "off" ? await inspectPageTitle(targetUrl) : undefined);
if (targetUrl && !dryRun) {
if (automationMode === "playwright" && automatedTitle) {
action = "browser_automation_executed";
if (shouldOpenVisibleBrowserAfterAutomation()) {
const visibleAction = await openTargetUrl(targetUrl, objective);
action = `${action}+${visibleAction}`;
}
} else {
const command = String(process.env.BOSS_BROWSER_OPEN_COMMAND || "").trim() || "open";
const prefixArgs =
parseArgsJson(process.env.BOSS_BROWSER_OPEN_ARGS_JSON) ??
parseArgs(process.env.BOSS_BROWSER_OPEN_ARGS);
await runCommand(command, [...prefixArgs, targetUrl]);
action = "open_url_executed";
action = await openTargetUrl(targetUrl, objective);
}
}
const artifacts = await writeArtifact({
@@ -246,8 +349,8 @@ writeJson({
? `浏览器控制已完成:${objective}。页面标题:${pageTitle}`
: `浏览器控制已完成:${objective}`,
executionSummary: pageTitle
? `${action} completed (risk=${riskLevel}, title=${pageTitle})`
: `${action} completed (risk=${riskLevel})`,
? `${action} completed (risk=${riskLevel}, title=${pageTitle}${automationError ? ", automationFallback=true" : ""})`
: `${action} completed (risk=${riskLevel}${automationError ? ", automationFallback=true" : ""})`,
targetUrl,
artifacts,
});

55
scripts/build-boss-agent-mac-app.sh Normal file → Executable file
View File

@@ -11,6 +11,11 @@ BINARY_PATH="$MACOS_DIR/boss-agent"
ICONSET_DIR="$RESOURCES_DIR/BossAgent.iconset"
ICON_PATH="$RESOURCES_DIR/BossAgent.icns"
SIGNING_IDENTITY="${BOSS_AGENT_CODESIGN_IDENTITY:-}"
NOTARIZE="${BOSS_AGENT_NOTARIZE:-0}"
NOTARY_PROFILE="${BOSS_AGENT_NOTARY_PROFILE:-}"
NOTARY_APPLE_ID="${BOSS_AGENT_NOTARY_APPLE_ID:-}"
NOTARY_TEAM_ID="${BOSS_AGENT_NOTARY_TEAM_ID:-}"
NOTARY_PASSWORD="${BOSS_AGENT_NOTARY_PASSWORD:-}"
if ! command -v swiftc >/dev/null 2>&1; then
echo "swiftc not found. Install Xcode Command Line Tools first." >&2
@@ -23,13 +28,24 @@ if ! command -v iconutil >/dev/null 2>&1; then
fi
if [[ -z "$SIGNING_IDENTITY" ]] && command -v security >/dev/null 2>&1; then
SIGNING_IDENTITY="$(
security find-identity -v -p codesigning 2>/dev/null \
| awk -F'"' '/"Apple Development:|Developer ID Application:|Mac Developer:|Boss Agent/ { print $2; exit }'
)"
if [[ "$NOTARIZE" == "1" ]]; then
SIGNING_IDENTITY="$(
security find-identity -v -p codesigning 2>/dev/null \
| awk -F'"' '/"Developer ID Application:/ { print $2; exit }'
)"
else
SIGNING_IDENTITY="$(
security find-identity -v -p codesigning 2>/dev/null \
| awk -F'"' '/"Apple Development:|Developer ID Application:|Mac Developer:|Boss Agent/ { print $2; exit }'
)"
fi
fi
if [[ -z "$SIGNING_IDENTITY" ]]; then
if [[ "$NOTARIZE" == "1" ]]; then
echo "boss-agent: BOSS_AGENT_NOTARIZE=1 requires a Developer ID Application signing identity." >&2
exit 1
fi
SIGNING_IDENTITY="-"
echo "boss-agent: no stable code signing identity found; falling back to ad-hoc signing." >&2
else
@@ -172,5 +188,34 @@ cat > "$CONTENTS_DIR/Info.plist" <<'PLIST'
PLIST
plutil -lint "$CONTENTS_DIR/Info.plist" >/dev/null
codesign --force --deep --timestamp=none --sign "$SIGNING_IDENTITY" "$APP_DIR" >/dev/null
if [[ "$NOTARIZE" == "1" ]]; then
if ! command -v xcrun >/dev/null 2>&1; then
echo "boss-agent: xcrun is required for notarization." >&2
exit 1
fi
codesign --force --deep --options runtime --timestamp --sign "$SIGNING_IDENTITY" "$APP_DIR" >/dev/null
NOTARY_ZIP="$ROOT_DIR/dist/boss-agent-notary.zip"
rm -f "$NOTARY_ZIP"
(
cd "$ROOT_DIR/dist"
ditto -c -k --keepParent "boss-agent.app" "$NOTARY_ZIP"
)
NOTARY_ARGS=()
if [[ -n "$NOTARY_PROFILE" ]]; then
NOTARY_ARGS=(--keychain-profile "$NOTARY_PROFILE")
elif [[ -n "$NOTARY_APPLE_ID" && -n "$NOTARY_TEAM_ID" && -n "$NOTARY_PASSWORD" ]]; then
NOTARY_ARGS=(--apple-id "$NOTARY_APPLE_ID" --team-id "$NOTARY_TEAM_ID" --password "$NOTARY_PASSWORD")
else
echo "boss-agent: notarization requires BOSS_AGENT_NOTARY_PROFILE or Apple ID/team/password env vars." >&2
exit 1
fi
xcrun notarytool submit "$NOTARY_ZIP" "${NOTARY_ARGS[@]}" --wait >/dev/null
xcrun stapler staple "$APP_DIR" >/dev/null
rm -f "$NOTARY_ZIP"
else
codesign --force --deep --timestamp=none --sign "$SIGNING_IDENTITY" "$APP_DIR" >/dev/null
fi
echo "$APP_DIR"

View File

@@ -0,0 +1,178 @@
#!/usr/bin/env node
import { fileURLToPath } from "node:url";
import path from "node:path";
import {
executeCodexAppServerTask,
getCodexAppServerRunnerConfig,
} from "../local-agent/codex-app-server-runner.mjs";
const DEFAULT_TIMEOUT_MS = 120_000;
function normalizeText(value) {
return String(value || "").trim();
}
function parseArgs(value) {
const args = String(value || "")
.trim()
.split(/\s+/)
.filter(Boolean);
return args.length > 0 ? args : undefined;
}
function parseArgsJson(value) {
const raw = normalizeText(value);
if (!raw) return undefined;
try {
const parsed = JSON.parse(raw);
return Array.isArray(parsed) ? parsed.map((item) => String(item)).filter(Boolean) : undefined;
} catch {
return undefined;
}
}
function parseTimeoutMs(value) {
const parsed = Number.parseInt(String(value || ""), 10);
return Number.isFinite(parsed) && parsed > 0 ? parsed : DEFAULT_TIMEOUT_MS;
}
function writeJson(payload) {
process.stdout.write(`${JSON.stringify(payload)}\n`);
}
async function readStdin() {
const chunks = [];
for await (const chunk of process.stdin) {
chunks.push(typeof chunk === "string" ? chunk : chunk.toString("utf8"));
}
return chunks.join("").trim();
}
function parseJsonPayload(raw) {
try {
const parsed = JSON.parse(String(raw || "{}"));
return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
} catch {
return {};
}
}
function detectTargetApp(objective) {
const text = normalizeText(objective).toLowerCase();
const candidates = [
["Codex", ["codex"]],
["Google Chrome", ["chrome", "google chrome", "谷歌"]],
["Safari", ["safari"]],
["QQ", ["qq"]],
["微信", ["微信", "wechat"]],
["飞书", ["飞书", "lark", "feishu"]],
["Telegram", ["telegram", "tg"]],
["Finder", ["finder", "访达"]],
["系统设置", ["系统设置", "system settings", "settings"]],
];
const matched = candidates.find(([, aliases]) => aliases.some((alias) => text.includes(alias)));
return matched?.[0];
}
function buildComputerUsePrompt(payload) {
const objective = normalizeText(payload.objective);
const targetApp = detectTargetApp(objective);
return [
"你是 Boss 的 Codex Computer Use 执行器,正在被要求控制当前这台 macOS 电脑。",
"请优先使用 Codex 自带的 Computer Use / Browser / Desktop 能力完成用户目标。",
"只执行当前目标直接需要的动作;不要扩展需求,不要改动无关文件。",
"遇到发送、提交、删除、支付、授权等高风险动作时,必须停下来要求用户确认,不要静默点击。",
targetApp ? `目标应用:${targetApp}` : "目标应用:如果用户没有明确说明,请先根据目标判断最小必要应用。",
`用户目标:${objective}`,
"完成后用中文返回简短小结,说明已做的动作、结果和是否需要用户下一步确认。",
]
.filter(Boolean)
.join("\n");
}
function buildRunnerConfig(env, payload) {
const cwd =
normalizeText(env.BOSS_CODEX_COMPUTER_USE_WORKDIR) ||
normalizeText(payload?.context?.projectCwd) ||
process.cwd();
return getCodexAppServerRunnerConfig(env, {
codexAppServerEnabled: true,
codexAppServerCommand: normalizeText(env.BOSS_CODEX_COMPUTER_USE_CODEX_COMMAND) || "codex",
codexAppServerArgs:
parseArgsJson(env.BOSS_CODEX_COMPUTER_USE_CODEX_ARGS_JSON) ??
parseArgs(env.BOSS_CODEX_COMPUTER_USE_CODEX_ARGS) ??
["app-server"],
codexAppServerWorkdir: cwd,
codexAppServerTimeoutMs: parseTimeoutMs(env.BOSS_CODEX_COMPUTER_USE_TIMEOUT_MS),
codexAppServerClientName: "boss_codex_computer_use",
codexAppServerClientTitle: "Boss Codex Computer Use",
codexAppServerClientVersion: "0.1.0",
masterAgentWorkdir: cwd,
masterAgentModel: normalizeText(env.BOSS_CODEX_COMPUTER_USE_MODEL),
});
}
export async function runCodexComputerUseTask(payload, options = {}) {
const env = options.env || process.env;
const requestId = normalizeText(payload?.requestId);
const objective = normalizeText(payload?.objective);
if (!objective) {
return {
status: "failed",
requestId: requestId || undefined,
error: "CODEX_COMPUTER_USE_OBJECTIVE_REQUIRED",
computerUseProvider: "codex-computer-use",
};
}
const runnerConfig = buildRunnerConfig(env, payload);
const result = await executeCodexAppServerTask(runnerConfig, {
taskId: requestId || "codex-computer-use",
taskType: "conversation_reply",
targetCodexThreadRef: normalizeText(payload?.context?.codexComputerUseThreadId),
targetCodexFolderRef: runnerConfig.cwd,
executionPrompt: buildComputerUsePrompt(payload),
});
if (result.status !== "completed") {
return {
status: "failed",
requestId: requestId || undefined,
error: result.errorMessage || "CODEX_COMPUTER_USE_FAILED",
detail: result.stderr,
computerUseProvider: "codex-computer-use",
};
}
return {
status: "completed",
requestId: requestId || undefined,
replyBody: result.replyBody || "Codex Computer Use 已完成本轮桌面控制任务。",
targetApp: detectTargetApp(objective),
executionSummary: "codex app-server computer use",
computerUseProvider: "codex-computer-use",
};
}
async function main() {
const raw = await readStdin();
const payload = parseJsonPayload(raw);
const result = await runCodexComputerUseTask(payload, {
env: process.env,
cwd: process.cwd(),
});
writeJson(result);
}
const currentFile = fileURLToPath(import.meta.url);
if (process.argv[1] && path.resolve(process.argv[1]) === path.resolve(currentFile)) {
main().catch((error) => {
writeJson({
status: "failed",
error: error instanceof Error ? error.message : String(error),
computerUseProvider: "codex-computer-use",
});
process.exitCode = 1;
});
}

View File

@@ -64,6 +64,26 @@ function detectTargetApp(objective) {
return undefined;
}
function resolvePlatformAppName(targetApp) {
if (process.platform === "darwin" && targetApp === "Chrome") {
return "Google Chrome";
}
return targetApp;
}
function isBrowserApp(targetApp) {
return ["Chrome", "Google Chrome", "Safari"].includes(String(targetApp || ""));
}
function extractTargetUrl(objective) {
const text = String(objective || "");
const quotedText = extractQuotedText(text);
if (/^https?:\/\//i.test(String(quotedText || ""))) {
return quotedText;
}
return text.match(/https?:\/\/[^\s、)"”]+/i)?.[0];
}
function detectDesktopAction(objective) {
const text = String(objective || "").toLowerCase();
if (text.includes("系统设置") || text.includes("settings")) {
@@ -342,6 +362,25 @@ async function runAppleScript(targetApp, objective) {
return `osascript activated ${targetApp}`;
}
async function runOpenApp(targetApp) {
const command = String(process.env.BOSS_COMPUTER_USE_OPEN_APP_COMMAND || "").trim() || "open";
const prefixArgs = resolveOpenAppPrefixArgs(command);
await runCommand(command, [...prefixArgs, targetApp]);
return `open activated ${targetApp}`;
}
async function runOpenBrowserUrl(targetApp, targetUrl) {
const command = String(process.env.BOSS_COMPUTER_USE_OPEN_APP_COMMAND || "").trim() || "open";
const prefixArgs = resolveOpenAppPrefixArgs(command);
const commandName = path.basename(command || "").toLowerCase();
const args =
commandName === "open" || prefixArgs.includes("-a")
? [...prefixArgs, targetApp, targetUrl]
: [...prefixArgs, targetUrl];
await runCommand(command, args);
return `open url in ${targetApp}`;
}
const raw = await readStdin();
const normalized = normalizePayload(raw);
@@ -359,6 +398,8 @@ const objective =
? payload.objective.trim()
: "桌面控制 smoke 链路正常";
const targetApp = detectTargetApp(objective);
const automationTargetApp = resolvePlatformAppName(targetApp);
const targetUrl = extractTargetUrl(objective);
const desktopAction = detectDesktopAction(objective);
const riskLevel =
typeof payload.context?.riskLevel === "string" && payload.context.riskLevel.trim()
@@ -387,13 +428,14 @@ const configuredMode = getDesktopAutomationMode();
const automationMode =
configuredMode === "auto" ? (process.platform === "darwin" ? "osascript" : "open") : configuredMode;
if (targetApp && !dryRun) {
if (automationMode === "osascript") {
await runAppleScript(targetApp, objective);
if (targetUrl && isBrowserApp(automationTargetApp)) {
await runOpenBrowserUrl(automationTargetApp, targetUrl);
action = `${desktopAction}_url_executed`;
} else if (automationMode === "osascript") {
await runAppleScript(automationTargetApp, objective);
action = `${desktopAction}_executed`;
} else if (automationMode !== "off") {
const command = String(process.env.BOSS_COMPUTER_USE_OPEN_APP_COMMAND || "").trim() || "open";
const prefixArgs = resolveOpenAppPrefixArgs(command);
await runCommand(command, [...prefixArgs, targetApp]);
await runOpenApp(automationTargetApp);
action = `${desktopAction}_executed`;
}
}
@@ -404,6 +446,8 @@ const artifacts = await writeArtifact({
action,
objective,
targetApp,
automationTargetApp,
targetUrl,
typedText: extractQuotedText(objective),
dryRun,
riskLevel,
@@ -420,6 +464,8 @@ writeJson({
dialogGuardState.decision?.disposition ? `, dialogGuard=${dialogGuardState.decision.disposition}` : ""
})`,
targetApp,
automationTargetApp,
targetUrl,
typedText: extractQuotedText(objective),
dialogGuard: dialogGuardState.decision
? {

View File

@@ -0,0 +1,528 @@
#!/usr/bin/env node
import { spawn } from "node:child_process";
import { access } from "node:fs/promises";
import { fileURLToPath } from "node:url";
import path from "node:path";
const DEFAULT_CUA_TIMEOUT_MS = 45000;
const TARGET_APPS = [
{
label: "Google Chrome",
name: "Google Chrome",
bundleId: "com.google.Chrome",
browser: true,
aliases: ["chrome", "google chrome", "谷歌浏览器", "谷歌"],
},
{
label: "Safari",
name: "Safari",
bundleId: "com.apple.Safari",
browser: true,
aliases: ["safari"],
},
{
label: "QQ",
name: "QQ",
aliases: ["qq"],
},
{
label: "微信",
name: "微信",
aliases: ["微信", "wechat"],
},
{
label: "飞书",
name: "飞书",
aliases: ["飞书", "lark", "feishu"],
},
{
label: "Telegram",
name: "Telegram",
aliases: ["telegram", "tg"],
},
{
label: "Finder",
name: "Finder",
bundleId: "com.apple.finder",
aliases: ["finder", "访达"],
},
{
label: "系统设置",
name: "System Settings",
bundleId: "com.apple.systempreferences",
aliases: ["系统设置", "system settings", "settings"],
},
{
label: "终端",
name: "Terminal",
bundleId: "com.apple.Terminal",
aliases: ["terminal", "终端"],
},
{
label: "Codex",
name: "Codex",
aliases: ["codex"],
},
];
function writeJson(payload) {
process.stdout.write(`${JSON.stringify(payload)}\n`);
}
async function readStdin() {
const chunks = [];
for await (const chunk of process.stdin) {
chunks.push(typeof chunk === "string" ? chunk : chunk.toString("utf8"));
}
return chunks.join("").trim();
}
function parseJsonPayload(raw) {
try {
const parsed = JSON.parse(String(raw || "{}"));
return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
} catch {
return {};
}
}
function parseArgs(value) {
return String(value || "")
.trim()
.split(/\s+/)
.filter(Boolean);
}
function parseArgsJson(value) {
const raw = String(value || "").trim();
if (!raw) return undefined;
try {
const parsed = JSON.parse(raw);
return Array.isArray(parsed) ? parsed.map((item) => String(item)).filter(Boolean) : undefined;
} catch {
return undefined;
}
}
function parseTimeoutMs(value) {
const parsed = Number.parseInt(String(value || ""), 10);
return Number.isFinite(parsed) && parsed > 0 ? parsed : DEFAULT_CUA_TIMEOUT_MS;
}
function normalizeText(value) {
return String(value || "").trim();
}
function normalizePlatform(value) {
const platform = normalizeText(value).toLowerCase();
return !platform || platform === "macos" || platform === "darwin" ? "macos" : platform;
}
function normalizeProvider(value) {
const provider = normalizeText(value);
return provider || "cua-driver-computer-use";
}
export function detectCuaTargetApp(objective) {
const text = normalizeText(objective).toLowerCase();
if (!text) return undefined;
return TARGET_APPS.find((candidate) =>
candidate.aliases.some((alias) => text.includes(alias.toLowerCase())),
);
}
function extractTargetUrl(objective) {
const text = normalizeText(objective);
return text.match(/https?:\/\/[^\s、)"”]+/i)?.[0];
}
function extractQuotedText(objective) {
const text = normalizeText(objective);
const patterns = [
/[“"]([^“”"]+)[”"]/,
/[「『]([^」』]+)[」』]/,
/输入[:]\s*([^\n。;]+)/,
/打字[:]\s*([^\n。;]+)/,
];
for (const pattern of patterns) {
const match = text.match(pattern);
const value = match?.[1]?.trim();
if (value) return value;
}
return undefined;
}
export function isSubmitLikeObjective(objective) {
const text = normalizeText(objective).toLowerCase();
return [
"发送",
"提交",
"发出去",
"回车发送",
"删除",
"购买",
"下单",
"支付",
"转账",
"send",
"submit",
"delete",
"purchase",
"pay",
].some((keyword) => text.includes(keyword));
}
function isSubmitAllowed(env, payload) {
if (String(env.BOSS_CUA_ALLOW_SUBMIT || "").trim().toLowerCase() === "true") {
return true;
}
return payload?.context?.desktopActionConfirmed === true || payload?.desktopActionConfirmed === true;
}
export function buildCuaLaunchArgs(targetApp, objective) {
if (!targetApp) return {};
const launchArgs = targetApp.bundleId ? { bundle_id: targetApp.bundleId } : { name: targetApp.name };
const url = extractTargetUrl(objective);
if (targetApp.browser) {
launchArgs.urls = [url || "about:blank"];
} else if (url) {
launchArgs.urls = [url];
}
return launchArgs;
}
function selectWindow(windows) {
const candidates = Array.isArray(windows) ? windows : [];
return (
candidates.find((window) => window?.is_on_screen === true && window?.on_current_space !== false) ||
candidates.find((window) => window?.on_current_space !== false) ||
candidates[0]
);
}
function getPid(launchResult) {
const value = launchResult?.structured?.pid ?? launchResult?.structured?.process_id ?? launchResult?.pid;
const parsed = Number(value);
return Number.isFinite(parsed) && parsed > 0 ? parsed : undefined;
}
function getWindowId(window) {
const value = window?.window_id ?? window?.id;
const parsed = Number(value);
return Number.isFinite(parsed) && parsed > 0 ? parsed : undefined;
}
function extractTextContent(parsed, raw) {
if (Array.isArray(parsed?.content)) {
return parsed.content
.map((item) => (typeof item?.text === "string" ? item.text.trim() : ""))
.filter(Boolean)
.join("\n")
.trim();
}
if (typeof parsed?.text === "string") return parsed.text.trim();
if (typeof raw === "string") return raw.trim();
return "";
}
function normalizeCuaToolOutput(rawOutput) {
const raw = String(rawOutput || "").trim();
if (!raw) {
return { raw: "", text: "", structured: {} };
}
try {
const parsed = JSON.parse(raw);
const structured =
parsed?.structuredContent && typeof parsed.structuredContent === "object"
? parsed.structuredContent
: parsed && typeof parsed === "object" && !Array.isArray(parsed)
? parsed
: {};
return {
raw,
text: extractTextContent(parsed, raw),
structured,
isError: parsed?.isError === true,
};
} catch {
return {
raw,
text: raw,
structured: {},
isError: false,
};
}
}
function buildExecutableCandidates(command, env, cwd) {
const normalizedCommand = normalizeText(command);
if (!normalizedCommand) return [];
if (normalizedCommand.includes("/") || path.isAbsolute(normalizedCommand)) {
return [path.isAbsolute(normalizedCommand) ? normalizedCommand : path.resolve(cwd || process.cwd(), normalizedCommand)];
}
const pathCandidates = String(env.PATH || "")
.split(path.delimiter)
.filter(Boolean)
.map((item) => path.join(item, normalizedCommand));
const home = normalizeText(env.HOME);
return [
...pathCandidates,
home ? path.join(home, ".local", "bin", normalizedCommand) : undefined,
path.join("/usr/local/bin", normalizedCommand),
path.join("/opt/homebrew/bin", normalizedCommand),
normalizedCommand === "cua-driver" ? "/Applications/CuaDriver.app/Contents/MacOS/cua-driver" : undefined,
].filter(Boolean);
}
async function resolveExecutableCommand(command, env, cwd) {
for (const candidate of buildExecutableCandidates(command, env, cwd)) {
try {
await access(candidate);
return candidate;
} catch {
// Try the next well-known install location.
}
}
throw new Error("CUA_DRIVER_COMMAND_NOT_FOUND");
}
async function callCuaTool(toolName, args, options) {
const env = options.env || process.env;
const command = await resolveExecutableCommand(
normalizeText(env.BOSS_CUA_DRIVER_COMMAND) || "cua-driver",
env,
options.cwd || process.cwd(),
);
const prefixArgs = parseArgsJson(env.BOSS_CUA_DRIVER_ARGS_JSON) ?? parseArgs(env.BOSS_CUA_DRIVER_ARGS);
const timeoutMs = parseTimeoutMs(env.BOSS_CUA_DRIVER_TIMEOUT_MS);
const childArgs = [...prefixArgs, "call", toolName, JSON.stringify(args || {}), "--raw", "--compact"];
return new Promise((resolve, reject) => {
const child = spawn(command, childArgs, {
cwd: options.cwd || process.cwd(),
env: {
...process.env,
...env,
},
stdio: ["ignore", "pipe", "pipe"],
});
let stdout = "";
let stderr = "";
let timedOut = false;
const timer = setTimeout(() => {
timedOut = true;
child.kill("SIGKILL");
}, timeoutMs);
child.stdout.setEncoding("utf8");
child.stderr.setEncoding("utf8");
child.stdout.on("data", (chunk) => {
stdout += chunk;
});
child.stderr.on("data", (chunk) => {
stderr += chunk;
});
child.on("error", (error) => {
clearTimeout(timer);
if (error?.code === "ENOENT") {
reject(new Error("CUA_DRIVER_COMMAND_NOT_FOUND"));
return;
}
reject(error);
});
child.on("close", (code) => {
clearTimeout(timer);
if (timedOut) {
reject(new Error("CUA_DRIVER_TIMEOUT"));
return;
}
if (code !== 0) {
const detail = stderr.trim() || stdout.trim() || `cua-driver exit code ${code}`;
reject(new Error(`CUA_DRIVER_TOOL_FAILED: ${toolName}: ${detail}`));
return;
}
const result = normalizeCuaToolOutput(stdout);
if (result.isError) {
reject(new Error(`CUA_DRIVER_TOOL_ERROR: ${toolName}: ${result.text || result.raw}`));
return;
}
resolve(result);
});
});
}
function matchesTargetApp(app, targetApp) {
const bundleId = normalizeText(app?.bundle_id).toLowerCase();
const name = normalizeText(app?.name).toLowerCase();
const targetBundleId = normalizeText(targetApp?.bundleId).toLowerCase();
const targetName = normalizeText(targetApp?.name).toLowerCase();
const targetLabel = normalizeText(targetApp?.label).toLowerCase();
if (targetBundleId && bundleId === targetBundleId) return true;
if (targetName && name === targetName) return true;
if (targetLabel && name === targetLabel) return true;
return targetApp?.aliases?.some((alias) => name.includes(alias.toLowerCase())) === true;
}
function selectRunningApp(apps, targetApp) {
const candidates = Array.isArray(apps) ? apps : [];
return candidates.find((app) => app?.running === true && matchesTargetApp(app, targetApp));
}
async function resolveTargetAppSession(targetApp, objective, options, toolTrace) {
try {
const launchResult = await callCuaTool("launch_app", buildCuaLaunchArgs(targetApp, objective), options);
toolTrace.push("launch_app");
const pid = getPid(launchResult);
return {
pid,
window: selectWindow(launchResult.structured?.windows),
sourceText: launchResult.text || launchResult.raw,
};
} catch (error) {
toolTrace.push("launch_app_failed");
const appsResult = await callCuaTool("list_apps", {}, options);
toolTrace.push("list_apps");
const runningApp = selectRunningApp(appsResult.structured?.apps, targetApp);
const pid = getPid({ structured: runningApp });
if (!pid) {
throw error;
}
const windowsResult = await callCuaTool("list_windows", { pid }, options);
toolTrace.push("list_windows");
return {
pid,
window: selectWindow(windowsResult.structured?.windows),
sourceText: windowsResult.text || appsResult.text || error?.message,
};
}
}
function buildConfirmationResult(payload, targetApp) {
return {
status: "needs_user_action",
requestId: normalizeText(payload.requestId) || undefined,
kind: "desktop_submit_confirmation_required",
risk: "high",
summary: "这条指令会在桌面应用里发送、提交或删除内容,需要你先确认。",
recommendedAction: "allow_once",
availableActions: ["allow_once", "deny"],
platform: "macos",
appName: targetApp?.label || targetApp?.name,
};
}
function buildFailure(requestId, error, detail) {
return {
status: "failed",
requestId: normalizeText(requestId) || undefined,
error,
detail: normalizeText(detail) || undefined,
};
}
export async function runCuaDriverComputerUseTask(payload, options = {}) {
const env = options.env || process.env;
const requestId = normalizeText(payload?.requestId);
const objective = normalizeText(payload?.objective);
const platform = normalizePlatform(payload?.platform || payload?.context?.controlPlatform);
const provider = normalizeProvider(payload?.provider || payload?.context?.computerUseProvider);
if (platform !== "macos") {
return buildFailure(requestId, "UNSUPPORTED_CONTROL_PLATFORM");
}
if (provider !== "cua-driver-computer-use") {
return buildFailure(requestId, "UNSUPPORTED_COMPUTER_USE_PROVIDER");
}
if (!objective) {
return buildFailure(requestId, "CUA_OBJECTIVE_REQUIRED");
}
const targetApp = detectCuaTargetApp(objective);
if (!targetApp) {
return buildFailure(
requestId,
"CUA_TARGET_APP_REQUIRED",
"请在指令里明确要控制的 macOS 应用,例如 Chrome、Safari、QQ、微信、Finder 或系统设置。",
);
}
if (isSubmitLikeObjective(objective) && !isSubmitAllowed(env, payload)) {
return buildConfirmationResult(payload, targetApp);
}
const toolTrace = [];
try {
const targetSession = await resolveTargetAppSession(targetApp, objective, {
...options,
env,
}, toolTrace);
const pid = targetSession.pid;
if (!pid) {
return buildFailure(requestId, "CUA_TARGET_PID_NOT_FOUND", targetSession.sourceText);
}
let window = targetSession.window;
if (!window) {
const windowsResult = await callCuaTool("list_windows", { pid }, { ...options, env });
toolTrace.push("list_windows");
window = selectWindow(windowsResult.structured?.windows);
}
const windowId = getWindowId(window);
if (!windowId) {
return buildFailure(requestId, "CUA_TARGET_WINDOW_NOT_FOUND", targetSession.sourceText);
}
const beforeState = await callCuaTool("get_window_state", { pid, window_id: windowId }, { ...options, env });
toolTrace.push("get_window_state");
const typedText = extractQuotedText(objective);
if (typedText) {
await callCuaTool("type_text", { pid, window_id: windowId, text: typedText, delay_ms: 20 }, { ...options, env });
toolTrace.push("type_text");
if (isSubmitLikeObjective(objective) && isSubmitAllowed(env, payload)) {
await callCuaTool("press_key", { pid, window_id: windowId, key: "return" }, { ...options, env });
toolTrace.push("press_key");
}
await callCuaTool("get_window_state", { pid, window_id: windowId }, { ...options, env });
toolTrace.push("get_window_state");
}
const observation = beforeState.text ? `窗口观测:${beforeState.text.split(/\r?\n/)[0]}` : "已完成窗口观测。";
const actionSummary = typedText ? `并已向目标应用写入 ${typedText.length} 个字符。` : "已打开并读取目标窗口。";
return {
status: "completed",
requestId: requestId || undefined,
replyBody: `已通过 Cua Driver 接入 ${targetApp.label}${actionSummary}${observation}`,
targetApp: targetApp.label,
executionSummary: toolTrace.join(" -> "),
};
} catch (error) {
return buildFailure(requestId, error?.message || "CUA_DRIVER_EXECUTION_FAILED");
}
}
async function main() {
const raw = await readStdin();
const payload = parseJsonPayload(raw);
const result = await runCuaDriverComputerUseTask(payload, {
env: process.env,
cwd: process.cwd(),
});
writeJson(result);
}
const currentFile = fileURLToPath(import.meta.url);
if (process.argv[1] && path.resolve(process.argv[1]) === currentFile) {
main().catch((error) => {
writeJson({
status: "failed",
error: error?.message || "CUA_DRIVER_RUNTIME_FAILED",
});
});
}

View File

@@ -1,32 +1,118 @@
#!/bin/zsh
set -euo pipefail
PLIST_SOURCE="/Users/kris/code/boss/deployment/launchd/com.hyzq.boss.local-agent.plist"
ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
PLIST_SOURCE="$ROOT_DIR/deployment/launchd/com.hyzq.boss.local-agent.plist"
PLIST_TARGET="$HOME/Library/LaunchAgents/com.hyzq.boss.local-agent.plist"
BRIDGE_PLIST_SOURCE="/Users/kris/code/boss/deployment/launchd/com.hyzq.boss.codex-desktop-bridge.plist"
BRIDGE_PLIST_SOURCE="$ROOT_DIR/deployment/launchd/com.hyzq.boss.codex-desktop-bridge.plist"
BRIDGE_PLIST_TARGET="$HOME/Library/LaunchAgents/com.hyzq.boss.codex-desktop-bridge.plist"
CONFIG_PATH="${1:-/Users/kris/code/boss/local-agent/config.cloud.json}"
CONFIG_SOURCE_ARG="${1:-}"
if [[ "$CONFIG_PATH" != /* ]]; then
CONFIG_PATH="/Users/kris/code/boss/${CONFIG_PATH}"
config_has_device_identity() {
python3 - "$1" <<'PY'
import json
from pathlib import Path
import sys
try:
config = json.loads(Path(sys.argv[1]).read_text())
except Exception:
raise SystemExit(1)
raise SystemExit(0 if config.get("deviceId") and config.get("token") else 1)
PY
}
resolve_default_config_source() {
local ACTIVE_CONFIG_PATH=""
local default_config_path="$ROOT_DIR/local-agent/config.cloud.json"
if [[ -f "$PLIST_TARGET" ]]; then
ACTIVE_CONFIG_PATH="$(/usr/libexec/PlistBuddy -c 'Print :ProgramArguments:2' "$PLIST_TARGET" 2>/dev/null || true)"
if [[ -n "$ACTIVE_CONFIG_PATH" && "$ACTIVE_CONFIG_PATH" == "$ROOT_DIR/local-agent/"*.json && -f "$ACTIVE_CONFIG_PATH" ]]; then
printf '%s\n' "$ACTIVE_CONFIG_PATH"
return 0
fi
fi
local custom_config=""
local custom_name=""
for custom_config in "$ROOT_DIR"/local-agent/config*.json(N); do
custom_name="$(basename "$custom_config")"
case "$custom_name" in
config.installed.json|config.cloud.json|config.example.json)
continue
;;
esac
if config_has_device_identity "$custom_config"; then
printf '%s\n' "$custom_config"
return 0
fi
done
if [[ -f "$ROOT_DIR/local-agent/config.installed.json" ]]; then
printf '%s\n' "$ROOT_DIR/local-agent/config.installed.json"
return 0
fi
printf '%s\n' "$default_config_path"
}
if [[ -n "$CONFIG_SOURCE_ARG" ]]; then
CONFIG_SOURCE_PATH="$CONFIG_SOURCE_ARG"
else
CONFIG_SOURCE_PATH="$(resolve_default_config_source)"
fi
if [[ ! -f "$CONFIG_PATH" ]]; then
echo "Config file not found: $CONFIG_PATH" >&2
if [[ "$CONFIG_SOURCE_PATH" != /* ]]; then
CONFIG_SOURCE_PATH="$ROOT_DIR/${CONFIG_SOURCE_PATH}"
fi
if [[ ! -f "$CONFIG_SOURCE_PATH" ]]; then
echo "Config file not found: $CONFIG_SOURCE_PATH" >&2
exit 1
fi
CONFIG_PATH="$ROOT_DIR/local-agent/config.installed.json"
python3 - <<'PY' "$CONFIG_SOURCE_PATH" "$CONFIG_PATH" "$ROOT_DIR"
import json
from pathlib import Path
import sys
source_path = Path(sys.argv[1])
target_path = Path(sys.argv[2])
root_dir = sys.argv[3]
config = json.loads(source_path.read_text())
for key in (
"masterAgentWorkdir",
"codexAppServerWorkdir",
"codexComputerUseWorkdir",
"browserControlWorkdir",
"computerUseWorkdir",
"codexDesktopRefreshWorkdir",
"omxWorkdir",
):
config[key] = root_dir
target_path.write_text(json.dumps(config, ensure_ascii=False, indent=2) + "\n")
PY
mkdir -p "$HOME/Library/LaunchAgents"
cp "$BRIDGE_PLIST_SOURCE" "$BRIDGE_PLIST_TARGET"
cp "$PLIST_SOURCE" "$PLIST_TARGET"
python3 - <<'PY' "$PLIST_TARGET" "$CONFIG_PATH"
python3 - <<'PY' "$PLIST_TARGET" "$BRIDGE_PLIST_TARGET" "$CONFIG_PATH" "$ROOT_DIR"
from pathlib import Path
import sys
plist_path = Path(sys.argv[1])
config_path = sys.argv[2]
text = plist_path.read_text()
plist_path.write_text(text.replace("__BOSS_AGENT_CONFIG__", config_path))
local_plist_path = Path(sys.argv[1])
bridge_plist_path = Path(sys.argv[2])
config_path = sys.argv[3]
root_dir = sys.argv[4]
for plist_path in (local_plist_path, bridge_plist_path):
text = plist_path.read_text()
text = text.replace("__BOSS_AGENT_CONFIG__", config_path)
text = text.replace("__BOSS_AGENT_ROOT__", root_dir)
# Keep older generated plists installable if a package contains a pre-placeholder file.
text = text.replace("/Users/kris/code/boss", root_dir)
plist_path.write_text(text)
PY
plutil -lint "$PLIST_TARGET" >/dev/null
plutil -lint "$BRIDGE_PLIST_TARGET" >/dev/null

View File

@@ -0,0 +1,311 @@
#!/bin/zsh
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
VERSION="${BOSS_AGENT_PACKAGE_VERSION:-$(date +%Y%m%d%H%M%S)}"
PACKAGE_NAME="boss-agent-mac-runtime-${VERSION}"
DIST_DIR="$ROOT_DIR/dist"
STAGE_DIR="$DIST_DIR/$PACKAGE_NAME"
RUNTIME_DIR="$STAGE_DIR/runtime"
ARCHIVE_PATH="$DIST_DIR/${PACKAGE_NAME}.zip"
rm -rf "$STAGE_DIR" "$ARCHIVE_PATH"
"$ROOT_DIR/scripts/build-boss-agent-mac-app.sh" >/dev/null
mkdir -p "$RUNTIME_DIR/scripts" "$RUNTIME_DIR/local-agent" "$RUNTIME_DIR/deployment/launchd"
cp -R "$ROOT_DIR/dist/boss-agent.app" "$STAGE_DIR/boss-agent.app"
rsync -a "$ROOT_DIR/local-agent/" "$RUNTIME_DIR/local-agent/"
cp "$ROOT_DIR/scripts/start-local-agent.sh" "$RUNTIME_DIR/scripts/start-local-agent.sh"
cp "$ROOT_DIR/scripts/install-local-launchagent.sh" "$RUNTIME_DIR/scripts/install-local-launchagent.sh"
cp "$ROOT_DIR/scripts/browser-control-smoke.mjs" "$RUNTIME_DIR/scripts/browser-control-smoke.mjs"
cp "$ROOT_DIR/scripts/codex-computer-use-runtime.mjs" "$RUNTIME_DIR/scripts/codex-computer-use-runtime.mjs"
cp "$ROOT_DIR/scripts/computer-use-smoke.mjs" "$RUNTIME_DIR/scripts/computer-use-smoke.mjs"
cp "$ROOT_DIR/scripts/cua-driver-computer-use-runtime.mjs" "$RUNTIME_DIR/scripts/cua-driver-computer-use-runtime.mjs"
cp "$ROOT_DIR/scripts/codex-desktop-refresh-hint.mjs" "$RUNTIME_DIR/scripts/codex-desktop-refresh-hint.mjs"
cp "$ROOT_DIR/scripts/codex-desktop-refresh-bridge-daemon.mjs" "$RUNTIME_DIR/scripts/codex-desktop-refresh-bridge-daemon.mjs"
cp "$ROOT_DIR/deployment/launchd/com.hyzq.boss.local-agent.plist" "$RUNTIME_DIR/deployment/launchd/com.hyzq.boss.local-agent.plist"
cp "$ROOT_DIR/deployment/launchd/com.hyzq.boss.codex-desktop-bridge.plist" "$RUNTIME_DIR/deployment/launchd/com.hyzq.boss.codex-desktop-bridge.plist"
node - <<'NODE' "$RUNTIME_DIR" "$VERSION"
const fs = require("fs");
const path = require("path");
const runtimeDir = process.argv[2];
const version = process.argv[3];
for (const name of ["config.cloud.json", "config.example.json"]) {
const configPath = path.join(runtimeDir, "local-agent", name);
if (!fs.existsSync(configPath)) continue;
const config = JSON.parse(fs.readFileSync(configPath, "utf8"));
config.bossAgentOtaEnabled = true;
config.bossAgentVersion = version;
config.bossAgentOtaAutoInstall = false;
config.bossAgentOtaCheckIntervalMs = config.bossAgentOtaCheckIntervalMs || 300000;
fs.writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`);
}
NODE
cat > "$RUNTIME_DIR/package.json" <<'JSON'
{
"name": "boss-agent-runtime",
"version": "0.1.0",
"private": true,
"dependencies": {
"qrcode": "^1.5.4"
}
}
JSON
node - <<'NODE' "$ROOT_DIR" "$RUNTIME_DIR"
const fs = require("fs");
const path = require("path");
const rootDir = process.argv[2];
const runtimeDir = process.argv[3];
const sourceModules = path.join(rootDir, "node_modules");
const targetModules = path.join(runtimeDir, "node_modules");
const seen = new Set();
function readPackage(name) {
return JSON.parse(fs.readFileSync(path.join(sourceModules, name, "package.json"), "utf8"));
}
function walk(name) {
if (seen.has(name)) return;
seen.add(name);
const pkg = readPackage(name);
for (const dep of Object.keys(pkg.dependencies || {})) {
walk(dep);
}
}
walk("qrcode");
fs.mkdirSync(targetModules, { recursive: true });
for (const name of seen) {
fs.cpSync(path.join(sourceModules, name), path.join(targetModules, name), {
recursive: true,
dereference: true,
filter: (source) => !/\/(test|tests|example|examples|\.github)\b/.test(source),
});
}
NODE
cat > "$STAGE_DIR/install.command" <<'SH'
#!/bin/zsh
set -euo pipefail
SOURCE_DIR="$(cd "$(dirname "$0")" && pwd)"
INSTALL_ROOT="${BOSS_AGENT_INSTALL_ROOT:-$HOME/boss-agent/current}"
APP_TARGET_DIR="${BOSS_AGENT_APP_TARGET_DIR:-$HOME/Applications}"
APP_TARGET="$APP_TARGET_DIR/boss-agent.app"
CONFIG_BACKUP_DIR=""
ACTIVE_CONFIG_BASENAME="config.cloud.json"
ACTIVE_PLIST="$HOME/Library/LaunchAgents/com.hyzq.boss.local-agent.plist"
NODE_BIN="${BOSS_NODE_BIN:-}"
if [[ -f "$ACTIVE_PLIST" ]]; then
ACTIVE_CONFIG_PATH="$(/usr/libexec/PlistBuddy -c 'Print :ProgramArguments:2' "$ACTIVE_PLIST" 2>/dev/null || true)"
if [[ -n "$ACTIVE_CONFIG_PATH" && "$ACTIVE_CONFIG_PATH" == "$INSTALL_ROOT/local-agent/"*.json ]]; then
ACTIVE_CONFIG_BASENAME="$(basename "$ACTIVE_CONFIG_PATH")"
fi
fi
if [[ -z "$NODE_BIN" ]]; then
NODE_BIN="$(command -v node 2>/dev/null || true)"
fi
if [[ -z "$NODE_BIN" ]]; then
NODE_CANDIDATES=("$HOME"/.boss-runtime/node-*/bin/node(N) /opt/homebrew/bin/node /usr/local/bin/node /usr/bin/node)
for candidate in "${NODE_CANDIDATES[@]}"; do
if [[ -x "$candidate" ]]; then
NODE_BIN="$candidate"
break
fi
done
fi
if [[ -z "$NODE_BIN" || ! -x "$NODE_BIN" ]]; then
echo "Node.js is required. Install Node.js 22 or newer first." >&2
exit 1
fi
NODE_MAJOR="$("$NODE_BIN" -p 'Number(process.versions.node.split(".")[0])')"
if [[ "$NODE_MAJOR" -lt 22 ]]; then
echo "Node.js 22 or newer is required. Current: $("$NODE_BIN" -v)" >&2
exit 1
fi
EXISTING_CONFIGS=("$INSTALL_ROOT"/local-agent/config*.json(N))
if [[ "${#EXISTING_CONFIGS[@]}" -gt 0 ]]; then
CONFIG_BACKUP_DIR="$(mktemp -d)"
cp "${EXISTING_CONFIGS[@]}" "$CONFIG_BACKUP_DIR"/
fi
mkdir -p "$(dirname "$INSTALL_ROOT")" "$APP_TARGET_DIR"
rsync -a --delete "$SOURCE_DIR/runtime/" "$INSTALL_ROOT/"
if [[ -n "$CONFIG_BACKUP_DIR" && -d "$CONFIG_BACKUP_DIR" ]]; then
cp "$CONFIG_BACKUP_DIR"/config*.json "$INSTALL_ROOT/local-agent/"
rm -rf "$CONFIG_BACKUP_DIR"
fi
PACKAGE_VERSION="__BOSS_AGENT_PACKAGE_VERSION__"
"$NODE_BIN" - <<'NODE' "$INSTALL_ROOT/local-agent" "$INSTALL_ROOT" "$PACKAGE_VERSION"
const fs = require("fs");
const os = require("os");
const path = require("path");
const configDir = process.argv[2];
const installRoot = process.argv[3];
const version = process.argv[4];
const home = os.homedir();
const configPaths = fs.readdirSync(configDir)
.filter((name) => /^config.*\.json$/.test(name))
.map((name) => path.join(configDir, name));
for (const configPath of configPaths) {
const config = JSON.parse(fs.readFileSync(configPath, "utf8"));
const nodeCommand = config.computerUseCommand && path.isAbsolute(config.computerUseCommand)
? config.computerUseCommand
: "node";
config.bossAgentOtaEnabled = true;
config.bossAgentVersion = version;
config.bossAgentInstallRoot = installRoot;
config.bossAgentOtaDownloadDir = path.join(home, "boss-agent", "updates");
config.bossAgentOtaCheckIntervalMs = config.bossAgentOtaCheckIntervalMs || 300000;
config.bossAgentOtaAutoInstall = false;
config.skillsDir = config.skillsDir || path.join(home, ".codex", "skills");
config.codexAppServerEnabled = config.codexAppServerEnabled !== false;
config.codexAppServerCommand = config.codexAppServerCommand || "codex";
config.codexAppServerArgs = Array.isArray(config.codexAppServerArgs) ? config.codexAppServerArgs : ["app-server"];
config.codexAppServerTimeoutMs = config.codexAppServerTimeoutMs || 120000;
config.codexAppServerFallbackToCli = config.codexAppServerFallbackToCli !== false;
config.codexComputerUseEnabled = config.codexComputerUseEnabled !== false;
config.codexComputerUseCommand = config.codexComputerUseCommand || nodeCommand;
config.codexComputerUseArgs = Array.isArray(config.codexComputerUseArgs)
? config.codexComputerUseArgs
: ["scripts/codex-computer-use-runtime.mjs"];
config.codexComputerUseTimeoutMs = config.codexComputerUseTimeoutMs || 120000;
config.codexComputerUseFallbackToCua = config.codexComputerUseFallbackToCua !== false;
if (!Array.isArray(config.computerUseArgs) || config.computerUseArgs.length === 0 || config.computerUseArgs.includes("scripts/computer-use-smoke.mjs")) {
config.computerUseArgs = ["scripts/cua-driver-computer-use-runtime.mjs"];
}
config.cuaDriverCommand = config.cuaDriverCommand || "cua-driver";
config.cuaDriverArgs = Array.isArray(config.cuaDriverArgs) ? config.cuaDriverArgs : [];
config.cuaDriverTimeoutMs = config.cuaDriverTimeoutMs || 45000;
for (const key of [
"masterAgentWorkdir",
"codexAppServerWorkdir",
"codexComputerUseWorkdir",
"browserControlWorkdir",
"computerUseWorkdir",
"codexDesktopRefreshWorkdir",
"omxWorkdir"
]) {
config[key] = installRoot;
}
fs.writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`);
}
NODE
if [[ "$ACTIVE_CONFIG_BASENAME" == "config.installed.json" || "$ACTIVE_CONFIG_BASENAME" == "config.cloud.json" || "$ACTIVE_CONFIG_BASENAME" == "config.example.json" ]]; then
CUSTOM_CONFIGS=("$INSTALL_ROOT"/local-agent/config*.json(N))
for custom_config in "${CUSTOM_CONFIGS[@]}"; do
custom_name="$(basename "$custom_config")"
case "$custom_name" in
config.installed.json|config.cloud.json|config.example.json)
continue
;;
esac
if "$NODE_BIN" - "$custom_config" <<'NODE'; then
const fs = require("fs");
const configPath = process.argv[2];
try {
const config = JSON.parse(fs.readFileSync(configPath, "utf8"));
process.exit(config.deviceId && config.token ? 0 : 1);
} catch {
process.exit(1);
}
NODE
ACTIVE_CONFIG_BASENAME="$custom_name"
break
fi
done
fi
if [[ ! -f "$INSTALL_ROOT/local-agent/$ACTIVE_CONFIG_BASENAME" ]]; then
ACTIVE_CONFIG_BASENAME="config.cloud.json"
fi
chmod +x "$INSTALL_ROOT/scripts/start-local-agent.sh"
chmod +x "$INSTALL_ROOT/scripts/install-local-launchagent.sh"
chmod +x "$INSTALL_ROOT/scripts/"*.mjs
rm -rf "$APP_TARGET"
cp -R "$SOURCE_DIR/boss-agent.app" "$APP_TARGET"
"$INSTALL_ROOT/scripts/install-local-launchagent.sh" "$INSTALL_ROOT/local-agent/$ACTIVE_CONFIG_BASENAME"
echo "boss-agent installed:"
echo " runtime: $INSTALL_ROOT"
echo " app: $APP_TARGET"
echo "Open $APP_TARGET to view status."
SH
perl -0pi -e "s/__BOSS_AGENT_PACKAGE_VERSION__/$VERSION/g" "$STAGE_DIR/install.command"
chmod +x "$STAGE_DIR/install.command"
cat > "$STAGE_DIR/README_INSTALL.txt" <<'TXT'
Boss Agent macOS runtime package
Install:
1. Unzip this package on the controlled Mac.
2. Double click install.command.
3. Open ~/Applications/boss-agent.app.
Notes:
- Existing ~/boss-agent/current/local-agent/config.cloud.json is preserved.
- The installer updates bossAgentVersion and local runtime paths after preserving binding credentials.
- boss-agent OTA downloads future macOS runtime packages from the Boss server, verifies sha256, then launches this installer flow again.
- Node.js 22 or newer is required because the local agent uses node:sqlite.
- This build includes the Cua Driver desktop control runtime. Install and authorize `cua-driver` on the controlled Mac before enabling full desktop GUI control.
TXT
(
cd "$DIST_DIR"
ditto -c -k --sequesterRsrc --keepParent "$PACKAGE_NAME" "$ARCHIVE_PATH"
)
node - <<'NODE' "$ARCHIVE_PATH" "$ROOT_DIR" "$VERSION"
const crypto = require("crypto");
const fs = require("fs");
const path = require("path");
const archivePath = process.argv[2];
const rootDir = process.argv[3];
const version = process.argv[4];
const downloadsDir = path.join(rootDir, "public", "downloads");
const latestPath = path.join(downloadsDir, "boss-agent-mac-latest.zip");
const latestMetaPath = path.join(downloadsDir, "boss-agent-mac-latest.json");
fs.mkdirSync(downloadsDir, { recursive: true });
fs.copyFileSync(archivePath, latestPath);
const content = fs.readFileSync(latestPath);
const stat = fs.statSync(latestPath);
const meta = {
packageType: "boss_agent_macos",
version,
fileName: "boss-agent-mac-latest.zip",
archiveFileName: path.basename(archivePath),
sizeBytes: stat.size,
sha256: crypto.createHash("sha256").update(content).digest("hex"),
updatedAt: stat.mtime.toISOString(),
downloadUrl: "/api/v1/boss-agent/ota/package"
};
fs.writeFileSync(latestMetaPath, `${JSON.stringify(meta, null, 2)}\n`);
NODE
echo "$ARCHIVE_PATH"

View File

@@ -3,6 +3,26 @@ set -euo pipefail
ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
CONFIG_PATH="${1:-$ROOT_DIR/local-agent/config.example.json}"
NODE_BIN="${BOSS_NODE_BIN:-}"
if [[ -z "$NODE_BIN" ]]; then
NODE_BIN="$(command -v node 2>/dev/null || true)"
fi
if [[ -z "$NODE_BIN" ]]; then
NODE_CANDIDATES=("$HOME"/.boss-runtime/node-*/bin/node(N) /opt/homebrew/bin/node /usr/local/bin/node /usr/bin/node)
for candidate in "${NODE_CANDIDATES[@]}"; do
if [[ -x "$candidate" ]]; then
NODE_BIN="$candidate"
break
fi
done
fi
if [[ -z "$NODE_BIN" || ! -x "$NODE_BIN" ]]; then
echo "Node.js 22 or newer is required. Set BOSS_NODE_BIN or install Node.js." >&2
exit 1
fi
cd "$ROOT_DIR"
exec node ./local-agent/server.mjs "$CONFIG_PATH"
exec "$NODE_BIN" ./local-agent/server.mjs "$CONFIG_PATH"

View File

@@ -1,51 +1,7 @@
import { cookies, headers } from "next/headers";
import "antd/dist/reset.css";
import { BossAdminApp } from "@/components/admin/boss-admin-app";
import type { BossAdminOverview } from "@/components/admin/boss-admin-data-provider";
import { requirePageSession } from "@/lib/boss-auth";
import { redirect } from "next/navigation";
export const dynamic = "force-dynamic";
async function loadInitialOverview() {
const headersList = await headers();
const cookieStore = await cookies();
const host = headersList.get("host");
const protocol = headersList.get("x-forwarded-proto") ?? "http";
if (!host) return null;
try {
const response = await fetch(`${protocol}://${host}/api/v1/admin/overview`, {
cache: "no-store",
headers: {
cookie: cookieStore.toString(),
},
});
if (!response.ok) return null;
return (await response.json()) as BossAdminOverview;
} catch {
return null;
}
}
export default async function AdminPage() {
const session = await requirePageSession();
if (session.role !== "highest_admin") {
return (
<main className="min-h-screen bg-[#F4F7F2] px-6 py-10">
<div className="mx-auto max-w-3xl rounded-2xl border border-[#DDE8DF] bg-white p-6 text-[#31443A] shadow-sm">
<div className="text-lg font-semibold text-[#102418]"></div>
<p className="mt-2 text-sm leading-6">
Boss 访
</p>
</div>
</main>
);
}
const initialOverview = await loadInitialOverview();
return <BossAdminApp initialOverview={initialOverview} />;
export default function AdminPage() {
redirect("/");
}

View File

@@ -1,5 +1,54 @@
import { NextRequest, NextResponse } from "next/server";
import { upsertDeviceHeartbeat } from "@/lib/boss-data";
import { isDeviceRevoked, readState, upsertDeviceHeartbeat, verifyDeviceToken } from "@/lib/boss-data";
function enrollmentAllowsHeartbeat(
state: Awaited<ReturnType<typeof readState>>,
deviceId: string,
pairingCode?: string,
token?: string,
) {
const enrollment = state.deviceEnrollments.find((item) => item.deviceId === deviceId);
if (!enrollment) return false;
const expiresAt = Date.parse(enrollment.expiresAt);
if (!Number.isFinite(expiresAt) || expiresAt <= Date.now()) return false;
return Boolean(
(token && token === enrollment.token) ||
(pairingCode && pairingCode === enrollment.pairingCode),
);
}
async function authorizeHeartbeat(body: {
deviceId?: string;
token?: string;
pairingCode?: string;
}) {
const deviceId = body.deviceId?.trim();
if (!deviceId) {
return { ok: false as const, status: 400, message: "DEVICE_ID_REQUIRED" };
}
const state = await readState();
const existingDevice = state.devices.find((item) => item.id === deviceId) ?? null;
if (!existingDevice) {
return enrollmentAllowsHeartbeat(state, deviceId, body.pairingCode, body.token)
? { ok: true as const }
: { ok: false as const, status: 401, message: "DEVICE_ENROLLMENT_REQUIRED" };
}
if (isDeviceRevoked(existingDevice)) {
return { ok: false as const, status: 403, message: "DEVICE_REVOKED" };
}
if (body.token && (await verifyDeviceToken(deviceId, body.token))) {
return { ok: true as const };
}
if (enrollmentAllowsHeartbeat(state, deviceId, body.pairingCode, body.token)) {
return { ok: true as const };
}
return { ok: false as const, status: 401, message: "DEVICE_TOKEN_REQUIRED" };
}
export async function POST(request: NextRequest) {
const body = (await request.json()) as {
@@ -23,6 +72,21 @@ export async function POST(request: NextRequest) {
lastSeenAt?: string;
lastActiveProjectId?: string;
};
browserAutomation?: {
connected?: boolean;
lastSeenAt?: string;
lastActiveProjectId?: string;
};
computerUse?: {
connected?: boolean;
lastSeenAt?: string;
lastActiveProjectId?: string;
};
codexAppServer?: {
connected?: boolean;
lastSeenAt?: string;
lastActiveProjectId?: string;
};
};
preferredExecutionMode?: "gui" | "cli";
projects?: string[];
@@ -56,42 +120,56 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ ok: false, message: "heartbeat 字段不完整" }, { status: 400 });
}
const device = await upsertDeviceHeartbeat({
deviceId: body.deviceId,
token: body.token,
pairingCode: body.pairingCode,
name: body.name,
avatar: body.avatar,
account: body.account,
status: body.status,
quota5h: body.quota5h ?? 0,
quota7d: body.quota7d ?? 0,
capabilities: body.capabilities,
preferredExecutionMode: body.preferredExecutionMode,
projects: body.projects,
projectCandidates: (body.projectCandidates ?? []).filter(
(candidate) =>
candidate.folderName?.trim() &&
candidate.threadId?.trim() &&
candidate.threadDisplayName?.trim(),
) as Array<{
folderName: string;
folderRef?: string;
threadId: string;
threadDisplayName: string;
codexFolderRef?: string;
codexThreadRef?: string;
lastActiveAt?: string;
suggestedImport?: boolean;
recentAssistantMessages?: Array<{
messageId?: string;
body?: string;
sentAt?: string;
phase?: string;
}>;
}>,
endpoint: body.endpoint,
});
const auth = await authorizeHeartbeat(body);
if (!auth.ok) {
return NextResponse.json({ ok: false, message: auth.message }, { status: auth.status });
}
let device;
try {
device = await upsertDeviceHeartbeat({
deviceId: body.deviceId,
token: body.token,
pairingCode: body.pairingCode,
name: body.name,
avatar: body.avatar,
account: body.account,
status: body.status,
quota5h: body.quota5h ?? 0,
quota7d: body.quota7d ?? 0,
capabilities: body.capabilities,
preferredExecutionMode: body.preferredExecutionMode,
projects: body.projects,
projectCandidates: (body.projectCandidates ?? []).filter(
(candidate) =>
candidate.folderName?.trim() &&
candidate.threadId?.trim() &&
candidate.threadDisplayName?.trim(),
) as Array<{
folderName: string;
folderRef?: string;
threadId: string;
threadDisplayName: string;
codexFolderRef?: string;
codexThreadRef?: string;
lastActiveAt?: string;
suggestedImport?: boolean;
recentAssistantMessages?: Array<{
messageId?: string;
body?: string;
sentAt?: string;
phase?: string;
}>;
}>,
endpoint: body.endpoint,
});
} catch (error) {
const message = error instanceof Error ? error.message : "UNKNOWN_ERROR";
return NextResponse.json(
{ ok: false, message },
{ status: message === "DEVICE_REVOKED" ? 403 : 400 },
);
}
return NextResponse.json({ ok: true, ...device });
}

View File

@@ -12,6 +12,7 @@ import {
readState,
reclaimAuthAccountByAdmin,
resetAuthAccountPasswordByAdmin,
revokeDeviceByAdmin,
revokeAccessGrant,
saveAccountDeviceGrant,
saveAccountProjectGrant,
@@ -64,6 +65,9 @@ function publicAdminDevice(device: Device) {
lastSeenAt: device.lastSeenAt,
preferredExecutionMode: device.preferredExecutionMode,
capabilities: device.capabilities,
revokedAt: device.revokedAt,
revokedBy: device.revokedBy,
revokeReason: device.revokeReason,
};
}
@@ -383,6 +387,25 @@ export async function POST(request: NextRequest) {
}
}
if (action === "revoke_device") {
const deviceId = stringValue(body.deviceId);
if (!deviceId) {
return jsonNoStore({ ok: false, message: "DEVICE_ID_REQUIRED" }, { status: 400 });
}
try {
const device = await revokeDeviceByAdmin({
deviceId,
reason: stringValue(body.reason) || undefined,
actorAccount: auth.session.account,
auditMeta,
});
return jsonNoStore({ ok: true, device: publicAdminDevice(device) });
} catch (error) {
const message = error instanceof Error ? error.message : "UNKNOWN_ERROR";
return jsonNoStore({ ok: false, message }, { status: message === "DEVICE_NOT_FOUND" ? 404 : 400 });
}
}
if (action === "bulk_import_accounts") {
const companyId = stringValue(body.companyId);
const accounts = accountImportValues(body.accounts);

View File

@@ -4,27 +4,33 @@ import { requireRequestSession } from "@/lib/boss-auth";
import { BOSS_PERMISSION_TEMPLATES } from "@/lib/boss-access-templates";
import { buildAdminOverview } from "@/lib/boss-admin-overview";
import { readState, type BossState } from "@/lib/boss-data";
import { getBossStateBackupStatus, type BossStateBackupStatus } from "@/lib/boss-state-backups";
const MENU_TREE = [
{ key: "workbench", label: "工作台" },
{ key: "tenant", label: "租户管理" },
{ key: "user", label: "账号管理" },
{ key: "role", label: "角色权限" },
{
key: "resource",
label: "资源授权",
children: [
{ key: "resource.devices", label: "设备资源" },
{ key: "resource.projects", label: "项目线程" },
{ key: "resource.skills", label: "Skill 资源" },
],
},
{ key: "skills", label: "Skill 中心" },
{ key: "risk", label: "风险告警" },
{ key: "audit", label: "审计日志" },
{ key: "system", label: "系统设置" },
const PLATFORM_MENU_TREE = [
{ key: "platform-overview", label: "平台总览" },
{ key: "platform-provisioning", label: "企业开通" },
{ key: "platform-customer-plans", label: "客户与套餐" },
{ key: "platform-devices", label: "全局设备" },
{ key: "platform-risk", label: "全局风险" },
{ key: "platform-customer-success", label: "客户成功" },
{ key: "platform-audit", label: "系统审计" },
{ key: "platform-billing", label: "计费与授权" },
{ key: "platform-settings", label: "平台设置" },
] as const;
const ENTERPRISE_MENU_TREE = [
{ key: "enterprise-overview", label: "企业总览" },
{ key: "enterprise-members", label: "组织与成员" },
{ key: "enterprise-devices-agents", label: "设备与项目" },
{ key: "enterprise-agent-flows", label: "Agent 与流程" },
{ key: "enterprise-skill", label: "Skill 中心" },
{ key: "enterprise-risk-backup", label: "风险与审计" },
{ key: "enterprise-backup", label: "备份与回退" },
{ key: "enterprise-settings", label: "企业设置" },
] as const;
type BackofficeSurface = "platform" | "enterprise";
function companyNameMap(state: BossState) {
return new Map(state.adminCompanies.map((company) => [company.companyId, company.name]));
}
@@ -34,6 +40,65 @@ function companyNameFor(state: BossState, companyId?: string) {
return companyNameMap(state).get(companyId) ?? companyId;
}
function accountCompanyId(state: BossState, account?: string) {
if (!account) return undefined;
return state.authAccounts.find((item) => item.account === account)?.companyId ?? "default";
}
function resolvedDeviceCompanyId(state: BossState, device: { account?: string; companyId?: string }) {
return device.companyId ?? accountCompanyId(state, device.account) ?? "default";
}
function filteredStateForCompany(state: BossState, companyId: string): BossState {
const companyAccounts = new Set(
state.authAccounts.filter((account) => (account.companyId ?? "default") === companyId).map((account) => account.account),
);
const devices = state.devices.filter((device) => resolvedDeviceCompanyId(state, device) === companyId);
const deviceIds = new Set(devices.map((device) => device.id));
const projects = state.projects.filter(
(project) =>
project.deviceIds.some((deviceId) => deviceIds.has(deviceId)) ||
project.groupMembers.some((member) => deviceIds.has(member.deviceId)),
);
const projectIds = new Set(projects.map((project) => project.id));
return {
...state,
adminCompanies: state.adminCompanies.filter((company) => company.companyId === companyId),
authAccounts: state.authAccounts.filter((account) => companyAccounts.has(account.account)),
authSessions: state.authSessions.filter((session) => companyAccounts.has(session.account)),
devices,
projects,
deviceSkills: state.deviceSkills.filter((skill) => deviceIds.has(skill.deviceId)),
accountDeviceGrants: state.accountDeviceGrants.filter((grant) => companyAccounts.has(grant.account) && deviceIds.has(grant.deviceId)),
accountProjectGrants: state.accountProjectGrants.filter((grant) => companyAccounts.has(grant.account) && projectIds.has(grant.projectId)),
accountSkillGrants: state.accountSkillGrants.filter((grant) => companyAccounts.has(grant.account)),
opsFaults: state.opsFaults.filter(
(fault) => (fault.nodeId && deviceIds.has(fault.nodeId)) || (fault.projectId && projectIds.has(fault.projectId)),
),
threadContextAlerts: state.threadContextAlerts.filter((alert) => projectIds.has(alert.projectId)),
masterAgentTasks: state.masterAgentTasks.filter(
(task) => (task.deviceId && deviceIds.has(task.deviceId)) || (task.projectId && projectIds.has(task.projectId)),
),
permissionAuditLogs: state.permissionAuditLogs.filter(
(log) =>
companyAccounts.has(log.actorAccount) ||
Boolean(log.targetAccount && companyAccounts.has(log.targetAccount)) ||
Boolean(log.deviceId && deviceIds.has(log.deviceId)) ||
Boolean(log.projectId && projectIds.has(log.projectId)),
),
adminRiskTimeline: state.adminRiskTimeline.filter((event) => event.companyId === companyId),
adminNotifications: state.adminNotifications.filter((notification) => {
const companyScoped = notification as { companyId?: string; deviceId?: string; projectId?: string };
return (
companyScoped.companyId === companyId ||
Boolean(companyScoped.deviceId && deviceIds.has(companyScoped.deviceId)) ||
Boolean(companyScoped.projectId && projectIds.has(companyScoped.projectId))
);
}),
};
}
function safeUsers(state: BossState) {
return state.authAccounts.map((account) => ({
id: account.id,
@@ -153,13 +218,158 @@ function rolesContract() {
};
}
function buildBackofficePayload(state: BossState) {
function safePercent(value: number, total: number) {
if (total <= 0) return 0;
return Math.max(0, Math.min(100, Math.round((value / total) * 100)));
}
function healthScore(company: { accountCount: number; deviceCount: number; onlineDeviceCount: number; openRiskCount: number }) {
const onlineScore = company.deviceCount > 0 ? safePercent(company.onlineDeviceCount, company.deviceCount) : 80;
const riskPenalty = Math.min(45, company.openRiskCount * 12);
const activityBonus = Math.min(10, company.accountCount);
return Math.max(0, Math.min(100, onlineScore - riskPenalty + activityBonus));
}
function riskAggregateValue(risks: Array<{ kind: string; title: string }>, matcher: (risk: { kind: string; title: string }) => boolean) {
return risks.filter(matcher).length;
}
function buildBackofficeInsights(state: BossState, options: { surface: BackofficeSurface; backupStatus: BossStateBackupStatus }) {
const overview = buildAdminOverview(state);
const devices = state.devices;
const onlineDevices = devices.filter((device) => device.status === "online").length;
const guiReady = devices.filter((device) => Boolean(device.capabilities?.gui.connected)).length;
const cliReady = devices.filter((device) => Boolean(device.capabilities?.cli.connected)).length;
const computerUseReady = devices.filter((device) => Boolean(device.capabilities?.computerUse.connected)).length;
const browserReady = devices.filter((device) => Boolean(device.capabilities?.browserAutomation.connected)).length;
const codexHealthy = devices.length === 0 || (guiReady + cliReady) >= devices.length;
const computerUseHealthy = devices.length === 0 || computerUseReady >= Math.ceil(devices.length / 2);
return {
onboardingSteps: ["企业信息", "老板账号", "套餐授权", "设备与交付"],
serviceStatuses: [
{ label: "Boss API", value: "正常", tone: "green" },
{ label: "OTA", value: "正常", tone: "green" },
{ label: "Codex Provider", value: codexHealthy ? "正常" : "降级", tone: codexHealthy ? "green" : "orange" },
{ label: "Computer Use", value: computerUseHealthy ? "正常" : "降级", tone: computerUseHealthy ? "green" : "orange" },
{ label: "Skill Hub", value: state.deviceSkills.length + state.skillCatalog.length > 0 ? "正常" : "待配置", tone: "green" },
],
openingPreview: [
{ label: "默认套餐", value: "企业专业版" },
{ label: "推荐设备", value: `${Math.max(1, devices.length || 1)} 台起` },
{ label: "Codex Provider", value: "App Server 优先CLI 兜底" },
{ label: "Computer Use", value: "macOS 桌面控制" },
],
deliveryChecklist: [
{ label: "API 可用", done: true },
{ label: "OTA 可用", done: true },
{ label: "boss-agent 安装包已生成", done: true },
{ label: "初始密码策略已设置", done: true },
{ label: "客户成功负责人已分配", done: overview.companies.some((company) => Boolean(company.successOwnerAccount)) },
],
recentCompanies: overview.companies.slice(0, 5).map((company) => ({
companyId: company.companyId,
label: company.name,
note: `${company.planTier ?? "enterprise"} · ${company.deviceCount} 台设备 · ${company.openRiskCount} 个风险`,
})),
customerHealthRows: overview.companies.map((company) => ({
companyId: company.companyId,
name: company.name,
healthScore: healthScore(company),
planTier: company.planTier ?? "enterprise",
onlineDevices: `${company.onlineDeviceCount}/${company.deviceCount}`,
openRiskCount: company.openRiskCount,
ownerAccount: company.successOwnerAccount || company.ownerAccount || "未指派",
})),
riskAggregates: [
{
label: "设备离线",
value: riskAggregateValue(overview.risks, (risk) => risk.kind === "device_offline"),
},
{
label: "主 Agent 执行失败",
value: riskAggregateValue(overview.risks, (risk) => risk.kind === "master_agent_task_failed"),
},
{
label: "Computer Use 权限缺失",
value: riskAggregateValue(overview.risks, (risk) => /Computer Use|权限/.test(risk.title)),
},
{
label: "Skill 升级失败",
value: riskAggregateValue(overview.risks, (risk) => /Skill/.test(risk.title)),
},
{
label: "备份异常",
value: riskAggregateValue(overview.risks, (risk) => /备份|backup/i.test(risk.title)),
},
],
customerFollowups: overview.riskTimeline.slice(0, 5).map((event) => ({
eventId: event.eventId,
label: event.note || event.action,
meta: `${event.actorAccount} · ${event.createdAt}`,
})),
enterpriseGoals: state.projects.slice(0, 3).map((project) => ({
projectId: project.id,
label: project.name,
progress: Math.max(30, Math.min(96, 82 - (project.unreadCount ?? 0) * 3 - (project.riskLevel === "high" ? 18 : 0))),
})),
organizationUnits: ["销售部", "客服部", "研发部", "财务部", "行政部"],
departmentProgress: [
{ label: "销售部", note: "线索跟进正常", tone: "green" },
{ label: "客服部", note: `${overview.summary.openNotifications} 个通知待处理`, tone: overview.summary.openNotifications > 0 ? "orange" : "green" },
{ label: "研发部", note: `${state.projects.length} 个项目运行中`, tone: "green" },
{ label: "财务部", note: "账单与授权正常", tone: "green" },
],
masterAgentSummary: [
`当前企业有 ${state.projects.length} 个项目、${onlineDevices}/${devices.length} 台电脑在线。`,
overview.summary.openRisks > 0 ? `建议优先处理 ${overview.summary.openRisks} 个开放风险。` : "当前没有开放风险。",
],
permissionHighlights: ["device.view", "thread.chat", "master_agent.takeover", "computer.control", "skill.use"],
agentFlowSteps: ["主 Agent", "项目 Agent", "本地 Agent", "Codex / Computer Use / Skill"],
skillUsageAudit: state.permissionAuditLogs.slice(0, 5).map((log) => ({
auditId: log.auditId,
label: log.detail || log.action,
meta: `${log.actorAccount} · ${log.createdAt}`,
})),
recoveryActions: ["消息恢复", "项目目标恢复", "权限撤销", "Skill 回滚", "Codex checkpoint"],
backupStatus: {
lastBackupAt: options.backupStatus.lastBackupAt ?? "",
status:
options.backupStatus.status === "ready"
? "校验通过"
: options.backupStatus.status === "empty"
? "暂无快照"
: "备份异常",
restorePointCount: options.backupStatus.restorePointCount,
backupDir: options.backupStatus.backupDir,
detail: options.backupStatus.detail,
},
capabilitySummary: {
guiReady,
cliReady,
computerUseReady,
browserReady,
},
surface: options.surface,
};
}
function buildBackofficePayload(
state: BossState,
options: { surface: BackofficeSurface; currentCompanyId?: string; backupStatus: BossStateBackupStatus },
) {
const overview = buildAdminOverview(state);
const skills = skillResources(state);
const currentCompany = options.currentCompanyId
? state.adminCompanies.find((company) => company.companyId === options.currentCompanyId) ?? null
: null;
return {
ok: true,
menuTree: MENU_TREE,
surface: options.surface,
currentCompany,
menuTree: options.surface === "platform" ? PLATFORM_MENU_TREE : ENTERPRISE_MENU_TREE,
insights: buildBackofficeInsights(state, { surface: options.surface, backupStatus: options.backupStatus }),
workbench: {
summary: overview.summary,
companies: overview.companies.slice(0, 10),
@@ -210,10 +420,29 @@ export async function GET(request: NextRequest) {
if (!session) {
return jsonNoStore({ ok: false, message: "UNAUTHORIZED" }, { status: 401 });
}
if (session.role !== "highest_admin") {
return jsonNoStore({ ok: false, message: "FORBIDDEN" }, { status: 403 });
}
const state = await readState();
return jsonNoStore(buildBackofficePayload(state));
const backupStatus = await getBossStateBackupStatus();
const url = new URL(request.url);
const scope = url.searchParams.get("scope") === "enterprise" ? "enterprise" : "platform";
if (scope === "platform") {
if (session.role !== "highest_admin") {
return jsonNoStore({ ok: false, message: "FORBIDDEN" }, { status: 403 });
}
return jsonNoStore(buildBackofficePayload(state, { surface: "platform", backupStatus }));
}
if (session.role !== "admin" && session.role !== "highest_admin") {
return jsonNoStore({ ok: false, message: "FORBIDDEN" }, { status: 403 });
}
const requestedCompanyId = url.searchParams.get("companyId")?.trim();
const companyId = session.role === "highest_admin"
? requestedCompanyId || state.adminCompanies[0]?.companyId || "default"
: accountCompanyId(state, session.account);
if (!companyId) {
return jsonNoStore({ ok: false, message: "COMPANY_NOT_FOUND" }, { status: 404 });
}
const scopedState = filteredStateForCompany(state, companyId);
return jsonNoStore(buildBackofficePayload(scopedState, { surface: "enterprise", currentCompanyId: companyId, backupStatus }));
}

View File

@@ -0,0 +1,79 @@
import { NextRequest } from "next/server";
import { jsonNoStore } from "@/lib/api-response";
import { requireRequestSession } from "@/lib/boss-auth";
import { requireCsrfSafeMutation } from "@/lib/boss-csrf";
import {
createBossStateBackup,
getBossStateBackupStatus,
listBossStateBackups,
restoreBossStateBackup,
} from "@/lib/boss-state-backups";
function forbidden() {
return jsonNoStore({ ok: false, message: "FORBIDDEN" }, { status: 403 });
}
function stringValue(value: unknown) {
return typeof value === "string" ? value.trim() : "";
}
async function requireHighestAdmin(request: NextRequest) {
const session = await requireRequestSession(request);
if (!session) {
return { response: jsonNoStore({ ok: false, message: "UNAUTHORIZED" }, { status: 401 }) };
}
if (session.role !== "highest_admin") {
return { response: forbidden() };
}
return { session };
}
export async function GET(request: NextRequest) {
const auth = await requireHighestAdmin(request);
if (auth.response) return auth.response;
const status = await getBossStateBackupStatus();
const snapshots = await listBossStateBackups(50);
return jsonNoStore({ ok: true, status, snapshots });
}
export async function POST(request: NextRequest) {
const csrf = requireCsrfSafeMutation(request);
if (csrf) return csrf;
const auth = await requireHighestAdmin(request);
if (auth.response) return auth.response;
const body = (await request.json().catch(() => ({}))) as Record<string, unknown>;
const action = stringValue(body.action);
try {
if (action === "create_snapshot") {
const snapshot = await createBossStateBackup({
actorAccount: auth.session.account,
reason: stringValue(body.reason) || "manual",
});
const status = await getBossStateBackupStatus();
return jsonNoStore({ ok: true, action, snapshot, status });
}
if (action === "restore_snapshot") {
const snapshotId = stringValue(body.snapshotId);
if (!snapshotId) {
return jsonNoStore({ ok: false, message: "BACKUP_SNAPSHOT_ID_REQUIRED" }, { status: 400 });
}
const result = await restoreBossStateBackup({
snapshotId,
actorAccount: auth.session.account,
});
const status = await getBossStateBackupStatus();
return jsonNoStore({ ok: true, action, ...result, status });
}
} catch (error) {
const message = error instanceof Error ? error.message : "BACKUP_ACTION_FAILED";
const status = message === "BACKUP_SNAPSHOT_NOT_FOUND" || message === "ENOENT" ? 404 : 400;
return jsonNoStore({ ok: false, message }, { status });
}
return jsonNoStore({ ok: false, message: "BACKUP_ACTION_INVALID" }, { status: 400 });
}

View File

@@ -0,0 +1,38 @@
import { promises as fs } from "node:fs";
import { NextRequest } from "next/server";
import { jsonNoStore } from "@/lib/api-response";
import { getPublishedBossAgentOtaAsset } from "@/lib/boss-agent-ota";
import { getDevice, verifyDeviceToken } from "@/lib/boss-data";
async function authorizeDownload(request: NextRequest) {
const requestUrl = new URL(request.url);
const deviceId = String(requestUrl.searchParams.get("deviceId") ?? request.headers.get("x-boss-device-id") ?? "").trim();
if (!deviceId || !(await getDevice(deviceId))) return false;
const token = request.headers.get("x-boss-device-token") ?? "";
return token ? verifyDeviceToken(deviceId, token) : false;
}
export async function GET(request: NextRequest) {
if (!(await authorizeDownload(request))) {
return jsonNoStore({ ok: false, message: "UNAUTHORIZED" }, { status: 401 });
}
const asset = await getPublishedBossAgentOtaAsset();
if (!asset) {
return jsonNoStore({ ok: false, message: "BOSS_AGENT_OTA_PACKAGE_NOT_FOUND" }, { status: 404 });
}
const content = await fs.readFile(asset.absolutePath);
return new Response(content, {
status: 200,
headers: {
"Content-Type": "application/zip",
"Content-Length": String(asset.sizeBytes),
"Content-Disposition": `attachment; filename=\"${asset.fileName}\"`,
ETag: asset.sha256,
"X-Boss-Agent-Ota-Version": asset.version,
"X-Boss-Agent-Ota-Sha256": asset.sha256,
"X-Boss-Agent-Ota-Updated-At": asset.updatedAt,
},
});
}

View File

@@ -0,0 +1,46 @@
import { NextRequest } from "next/server";
import { jsonNoStore } from "@/lib/api-response";
import { getPublishedBossAgentOtaAsset } from "@/lib/boss-agent-ota";
import { getDevice, verifyDeviceToken } from "@/lib/boss-data";
function normalizeVersion(value: string | null) {
return String(value ?? "").trim();
}
async function authorizeAgentOtaRequest(request: NextRequest, deviceId: string) {
if (!deviceId) return false;
const device = await getDevice(deviceId);
if (!device) return false;
const token = request.headers.get("x-boss-device-token") ?? "";
return token ? verifyDeviceToken(deviceId, token) : false;
}
export async function GET(request: NextRequest) {
const requestUrl = new URL(request.url);
const deviceId = normalizeVersion(requestUrl.searchParams.get("deviceId"));
if (!(await authorizeAgentOtaRequest(request, deviceId))) {
return jsonNoStore({ ok: false, message: "UNAUTHORIZED" }, { status: 401 });
}
const currentVersion = normalizeVersion(requestUrl.searchParams.get("currentVersion")) || "unknown";
const latest = await getPublishedBossAgentOtaAsset();
const hasUpdate = Boolean(latest && latest.version !== currentVersion);
return jsonNoStore({
ok: true,
deviceId,
currentVersion,
hasUpdate,
latest: latest
? {
version: latest.version,
fileName: latest.fileName,
sizeBytes: latest.sizeBytes,
sha256: latest.sha256,
updatedAt: latest.updatedAt,
downloadUrl: latest.downloadUrl,
packageType: latest.packageType,
}
: null,
});
}

View File

@@ -30,6 +30,21 @@ export async function PATCH(
lastSeenAt?: string;
lastActiveProjectId?: string;
};
browserAutomation?: {
connected?: boolean;
lastSeenAt?: string;
lastActiveProjectId?: string;
};
computerUse?: {
connected?: boolean;
lastSeenAt?: string;
lastActiveProjectId?: string;
};
codexAppServer?: {
connected?: boolean;
lastSeenAt?: string;
lastActiveProjectId?: string;
};
};
preferredExecutionMode?: "gui" | "cli";
projectId?: string;

View File

@@ -0,0 +1,38 @@
import { NextRequest } from "next/server";
import { requireRequestSession } from "@/lib/boss-auth";
import { cancelMasterAgentTask, getMasterAgentTask, readState } from "@/lib/boss-data";
import { canAccessDevice } from "@/lib/boss-permissions";
import { jsonNoStore } from "@/lib/api-response";
export async function POST(
request: NextRequest,
context: { params: Promise<{ taskId: string }> },
) {
const session = await requireRequestSession(request);
if (!session) {
return jsonNoStore({ ok: false, message: "UNAUTHORIZED" }, { status: 401 });
}
const { taskId } = await context.params;
const task = await getMasterAgentTask(taskId);
if (!task) {
return jsonNoStore({ ok: false, message: "MASTER_AGENT_TASK_NOT_FOUND" }, { status: 404 });
}
const state = await readState();
const canCancel =
session.role === "highest_admin" ||
task.requestedByAccount === session.account ||
canAccessDevice(state, session, task.deviceId, "device.manage");
if (!canCancel) {
return jsonNoStore({ ok: false, message: "FORBIDDEN" }, { status: 403 });
}
const body = (await request.json().catch(() => ({}))) as { reason?: string };
const canceled = await cancelMasterAgentTask({
taskId,
actorAccount: session.account,
reason: typeof body.reason === "string" ? body.reason : undefined,
});
return jsonNoStore({ ok: true, task: canceled });
}

View File

@@ -1,6 +1,6 @@
import { NextRequest, NextResponse } from "next/server";
import { authorizeDeviceWriteRequest } from "@/lib/boss-device-auth";
import type { ExecutionProgressInput } from "@/lib/boss-data";
import type { ComputerUseProvider, ExecutionProgressInput } from "@/lib/boss-data";
import { completeMasterAgentTask } from "@/lib/boss-data";
import { normalizeRemoteExecutionResult } from "@/lib/execution/remote-runtime-adapter";
import { deliverTelegramReplyForCompletedTask } from "@/lib/telegram-gateway";
@@ -28,6 +28,7 @@ export async function POST(
targetThreadId?: string;
targetUrl?: string;
targetApp?: string;
computerUseProvider?: ComputerUseProvider;
rawThreadReply?: string;
executionProgress?: ExecutionProgressInput;
};
@@ -78,6 +79,7 @@ export async function POST(
targetThreadId: normalized.targetThreadId,
targetUrl: normalized.targetUrl,
targetApp: normalized.targetApp,
computerUseProvider: body.computerUseProvider,
rawThreadReply: normalized.rawThreadReply,
executionProgress: normalized.executionProgress,
});

View File

@@ -1,9 +1,24 @@
import { NextRequest, NextResponse } from "next/server";
import { authorizeDeviceWriteRequest } from "@/lib/boss-device-auth";
import { claimNextMasterAgentTask } from "@/lib/boss-data";
import { waitForMasterAgentTaskWakeup } from "@/lib/master-agent-task-wakeup";
const DEFAULT_CLAIM_WAIT_MS = 25_000;
const MAX_CLAIM_WAIT_MS = 30_000;
function normalizeClaimWaitMs(value: unknown) {
const parsed =
typeof value === "number"
? value
: typeof value === "string" && value.trim()
? Number(value)
: DEFAULT_CLAIM_WAIT_MS;
if (!Number.isFinite(parsed)) return DEFAULT_CLAIM_WAIT_MS;
return Math.max(0, Math.min(MAX_CLAIM_WAIT_MS, Math.floor(parsed)));
}
export async function POST(request: NextRequest) {
const body = (await request.json().catch(() => ({}))) as { deviceId?: string };
const body = (await request.json().catch(() => ({}))) as { deviceId?: string; waitMs?: number };
const deviceId = body.deviceId?.trim();
if (!deviceId) {
return NextResponse.json({ ok: false, message: "DEVICE_ID_REQUIRED" }, { status: 400 });
@@ -14,6 +29,13 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ ok: false, message: "UNAUTHORIZED" }, { status: 401 });
}
const task = await claimNextMasterAgentTask(deviceId);
let task = await claimNextMasterAgentTask(deviceId);
if (!task) {
const waitMs = normalizeClaimWaitMs(body.waitMs ?? request.nextUrl.searchParams.get("waitMs"));
if (waitMs > 0) {
await waitForMasterAgentTaskWakeup(deviceId, waitMs, request.signal);
task = await claimNextMasterAgentTask(deviceId);
}
}
return NextResponse.json({ ok: true, task });
}

View File

@@ -17,6 +17,7 @@ import { buildProjectMessagesRealtimePayloadForSession } from "@/lib/boss-projec
import { canAccessProject } from "@/lib/boss-permissions";
import {
buildMasterAgentProjectSummarySyncAck,
classifyMasterAgentControlIntent,
getThreadConversationExecutionConflict,
queueGroupDispatchPlan,
queueThreadConversationReplyTask,
@@ -57,6 +58,29 @@ function forbiddenResponse(message = "FORBIDDEN") {
return NextResponse.json({ ok: false, message }, { status: 403 });
}
function isComputerControlIntent(intent: ReturnType<typeof classifyMasterAgentControlIntent>) {
return intent.intentCategory === "browser_control" || intent.intentCategory === "desktop_control";
}
function hasProjectGuiControlRuntime(
state: Awaited<ReturnType<typeof readState>>,
project: NonNullable<Awaited<ReturnType<typeof readState>>["projects"][number]>,
intent: ReturnType<typeof classifyMasterAgentControlIntent>,
) {
if (!isComputerControlIntent(intent)) return false;
const deviceById = new Map(state.devices.map((device) => [device.id, device]));
return project.deviceIds.some((deviceId) => {
const device = deviceById.get(deviceId);
if (!device || device.status !== "online" || device.preferredExecutionMode !== "gui") {
return false;
}
if (intent.intentCategory === "browser_control") {
return device.capabilities?.browserAutomation?.connected === true;
}
return device.capabilities?.computerUse?.connected === true;
});
}
export async function GET(
request: NextRequest,
context: { params: Promise<{ projectId: string }> },
@@ -109,13 +133,23 @@ export async function POST(
project && projectId !== "master-agent" && requestKind === "text"
? parseMasterAgentMention(requestText)
: null;
const directControlIntent =
project && projectId !== "master-agent" && !project.isGroup && !masterAgentMention && requestKind === "text"
? classifyMasterAgentControlIntent(requestText)
: null;
const shouldRouteDirectControlViaMaster = Boolean(
project && directControlIntent && hasProjectGuiControlRuntime(state, project, directControlIntent),
);
if (!canAccessProject(state, session, projectId, "project.view")) {
return forbiddenResponse();
}
if (masterAgentMention || projectId === "master-agent") {
if (masterAgentMention || projectId === "master-agent" || shouldRouteDirectControlViaMaster) {
if (!canAccessProject(state, session, projectId, "master_agent.ask")) {
return forbiddenResponse("MASTER_AGENT_FORBIDDEN");
}
if (shouldRouteDirectControlViaMaster && !canAccessProject(state, session, projectId, "computer.control")) {
return forbiddenResponse("COMPUTER_CONTROL_FORBIDDEN");
}
} else if (!canAccessProject(state, session, projectId, "thread.chat")) {
return forbiddenResponse("THREAD_CHAT_FORBIDDEN");
}
@@ -168,7 +202,7 @@ export async function POST(
? await getProjectAgentControls(projectId, session.account)
: null;
const singleThreadTakeoverEnabled = singleThreadAgentControls?.effectiveTakeoverEnabled === true;
const singleThreadExecutionConflict = isSingleThreadTextMessage && !singleThreadTakeoverEnabled
const singleThreadExecutionConflict = isSingleThreadTextMessage && !singleThreadTakeoverEnabled && !shouldRouteDirectControlViaMaster
? await getThreadConversationExecutionConflict(projectId)
: null;
@@ -187,6 +221,7 @@ export async function POST(
if (masterAgentMention && project) {
const message = await appendProjectMessage({
projectId,
account: session.account,
senderLabel: session.displayName || "你",
body: body.body,
kind: requestKind,
@@ -198,6 +233,7 @@ export async function POST(
const nextProject = nextState.projects.find((item) => item.id === projectId) ?? project;
const replyMessage = await appendProjectMessage({
projectId,
account: session.account,
sender: "master",
senderLabel: "主 Agent",
body: buildMasterMentionTakeoverDisabledReply(nextProject),
@@ -231,6 +267,7 @@ export async function POST(
const nextProject = nextState.projects.find((item) => item.id === projectId) ?? project;
const replyMessage = await appendProjectMessage({
projectId,
account: session.account,
sender: "master",
senderLabel: "主 Agent",
body: buildMasterMentionTakeoverEnabledReply(nextProject),
@@ -280,6 +317,7 @@ export async function POST(
});
const replyMessage = await appendProjectMessage({
projectId,
account: session.account,
sender: "master",
senderLabel: "主 Agent",
body: buildMasterAgentProjectSummarySyncAck(project, {
@@ -385,11 +423,13 @@ export async function POST(
projectId,
messages: [
{
account: session.account,
senderLabel: session.displayName || "你",
body: body.body,
kind: requestKind,
},
{
account: session.account,
sender: "master",
senderLabel: localMasterReply.senderLabel,
body: localMasterReply.replyBody,
@@ -418,6 +458,7 @@ export async function POST(
const message = await appendProjectMessage({
projectId,
account: session.account,
senderLabel: session.displayName || "你",
body: body.body,
kind: requestKind,
@@ -496,6 +537,7 @@ export async function POST(
});
replyMessage = await appendProjectMessage({
projectId,
account: session.account,
sender: "master",
senderLabel: "主 Agent",
body: buildMasterAgentProjectSummarySyncAck(masterAgentProjectSummarySyncTarget, {
@@ -543,6 +585,7 @@ export async function POST(
if (!recommendation.ok) {
await appendProjectMessage({
projectId,
account: session.account,
sender: "master",
senderLabel: "主 Agent",
body: dispatchFailureNotice(recommendation.error),
@@ -557,6 +600,7 @@ export async function POST(
};
await appendProjectMessage({
projectId,
account: session.account,
sender: "master",
senderLabel: "主 Agent",
body: dispatchFailureNotice(dispatchRecommendation.error),
@@ -565,13 +609,39 @@ export async function POST(
}
} else if (project && projectId !== "master-agent" && !project.isGroup && message.body.trim().length > 0) {
const relayViaMasterAgent = singleThreadTakeoverEnabled;
if (relayViaMasterAgent) {
if (shouldRouteDirectControlViaMaster) {
masterReply = await replyToMasterAgentUserMessage({
requestMessageId: message.id,
requestText: message.body,
requestedBy: session.displayName || session.account,
requestedByAccount: session.account,
currentSessionExpiresAt: session.expiresAt,
projectId,
interactionMode: "direct",
mode: "enqueue",
});
if (masterReply?.taskId) {
task = masterReply.task ?? {
taskId: masterReply.taskId,
taskType: directControlIntent?.intentCategory === "desktop_control" ? "desktop_control" : "browser_control",
status: masterReply.masterReplyState ?? "queued",
};
masterReplyState = masterReply.masterReplyState ?? null;
}
replyMessage = masterReply?.replyMessage;
executionMode = (masterReply as { executionMode?: typeof executionMode }).executionMode;
riskLevel = (masterReply as { riskLevel?: typeof riskLevel }).riskLevel;
requiresConfirmation = (
masterReply as { requiresConfirmation?: typeof requiresConfirmation }
).requiresConfirmation;
} else if (relayViaMasterAgent) {
if (shouldDisableCurrentProjectTakeoverFromMasterMention(message.body)) {
await updateProjectAgentControls(projectId, { takeoverEnabled: false }, session.account);
const nextState = await readState();
const nextProject = nextState.projects.find((item) => item.id === projectId) ?? project;
replyMessage = await appendProjectMessage({
projectId,
account: session.account,
sender: "master",
senderLabel: "主 Agent",
body: buildMasterMentionTakeoverDisabledReply(nextProject),
@@ -605,6 +675,7 @@ export async function POST(
});
replyMessage = await appendProjectMessage({
projectId,
account: session.account,
sender: "master",
senderLabel: "主 Agent",
body: buildMasterAgentProjectSummarySyncAck(project, {
@@ -706,7 +777,7 @@ export async function POST(
status: "queued",
};
}
replyPresenter = relayViaMasterAgent ? "master" : "thread";
replyPresenter = shouldRouteDirectControlViaMaster || relayViaMasterAgent ? "master" : "thread";
} else {
dispatchRecommendation = {
ok: false,

View File

@@ -1,17 +1,7 @@
import { AppShell, AuthForm, PageNav, StatusBar } from "@/components/app-ui";
import { EnterpriseAdminLoginShell } from "@/components/enterprise-admin-login-shell";
import { redirectIfAuthenticated } from "@/lib/boss-auth";
export default async function LoginPage() {
await redirectIfAuthenticated();
return (
<AppShell bottomNav={false}>
<StatusBar />
<PageNav title="登录" rightLabel="帮助" rightHref="/auth/help" />
<AuthForm
mode="login"
title="登录 Codex 协同"
description="使用企业账号密码或验证码登录。"
/>
</AppShell>
);
return <EnterpriseAdminLoginShell />;
}

View File

@@ -9,7 +9,7 @@ export default async function Home() {
const host = headersList.get("host")?.split(":")[0];
const session = await getCurrentPageSession();
if (host === PLATFORM_ADMIN_HOST) {
redirect(session ? "/admin" : "/auth/login");
redirect(session ? "/admin-web/index.html" : "/auth/login");
}
redirect(session ? "/conversations" : "/auth/login");
}

View File

@@ -1,721 +0,0 @@
"use client";
import { useEffect, useState } from "react";
import type { ReactNode } from "react";
import { Refine } from "@refinedev/core";
import {
Alert,
Button,
Card,
ConfigProvider,
Empty,
Input,
Statistic,
Table,
Tabs,
Tag,
theme,
message,
} from "antd";
import zhCN from "antd/locale/zh_CN";
import type { ColumnsType } from "antd/es/table";
import { AdminAccessPanel } from "@/components/admin/admin-access-panel";
import { AdminSkillLifecyclePanel } from "@/components/admin/admin-skill-lifecycle-panel";
import {
type BossAdminOverview,
createBossAdminDataProvider,
} from "@/components/admin/boss-admin-data-provider";
type AdminRow = Record<string, unknown>;
type BossAdminAppProps = {
initialOverview?: BossAdminOverview | null;
};
type AdminSection = "dashboard" | "customers" | "permissions" | "governance";
type RiskAction = "ack" | "resolve" | "create_repair_ticket" | "assign_owner" | "set_sla";
const resources = [
{ name: "companies", list: "/admin#companies", meta: { label: "公司" } },
{ name: "accounts", list: "/admin#accounts", meta: { label: "账号" } },
{ name: "devices", list: "/admin#devices", meta: { label: "设备" } },
{ name: "risks", list: "/admin#risks", meta: { label: "风险" } },
{ name: "notifications", list: "/admin#notifications", meta: { label: "通知" } },
{ name: "auditLogs", list: "/admin#auditLogs", meta: { label: "审计日志" } },
];
const adminShell = "min-h-screen bg-[#F3F5F2] p-5 text-[#101814]";
const adminChrome =
"mx-auto grid min-h-[calc(100vh-40px)] max-w-[1680px] grid-cols-[248px_minmax(0,1fr)] overflow-hidden rounded-[30px] border border-[#E0E6E1] bg-white shadow-[0_32px_100px_rgba(22,37,28,0.10)]";
const adminSidebar = "border-r border-[#E3E8E4] bg-[#FBFCFB] px-4 py-5";
const adminHeader = "flex min-h-[86px] items-center border-b border-[#E3E8E4] bg-white px-7";
const adminCardClass = "boss-admin-card border-[#E3E8E4] shadow-[0_14px_42px_rgba(20,35,25,0.045)]";
const adminDense = "boss-admin-dense";
const navItems: Array<{
key: AdminSection;
title: string;
subtitle: string;
marker: string;
}> = [
{ key: "dashboard", title: "平台运营驾驶舱", subtitle: "全局健康与待处理事项", marker: "D" },
{ key: "customers", title: "客户与账号", subtitle: "公司、老板账号与子账号", marker: "C" },
{ key: "permissions", title: "授权工作台", subtitle: "设备、项目与 Skill 权限", marker: "P" },
{ key: "governance", title: "风险与治理", subtitle: "风险、SLA、Skill", marker: "R" },
];
function text(value: unknown, fallback = "-") {
if (value === null || value === undefined || value === "") return fallback;
return String(value);
}
function numberValue(value: unknown) {
return typeof value === "number" && Number.isFinite(value) ? value : 0;
}
function rowId(row: AdminRow, index?: number) {
return text(row.id ?? row.companyId ?? row.account ?? row.deviceId ?? row.riskId ?? row.auditId, String(index ?? 0));
}
function statusTag(value: unknown) {
const status = text(value, "unknown");
const color =
status === "online" || status === "active" || status === "healthy" || status === "completed"
? "green"
: status === "offline" || status === "disabled"
? "default"
: status === "failed" || status === "critical"
? "red"
: "orange";
return <Tag color={color}>{status}</Tag>;
}
function severityTag(value: unknown) {
const severity = text(value, "info");
const color = severity === "critical" || severity === "high" ? "red" : severity === "warning" || severity === "medium" ? "orange" : "blue";
return <Tag color={color}>{severity}</Tag>;
}
function riskTarget(row: AdminRow) {
return text(row.target ?? row.deviceId ?? row.projectId ?? row.account ?? row.companyId);
}
function sectionTitle(section: AdminSection) {
return navItems.find((item) => item.key === section)?.title ?? "平台运营驾驶舱";
}
function currentSubtitle(section: AdminSection) {
return navItems.find((item) => item.key === section)?.subtitle ?? "全局健康与待处理事项";
}
function customerHealthTone(company: AdminRow) {
const riskCount = numberValue(company.openRiskCount);
const deviceCount = numberValue(company.deviceCount);
const onlineCount = numberValue(company.onlineDeviceCount);
if (riskCount >= 3) return { label: "需介入", color: "red" };
if (deviceCount > 0 && onlineCount === 0) return { label: "离线", color: "orange" };
if (riskCount > 0) return { label: "观察", color: "gold" };
return { label: "健康", color: "green" };
}
const riskColumns: ColumnsType<AdminRow> = [
{ title: "风险", dataIndex: "title", render: (_, row) => text(row.title ?? row.name ?? row.kind) },
{ title: "级别", dataIndex: "severity", width: 104, render: severityTag },
{ title: "对象", dataIndex: "target", width: 170, render: (_, row) => riskTarget(row) },
{ title: "负责人", dataIndex: "ownerAccount", width: 150, render: (_, row) => text(row.ownerAccount, "未指派") },
{ title: "SLA", dataIndex: "slaDueAt", width: 180, render: (_, row) => text(row.slaDueAt, "未设置") },
{ title: "状态", dataIndex: "status", width: 104, render: statusTag },
];
const deviceColumns: ColumnsType<AdminRow> = [
{ title: "设备", dataIndex: "name", render: (_, row) => text(row.name ?? row.deviceName ?? row.deviceId ?? row.id) },
{ title: "状态", dataIndex: "status", width: 105, render: (_, row) => statusTag(row.status ?? row.onlineStatus) },
{ title: "GUI", dataIndex: "codexGuiOnline", width: 86, render: (_, row) => statusTag(row.codexGuiOnline ? "online" : "offline") },
{ title: "CLI", dataIndex: "codexCliOnline", width: 86, render: (_, row) => statusTag(row.codexCliOnline ? "online" : "offline") },
{ title: "风险", dataIndex: "openRiskCount", width: 86, render: numberValue },
{ title: "最近心跳", dataIndex: "lastSeenAt", width: 210, render: (_, row) => text(row.lastSeenAt ?? row.updatedAt) },
];
const companyColumns: ColumnsType<AdminRow> = [
{ title: "公司", dataIndex: "name", render: (_, row) => text(row.name ?? row.companyName ?? row.companyId) },
{ title: "健康", dataIndex: "health", width: 100, render: (_, row) => {
const tone = customerHealthTone(row);
return <Tag color={tone.color}>{tone.label}</Tag>;
} },
{ title: "账号", dataIndex: "accountCount", width: 86, render: numberValue },
{ title: "在线设备", dataIndex: "onlineDeviceCount", width: 112, render: (_, row) => `${numberValue(row.onlineDeviceCount)}/${numberValue(row.deviceCount)}` },
{ title: "开放风险", dataIndex: "openRiskCount", width: 104, render: numberValue },
{ title: "客户成功", dataIndex: "successOwnerAccount", width: 150, render: (_, row) => text(row.successOwnerAccount, "未指派") },
];
const accountColumns: ColumnsType<AdminRow> = [
{ title: "账号", dataIndex: "account", render: (_, row) => text(row.account ?? row.phone ?? row.id) },
{ title: "角色", dataIndex: "role", width: 130, render: statusTag },
{ title: "公司", dataIndex: "companyName", render: (_, row) => text(row.companyName ?? row.companyId) },
{ title: "状态", dataIndex: "status", width: 118, render: statusTag },
{ title: "最近登录", dataIndex: "lastLoginAt", width: 210, render: (_, row) => text(row.lastLoginAt, "暂无") },
];
const notificationColumns: ColumnsType<AdminRow> = [
{ title: "通知", dataIndex: "title", render: (_, row) => text(row.title ?? row.kind) },
{ title: "级别", dataIndex: "severity", width: 110, render: severityTag },
{ title: "公司", dataIndex: "companyId", width: 150, render: (_, row) => text(row.companyId) },
{ title: "风险", dataIndex: "riskId", width: 220, render: (_, row) => text(row.riskId) },
{ title: "时间", dataIndex: "createdAt", width: 190, render: (_, row) => text(row.createdAt) },
];
async function loadOverview() {
const response = await fetch("/api/v1/admin/overview", {
credentials: "include",
cache: "no-store",
});
if (!response.ok) {
throw new Error(`后台总览读取失败:${response.status}`);
}
return (await response.json()) as BossAdminOverview;
}
function MetricCard({
title,
value,
tone = "default",
hint,
}: {
title: string;
value: number;
tone?: "default" | "green" | "red" | "orange";
hint?: string;
}) {
const valueColor = tone === "green" ? "#07A85A" : tone === "red" ? "#E23D3D" : tone === "orange" ? "#D97706" : "#101814";
return (
<Card className={adminCardClass}>
<Statistic title={title} value={value} valueStyle={{ color: valueColor, fontWeight: 800 }} />
{hint ? <div className="mt-2 text-xs text-[#758078]">{hint}</div> : null}
</Card>
);
}
function PanelTitle({ title, subtitle, extra }: { title: string; subtitle?: string; extra?: ReactNode }) {
return (
<div className="mb-4 flex items-end justify-between gap-4">
<div>
<div className="text-[18px] font-black tracking-[-0.02em] text-[#101814]">{title}</div>
{subtitle ? <div className="mt-1 text-sm text-[#68746D]">{subtitle}</div> : null}
</div>
{extra ? <div className="shrink-0">{extra}</div> : null}
</div>
);
}
function EmptyBlock({ textValue }: { textValue: string }) {
return <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description={textValue} />;
}
type RiskActionsProps = {
selectedRisk?: AdminRow;
actionBusy: string;
onSubmit: (risk: AdminRow, action: RiskAction, extraBody?: Record<string, unknown>) => void;
};
function RiskActionPanel({ selectedRisk, actionBusy, onSubmit }: RiskActionsProps) {
const [ownerAccount, setOwnerAccount] = useState("");
const [slaDueAt, setSlaDueAt] = useState("");
const riskId = selectedRisk ? text(selectedRisk.riskId ?? selectedRisk.id, "") : "";
const kind = selectedRisk ? text(selectedRisk.kind, "") : "";
const canAckResolve = kind === "ops_fault" || kind === "thread_context_alert";
const canCreateTicket = kind === "ops_fault";
if (!selectedRisk) {
return (
<Card className={adminCardClass}>
<PanelTitle title="处理面板" subtitle="选择左侧风险后,在这里指派负责人、设置 SLA 或创建修复工单。" />
<EmptyBlock textValue="暂无选中风险" />
</Card>
);
}
return (
<Card className={adminCardClass}>
<PanelTitle title="处理面板" subtitle="所有动作都会写入风险时间线和权限审计。" />
<div className="space-y-4">
<div className="rounded-2xl border border-[#E3E8E4] bg-[#F8FAF8] p-4">
<div className="flex items-center gap-2">
{severityTag(selectedRisk.severity)}
<span className="font-bold text-[#101814]">{text(selectedRisk.title ?? selectedRisk.kind)}</span>
</div>
<div className="mt-2 text-sm leading-6 text-[#5F6B64]">{text(selectedRisk.detail ?? selectedRisk.summary, "暂无详情")}</div>
<div className="mt-3 text-xs text-[#7B857E]">{riskTarget(selectedRisk)}</div>
</div>
<div>
<div className="mb-1 text-xs font-bold text-[#68746D]"></div>
<div className="flex gap-2">
<Input value={ownerAccount} onChange={(event) => setOwnerAccount(event.target.value)} placeholder="例如 ops@company.com" />
<Button
disabled={!canAckResolve || !ownerAccount.trim()}
loading={actionBusy === `${riskId}:assign_owner`}
onClick={() => onSubmit(selectedRisk, "assign_owner", { ownerAccount: ownerAccount.trim() })}
>
</Button>
</div>
</div>
<div>
<div className="mb-1 text-xs font-bold text-[#68746D]">SLA </div>
<div className="flex gap-2">
<Input value={slaDueAt} onChange={(event) => setSlaDueAt(event.target.value)} placeholder="2026-04-30T18:00:00+08:00" />
<Button
disabled={!canAckResolve || !slaDueAt.trim()}
loading={actionBusy === `${riskId}:set_sla`}
onClick={() => onSubmit(selectedRisk, "set_sla", { slaDueAt: slaDueAt.trim() })}
>
SLA
</Button>
</div>
</div>
<div className="grid grid-cols-3 gap-2">
<Button disabled={!canAckResolve} loading={actionBusy === `${riskId}:ack`} onClick={() => onSubmit(selectedRisk, "ack")}>
</Button>
<Button disabled={!canAckResolve} loading={actionBusy === `${riskId}:resolve`} onClick={() => onSubmit(selectedRisk, "resolve")}>
</Button>
<Button
type="primary"
disabled={!canCreateTicket}
loading={actionBusy === `${riskId}:create_repair_ticket`}
onClick={() => onSubmit(selectedRisk, "create_repair_ticket")}
>
</Button>
</div>
{!canAckResolve ? (
<Alert
type="warning"
showIcon
message="该风险类型当前只读"
description="当前动作接口暂不支持该风险类型,后台保留展示但不会假装处置成功。"
/>
) : null}
</div>
</Card>
);
}
function DashboardView({
stats,
companies,
devices,
risks,
notifications,
timeline,
onOpenRisk,
}: {
stats: AdminRow;
companies: AdminRow[];
devices: AdminRow[];
risks: AdminRow[];
notifications: AdminRow[];
timeline: AdminRow[];
onOpenRisk: () => void;
}) {
const topRisks = risks.slice(0, 5);
const topCompanies = companies.slice().sort((left, right) => numberValue(right.openRiskCount) - numberValue(left.openRiskCount)).slice(0, 6);
return (
<div className={`${adminDense} space-y-5`}>
<section>
<PanelTitle title="今日待处理" subtitle="先看需要平台侧介入的客户、设备和主 Agent 风险。" />
<div className="grid gap-4 lg:grid-cols-5">
<MetricCard title="客户公司" value={numberValue(stats.companies ?? companies.length)} hint="当前纳入平台管理的公司" />
<MetricCard title="账号" value={numberValue(stats.accounts)} hint="含最高管理员与客户账号" />
<MetricCard title="在线设备" value={numberValue(stats.onlineDevices)} tone="green" hint={`总设备 ${numberValue(stats.devices ?? devices.length)}`} />
<MetricCard title="开放风险" value={numberValue(stats.openRisks ?? risks.length)} tone="red" hint={`关键 ${numberValue(stats.criticalRisks)}`} />
<MetricCard title="风险通知" value={numberValue(stats.openNotifications ?? notifications.length)} tone="orange" hint="SLA 与主动通知" />
</div>
</section>
<div className="grid gap-5 lg:grid-cols-[0.95fr_1.05fr]">
<Card className={adminCardClass}>
<PanelTitle title="客户健康排行" subtitle="优先跟进开放风险最多或设备离线的客户。" />
<div className="space-y-3">
{topCompanies.length > 0 ? topCompanies.map((company) => {
const tone = customerHealthTone(company);
return (
<div key={rowId(company)} className="flex items-center justify-between rounded-2xl border border-[#E3E8E4] bg-[#FBFCFB] px-4 py-3">
<div>
<div className="font-bold text-[#101814]">{text(company.name ?? company.companyName ?? company.companyId)}</div>
<div className="mt-1 text-xs text-[#68746D]">
{numberValue(company.accountCount)} · {numberValue(company.onlineDeviceCount)}/{numberValue(company.deviceCount)} · {text(company.successOwnerAccount, "未指派")}
</div>
</div>
<div className="flex items-center gap-2">
<Tag color={tone.color}>{tone.label}</Tag>
<span className="text-sm font-bold text-[#E23D3D]">{numberValue(company.openRiskCount)} </span>
</div>
</div>
);
}) : <EmptyBlock textValue="暂无客户数据" />}
</div>
</Card>
<Card className={adminCardClass}>
<PanelTitle
title="关键风险队列"
subtitle="只展示最该处理的前几条,完整队列在风险与治理里。"
extra={<Button onClick={onOpenRisk}></Button>}
/>
{topRisks.length > 0 ? (
<Table rowKey={rowId} columns={riskColumns} dataSource={topRisks} pagination={false} size="small" />
) : (
<EmptyBlock textValue="暂无开放高优风险" />
)}
</Card>
</div>
<div className="grid gap-5 lg:grid-cols-[1.05fr_0.95fr]">
<Card className={adminCardClass}>
<PanelTitle title="节点健康" subtitle="集中查看客户电脑、Codex GUI/CLI 与最近心跳。" />
<Table rowKey={rowId} columns={deviceColumns} dataSource={devices.slice(0, 8)} pagination={false} size="small" />
</Card>
<Card className={adminCardClass}>
<PanelTitle title="最近事件" subtitle="风险通知和处置时间线,避免平台侧漏跟进。" />
<div className="space-y-3">
{[...notifications, ...timeline].slice(0, 7).map((event, index) => (
<div key={rowId(event, index)} className="rounded-2xl border border-[#E3E8E4] bg-[#FBFCFB] px-4 py-3">
<div className="flex items-center justify-between gap-2">
<div className="font-bold text-[#101814]">{text(event.title ?? event.action ?? event.kind, "事件")}</div>
{severityTag(event.severity ?? "info")}
</div>
<div className="mt-1 text-xs text-[#68746D]">{text(event.createdAt ?? event.updatedAt ?? event.time, "暂无时间")}</div>
</div>
))}
{notifications.length === 0 && timeline.length === 0 ? <EmptyBlock textValue="暂无风险事件" /> : null}
</div>
</Card>
</div>
</div>
);
}
function CustomersView({ companies, accounts, devices }: { companies: AdminRow[]; accounts: AdminRow[]; devices: AdminRow[] }) {
return (
<div className={`${adminDense} space-y-5`}>
<div className="grid gap-5 lg:grid-cols-[1.1fr_0.9fr]">
<Card className={adminCardClass}>
<PanelTitle title="客户与账号" subtitle="先看客户公司,再进入账号、设备和权限配置。" />
<Table rowKey={rowId} columns={companyColumns} dataSource={companies} pagination={{ pageSize: 8 }} size="small" />
</Card>
<Card className={adminCardClass}>
<PanelTitle title="客户开通任务流" subtitle="把公司、老板账号、设备和 Skill 权限串成一个交付动作。" />
<div className="space-y-3">
{["创建客户公司", "开通老板账号", "绑定客户电脑", "分配项目与 Skill 权限"].map((item, index) => (
<div key={item} className="flex items-center gap-3 rounded-2xl border border-[#E3E8E4] bg-[#FBFCFB] px-4 py-3">
<span className="grid size-8 place-items-center rounded-full bg-[#101814] text-xs font-black text-white">{index + 1}</span>
<div>
<div className="font-bold text-[#101814]">{item}</div>
<div className="text-xs text-[#68746D]"></div>
</div>
</div>
))}
</div>
</Card>
</div>
<div className="grid gap-5 lg:grid-cols-[1fr_1fr]">
<Card className={adminCardClass}>
<PanelTitle title="账号列表" subtitle="查看角色、状态、公司和最近登录。" />
<Table rowKey={rowId} columns={accountColumns} dataSource={accounts} pagination={{ pageSize: 8 }} size="small" />
</Card>
<Card className={adminCardClass}>
<PanelTitle title="客户设备" subtitle="确认设备归属和在线状态。" />
<Table rowKey={rowId} columns={deviceColumns} dataSource={devices} pagination={{ pageSize: 8 }} size="small" />
</Card>
</div>
</div>
);
}
function PermissionsView() {
return (
<div className={`${adminDense} space-y-5`}>
<Card className={adminCardClass}>
<PanelTitle
title="授权工作台"
subtitle="按账号分配设备、项目与 Skill 权限;高危动作保留二次确认和审计。"
/>
<AdminAccessPanel className={adminDense} />
</Card>
</div>
);
}
function GovernanceView({
risks,
notifications,
selectedRisk,
setSelectedRisk,
actionBusy,
submitRiskAction,
}: {
risks: AdminRow[];
notifications: AdminRow[];
selectedRisk?: AdminRow;
setSelectedRisk: (risk?: AdminRow) => void;
actionBusy: string;
submitRiskAction: (risk: AdminRow, action: RiskAction, extraBody?: Record<string, unknown>) => void;
}) {
return (
<Tabs
className="boss-admin-governance-tabs"
items={[
{
key: "risk",
label: "风险战情室",
children: (
<div className="grid gap-5 lg:grid-cols-[minmax(0,1fr)_420px]">
<Card className={adminCardClass}>
<PanelTitle title="风险与治理" subtitle="按严重程度、客户影响、负责人和 SLA 推进处置。" />
<Table
rowKey={rowId}
columns={riskColumns}
dataSource={risks}
pagination={{ pageSize: 8 }}
size="small"
onRow={(risk) => ({
onClick: () => setSelectedRisk(risk),
className: rowId(risk) === rowId(selectedRisk ?? {}) ? "cursor-pointer bg-[#F1FAF4]" : "cursor-pointer",
})}
/>
</Card>
<div className="space-y-5">
<RiskActionPanel
key={selectedRisk ? rowId(selectedRisk) : "empty-risk"}
selectedRisk={selectedRisk}
actionBusy={actionBusy}
onSubmit={submitRiskAction}
/>
<Card className={adminCardClass}>
<PanelTitle title="风险通知" subtitle="SLA 扫描和主动通知生成的待跟进事项。" />
<Table rowKey={rowId} columns={notificationColumns} dataSource={notifications} pagination={{ pageSize: 5 }} size="small" />
</Card>
</div>
</div>
),
},
{
key: "skills",
label: "Skill 生命周期",
children: (
<Card className={adminCardClass}>
<PanelTitle title="Skill 生命周期" subtitle="安装、更新、卸载、回滚和版本锁定统一排队,设备端按安全策略执行。" />
<AdminSkillLifecyclePanel className={adminDense} />
</Card>
),
},
]}
/>
);
}
export function BossAdminApp({ initialOverview = null }: BossAdminAppProps) {
const [overview, setOverview] = useState<BossAdminOverview | null>(initialOverview);
const [error, setError] = useState("");
const [actionBusy, setActionBusy] = useState("");
const [activeSection, setActiveSection] = useState<AdminSection>("dashboard");
const [selectedRiskId, setSelectedRiskId] = useState("");
const [messageApi, messageContext] = message.useMessage();
useEffect(() => {
if (overview) return;
let active = true;
loadOverview()
.then((nextOverview) => {
if (active) setOverview(nextOverview);
})
.catch((nextError: Error) => {
if (active) setError(nextError.message);
});
return () => {
active = false;
};
}, [overview]);
const stats = overview?.summary ?? overview?.stats ?? {};
const companies = overview?.companies ?? [];
const accounts = overview?.accounts ?? [];
const devices = overview?.devices ?? [];
const risks = overview?.risks ?? [];
const notifications = overview?.notifications ?? [];
const timeline = Array.isArray((overview as { riskTimeline?: AdminRow[] } | null)?.riskTimeline)
? ((overview as { riskTimeline?: AdminRow[] }).riskTimeline ?? [])
: [];
const selectedRisk = risks.find((risk) => rowId(risk) === selectedRiskId) ?? risks[0];
async function refreshOverview() {
setOverview(await loadOverview());
}
async function submitRiskAction(risk: AdminRow, action: RiskAction, extraBody: Record<string, unknown> = {}) {
const riskId = text(risk.riskId ?? risk.id, "");
if (!riskId) return;
setActionBusy(`${riskId}:${action}`);
setError("");
try {
const response = await fetch("/api/v1/admin/risks/actions", {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ riskId, action, ...extraBody }),
});
const payload = (await response.json().catch(() => null)) as { ok?: boolean; message?: string } | null;
if (!response.ok || payload?.ok === false) {
throw new Error(payload?.message || `风险动作失败:${response.status}`);
}
messageApi.success(
action === "ack"
? "已确认风险"
: action === "resolve"
? "已关闭风险"
: action === "assign_owner"
? "已指派负责人"
: action === "set_sla"
? "已设置 SLA"
: "已创建修复工单",
);
await refreshOverview();
} catch (nextError) {
const messageText = nextError instanceof Error ? nextError.message : "风险动作失败";
setError(messageText);
messageApi.error(messageText);
} finally {
setActionBusy("");
}
}
function renderActiveSection() {
if (activeSection === "customers") {
return <CustomersView companies={companies} accounts={accounts} devices={devices} />;
}
if (activeSection === "permissions") {
return <PermissionsView />;
}
if (activeSection === "governance") {
return (
<GovernanceView
risks={risks}
notifications={notifications}
selectedRisk={selectedRisk}
setSelectedRisk={(risk) => setSelectedRiskId(risk ? rowId(risk) : "")}
actionBusy={actionBusy}
submitRiskAction={(risk, action, extraBody) => void submitRiskAction(risk, action, extraBody)}
/>
);
}
return (
<DashboardView
stats={stats}
companies={companies}
devices={devices}
risks={risks}
notifications={notifications}
timeline={timeline}
onOpenRisk={() => setActiveSection("governance")}
/>
);
}
return (
<ConfigProvider
locale={zhCN}
theme={{
algorithm: theme.defaultAlgorithm,
token: {
colorPrimary: "#07C160",
borderRadius: 14,
fontFamily: '"PingFang SC", "Microsoft YaHei", sans-serif',
colorBgLayout: "#F3F5F2",
colorBorderSecondary: "#E3E8E4",
},
components: {
Button: {
controlHeight: 34,
borderRadius: 10,
},
Card: {
headerFontSize: 16,
},
Table: {
headerBg: "#F7F8F7",
cellPaddingBlock: 9,
cellPaddingInline: 10,
},
},
}}
>
<Refine dataProvider={createBossAdminDataProvider(initialOverview ?? undefined)} resources={resources}>
<main className={adminShell}>
{messageContext}
<div className={adminChrome}>
<aside className={adminSidebar}>
<div className="mb-7 px-2">
<div className="text-[24px] font-black tracking-[-0.04em] text-[#101814]">Boss</div>
<div className="mt-1 text-xs font-semibold text-[#7A857D]">To B </div>
</div>
<div className="space-y-2">
{navItems.map((item) => {
const active = activeSection === item.key;
return (
<button
key={item.key}
type="button"
onClick={() => setActiveSection(item.key)}
className={[
"flex w-full items-center gap-3 rounded-2xl px-3 py-3 text-left transition",
active ? "bg-[#EAF8EF] text-[#075F31] shadow-[inset_0_0_0_1px_rgba(7,193,96,0.18)]" : "text-[#46524B] hover:bg-[#F2F5F2]",
].join(" ")}
>
<span className={active ? "grid size-9 place-items-center rounded-xl bg-[#07C160] text-xs font-black text-white" : "grid size-9 place-items-center rounded-xl bg-white text-xs font-black text-[#7A857D] ring-1 ring-[#E3E8E4]"}>
{item.marker}
</span>
<span className="min-w-0">
<span className="block text-sm font-black">{item.title}</span>
<span className="mt-0.5 block truncate text-xs opacity-70">{item.subtitle}</span>
</span>
</button>
);
})}
</div>
<div className="mt-8 rounded-2xl border border-[#E3E8E4] bg-white p-4">
<div className="text-xs font-bold text-[#7A857D]"></div>
<div className="mt-2 flex items-center gap-2">
<span className="grid size-8 place-items-center rounded-full bg-[#07C160] text-xs font-black text-white">k</span>
<span className="text-sm font-bold text-[#101814]">highest_admin</span>
</div>
</div>
</aside>
<section className="min-w-0 bg-[#F8FAF8]">
<header className={adminHeader}>
<div>
<div className="text-[26px] font-black tracking-[-0.04em] text-[#101814]">{sectionTitle(activeSection)}</div>
<div className="mt-1 text-sm text-[#68746D]">{currentSubtitle(activeSection)}</div>
</div>
<div className="ml-auto flex items-center gap-3">
<Button onClick={() => void refreshOverview()}></Button>
<div className="rounded-full border border-[#E3E8E4] bg-white px-4 py-2 text-sm font-semibold text-[#4B5750]">
</div>
</div>
</header>
<div className="p-7">
{error ? <Alert className="mb-5" type="warning" showIcon message="后台数据暂不可用" description={error} /> : null}
{renderActiveSection()}
</div>
</section>
</div>
</main>
</Refine>
</ConfigProvider>
);
}

View File

@@ -1,121 +0,0 @@
import type {
BaseRecord,
CreateResponse,
DataProvider,
DeleteOneResponse,
DeleteOneParams,
GetListResponse,
GetListParams,
GetOneResponse,
GetOneParams,
CreateParams,
UpdateParams,
UpdateResponse,
} from "@refinedev/core";
export type BossAdminSeverity = "critical" | "high" | "medium" | "low" | "info";
export type BossAdminOverview = {
ok?: boolean;
summary?: {
companies?: number;
accounts?: number;
devices?: number;
onlineDevices?: number;
openRisks?: number;
openNotifications?: number;
criticalRisks?: number;
};
stats?: {
companies?: number;
accounts?: number;
devices?: number;
onlineDevices?: number;
openRisks?: number;
openNotifications?: number;
criticalRisks?: number;
};
companies?: Array<Record<string, unknown>>;
accounts?: Array<Record<string, unknown>>;
devices?: Array<Record<string, unknown>>;
risks?: Array<Record<string, unknown>>;
notifications?: Array<Record<string, unknown>>;
auditLogs?: Array<Record<string, unknown>>;
};
const resourceKeys = new Set(["companies", "accounts", "devices", "risks", "notifications", "auditLogs"]);
async function fetchOverview() {
const response = await fetch("/api/v1/admin/overview", {
credentials: "include",
cache: "no-store",
});
if (!response.ok) {
throw new Error(`Failed to load admin overview: ${response.status}`);
}
return (await response.json()) as BossAdminOverview;
}
function listFromOverview(overview: BossAdminOverview | undefined, resource: string) {
if (!resourceKeys.has(resource)) return [];
const value = overview?.[resource as keyof BossAdminOverview];
return Array.isArray(value) ? value : [];
}
function recordId(item: Record<string, unknown>, index: number) {
return String(item.id ?? item.companyId ?? item.account ?? item.deviceId ?? item.riskId ?? item.auditId ?? index);
}
export function createBossAdminDataProvider(initialOverview?: BossAdminOverview): DataProvider {
let overviewCache = initialOverview;
return {
getList: async <TData extends BaseRecord = BaseRecord>({ resource }: GetListParams) => {
if (!overviewCache) {
overviewCache = await fetchOverview();
}
const data = listFromOverview(overviewCache, resource).map((item, index) => ({
id: recordId(item, index),
...item,
})) as TData[];
return {
data,
total: data.length,
} satisfies GetListResponse<TData>;
},
getOne: async <TData extends BaseRecord = BaseRecord>({ resource, id }: GetOneParams) => {
if (!overviewCache) {
overviewCache = await fetchOverview();
}
const item = listFromOverview(overviewCache, resource).find((entry, index) => {
return recordId(entry, index) === String(id);
});
return {
data: {
id,
...(item ?? {}),
} as TData,
} satisfies GetOneResponse<TData>;
},
create: async <TData extends BaseRecord = BaseRecord, TVariables = unknown>({
variables,
}: CreateParams<TVariables>) =>
({ data: variables as unknown as TData }) satisfies CreateResponse<TData>,
update: async <TData extends BaseRecord = BaseRecord, TVariables = unknown>({
id,
variables,
}: UpdateParams<TVariables>) =>
({ data: { id, ...(variables as BaseRecord) } as TData }) satisfies UpdateResponse<TData>,
deleteOne: async <TData extends BaseRecord = BaseRecord, TVariables = unknown>({
id,
}: DeleteOneParams<TVariables>) =>
({ data: { id } as TData }) satisfies DeleteOneResponse<TData>,
getApiUrl: () => "/api/v1/admin/overview",
};
}

View File

@@ -123,7 +123,9 @@ async function waitForLoginSessionReady(nativeClient: boolean) {
}
function resolvePostLoginPath() {
return window.location.hostname === "admin.boss.hyzq.net" ? "/admin" : "/conversations";
return window.location.hostname === "admin.boss.hyzq.net"
? "/"
: "/conversations";
}
function navigateAfterLogin(router: ReturnType<typeof useRouter>) {

View File

@@ -0,0 +1,248 @@
"use client";
import { useRouter } from "next/navigation";
import { useState } from "react";
import {
isNativeBossApp,
persistNativeSessionSnapshot,
} from "@/lib/boss-app-client";
type LoginResult = {
ok: boolean;
message: string;
account?: string;
displayName?: string;
sessionExpiresAt?: string;
restoreToken?: string;
};
function resolvePostLoginPath() {
return window.location.hostname === "admin.boss.hyzq.net"
? "/"
: "/conversations";
}
async function waitForLoginSessionReady(nativeClient: boolean) {
for (let attempt = 0; attempt < 5; attempt += 1) {
const response = await fetch("/api/auth/session", {
cache: "no-store",
headers: nativeClient ? { "x-boss-native-app": "1" } : undefined,
}).catch(() => null);
if (response?.ok) return true;
await new Promise((resolve) => window.setTimeout(resolve, 120));
}
return false;
}
export function EnterpriseAdminLoginShell() {
const router = useRouter();
const [account, setAccount] = useState("");
const [password, setPassword] = useState("");
const [remember, setRemember] = useState(true);
const [message, setMessage] = useState("");
const [submitting, setSubmitting] = useState(false);
async function submit() {
if (!account.trim() || !password) {
setMessage("请填写账号和密码。");
return;
}
setSubmitting(true);
setMessage("");
const nativeClient = await isNativeBossApp();
try {
const response = await fetch("/api/auth/login", {
method: "POST",
headers: {
"Content-Type": "application/json",
...(nativeClient ? { "x-boss-native-app": "1" } : {}),
},
body: JSON.stringify({
account,
password,
method: "password",
}),
});
const result = (await response.json()) as LoginResult;
if (!result.ok) {
setMessage(result.message);
return;
}
if (
nativeClient &&
result.restoreToken &&
result.account &&
result.displayName &&
result.sessionExpiresAt
) {
await persistNativeSessionSnapshot({
restoreToken: result.restoreToken,
account: result.account,
displayName: result.displayName,
expiresAt: result.sessionExpiresAt,
lastSyncedAt: new Date().toISOString(),
}).catch(() => undefined);
}
await waitForLoginSessionReady(nativeClient);
const targetPath = resolvePostLoginPath();
router.replace(targetPath, { scroll: false });
router.refresh();
window.setTimeout(() => {
if (window.location.pathname !== targetPath) {
window.location.replace(targetPath);
}
}, 180);
} catch {
setMessage("登录链路发生异常,请重试。");
} finally {
setSubmitting(false);
}
}
return (
<main className="min-h-[100dvh] bg-[#eef7f1] px-5 py-6 text-[#102418] md:px-10 md:py-10">
<div className="mx-auto flex min-h-[calc(100dvh-48px)] max-w-6xl overflow-hidden rounded-[34px] border border-white/80 bg-white shadow-[0_24px_80px_rgba(16,36,24,0.12)]">
<section className="relative hidden flex-1 flex-col justify-between overflow-hidden bg-[#e9f8f0] px-12 py-12 lg:flex">
<div className="absolute -left-20 top-12 h-72 w-72 rounded-full bg-[#c8f5dd] blur-3xl" />
<div className="absolute -bottom-24 right-4 h-80 w-80 rounded-full bg-white/70 blur-2xl" />
<div className="relative">
<div className="flex items-center gap-4">
<div className="flex h-14 w-14 items-center justify-center rounded-2xl bg-[#12c66a] text-[22px] font-black text-white shadow-[0_16px_36px_rgba(18,198,106,0.28)]">
B
</div>
<div>
<div className="text-[25px] font-black tracking-[-0.04em]">
Boss
</div>
<div className="mt-1 text-[14px] font-medium text-[#66746c]">
Skill
</div>
</div>
</div>
<div className="mt-16 max-w-xl">
<div className="inline-flex rounded-full border border-[#bfead1] bg-white/72 px-4 py-2 text-[13px] font-semibold text-[#0b8f4a]">
· · ·
</div>
<h1 className="mt-7 text-[48px] font-black leading-[1.08] tracking-[-0.06em] text-[#102418]">
Agent
</h1>
<p className="mt-5 text-[17px] leading-8 text-[#607269]">
To B Skill
</p>
</div>
<div className="mt-10 grid max-w-xl grid-cols-3 gap-3">
{["企业开通", "设备授权", "风险治理"].map((item) => (
<div
key={item}
className="rounded-2xl border border-white/80 bg-white/72 px-4 py-4 text-[15px] font-bold text-[#17372a] shadow-sm"
>
{item}
</div>
))}
</div>
</div>
<div className="relative rounded-3xl border border-white/80 bg-white/70 p-5 text-[13px] leading-6 text-[#617168]">
访
</div>
</section>
<section className="flex w-full items-center justify-center px-5 py-8 md:px-10 lg:w-[470px]">
<div className="w-full max-w-[390px]">
<div className="mb-9 lg:hidden">
<div className="flex h-13 w-13 items-center justify-center rounded-2xl bg-[#12c66a] text-[20px] font-black text-white">
B
</div>
<h1 className="mt-5 text-[30px] font-black tracking-[-0.04em]">
Boss
</h1>
<p className="mt-2 text-[14px] leading-6 text-[#66746c]">
Skill
</p>
</div>
<div className="rounded-[28px] border border-[#dfe9e3] bg-white p-6 shadow-[0_18px_48px_rgba(16,36,24,0.08)] md:p-8">
<div>
<div className="text-[28px] font-black tracking-[-0.04em]">
</div>
<p className="mt-2 text-[14px] leading-6 text-[#66746c]">
访
</p>
</div>
<form
className="mt-8 space-y-5"
onSubmit={(event) => {
event.preventDefault();
void submit();
}}
>
<label className="block">
<span className="text-[13px] font-bold text-[#42554a]"></span>
<input
value={account}
onChange={(event) => setAccount(event.target.value)}
placeholder="输入管理员账号"
autoComplete="username"
className="mt-2 h-13 w-full rounded-2xl border border-[#dfe9e3] bg-[#f8fbf9] px-4 text-[16px] text-[#102418] outline-none transition focus:border-[#12c66a] focus:bg-white focus:ring-4 focus:ring-[#12c66a]/10"
/>
</label>
<label className="block">
<span className="text-[13px] font-bold text-[#42554a]"></span>
<input
value={password}
onChange={(event) => setPassword(event.target.value)}
placeholder="输入登录密码"
type="password"
autoComplete="current-password"
className="mt-2 h-13 w-full rounded-2xl border border-[#dfe9e3] bg-[#f8fbf9] px-4 text-[16px] text-[#102418] outline-none transition focus:border-[#12c66a] focus:bg-white focus:ring-4 focus:ring-[#12c66a]/10"
/>
</label>
<div className="flex items-center justify-between gap-3">
<label className="flex cursor-pointer items-center gap-2 text-[13px] font-semibold text-[#526258]">
<input
checked={remember}
onChange={(event) => setRemember(event.target.checked)}
type="checkbox"
className="h-4 w-4 accent-[#12c66a]"
/>
</label>
<span className="text-[12px] text-[#8b9990]">HTTPS </span>
</div>
<button
type="submit"
disabled={submitting}
className="flex h-13 w-full items-center justify-center rounded-2xl bg-[#12c66a] text-[16px] font-black text-white shadow-[0_16px_32px_rgba(18,198,106,0.22)] transition hover:bg-[#0fb85f] disabled:cursor-not-allowed disabled:bg-[#9adfba]"
>
{submitting ? "登录中..." : "登录"}
</button>
</form>
{message ? (
<div className="mt-5 rounded-2xl border border-[#bfead1] bg-[#eefaf3] px-4 py-3 text-[13px] leading-6 text-[#1c6b3e]">
{message}
</div>
) : null}
</div>
<div className="mt-6 rounded-2xl border border-[#e0e9e4] bg-white/74 px-4 py-4 text-[12px] leading-6 text-[#6f7d75]">
访
</div>
</div>
</section>
</div>
</main>
);
}

97
src/lib/boss-agent-ota.ts Normal file
View File

@@ -0,0 +1,97 @@
import { createHash } from "node:crypto";
import { existsSync } from "node:fs";
import { promises as fs } from "node:fs";
import path from "node:path";
export interface PublishedBossAgentOtaAsset {
absolutePath: string;
version: string;
fileName: string;
sizeBytes: number;
sha256: string;
updatedAt: string;
downloadUrl: string;
packageType: "boss_agent_macos";
}
const BOSS_AGENT_OTA_PACKAGE_FILE_NAME = "boss-agent-mac-latest.zip";
const BOSS_AGENT_OTA_META_FILE_NAME = "boss-agent-mac-latest.json";
const BOSS_AGENT_OTA_DOWNLOAD_URL = "/api/v1/boss-agent/ota/package";
function detectRuntimeRoot(startDir: string) {
let current = startDir;
while (true) {
if (existsSync(path.join(current, "package.json")) && existsSync(path.join(current, "src", "app"))) {
return current;
}
const parent = path.dirname(current);
if (parent === current) {
return startDir;
}
current = parent;
}
}
function resolveRuntimeRoot() {
if (process.env.BOSS_RUNTIME_ROOT?.trim()) {
return path.resolve(process.env.BOSS_RUNTIME_ROOT);
}
if (process.env.BOSS_STATE_FILE?.trim()) {
return path.dirname(path.dirname(path.resolve(process.env.BOSS_STATE_FILE)));
}
return detectRuntimeRoot(/* turbopackIgnore: true */ process.cwd());
}
const runtimeRoot = resolveRuntimeRoot();
function otaPublicDir() {
return path.join(runtimeRoot, "public", "downloads");
}
function packagePath() {
return path.join(otaPublicDir(), BOSS_AGENT_OTA_PACKAGE_FILE_NAME);
}
function metaPath() {
return path.join(otaPublicDir(), BOSS_AGENT_OTA_META_FILE_NAME);
}
function inferVersionFromFile(filePath: string, updatedAt: string) {
const versionMatch = path.basename(filePath).match(/boss-agent-mac-runtime-([0-9A-Za-z._-]+)\.zip/i);
if (versionMatch?.[1]) return versionMatch[1];
return updatedAt.replace(/[-:TZ.]/g, "").slice(0, 14) || "latest";
}
export async function getPublishedBossAgentOtaAsset(): Promise<PublishedBossAgentOtaAsset | null> {
const archivePath = packagePath();
if (!existsSync(archivePath)) {
return null;
}
const stat = await fs.stat(archivePath);
const content = await fs.readFile(archivePath);
const fallbackUpdatedAt = stat.mtime.toISOString();
const fallbackSha256 = createHash("sha256").update(content).digest("hex");
let meta: Partial<PublishedBossAgentOtaAsset> & { urlPath?: string } = {};
if (existsSync(metaPath())) {
try {
meta = JSON.parse(await fs.readFile(metaPath(), "utf8")) as Partial<PublishedBossAgentOtaAsset> & {
urlPath?: string;
};
} catch {
meta = {};
}
}
return {
absolutePath: archivePath,
version: meta.version ?? inferVersionFromFile(meta.fileName ?? archivePath, meta.updatedAt ?? fallbackUpdatedAt),
fileName: meta.fileName ?? BOSS_AGENT_OTA_PACKAGE_FILE_NAME,
sizeBytes: meta.sizeBytes ?? stat.size,
sha256: meta.sha256 ?? fallbackSha256,
updatedAt: meta.updatedAt ?? fallbackUpdatedAt,
downloadUrl: meta.downloadUrl ?? meta.urlPath ?? BOSS_AGENT_OTA_DOWNLOAD_URL,
packageType: "boss_agent_macos",
};
}

View File

@@ -1,9 +1,14 @@
import { cookies } from "next/headers";
import { cookies, headers } from "next/headers";
import { redirect } from "next/navigation";
import type { NextRequest, NextResponse } from "next/server";
import { getAuthSession } from "@/lib/boss-data";
export const AUTH_SESSION_COOKIE = "boss_session";
const PLATFORM_ADMIN_HOST = "admin.boss.hyzq.net";
async function currentHost() {
return (await headers()).get("host")?.split(":")[0] ?? "";
}
function shouldUseSecureCookie(request?: NextRequest) {
const forwardedProto = request?.headers.get("x-forwarded-proto");
@@ -26,6 +31,9 @@ export async function requirePageSession() {
export async function redirectIfAuthenticated() {
const session = await getCurrentPageSession();
if (session) {
if ((await currentHost()) === PLATFORM_ADMIN_HOST) {
redirect("/");
}
redirect("/conversations");
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
import type { NextRequest } from "next/server";
import { requireRequestSession } from "@/lib/boss-auth";
import { getDevice, readState, verifyDeviceToken } from "@/lib/boss-data";
import { getDevice, isDeviceRevoked, readState, verifyDeviceToken } from "@/lib/boss-data";
import { canAccessDevice } from "@/lib/boss-permissions";
export async function authorizeDeviceWriteRequest(
@@ -8,6 +8,13 @@ export async function authorizeDeviceWriteRequest(
deviceId: string,
) {
const device = await getDevice(deviceId);
if (isDeviceRevoked(device)) {
return {
ok: false as const,
device,
principal: null,
};
}
const session = await requireRequestSession(request);
if (device && session) {

View File

@@ -26,8 +26,6 @@ import type {
AiProvider,
AuthRole,
BossPermission,
ComputerControlIntentCategory,
ComputerControlRiskLevel,
DispatchPlanTarget,
ExternalReplyTarget,
Project,
@@ -61,6 +59,9 @@ import {
getUserMasterPromptView,
listUserMasterMemoriesView,
} from "@/lib/boss-projections";
import {
classifyMasterAgentControlIntent,
} from "@/lib/master-agent-intent-router";
type MasterAgentReplyState = "queued" | "running" | "completed";
const OPENAI_MASTER_AGENT_DEVICE_ID = "master-agent-openai";
@@ -222,99 +223,8 @@ export function buildAuthorizedMasterAgentPromptForTest(params: {
};
}
export interface MasterAgentControlIntentClassification {
intentCategory: ComputerControlIntentCategory;
executionMode: "discussion" | "thread" | "development" | "browser" | "desktop";
riskLevel: ComputerControlRiskLevel;
}
function includesAny(text: string, keywords: string[]) {
return keywords.some((keyword) => text.includes(keyword));
}
export function classifyMasterAgentControlIntent(
requestText: string,
): MasterAgentControlIntentClassification {
const normalized = requestText.trim().toLowerCase();
if (!normalized) {
return {
intentCategory: "discussion_only",
executionMode: "discussion",
riskLevel: "low",
};
}
const browserSignals = [
"chrome",
"浏览器",
"网页",
"网站",
"表单",
"登录网站",
"打开网站",
"打开后台",
"打开页面",
"提交表单",
];
const desktopSignals = [
"桌面",
"系统设置",
"finder",
"微信",
"飞书",
"telegram",
"打开应用",
"打开软件",
"app",
"应用",
"窗口",
];
const developmentSignals = [
"开发",
"改代码",
"修复",
"跑测试",
"联调",
"实现",
"提交",
"构建",
"回归测试",
"debug",
"编译",
];
if (includesAny(normalized, browserSignals)) {
return {
intentCategory: "browser_control",
executionMode: "browser",
riskLevel: "medium",
};
}
if (includesAny(normalized, desktopSignals)) {
return {
intentCategory: "desktop_control",
executionMode: "desktop",
riskLevel: "medium",
};
}
if (includesAny(normalized, developmentSignals)) {
return {
intentCategory: "project_development",
executionMode: "development",
riskLevel: "medium",
};
}
return {
intentCategory: "discussion_only",
executionMode: "discussion",
riskLevel: "low",
};
}
export const classifyMasterAgentControlIntentForTesting = classifyMasterAgentControlIntent;
export { classifyMasterAgentControlIntent };
type ControlTargetDeviceInput = {
replyProjectId: string;
@@ -2078,6 +1988,7 @@ async function resolveMasterNodeExecutionCandidate(params: {
async function replyViaOpenAiAccount(params: {
account: AiAccount;
requestText: string;
requestedByAccount: string;
projectId?: string;
currentSessionExpiresAt?: string;
senderLabel: string;
@@ -2116,6 +2027,7 @@ async function replyViaOpenAiAccount(params: {
generated.content,
params.senderLabel,
params.projectId,
params.requestedByAccount,
);
await updateAiAccountHealth({
accountId: params.account.accountId,
@@ -2668,9 +2580,15 @@ export async function probeOpenAiApiAccount(params: { apiKey: string; model?: st
});
}
async function appendMasterAgentSystemReply(body: string, senderLabel = "主 Agent", projectId = "master-agent") {
async function appendMasterAgentSystemReply(
body: string,
senderLabel = "主 Agent",
projectId = "master-agent",
account?: string,
) {
return appendProjectMessage({
projectId,
account,
sender: "master",
senderLabel,
body,
@@ -2736,7 +2654,12 @@ async function replyViaClawBackend(params: {
});
if (result.status === "completed") {
await appendMasterAgentSystemReply(result.output, "主 Agent · Claw Runtime", params.projectId);
await appendMasterAgentSystemReply(
result.output,
"主 Agent · Claw Runtime",
params.projectId,
params.requestedByAccount,
);
return {
ok: true as const,
accountId: CLAW_BACKEND_ID,
@@ -3672,6 +3595,7 @@ export async function replyToMasterAgentUserMessage(params: {
"我已经收到你的消息,但当前没有可用的主控 AI 账号。请到“我的 > AI 账号”至少配置一个可用的 API 链路,或接回 Master Codex Node 后,再继续对话。",
"主 Agent",
replyProjectId,
params.requestedByAccount,
);
return { ok: false as const, reason: "NO_AI_ACCOUNT" };
}
@@ -3700,6 +3624,7 @@ export async function replyToMasterAgentUserMessage(params: {
"这个会话还没有授权给当前账号,主 Agent 不能读取或接管它。请让超级管理员先分配项目权限。",
"主 Agent",
replyProjectId,
params.requestedByAccount,
);
return { ok: false as const, reason: "FORBIDDEN" };
}
@@ -3810,6 +3735,7 @@ export async function replyToMasterAgentUserMessage(params: {
localFastReply.replyBody,
localFastReply.senderLabel,
replyProjectId,
params.requestedByAccount,
);
return {
replyMessage,
@@ -3846,6 +3772,8 @@ export async function replyToMasterAgentUserMessage(params: {
controlIntent.intentCategory === "browser_control"
? "browser-automation-runtime"
: "computer-use-runtime",
controlPlatform: controlIntent.platform,
computerUseProvider: controlIntent.recommendedProvider,
riskLevel: controlIntent.riskLevel,
confirmationPolicy: controlIntent.riskLevel === "high" ? "strong_confirm" : "light_confirm",
requiresUserConfirmation: false,
@@ -3879,6 +3807,7 @@ export async function replyToMasterAgentUserMessage(params: {
].join(""),
`主 Agent · ${runtime.summary.roleLabel}`,
replyProjectId,
params.requestedByAccount,
);
return { ok: false as const, reason: "MASTER_NODE_NOT_CONNECTED" };
}
@@ -3902,6 +3831,7 @@ export async function replyToMasterAgentUserMessage(params: {
`主 GPT 不在手机里直接登录。当前绑定设备 ${boundNodeLabel}${boundDevice ? " 不在线" : " 未找到"},主 Agent 暂时无法通过这台设备对话。请先在该设备上登录 Codex / ChatGPT Plus并确保 local-agent 在线后再重试。`,
`主 Agent · ${selectedMasterAccount.label || runtime.summary.roleLabel}`,
replyProjectId,
params.requestedByAccount,
);
return { ok: false as const, reason: "MASTER_NODE_OFFLINE" };
}
@@ -3943,6 +3873,8 @@ export async function replyToMasterAgentUserMessage(params: {
...masterTaskAuthorization(["master_agent.ask", "computer.control"]),
intentCategory: controlIntent.intentCategory,
runtimeKind,
controlPlatform: controlIntent.platform,
computerUseProvider: controlIntent.recommendedProvider,
riskLevel: controlIntent.riskLevel,
confirmationPolicy: controlIntent.riskLevel === "high" ? "strong_confirm" : "light_confirm",
requiresUserConfirmation: false,
@@ -4130,6 +4062,7 @@ export async function replyToMasterAgentUserMessage(params: {
`我已经收到你的消息,但 Claw Runtime 当前执行失败:${clawReply.message}。请检查 Claw 可执行入口,或先切回其他主控后再试。`,
"主 Agent · Claw Runtime",
replyProjectId,
params.requestedByAccount,
);
return clawReply;
}
@@ -4146,6 +4079,7 @@ export async function replyToMasterAgentUserMessage(params: {
const reply = await replyViaOpenAiAccount({
account: candidate.account,
requestText: params.requestText,
requestedByAccount: params.requestedByAccount,
projectId: replyProjectId,
currentSessionExpiresAt: params.currentSessionExpiresAt,
senderLabel: `主 Agent · ${candidate.model}`,
@@ -4187,6 +4121,7 @@ export async function replyToMasterAgentUserMessage(params: {
].join(""),
`主 Agent · ${lastFailedAccount?.label || runtime.summary.roleLabel}`,
replyProjectId,
params.requestedByAccount,
);
return { ok: false as const, reason: "MODEL_CALL_FAILED", message: lastApiFailureMessage };
}
@@ -4200,6 +4135,7 @@ export async function replyToMasterAgentUserMessage(params: {
].join(""),
`主 Agent · ${runtime.summary.roleLabel}`,
replyProjectId,
params.requestedByAccount,
);
return { ok: false as const, reason: "MASTER_NODE_NOT_CONNECTED" };
}

View File

@@ -20,6 +20,19 @@ function permissionSetIncludes(permissions: BossPermission[], required: BossPerm
return permissions.includes(required);
}
function accountHasActiveProjectPermission(
state: BossState,
account: string,
permission: BossPermission,
) {
return state.accountProjectGrants.some(
(grant) =>
grant.account === account &&
!isExpired(grant.expiresAt) &&
permissionSetIncludes(grant.permissions, permission),
);
}
function projectUsesDevice(project: Project, deviceId: string) {
if (project.deviceIds.includes(deviceId)) return true;
return project.groupMembers.some((member) => member.deviceId === deviceId);
@@ -92,6 +105,15 @@ export function canAccessProject(
if (isHighestAdmin(session)) return true;
const project = state.projects.find((item) => item.id === projectId);
if (!project) return false;
if (
projectId === "master-agent" &&
(permission === "project.view" || permission === "master_agent.ask") &&
accountHasActiveProjectPermission(state, session.account, "master_agent.ask")
) {
return true;
}
if (!tenantAllowsCompanies(state, session, projectCompanyIds(state, project))) return false;
const directProjectGrant = state.accountProjectGrants.some(

View File

@@ -862,13 +862,18 @@ function stateForSession(state: BossState, session: PermissionSession): BossStat
deviceIds: project.deviceIds.filter((deviceId) => visibleDeviceIds.has(deviceId)),
groupMembers: project.groupMembers.filter((member) => visibleDeviceIds.has(member.deviceId)),
}));
const visibleProjectIds = new Set(visibleProjects.map((project) => project.id));
const scopedVisibleProjects = visibleProjects.map((project) =>
project.id === "master-agent" && session.role !== "highest_admin"
? projectWithAccountScopedMasterMessages(project, session.account)
: project,
);
const visibleProjectIds = new Set(scopedVisibleProjects.map((project) => project.id));
const canSeeThreadOnDevice = (projectId: string, deviceId: string) =>
visibleProjectIds.has(projectId) && visibleDeviceIds.has(deviceId);
return {
...state,
devices: visibleDevices,
projects: visibleProjects,
projects: scopedVisibleProjects,
deviceSkills: state.deviceSkills.filter((skill) =>
visibleDeviceIds.has(skill.deviceId) &&
(session.role === "highest_admin" ||
@@ -921,6 +926,20 @@ function stateForSession(state: BossState, session: PermissionSession): BossStat
};
}
function projectWithAccountScopedMasterMessages(project: Project, account: string): Project {
const messages = project.messages.filter((message) => message.account === account);
const latestMessage = [...messages].sort(
(left, right) => Date.parse(right.sentAt) - Date.parse(left.sentAt),
)[0];
return {
...project,
messages,
preview: latestMessage?.body ?? "",
lastMessageAt: latestMessage?.sentAt ?? project.updatedAt,
unreadCount: messages.filter((message) => message.sender !== "user").length,
};
}
export function getAuthorizedStateSnapshot(
state: BossState,
session: Pick<AuthSession, "account" | "role" | "displayName">,
@@ -1161,9 +1180,13 @@ export function buildProjectMessagesRealtimePayloadForSession(
if (!project) {
return null;
}
const scopedProject =
project.id === "master-agent" && session.role !== "highest_admin"
? projectWithAccountScopedMasterMessages(cloneProjectWithDisplayTitles(project), session.account)
: cloneProjectWithDisplayTitles(project);
return {
ok: true,
project: cloneProjectWithDisplayTitles(project),
project: scopedProject,
devices: filterProjectDevicesForSession(state, session, project),
};
}

View File

@@ -3,6 +3,7 @@ import type {
AuthAccount,
BossState,
Device,
AppLogEntry,
OpsFault,
OpsSeverity,
ThreadContextAlert,
@@ -71,6 +72,77 @@ function buildBody(summary: string, slaDueAt: string) {
return `${summary || "风险已超过 SLA需要平台协助跟进"}SLA 截止 ${slaDueAt}`;
}
function safeIdSegment(value: string) {
return value.trim().toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "unknown";
}
function faultIdFor(kind: string, deviceId: string, suffix = "") {
return ["fault", safeIdSegment(kind), safeIdSegment(deviceId), safeIdSegment(suffix)].filter(Boolean).join("-");
}
function latestLogsByDevice(logs: AppLogEntry[], category: string) {
const byDevice = new Map<string, AppLogEntry>();
for (const log of logs) {
if (log.category !== category) continue;
const existing = byDevice.get(log.deviceId);
if (!existing || log.createdAt.localeCompare(existing.createdAt) > 0) {
byDevice.set(log.deviceId, log);
}
}
return byDevice;
}
export function buildOperationalRiskFaultDrafts(
state: BossState,
now: Date = new Date(),
): OpsFault[] {
const createdAt = now.toISOString();
const drafts: OpsFault[] = [];
const otaFailureLogs = latestLogsByDevice(state.appLogs, "local_agent.boss_agent_ota_failed");
for (const device of state.devices) {
if (device.status === "online" && device.capabilities?.computerUse?.connected === false) {
drafts.push({
faultId: faultIdFor("computer-use-unavailable", device.id),
faultKey: "BOSS.COMPUTER_USE.UNAVAILABLE",
severity: "warning",
status: "opened",
nodeId: device.id,
serviceName: "computer-use",
traceId: `capability:${device.id}:computerUse`,
runbookId: "runbook-computer-use-permission",
firstSeenAt: createdAt,
lastSeenAt: device.capabilities.computerUse.lastSeenAt ?? device.lastSeenAt ?? createdAt,
summary: `${device.name} 已在线,但 Computer Use 能力不可用,远程桌面控制会降级或失败。`,
suggestedNextAction: "检查 boss-agent 本机权限、Codex Computer Use、CUA fallback 和本机 runtime 配置。",
autoRepairable: false,
});
}
const otaLog = otaFailureLogs.get(device.id);
if (otaLog) {
drafts.push({
faultId: faultIdFor("boss-agent-ota-failed", device.id),
faultKey: "BOSS_AGENT.OTA.FAILED",
severity: "warning",
status: "opened",
nodeId: device.id,
serviceName: "boss-agent-ota",
projectId: otaLog.projectId,
traceId: otaLog.logId,
runbookId: "runbook-boss-agent-ota",
firstSeenAt: otaLog.createdAt,
lastSeenAt: otaLog.createdAt,
summary: otaLog.message || "boss-agent OTA 更新失败",
suggestedNextAction: otaLog.detail || "检查 OTA 包 sha256、下载链路、安装脚本和设备绑定配置。",
autoRepairable: false,
});
}
}
return drafts;
}
export function buildRiskSlaNotificationDrafts(
state: BossState,
now: Date = new Date(),

View File

@@ -0,0 +1,219 @@
import { createHash, randomBytes } from "node:crypto";
import fs from "node:fs/promises";
import path from "node:path";
import { readState, writeState, type BossState } from "@/lib/boss-data";
export interface BossStateBackupSnapshot {
snapshotId: string;
fileName: string;
absolutePath: string;
bytes: number;
sha256: string;
createdAt: string;
actorAccount?: string;
reason?: string;
schemaVersion?: number;
}
export interface BossStateBackupStatus {
mode: "file";
backupDir: string;
stateFile: string;
restorePointCount: number;
lastBackupAt?: string;
status: "ready" | "empty" | "error";
detail?: string;
}
function stateFilePath() {
const configuredStateFile = process.env.BOSS_STATE_FILE?.trim();
if (configuredStateFile) {
return path.resolve(configuredStateFile);
}
const runtimeRoot = process.env.BOSS_RUNTIME_ROOT?.trim();
if (runtimeRoot) {
return path.resolve(runtimeRoot, "data", "boss-state.json");
}
return path.join(process.cwd(), "data", "boss-state.json");
}
function backupDirPath() {
const configuredBackupDir = process.env.BOSS_STATE_BACKUP_DIR?.trim();
if (configuredBackupDir) {
return path.resolve(configuredBackupDir);
}
return path.join(path.dirname(stateFilePath()), "backups");
}
function timestampSegment() {
return new Date().toISOString().replace(/[:.]/g, "-");
}
function snapshotIdFor(createdAtSegment: string, text: string) {
const digest = createHash("sha256")
.update(text)
.update(randomBytes(6))
.digest("hex")
.slice(0, 12);
return `state-snapshot-${createdAtSegment}-${digest}`;
}
function snapshotPath(snapshotId: string) {
if (!/^state-snapshot-[0-9TZ-]+-[a-f0-9]{12}$/.test(snapshotId)) {
throw new Error("BACKUP_SNAPSHOT_ID_INVALID");
}
return path.join(backupDirPath(), `${snapshotId}.json`);
}
async function readStateText() {
const state = await readState();
return `${JSON.stringify(state, null, 2)}\n`;
}
function parseStateText(text: string, source: string) {
const parsed = JSON.parse(text) as BossState;
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
throw new Error(`BACKUP_STATE_INVALID:${source}`);
}
return parsed;
}
async function writeMeta(snapshotId: string, meta: Pick<BossStateBackupSnapshot, "actorAccount" | "reason" | "createdAt" | "sha256" | "bytes" | "schemaVersion">) {
await fs.writeFile(path.join(backupDirPath(), `${snapshotId}.meta.json`), `${JSON.stringify(meta, null, 2)}\n`, "utf8");
}
async function readMeta(snapshotId: string) {
const metaPath = path.join(backupDirPath(), `${snapshotId}.meta.json`);
try {
return JSON.parse(await fs.readFile(metaPath, "utf8")) as Partial<BossStateBackupSnapshot>;
} catch {
return {};
}
}
export async function createBossStateBackup(input: {
actorAccount: string;
reason?: string;
prefix?: string;
}): Promise<BossStateBackupSnapshot> {
const text = await readStateText();
const parsed = parseStateText(text, stateFilePath());
const createdAtSegment = timestampSegment();
const snapshotId = snapshotIdFor(createdAtSegment, text);
const dir = backupDirPath();
const absolutePath = path.join(dir, `${snapshotId}.json`);
const sha256 = createHash("sha256").update(text).digest("hex");
await fs.mkdir(dir, { recursive: true });
await fs.writeFile(absolutePath, text.endsWith("\n") ? text : `${text}\n`, "utf8");
const stat = await fs.stat(absolutePath);
const createdAt = new Date().toISOString();
await writeMeta(snapshotId, {
actorAccount: input.actorAccount,
reason: input.reason,
createdAt,
sha256,
bytes: stat.size,
schemaVersion: parsed.schemaVersion,
});
return {
snapshotId,
fileName: `${snapshotId}.json`,
absolutePath,
bytes: stat.size,
sha256,
createdAt,
actorAccount: input.actorAccount,
reason: input.reason,
schemaVersion: parsed.schemaVersion,
};
}
export async function listBossStateBackups(limit = 20): Promise<BossStateBackupSnapshot[]> {
const dir = backupDirPath();
const entries = await fs.readdir(dir).catch(() => []);
const snapshots = await Promise.all(
entries
.filter((fileName) => /^state-snapshot-.*\.json$/.test(fileName) && !fileName.endsWith(".meta.json"))
.map(async (fileName) => {
const absolutePath = path.join(dir, fileName);
const text = await fs.readFile(absolutePath, "utf8");
const stat = await fs.stat(absolutePath);
const snapshotId = fileName.replace(/\.json$/, "");
const meta = await readMeta(snapshotId);
let schemaVersion: number | undefined;
try {
const parsed = JSON.parse(text) as Partial<BossState>;
schemaVersion = typeof parsed.schemaVersion === "number" ? parsed.schemaVersion : undefined;
} catch {
schemaVersion = undefined;
}
return {
snapshotId,
fileName,
absolutePath,
bytes: typeof meta.bytes === "number" ? meta.bytes : stat.size,
sha256: typeof meta.sha256 === "string" ? meta.sha256 : createHash("sha256").update(text).digest("hex"),
createdAt: typeof meta.createdAt === "string" ? meta.createdAt : stat.mtime.toISOString(),
actorAccount: typeof meta.actorAccount === "string" ? meta.actorAccount : undefined,
reason: typeof meta.reason === "string" ? meta.reason : undefined,
schemaVersion: typeof meta.schemaVersion === "number" ? meta.schemaVersion : schemaVersion,
};
}),
);
return snapshots
.sort((left, right) => right.createdAt.localeCompare(left.createdAt))
.slice(0, Math.max(1, Math.min(100, limit)));
}
export async function getBossStateBackupStatus(): Promise<BossStateBackupStatus> {
try {
const snapshots = await listBossStateBackups(100);
return {
mode: "file",
backupDir: backupDirPath(),
stateFile: stateFilePath(),
restorePointCount: snapshots.length,
lastBackupAt: snapshots[0]?.createdAt,
status: snapshots.length > 0 ? "ready" : "empty",
detail: snapshots.length > 0 ? `最近快照:${snapshots[0]?.snapshotId}` : "暂无可用快照",
};
} catch (error) {
return {
mode: "file",
backupDir: backupDirPath(),
stateFile: stateFilePath(),
restorePointCount: 0,
status: "error",
detail: error instanceof Error ? error.message : String(error),
};
}
}
export async function restoreBossStateBackup(input: {
snapshotId: string;
actorAccount: string;
}): Promise<{
restored: BossStateBackupSnapshot;
preRestoreSnapshot: BossStateBackupSnapshot;
}> {
const absolutePath = snapshotPath(input.snapshotId);
const text = await fs.readFile(absolutePath, "utf8");
const parsed = parseStateText(text, absolutePath);
const restored = (await listBossStateBackups(100)).find((snapshot) => snapshot.snapshotId === input.snapshotId);
if (!restored) {
throw new Error("BACKUP_SNAPSHOT_NOT_FOUND");
}
const preRestoreSnapshot = await createBossStateBackup({
actorAccount: input.actorAccount,
reason: `pre-restore:${input.snapshotId}`,
});
await writeState(parsed);
return {
restored,
preRestoreSnapshot,
};
}

View File

@@ -1,4 +1,4 @@
import { randomBytes } from "node:crypto";
import { createHash, randomBytes } from "node:crypto";
import fs from "node:fs/promises";
import path from "node:path";
import type { Client as PgClient } from "pg";
@@ -67,10 +67,120 @@ function createFileStateStore(paths: BossStateStorePaths): BossStateStore {
await fs.writeFile(tempFile, text, "utf8");
await fs.rename(tempFile, paths.dataFile);
await fs.writeFile(paths.backupFile, text, "utf8");
await writeAutomaticStateSnapshot(paths, text);
},
};
}
let lastAutomaticSnapshotAt = 0;
function boolEnv(name: string, fallback: boolean) {
const value = process.env[name]?.trim();
if (value === undefined || value === "") return fallback;
return value !== "0" && value.toLowerCase() !== "false";
}
function numberEnv(name: string, fallback: number) {
const value = Number(process.env[name]);
return Number.isFinite(value) && value >= 0 ? value : fallback;
}
function autoBackupDir(paths: BossStateStorePaths) {
const configured = process.env.BOSS_STATE_BACKUP_DIR?.trim();
return configured ? path.resolve(configured) : path.join(path.dirname(paths.dataFile), "backups");
}
function automaticSnapshotId(text: string, now: Date) {
const timestamp = now.toISOString().replace(/[:.]/g, "-");
const digest = createHash("sha256")
.update(text)
.update(String(now.getTime()))
.update(randomBytes(6))
.digest("hex")
.slice(0, 12);
return `state-snapshot-${timestamp}-${digest}`;
}
async function pruneAutomaticSnapshots(dir: string, keep: number) {
if (keep <= 0) return;
const entries = await fs.readdir(dir).catch(() => []);
const autos = await Promise.all(
entries
.filter((fileName) => /^state-snapshot-.*\.meta\.json$/.test(fileName))
.map(async (fileName) => {
const metaPath = path.join(dir, fileName);
try {
const meta = JSON.parse(await fs.readFile(metaPath, "utf8")) as { reason?: string; createdAt?: string };
if (meta.reason !== "auto:writeState") return null;
return {
snapshotId: fileName.replace(/\.meta\.json$/, ""),
createdAt: meta.createdAt || "",
};
} catch {
return null;
}
}),
);
const stale = autos
.filter((entry): entry is { snapshotId: string; createdAt: string } => Boolean(entry))
.sort((left, right) => right.createdAt.localeCompare(left.createdAt))
.slice(keep);
await Promise.all(
stale.flatMap((entry) => [
fs.rm(path.join(dir, `${entry.snapshotId}.json`), { force: true }),
fs.rm(path.join(dir, `${entry.snapshotId}.meta.json`), { force: true }),
]),
);
}
async function writeAutomaticStateSnapshot(paths: BossStateStorePaths, text: string) {
if (!boolEnv("BOSS_STATE_AUTO_BACKUP_ENABLED", true)) return;
const intervalMs = numberEnv("BOSS_STATE_AUTO_BACKUP_INTERVAL_MS", 60 * 60 * 1000);
const nowMs = Date.now();
if (lastAutomaticSnapshotAt > 0 && nowMs - lastAutomaticSnapshotAt < intervalMs) return;
const run = async () => {
const now = new Date(nowMs);
const dir = autoBackupDir(paths);
const snapshotId = automaticSnapshotId(text, now);
const filePath = path.join(dir, `${snapshotId}.json`);
const metaPath = path.join(dir, `${snapshotId}.meta.json`);
const normalizedText = text.endsWith("\n") ? text : `${text}\n`;
await fs.mkdir(dir, { recursive: true });
await fs.writeFile(filePath, normalizedText, "utf8");
const stat = await fs.stat(filePath);
await fs.writeFile(
metaPath,
`${JSON.stringify(
{
actorAccount: "system",
reason: "auto:writeState",
createdAt: now.toISOString(),
sha256: createHash("sha256").update(normalizedText).digest("hex"),
bytes: stat.size,
},
null,
2,
)}\n`,
"utf8",
);
lastAutomaticSnapshotAt = nowMs;
await pruneAutomaticSnapshots(dir, numberEnv("BOSS_STATE_AUTO_BACKUP_KEEP", 200));
};
try {
await run();
} catch (error) {
if (boolEnv("BOSS_STATE_AUTO_BACKUP_STRICT", false)) {
throw error;
}
console.warn(`boss-state auto backup failed: ${error instanceof Error ? error.message : String(error)}`);
}
}
async function postgresClient() {
const connectionString = process.env.BOSS_DATABASE_URL?.trim();
if (!connectionString) {

View File

@@ -0,0 +1,285 @@
import type {
ComputerControlIntentCategory,
ComputerControlPlatform,
ComputerControlRiskLevel,
ComputerUseProvider,
} from "@/lib/boss-data";
export type MasterAgentExecutionMode = "discussion" | "thread" | "development" | "browser" | "desktop";
export type MasterAgentIntentRoutingSource = "fast_path" | "semantic_heuristic";
export type MacComputerUseProvider = ComputerUseProvider;
export interface MasterAgentControlIntentClassification {
intentCategory: ComputerControlIntentCategory;
executionMode: MasterAgentExecutionMode;
riskLevel: ComputerControlRiskLevel;
source?: MasterAgentIntentRoutingSource;
confidence?: number;
platform?: ComputerControlPlatform;
recommendedProvider?: MacComputerUseProvider;
}
function normalizeIntentText(value: string) {
return value.trim().toLowerCase();
}
function includesAny(text: string, keywords: string[]) {
return keywords.some((keyword) => text.includes(keyword.toLowerCase()));
}
function buildResult(
intentCategory: ComputerControlIntentCategory,
executionMode: MasterAgentExecutionMode,
riskLevel: ComputerControlRiskLevel,
options: {
source?: MasterAgentIntentRoutingSource;
confidence?: number;
recommendedProvider?: MacComputerUseProvider;
} = {},
): MasterAgentControlIntentClassification {
const platform =
executionMode === "browser" || executionMode === "desktop" ? ("macos" as const) : undefined;
return {
intentCategory,
executionMode,
riskLevel,
source: options.source ?? "semantic_heuristic",
confidence: options.confidence,
platform,
recommendedProvider: options.recommendedProvider,
};
}
const DISCUSSION_SIGNALS = [
"讨论",
"分析",
"评估",
"方案",
"怎么",
"如何",
"为什么",
"是什么",
"能否",
"可不可以",
"有没有",
"设计",
"建议",
"解释",
"总结",
"规划",
];
const ACTION_SIGNALS = [
"帮我",
"你去",
"打开",
"点开",
"点击",
"输入",
"填写",
"发送",
"搜索",
"搜一下",
"搜",
"查一下",
"找一下",
"播放",
"切到",
"进入",
"访问",
"登录",
"操作",
"控制",
"运行",
"启动",
"关闭",
"关掉",
"整理",
"复制",
"移动",
"删除",
"上传",
"下载",
"安装",
"卸载",
];
const BROWSER_SIGNALS = [
"chrome",
"safari",
"youtube",
"油管",
"google",
"百度",
"浏览器",
"网页",
"网站",
"url",
"http://",
"https://",
"搜索页面",
"页面里面搜索",
"页面里搜索",
"表单",
"登录网站",
"打开网站",
"打开后台",
"打开页面",
"提交表单",
];
const WEB_RESEARCH_SIGNALS = [
"搜一下",
"搜索",
"查一下",
"查找",
"检索",
"找资料",
"资料",
"调研",
];
const MAC_DESKTOP_SIGNALS = [
"mac",
"电脑",
"本机",
"桌面",
"系统设置",
"finder",
"访达",
"微信",
"飞书",
"telegram",
"qq",
"应用",
"软件",
"窗口",
"文件",
"下载目录",
"安装包",
];
const DEVELOPMENT_ACTION_SIGNALS = [
"继续开发",
"直接开发",
"开始开发",
"改代码",
"修复",
"跑测试",
"联调",
"实现",
"提交代码",
"构建",
"回归测试",
"debug",
"编译",
"打包",
"部署",
];
const HIGH_RISK_ACTION_SIGNALS = [
"删除",
"卸载",
"发送",
"发消息",
"发邮件",
"提交表单",
"提交订单",
"发布",
"付款",
"购买",
"转账",
"授权",
"改密码",
];
function hasActionIntent(text: string) {
return includesAny(text, ACTION_SIGNALS);
}
function isDiscussionOnly(text: string) {
if (!includesAny(text, DISCUSSION_SIGNALS)) return false;
if (text.includes("讨论") || text.includes("分析") || text.includes("评估") || text.includes("方案")) {
return !includesAny(text, ["你去", "帮我打开", "帮我搜索", "帮我查", "帮我找", "帮我点击"]);
}
if (text.includes("怎么开发") || text.includes("如何开发") || text.includes("怎么实现") || text.includes("如何实现")) {
return !includesAny(text, ["继续", "直接", "开始", "马上", "修复", "改代码", "跑测试"]);
}
return !hasActionIntent(text);
}
function resolveRiskLevel(text: string): ComputerControlRiskLevel {
return includesAny(text, HIGH_RISK_ACTION_SIGNALS) ? "high" : "medium";
}
function isMacDesktopAction(text: string) {
if (!hasActionIntent(text)) return false;
if (includesAny(text, ["打开应用", "打开软件", "系统设置", "finder", "访达"])) return true;
return includesAny(text, MAC_DESKTOP_SIGNALS) &&
includesAny(text, ["打开", "找一下", "查找", "切到", "点击", "输入", "操作", "控制", "整理", "复制", "移动", "删除"]);
}
function isBrowserAction(text: string) {
if (includesAny(text, ["youtube", "油管"]) && includesAny(text, ["打开", "搜索", "搜", "播放", "找"])) {
return true;
}
if (includesAny(text, BROWSER_SIGNALS) && hasActionIntent(text)) {
return true;
}
if (includesAny(text, ["用电脑", "在电脑", "这台电脑", "这台 mac", "这台mac"]) && includesAny(text, WEB_RESEARCH_SIGNALS)) {
return true;
}
return false;
}
function isDevelopmentAction(text: string) {
if (isDiscussionOnly(text)) return false;
return includesAny(text, DEVELOPMENT_ACTION_SIGNALS);
}
export function classifyMasterAgentControlIntent(
requestText: string,
): MasterAgentControlIntentClassification {
const text = normalizeIntentText(requestText);
if (!text) {
return buildResult("discussion_only", "discussion", "low", {
source: "fast_path",
confidence: 1,
});
}
if (isDiscussionOnly(text)) {
return buildResult("discussion_only", "discussion", "low", {
source: "fast_path",
confidence: 0.92,
});
}
if (isDevelopmentAction(text)) {
return buildResult("project_development", "development", "medium", {
source: "semantic_heuristic",
confidence: 0.86,
});
}
if (isBrowserAction(text)) {
return buildResult("browser_control", "browser", resolveRiskLevel(text), {
source: "semantic_heuristic",
confidence: 0.88,
recommendedProvider: "openai-computer-use",
});
}
if (isMacDesktopAction(text)) {
return buildResult("desktop_control", "desktop", resolveRiskLevel(text), {
source: "semantic_heuristic",
confidence: 0.87,
recommendedProvider: "codex-computer-use",
});
}
return buildResult("discussion_only", "discussion", "low", {
source: "semantic_heuristic",
confidence: 0.72,
});
}

View File

@@ -0,0 +1,35 @@
import { subscribeBossEvents } from "@/lib/boss-events";
export function waitForMasterAgentTaskWakeup(
deviceId: string,
waitMs: number,
signal?: AbortSignal,
) {
const normalizedWaitMs = Math.max(0, Math.floor(waitMs));
if (!deviceId || normalizedWaitMs <= 0 || signal?.aborted) {
return Promise.resolve(false);
}
return new Promise<boolean>((resolve) => {
let settled = false;
const finish = (woke: boolean) => {
if (settled) return;
settled = true;
clearTimeout(timer);
unsubscribe();
signal?.removeEventListener("abort", onAbort);
resolve(woke);
};
const onAbort = () => finish(false);
const unsubscribe = subscribeBossEvents((event, payload) => {
if (event !== "master_agent.task.updated") return;
if (payload.deviceId !== deviceId) return;
if (payload.status !== "queued") return;
finish(true);
});
const timer = setTimeout(() => finish(false), normalizedWaitMs);
signal?.addEventListener("abort", onAbort, { once: true });
});
}

View File

@@ -50,6 +50,17 @@ test.beforeEach(async () => {
createdAt: now,
updatedAt: now,
},
{
companyId: "otherco",
name: "OtherCo 制造",
ownerAccount: "owner@otherco.com",
successOwnerAccount: "cs2@boss.com",
planTier: "standard",
contractExpiresAt: "2027-04-30T00:00:00+08:00",
status: "active",
createdAt: now,
updatedAt: now,
},
];
state.authAccounts = [
{
@@ -78,6 +89,17 @@ test.beforeEach(async () => {
createdAt: now,
updatedAt: now,
},
{
id: "account-other",
account: "owner@otherco.com",
passwordHash: "do-not-leak-other-password-hash",
displayName: "OtherCo 老板",
role: "admin",
status: "active",
companyId: "otherco",
createdAt: now,
updatedAt: now,
},
];
state.authSessions = [];
state.devices = [
@@ -121,6 +143,26 @@ test.beforeEach(async () => {
computerUse: { connected: false },
},
},
{
id: "other-mac",
name: "OtherCo Mac",
avatar: "O",
account: "owner@otherco.com",
companyId: "otherco",
source: "production",
status: "online",
projects: ["project-other"],
quota5h: 0,
quota7d: 0,
lastSeenAt: "2026-04-30T09:58:00+08:00",
preferredExecutionMode: "cli",
capabilities: {
gui: { connected: true },
cli: { connected: true },
browserAutomation: { connected: true },
computerUse: { connected: true },
},
},
];
state.projects = [
{
@@ -150,6 +192,33 @@ test.beforeEach(async () => {
goals: [],
versions: [],
},
{
id: "project-other",
name: "OtherCo 私有项目",
pinned: false,
deviceIds: ["other-mac"],
preview: "不应出现在 Acme 企业后台",
updatedAt: now,
lastMessageAt: now,
isGroup: false,
threadMeta: {
projectId: "project-other",
threadId: "thread-other",
threadDisplayName: "OtherCo 线程",
folderName: "otherco",
activityIconCount: 0,
updatedAt: now,
},
groupMembers: [],
createdByAgent: false,
collaborationMode: "development",
approvalState: "not_required",
unreadCount: 0,
riskLevel: "low",
messages: [],
goals: [],
versions: [],
},
];
state.deviceSkills = [
{
@@ -221,12 +290,65 @@ async function authedRequest(account: string, role: "member" | "admin" | "highes
});
}
async function authedScopedRequest(account: string, role: "member" | "admin" | "highest_admin", scope: string) {
const session = await data.createAuthSession({
account,
role,
displayName: account,
loginMethod: "password",
});
return new NextRequest(`http://127.0.0.1:3000/api/v1/admin/backoffice?scope=${scope}`, {
headers: {
cookie: `${authCookie}=${session.sessionToken}`,
},
});
}
test("backoffice bff rejects non highest admin accounts", async () => {
await setup();
const response = await getBackoffice(await authedRequest("dev@acme.com", "member"));
assert.equal(response.status, 403);
});
test("enterprise backoffice allows company admins and filters to their company", async () => {
await setup();
const response = await getBackoffice(await authedScopedRequest("owner@acme.com", "admin", "enterprise"));
assert.equal(response.status, 200);
const payload = await response.json();
assert.equal(payload.ok, true);
assert.equal(payload.surface, "enterprise");
assert.equal(payload.currentCompany.companyId, "acme");
assert.deepEqual(
payload.menuTree.map((item: { label: string }) => item.label),
["企业总览", "组织与成员", "设备与项目", "Agent 与流程", "Skill 中心", "风险与审计", "备份与回退", "企业设置"],
);
assert.deepEqual(
payload.insights.agentFlowSteps,
["主 Agent", "项目 Agent", "本地 Agent", "Codex / Computer Use / Skill"],
);
assert.deepEqual(
payload.insights.recoveryActions,
["消息恢复", "项目目标恢复", "权限撤销", "Skill 回滚", "Codex checkpoint"],
);
assert.equal(payload.insights.organizationUnits.includes("研发部"), true);
assert.equal(payload.tenants.length, 1);
assert.equal(payload.tenants[0].companyId, "acme");
assert.equal(payload.users.every((user: { companyId: string }) => user.companyId === "acme"), true);
assert.equal(payload.resourceGroups.devices.some((device: { id: string }) => device.id === "other-mac"), false);
assert.equal(
payload.resourceGroups.projects.some((project: { name: string }) => project.name === "OtherCo 私有项目"),
false,
);
assert.equal(JSON.stringify(payload).includes("do-not-leak-other-password-hash"), false);
});
test("enterprise backoffice rejects normal members", async () => {
await setup();
const response = await getBackoffice(await authedScopedRequest("dev@acme.com", "member", "enterprise"));
assert.equal(response.status, 403);
});
test("backoffice bff exposes yudao style management contract without secrets", async () => {
await setup();
const response = await getBackoffice(await authedRequest("owner@acme.com", "highest_admin"));
@@ -236,8 +358,18 @@ test("backoffice bff exposes yudao style management contract without secrets", a
assert.equal(payload.ok, true);
assert.deepEqual(
payload.menuTree.map((item: { label: string }) => item.label),
["工作台", "租户管理", "账号管理", "角色权限", "资源授权", "Skill 中心", "风险告警", "审计日志", "系统设置"],
["平台总览", "企业开通", "客户与套餐", "全局设备", "全局风险", "客户成功", "系统审计", "计费与授权", "平台设置"],
);
assert.equal(payload.surface, "platform");
assert.deepEqual(
payload.insights.onboardingSteps,
["企业信息", "老板账号", "套餐授权", "设备与交付"],
);
assert.deepEqual(
payload.insights.serviceStatuses.map((item: { label: string }) => item.label),
["Boss API", "OTA", "Codex Provider", "Computer Use", "Skill Hub"],
);
assert.equal(payload.insights.riskAggregates.some((item: { label: string }) => item.label === "设备离线"), true);
assert.equal(payload.yudaoMapping.tenant, "adminCompanies");
assert.equal(payload.yudaoMapping.user, "authAccounts");
assert.equal(payload.yudaoMapping.role, "BOSS_PERMISSION_TEMPLATES");
@@ -249,7 +381,7 @@ test("backoffice bff exposes yudao style management contract without secrets", a
assert.equal(payload.users[0].passwordHash, undefined);
assert.equal(payload.users[0].mfaSecret, undefined);
assert.equal(payload.roles.permissionTemplates.length >= 3, true);
assert.equal(payload.resourceGroups.devices.length, 2);
assert.equal(payload.resourceGroups.devices.length, 3);
assert.equal(
payload.resourceGroups.projects.some((project: { name: string }) => project.name === "Acme 生产项目"),
true,

View File

@@ -0,0 +1,180 @@
import test from "node:test";
import assert from "node:assert/strict";
import os from "node:os";
import path from "node:path";
import { mkdtemp, rm } from "node:fs/promises";
import { NextRequest } from "next/server";
let runtimeRoot = "";
let data: typeof import("../src/lib/boss-data.ts");
let authCookie = "";
let getBackups: (typeof import("../src/app/api/v1/admin/backups/route.ts"))["GET"];
let postBackups: (typeof import("../src/app/api/v1/admin/backups/route.ts"))["POST"];
let baseState: Awaited<ReturnType<typeof import("../src/lib/boss-data.ts")["readState"]>>;
async function setup() {
if (runtimeRoot) return;
runtimeRoot = await mkdtemp(path.join(os.tmpdir(), "boss-admin-backups-"));
process.env.BOSS_RUNTIME_ROOT = runtimeRoot;
process.env.BOSS_STATE_FILE = path.join(runtimeRoot, "boss-state.json");
process.env.BOSS_STATE_BACKUP_DIR = path.join(runtimeRoot, "state-backups");
process.env.BOSS_STATE_AUTO_BACKUP_ENABLED = "1";
process.env.BOSS_STATE_AUTO_BACKUP_INTERVAL_MS = "0";
const [dataModule, authModule, routeModule] = await Promise.all([
import("../src/lib/boss-data.ts"),
import("../src/lib/boss-auth.ts"),
import("../src/app/api/v1/admin/backups/route.ts"),
]);
data = dataModule;
authCookie = authModule.AUTH_SESSION_COOKIE;
getBackups = routeModule.GET;
postBackups = routeModule.POST;
baseState = structuredClone(await data.readState());
}
test.after(async () => {
if (runtimeRoot) {
await rm(runtimeRoot, { recursive: true, force: true });
}
});
test.beforeEach(async () => {
await setup();
const state = structuredClone(baseState);
state.authAccounts = [
{
id: "account-owner",
account: "owner@boss.com",
passwordHash: "secret",
displayName: "平台管理员",
role: "highest_admin",
createdAt: "2026-05-16T10:00:00+08:00",
updatedAt: "2026-05-16T10:00:00+08:00",
},
{
id: "account-member",
account: "member@boss.com",
passwordHash: "secret",
displayName: "普通成员",
role: "member",
createdAt: "2026-05-16T10:00:00+08:00",
updatedAt: "2026-05-16T10:00:00+08:00",
},
];
state.projects = [
{
id: "project-before",
name: "备份前项目",
pinned: false,
deviceIds: [],
preview: "before",
updatedAt: "2026-05-16T10:00:00+08:00",
lastMessageAt: "2026-05-16T10:00:00+08:00",
isGroup: false,
threadMeta: {
projectId: "project-before",
threadId: "thread-before",
threadDisplayName: "备份前线程",
folderName: "before",
activityIconCount: 0,
updatedAt: "2026-05-16T10:00:00+08:00",
},
groupMembers: [],
createdByAgent: false,
collaborationMode: "development",
approvalState: "not_required",
unreadCount: 0,
riskLevel: "low",
messages: [],
goals: [],
versions: [],
},
];
state.authSessions = [];
await data.writeState(state);
});
async function requestFor(account: string, role: "member" | "admin" | "highest_admin", init: RequestInit = {}) {
const session = await data.createAuthSession({
account,
role,
displayName: account,
loginMethod: "password",
});
return new NextRequest("http://127.0.0.1:3000/api/v1/admin/backups", {
...init,
headers: {
"content-type": "application/json",
...(init.headers ?? {}),
cookie: `${authCookie}=${session.sessionToken}`,
},
});
}
test("admin backups require highest admin", async () => {
await setup();
const unauthenticated = await getBackups(new NextRequest("http://127.0.0.1:3000/api/v1/admin/backups"));
assert.equal(unauthenticated.status, 401);
const forbidden = await getBackups(await requestFor("member@boss.com", "member"));
assert.equal(forbidden.status, 403);
});
test("highest admin can create, list, and restore a state snapshot", async () => {
const createResponse = await postBackups(
await requestFor("owner@boss.com", "highest_admin", {
method: "POST",
body: JSON.stringify({ action: "create_snapshot", reason: "before risky operation" }),
}),
);
assert.equal(createResponse.status, 200);
const createPayload = await createResponse.json();
assert.equal(createPayload.ok, true);
assert.equal(createPayload.snapshot.reason, "before risky operation");
assert.equal(createPayload.snapshot.actorAccount, "owner@boss.com");
assert.match(createPayload.snapshot.snapshotId, /^state-snapshot-/);
const mutated = await data.readState();
mutated.projects[0]!.name = "误操作后的项目";
await data.writeState(mutated);
const listResponse = await getBackups(await requestFor("owner@boss.com", "highest_admin"));
assert.equal(listResponse.status, 200);
const listPayload = await listResponse.json();
assert.equal(listPayload.ok, true);
assert.equal(listPayload.status.restorePointCount >= 1, true);
assert.equal(listPayload.snapshots.some((snapshot: { snapshotId: string }) => snapshot.snapshotId === createPayload.snapshot.snapshotId), true);
const restoreResponse = await postBackups(
await requestFor("owner@boss.com", "highest_admin", {
method: "POST",
body: JSON.stringify({ action: "restore_snapshot", snapshotId: createPayload.snapshot.snapshotId }),
}),
);
assert.equal(restoreResponse.status, 200);
const restorePayload = await restoreResponse.json();
assert.equal(restorePayload.ok, true);
assert.equal(restorePayload.restored.snapshotId, createPayload.snapshot.snapshotId);
assert.match(restorePayload.preRestoreSnapshot.snapshotId, /^state-snapshot-/);
const restored = await data.readState();
assert.equal(restored.projects.find((project) => project.id === "project-before")?.name, "备份前项目");
});
test("state writes create automatic restore points", async () => {
const state = await data.readState();
state.projects[0]!.name = "自动备份触发项目";
await data.writeState(state);
const listResponse = await getBackups(await requestFor("owner@boss.com", "highest_admin"));
assert.equal(listResponse.status, 200);
const listPayload = await listResponse.json();
assert.equal(listPayload.ok, true);
assert.equal(
listPayload.snapshots.some((snapshot: { reason?: string; actorAccount?: string }) =>
snapshot.reason === "auto:writeState" && snapshot.actorAccount === "system",
),
true,
);
});

View File

@@ -4,18 +4,19 @@ import { readFile } from "node:fs/promises";
const appRootPath = new URL("../src/app/page.tsx", import.meta.url);
const appUiPath = new URL("../src/components/app-ui.tsx", import.meta.url);
const bossAuthPath = new URL("../src/lib/boss-auth.ts", import.meta.url);
const caddyfilePath = new URL("../deployment/Caddyfile", import.meta.url);
async function readSource(path: URL) {
return readFile(path, "utf8");
}
test("admin host root redirects to the platform admin console", async () => {
test("admin host root uses the admin web shell as the platform console", async () => {
const source = await readSource(appRootPath);
assert.match(source, /headers/);
assert.match(source, /admin\.boss\.hyzq\.net/);
assert.match(source, /redirect\(session \? "\/admin" : "\/auth\/login"\)/);
assert.match(source, /redirect\(session \? "\/admin-web\/index\.html" : "\/auth\/login"\)/);
});
test("web login returns admin host users to the admin console", async () => {
@@ -23,13 +24,23 @@ test("web login returns admin host users to the admin console", async () => {
assert.match(source, /admin\.boss\.hyzq\.net/);
assert.match(source, /navigateAfterLogin/);
assert.match(source, /\/admin/);
assert.match(source, /\?\s*"\/"\s*:\s*"\/conversations"/);
});
test("authenticated admin host users return to the admin root domain", async () => {
const source = await readSource(bossAuthPath);
assert.match(source, /headers/);
assert.match(source, /admin\.boss\.hyzq\.net/);
assert.match(source, /redirect\("\/"\)/);
});
test("Caddy serves the platform admin subdomain", async () => {
const source = await readSource(caddyfilePath);
assert.match(source, /admin\.boss\.hyzq\.net/);
assert.match(source, /redir \/ \/admin/);
assert.match(source, /@adminRoot path \//);
assert.match(source, /rewrite \* \/admin-web\/index\.html/);
assert.doesNotMatch(source, /redir \/ \/enterprise-admin/);
assert.match(source, /reverse_proxy 127\.0\.0\.1:3000/);
});

View File

@@ -1,6 +1,6 @@
import test from "node:test";
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import { access, readFile } from "node:fs/promises";
const adminPagePath = new URL("../src/app/admin/page.tsx", import.meta.url);
const adminAppPath = new URL("../src/components/admin/boss-admin-app.tsx", import.meta.url);
@@ -10,59 +10,26 @@ async function readSource(path: URL) {
return readFile(path, "utf8");
}
test("/admin page gates the refine shell behind highest_admin", async () => {
async function fileExists(path: URL) {
try {
await access(path);
return true;
} catch {
return false;
}
}
test("/admin is only a compatibility redirect to the admin root domain", async () => {
const source = await readSource(adminPagePath);
assert.match(source, /requirePageSession/);
assert.match(source, /BossAdminApp/);
assert.match(source, /session\.role\s*!==\s*["']highest_admin["']/);
assert.match(source, /仅最高管理员可用/);
assert.match(source, /redirect/);
assert.match(source, /redirect\("\/"\)/);
assert.doesNotMatch(source, /BossAdminApp/);
assert.doesNotMatch(source, /antd\/dist\/reset\.css/);
assert.doesNotMatch(source, /api\/v1\/admin\/overview/);
});
test("BossAdminApp wires refine resources and enterprise overview sections", async () => {
const source = await readSource(adminAppPath);
assert.match(source, /['"]use client['"]/);
assert.doesNotMatch(source, /@refinedev\/antd/);
assert.match(source, /Refine/);
assert.match(source, /@refinedev\/core/);
assert.match(source, /ConfigProvider/);
assert.match(source, /Table/);
assert.match(source, /Alert/);
for (const resource of ["companies", "accounts", "devices", "risks", "auditLogs"]) {
assert.match(source, new RegExp(`name:\\s*["']${resource}["']`));
}
for (const title of ["平台运营驾驶舱", "客户与账号", "授权工作台", "风险与治理"]) {
assert.match(source, new RegExp(title));
}
for (const title of ["今日待处理", "客户健康排行", "关键风险队列", "节点健康", "最近事件"]) {
assert.match(source, new RegExp(title));
}
for (const riskAction of ["assign_owner", "set_sla", "负责人", "SLA"]) {
assert.match(source, new RegExp(riskAction));
}
assert.doesNotMatch(source, /window\.prompt/);
});
test("BossAdminApp uses the approved PC To B admin shell", async () => {
const source = await readSource(adminAppPath);
assert.match(source, /adminShell/);
assert.match(source, /adminSidebar/);
assert.match(source, /adminHeader/);
assert.match(source, /currentSubtitle/);
assert.match(source, /bg-\[#F3F5F2\]/);
assert.match(source, /highest_admin/);
assert.match(source, /开放风险/);
assert.match(source, /风险通知/);
});
test("admin data provider reads the overview endpoint and supports initialOverview", async () => {
const appSource = await readSource(adminAppPath);
const providerSource = await readSource(dataProviderPath);
assert.match(appSource, /initialOverview/);
assert.doesNotMatch(providerSource, /@refinedev\/antd/);
assert.match(providerSource, /\/api\/v1\/admin\/overview/);
assert.match(providerSource, /initialOverview/);
test("legacy Next admin UI files are removed after enterprise-admin migration", async () => {
assert.equal(await fileExists(adminAppPath), false);
assert.equal(await fileExists(dataProviderPath), false);
});

View File

@@ -145,3 +145,66 @@ test("admin overview includes open risk notifications", async () => {
assert.equal(payload.summary.openNotifications, 1);
assert.equal(payload.notifications[0].riskId, "ops-fault:fault-overdue");
});
test("risk scan creates operational faults for computer use and boss-agent OTA failures", async () => {
const state = await data.readState();
await data.writeState({
...state,
adminNotifications: [],
opsFaults: [],
devices: [
{
id: "mac-a",
name: "客户 Mac",
avatar: "M",
account: "customer@example.com",
companyId: "tenant-a",
source: "production",
status: "online",
projects: ["project-a"],
quota5h: 0,
quota7d: 0,
lastSeenAt: "2026-04-27T17:20:00+08:00",
capabilities: {
gui: { connected: true },
cli: { connected: true },
browserAutomation: { connected: true },
computerUse: { connected: false },
codexAppServer: { connected: true },
},
},
],
appLogs: [
{
logId: "log-ota-failed",
deviceId: "mac-a",
level: "error",
source: "local_agent",
category: "local_agent.boss_agent_ota_failed",
message: "boss-agent OTA 更新失败",
detail: "BOSS_AGENT_OTA_CHECKSUM_MISMATCH",
mirroredToProject: false,
createdAt: "2026-04-27T17:21:00+08:00",
},
],
});
const response = await postScan(await adminRequest("http://127.0.0.1:3000/api/v1/admin/risks/scan", {
method: "POST",
}));
assert.equal(response.status, 200);
const payload = await response.json();
assert.equal(payload.createdFaults.length, 2);
assert.equal(payload.createdFaults.some((fault: { faultKey: string }) => fault.faultKey === "BOSS.COMPUTER_USE.UNAVAILABLE"), true);
assert.equal(payload.createdFaults.some((fault: { faultKey: string }) => fault.faultKey === "BOSS_AGENT.OTA.FAILED"), true);
const nextState = await data.readState();
assert.equal(nextState.opsFaults.some((fault) => fault.faultKey === "BOSS.COMPUTER_USE.UNAVAILABLE"), true);
assert.equal(nextState.opsFaults.some((fault) => fault.faultKey === "BOSS_AGENT.OTA.FAILED"), true);
const second = await postScan(await adminRequest("http://127.0.0.1:3000/api/v1/admin/risks/scan", {
method: "POST",
}));
const secondPayload = await second.json();
assert.equal(secondPayload.createdFaults.length, 0);
});

View File

@@ -4,6 +4,10 @@ import { readFile } from "node:fs/promises";
const authHelpPagePath = new URL("../src/app/auth/help/page.tsx", import.meta.url);
const loginPagePath = new URL("../src/app/auth/login/page.tsx", import.meta.url);
const enterpriseLoginShellPath = new URL(
"../src/components/enterprise-admin-login-shell.tsx",
import.meta.url,
);
const securityPagePath = new URL("../src/app/me/security/page.tsx", import.meta.url);
const bossDataPath = new URL("../src/lib/boss-data.ts", import.meta.url);
@@ -25,6 +29,19 @@ test("auth-facing pages do not advertise temporary auto login", async () => {
assert.match(combined, /krisolo/);
});
test("web login page uses the enterprise admin visual shell", async () => {
const source = [
await readSource(loginPagePath),
await readSource(enterpriseLoginShellPath),
].join("\n");
assert.match(source, /EnterpriseAdminLoginShell/);
assert.match(source, /Boss 企业管理后台/);
assert.match(source, /登录企业后台/);
assert.match(source, /仅限授权管理员访问/);
assert.match(source, /平台级权限/);
});
test("seeded OTA release notes do not describe login as one-click entry", async () => {
const source = await readSource(bossDataPath);

View File

@@ -27,33 +27,60 @@ test("independent Boss admin web app uses the backoffice BFF with cookie session
assert.match(apiSource, /\/api\/v1\/admin\/access/);
assert.match(apiSource, /\/api\/v1\/admin\/risks\/actions/);
assert.match(apiSource, /\/api\/v1\/admin\/skills\/requests/);
assert.match(apiSource, /\/api\/v1\/admin\/backups/);
assert.match(apiSource, /credentials:\s*["']include["']/);
assert.match(apiSource, /menuTree/);
assert.match(apiSource, /tenants/);
assert.match(apiSource, /resourceGroups/);
for (const fn of ["postAdminAccess", "postRiskAction", "postSkillLifecycleRequest"]) {
for (const fn of ["postAdminAccess", "postRiskAction", "postSkillLifecycleRequest", "fetchAdminBackups", "createAdminBackup", "restoreAdminBackup"]) {
assert.match(apiSource, new RegExp(`function\\s+${fn}|const\\s+${fn}`));
}
for (const action of ["create_snapshot", "restore_snapshot"]) {
assert.match(apiSource, new RegExp(action));
}
});
test("independent Boss admin web app includes enterprise management sections", async () => {
const appSource = await readSource("../apps/boss-admin-web/src/App.vue");
for (const label of [
"Boss 企业后台",
"工作台",
"租户管理",
"账号管理",
"角色权限",
"资源授权",
"Boss 平台总后台",
"企业管理后台",
"平台总览",
"企业开通",
"客户与套餐",
"全局设备",
"客户成功",
"全局风险",
"服务连接状态",
"最近开通企业",
"开通预览",
"交付检查",
"客户健康列表",
"风险聚合",
"客户成功跟进记录",
"企业总览",
"组织与成员",
"设备与项目",
"Agent 与流程",
"Skill 中心",
"风险告警",
"审计日志",
"风险与审计",
"备份与回退",
"经营目标",
"部门进展",
"主 Agent 摘要",
"组织结构",
"权限详情",
"使用审计",
"业务级回退",
]) {
assert.match(appSource, new RegExp(label));
}
assert.match(appSource, /adminSurface/);
assert.match(appSource, /setSurface/);
assert.match(appSource, /menuTree/);
assert.match(appSource, /workbench/);
assert.match(appSource, /platform-overview/);
assert.match(appSource, /enterprise-overview/);
assert.match(appSource, /tenants/);
});
@@ -76,6 +103,9 @@ test("independent Boss admin web app exposes management actions instead of read
"关闭风险",
"创建工单",
"创建 Skill 请求",
"创建状态快照",
"恢复到此快照",
"快照清单",
]) {
assert.match(appSource, new RegExp(label));
}
@@ -94,6 +124,10 @@ test("independent Boss admin web app exposes management actions instead of read
]) {
assert.match(appSource, new RegExp(action));
}
assert.match(appSource, /backupSnapshots/);
assert.match(appSource, /loadBackupSnapshots/);
assert.match(appSource, /createAdminBackup/);
assert.match(appSource, /restoreAdminBackup/);
});
test("root Next project isolates the independent Vue admin workspace", async () => {

View File

@@ -0,0 +1,51 @@
import test from "node:test";
import assert from "node:assert/strict";
import { createHash } from "node:crypto";
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
function sha256(value: string) {
return createHash("sha256").update(value).digest("hex");
}
test("boss-agent ota asset reader exposes published mac package metadata", async () => {
const runtimeRoot = await mkdtemp(path.join(os.tmpdir(), "boss-agent-ota-asset-"));
const downloads = path.join(runtimeRoot, "public", "downloads");
await mkdir(downloads, { recursive: true });
const archive = "boss agent zip";
const hash = sha256(archive);
await writeFile(path.join(downloads, "boss-agent-mac-latest.zip"), archive, "utf8");
await writeFile(
path.join(downloads, "boss-agent-mac-latest.json"),
JSON.stringify({
version: "20260516194026",
fileName: "boss-agent-mac-latest.zip",
sizeBytes: Buffer.byteLength(archive),
sha256: hash,
updatedAt: "2026-05-16T11:40:26.000Z",
downloadUrl: "/api/v1/boss-agent/ota/package",
}),
"utf8",
);
const previousRoot = process.env.BOSS_RUNTIME_ROOT;
process.env.BOSS_RUNTIME_ROOT = runtimeRoot;
try {
const otaModule = await import(`../src/lib/boss-agent-ota.ts?asset=${Date.now()}`);
const asset = await otaModule.getPublishedBossAgentOtaAsset();
assert.ok(asset);
assert.equal(asset.version, "20260516194026");
assert.equal(asset.fileName, "boss-agent-mac-latest.zip");
assert.equal(asset.sizeBytes, Buffer.byteLength(archive));
assert.equal(asset.sha256, hash);
assert.equal(asset.downloadUrl, "/api/v1/boss-agent/ota/package");
} finally {
if (previousRoot === undefined) {
delete process.env.BOSS_RUNTIME_ROOT;
} else {
process.env.BOSS_RUNTIME_ROOT = previousRoot;
}
await rm(runtimeRoot, { recursive: true, force: true });
}
});

View File

@@ -0,0 +1,221 @@
import test from "node:test";
import assert from "node:assert/strict";
import { createHash } from "node:crypto";
import { createServer } from "node:http";
import os from "node:os";
import path from "node:path";
import { mkdtemp, readFile, rm, stat } from "node:fs/promises";
import {
applyBossAgentOtaUpdate,
checkBossAgentOtaUpdate,
getBossAgentOtaRunnerConfig,
} from "../local-agent/boss-agent-ota-runner.mjs";
function listen(server, host = "127.0.0.1") {
return new Promise((resolve, reject) => {
server.once("error", reject);
server.listen(0, host, () => {
server.off("error", reject);
resolve(server.address().port);
});
});
}
function sha256(buffer) {
return createHash("sha256").update(buffer).digest("hex");
}
test("boss-agent ota runner derives config from local-agent config", () => {
const config = getBossAgentOtaRunnerConfig({}, {
bossAgentOtaEnabled: true,
bossAgentVersion: "20260516194026",
bossAgentInstallRoot: "/tmp/boss-agent/current",
bossAgentOtaDownloadDir: "/tmp/boss-agent/updates",
bossAgentOtaCheckIntervalMs: 12345,
bossAgentOtaAutoInstall: true,
});
assert.equal(config.enabled, true);
assert.equal(config.currentVersion, "20260516194026");
assert.equal(config.installRoot, "/tmp/boss-agent/current");
assert.equal(config.downloadDir, "/tmp/boss-agent/updates");
assert.equal(config.checkIntervalMs, 12345);
assert.equal(config.autoInstall, true);
});
test("boss-agent ota runner checks, downloads, verifies, and stages a mac runtime package", async () => {
const tmp = await mkdtemp(path.join(os.tmpdir(), "boss-agent-ota-runner-"));
const packageBuffer = Buffer.from("boss-agent mac runtime zip", "utf8");
const packageHash = sha256(packageBuffer);
const requestLog = [];
const server = createServer((request, response) => {
requestLog.push({
url: request.url,
token: request.headers["x-boss-device-token"],
});
if (request.url?.startsWith("/api/v1/boss-agent/ota?")) {
response.writeHead(200, { "content-type": "application/json" });
response.end(JSON.stringify({
ok: true,
currentVersion: "20260501000000",
hasUpdate: true,
latest: {
version: "20260516194026",
fileName: "boss-agent-mac-latest.zip",
sizeBytes: packageBuffer.length,
sha256: packageHash,
downloadUrl: "/api/v1/boss-agent/ota/package",
updatedAt: "2026-05-16T11:40:26.000Z",
},
}));
return;
}
if (request.url?.startsWith("/api/v1/boss-agent/ota/package")) {
response.writeHead(200, {
"content-type": "application/zip",
"content-length": String(packageBuffer.length),
"x-boss-agent-ota-sha256": packageHash,
});
response.end(packageBuffer);
return;
}
response.writeHead(404, { "content-type": "application/json" });
response.end(JSON.stringify({ ok: false }));
});
try {
const port = await listen(server);
const localConfig = {
controlPlaneUrl: `http://127.0.0.1:${port}`,
deviceId: "mac-studio",
token: "device-token",
bossAgentOtaEnabled: true,
bossAgentVersion: "20260501000000",
bossAgentOtaDownloadDir: path.join(tmp, "updates"),
bossAgentInstallRoot: path.join(tmp, "current"),
};
const runtime = {};
const status = await checkBossAgentOtaUpdate(localConfig, runtime);
assert.equal(status.hasUpdate, true);
assert.equal(status.latest.version, "20260516194026");
assert.equal(requestLog.at(0)?.token, "device-token");
assert.match(requestLog.at(0)?.url ?? "", /deviceId=mac-studio/);
assert.match(requestLog.at(0)?.url ?? "", /currentVersion=20260501000000/);
const result = await applyBossAgentOtaUpdate(localConfig, runtime, {
launchInstaller: false,
});
assert.equal(result.status, "staged");
assert.equal(result.version, "20260516194026");
assert.equal(result.sha256, packageHash);
assert.match(result.archivePath, /boss-agent-mac-latest\.zip$/);
assert.equal(await readFile(result.archivePath, "utf8"), "boss-agent mac runtime zip");
const installInfo = await stat(path.join(result.stageDir, "install.command"));
assert.equal(installInfo.isFile(), true);
assert.equal(runtime.lastBossAgentOtaApply.status, "staged");
} finally {
server.close();
await rm(tmp, { recursive: true, force: true });
}
});
test("boss-agent ota runner reports up-to-date when latest equals current version", async () => {
const server = createServer((request, response) => {
if (request.url?.startsWith("/api/v1/boss-agent/ota?")) {
response.writeHead(200, { "content-type": "application/json" });
response.end(JSON.stringify({
ok: true,
currentVersion: "20260516201125",
hasUpdate: false,
latest: {
version: "20260516201125",
fileName: "boss-agent-mac-latest.zip",
sizeBytes: 1,
sha256: "0".repeat(64),
downloadUrl: "/api/v1/boss-agent/ota/package",
updatedAt: "2026-05-16T12:11:30.842Z",
},
}));
return;
}
response.writeHead(404);
response.end();
});
try {
const port = await listen(server);
const status = await checkBossAgentOtaUpdate(
{
controlPlaneUrl: `http://127.0.0.1:${port}`,
deviceId: "macbook-air",
token: "device-token",
bossAgentOtaEnabled: true,
bossAgentVersion: "20260516201125",
},
{},
);
assert.equal(status.hasUpdate, false);
assert.equal(status.message, "BOSS_AGENT_OTA_UP_TO_DATE");
assert.equal(status.latest.version, "20260516201125");
} finally {
server.close();
}
});
test("boss-agent ota runner rejects a package with mismatched checksum", async () => {
const tmp = await mkdtemp(path.join(os.tmpdir(), "boss-agent-ota-bad-checksum-"));
const packageBuffer = Buffer.from("tampered", "utf8");
const server = createServer((request, response) => {
if (request.url?.startsWith("/api/v1/boss-agent/ota?")) {
response.writeHead(200, { "content-type": "application/json" });
response.end(JSON.stringify({
ok: true,
currentVersion: "20260501000000",
hasUpdate: true,
latest: {
version: "20260516194026",
fileName: "boss-agent-mac-latest.zip",
sizeBytes: packageBuffer.length,
sha256: "0".repeat(64),
downloadUrl: "/api/v1/boss-agent/ota/package",
updatedAt: "2026-05-16T11:40:26.000Z",
},
}));
return;
}
if (request.url?.startsWith("/api/v1/boss-agent/ota/package")) {
response.writeHead(200, { "content-type": "application/zip" });
response.end(packageBuffer);
return;
}
response.writeHead(404);
response.end();
});
try {
const port = await listen(server);
const result = await applyBossAgentOtaUpdate(
{
controlPlaneUrl: `http://127.0.0.1:${port}`,
deviceId: "mac-studio",
token: "device-token",
bossAgentOtaEnabled: true,
bossAgentVersion: "20260501000000",
bossAgentOtaDownloadDir: path.join(tmp, "updates"),
bossAgentInstallRoot: path.join(tmp, "current"),
},
{},
{ launchInstaller: false },
);
assert.equal(result.status, "failed");
assert.equal(result.error, "BOSS_AGENT_OTA_CHECKSUM_MISMATCH");
} finally {
server.close();
await rm(tmp, { recursive: true, force: true });
}
});

View File

@@ -24,6 +24,10 @@ test("boss-agent status exposes unbound QR binding and local permission states",
token: "",
primaryApiLabel: "DeepSeek V4",
backupApiLabel: "未启用",
codexAppServerEnabled: true,
codexComputerUseEnabled: true,
bossAgentVersion: "20260501000000",
bossAgentOtaEnabled: true,
licenseExpiresAt: "2027-05-12T00:00:00.000Z",
},
{
@@ -36,6 +40,13 @@ test("boss-agent status exposes unbound QR binding and local permission states",
],
lastSkillSyncOk: true,
lastSkillSyncAt: "2026-05-12T05:30:00.000Z",
lastBossAgentOtaStatus: {
hasUpdate: true,
latest: {
version: "20260516194026",
fileName: "boss-agent-mac-latest.zip",
},
},
},
{
permissions: {
@@ -55,6 +66,12 @@ test("boss-agent status exposes unbound QR binding and local permission states",
assert.equal(status.api.backup, "未启用");
assert.equal(status.license.status, "pending_binding");
assert.equal(status.skills.total, 2);
assert.equal(status.codex.bindingStatus, "connected");
assert.equal(status.codex.defaultDesktopProvider, "codex-computer-use");
assert.equal(status.agentOta.enabled, true);
assert.equal(status.agentOta.currentVersion, "20260501000000");
assert.equal(status.agentOta.hasUpdate, true);
assert.equal(status.agentOta.latestVersion, "20260516194026");
assert.equal(status.skills.syncOk, true);
assert.equal(status.permissionReadiness.coreReady, false);
assert.equal(status.permissionReadiness.fullControlReady, false);
@@ -94,6 +111,8 @@ test("boss-agent status treats token-backed devices as bound and renders enterpr
token: "boss-secret-token",
primaryApiLabel: "DeepSeek V4",
backupApiLabel: "OpenAI 备用",
codexAppServerEnabled: true,
codexComputerUseEnabled: true,
license: {
enterpriseName: "默认公司",
status: "valid",
@@ -138,6 +157,10 @@ test("boss-agent status treats token-backed devices as bound and renders enterpr
assert.doesNotMatch(html, /<div class="sidebar-title">本机权限获取<\/div>/);
assert.match(html, /Skill/);
assert.match(html, /DeepSeek V4/);
assert.match(html, /Codex 默认接管/);
assert.match(html, /Codex Computer Use/);
assert.match(html, /boss-agent OTA/);
assert.match(html, /发现新版本/);
assert.match(html, /默认公司/);
assert.doesNotMatch(html, /boss-secret-token/);
assert.doesNotMatch(html, /<h2>本机电脑权限状态<\/h2>/);
@@ -397,9 +420,13 @@ test("boss-agent mac app intercepts permission links and triggers native app per
assert.match(buildScript, /BossAgent\.icns/);
assert.match(buildScript, /iconutil -c icns/);
assert.match(buildScript, /BOSS_AGENT_CODESIGN_IDENTITY/);
assert.match(buildScript, /BOSS_AGENT_NOTARIZE/);
assert.match(buildScript, /BOSS_AGENT_NOTARY_PROFILE/);
assert.match(buildScript, /security find-identity -v -p codesigning/);
assert.match(buildScript, /falling back to ad-hoc signing/);
assert.match(buildScript, /codesign --force --deep --timestamp=none --sign "\$SIGNING_IDENTITY" "\$APP_DIR"/);
assert.match(buildScript, /xcrun notarytool submit/);
assert.match(buildScript, /xcrun stapler staple "\$APP_DIR"/);
const statusSource = readFileSync("local-agent/boss-agent-status.mjs", "utf8");
assert.match(statusSource, /boss-agent:\/\/permissions\/open/);

View File

@@ -137,3 +137,53 @@ test("completed desktop control task mirrors a control summary message into mast
assert.equal(controlSummary?.body, "桌面控制已完成:打开微信并准备切到聊天窗口");
assert.equal((controlSummary as { controlTarget?: string }).controlTarget, "微信");
});
test("failed browser control task uses demo-safe remote control wording", async () => {
await setup();
const [requestMessage] = await appendProjectMessages({
projectId: "master-agent",
messages: [
{
senderLabel: "Boss 超级管理员",
body: "打开 Chrome 访问 example.com",
kind: "text",
},
],
});
const task = await queueMasterAgentTask({
projectId: "master-agent",
taskType: "browser_control",
requestMessageId: requestMessage.id,
requestText: "打开 Chrome 访问 example.com",
requestedBy: "Boss 超级管理员",
requestedByAccount: "krisolo",
deviceId: "mac-studio",
accountId: "openai-master",
accountLabel: "gpt-5.4-mini",
intentCategory: "browser_control",
runtimeKind: "browser-automation-runtime",
riskLevel: "medium",
confirmationPolicy: "light_confirm",
requiresUserConfirmation: true,
confirmationScopeKey: "mac-studio:master-agent",
});
await completeMasterAgentTask({
taskId: task.taskId,
deviceId: "mac-studio",
status: "failed",
errorMessage: "Master Codex Node 执行失败connect timeout",
});
const state = await readState();
const project = state.projects.find((item) => item.id === "master-agent");
const visibleFailure = project?.messages.at(-1);
assert.equal(visibleFailure?.sender, "master");
assert.equal(visibleFailure?.senderLabel, "主 Agent · gpt-5.4-mini");
assert.match(visibleFailure?.body ?? "", /远程控制未完成/);
assert.match(visibleFailure?.body ?? "", /目标电脑响应超时/);
assert.doesNotMatch(visibleFailure?.body ?? "", /Master Codex Node|local_agent|执行失败/);
});

View File

@@ -6,7 +6,7 @@ import { fileURLToPath } from "node:url";
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
test("shipped local-agent configs include browser and desktop runtime smoke defaults", async () => {
test("shipped local-agent configs include browser and desktop runtime defaults", async () => {
const exampleConfig = JSON.parse(
await readFile(path.join(repoRoot, "local-agent", "config.example.json"), "utf8"),
);
@@ -25,10 +25,26 @@ test("shipped local-agent configs include browser and desktop runtime smoke defa
assert.equal(exampleConfig.computerUseEnabled, true);
assert.equal(cloudConfig.computerUseEnabled, true);
assert.equal(exampleConfig.codexAppServerEnabled, true);
assert.equal(cloudConfig.codexAppServerEnabled, true);
assert.equal(exampleConfig.codexComputerUseEnabled, true);
assert.equal(cloudConfig.codexComputerUseEnabled, true);
assert.equal(exampleConfig.codexComputerUseCommand, "node");
assert.equal(cloudConfig.codexComputerUseCommand, "node");
assert.deepEqual(exampleConfig.codexComputerUseArgs, ["scripts/codex-computer-use-runtime.mjs"]);
assert.deepEqual(cloudConfig.codexComputerUseArgs, ["scripts/codex-computer-use-runtime.mjs"]);
assert.equal(exampleConfig.codexComputerUseFallbackToCua, true);
assert.equal(cloudConfig.codexComputerUseFallbackToCua, true);
assert.equal(exampleConfig.computerUseCommand, "node");
assert.equal(cloudConfig.computerUseCommand, "node");
assert.deepEqual(exampleConfig.computerUseArgs, ["scripts/computer-use-smoke.mjs"]);
assert.deepEqual(cloudConfig.computerUseArgs, ["scripts/computer-use-smoke.mjs"]);
assert.deepEqual(exampleConfig.computerUseArgs, ["scripts/cua-driver-computer-use-runtime.mjs"]);
assert.deepEqual(cloudConfig.computerUseArgs, ["scripts/cua-driver-computer-use-runtime.mjs"]);
assert.equal(exampleConfig.cuaDriverCommand, "cua-driver");
assert.equal(cloudConfig.cuaDriverCommand, "cua-driver");
assert.deepEqual(exampleConfig.cuaDriverArgs, []);
assert.deepEqual(cloudConfig.cuaDriverArgs, []);
assert.equal(exampleConfig.cuaDriverTimeoutMs, 45000);
assert.equal(cloudConfig.cuaDriverTimeoutMs, 45000);
assert.equal(exampleConfig.computerUseConnected, true);
assert.equal(cloudConfig.computerUseConnected, true);
assert.equal(exampleConfig.dialogGuardEnabled, true);
@@ -46,6 +62,15 @@ test("shipped local-agent configs include browser and desktop runtime smoke defa
assert.deepEqual(exampleConfig.dialogGuardWindowsActionArgs, []);
assert.deepEqual(cloudConfig.dialogGuardWindowsActionArgs, []);
assert.equal(exampleConfig.bossAgentOtaEnabled, true);
assert.equal(cloudConfig.bossAgentOtaEnabled, true);
assert.equal(exampleConfig.bossAgentVersion, "dev");
assert.equal(cloudConfig.bossAgentVersion, "dev");
assert.equal(exampleConfig.bossAgentOtaAutoInstall, false);
assert.equal(cloudConfig.bossAgentOtaAutoInstall, false);
assert.equal(exampleConfig.bossAgentOtaCheckIntervalMs, 300000);
assert.equal(cloudConfig.bossAgentOtaCheckIntervalMs, 300000);
assert.equal(exampleConfig.codexDesktopRefreshEnabled, true);
assert.equal(cloudConfig.codexDesktopRefreshEnabled, true);
assert.equal(exampleConfig.codexDesktopRefreshCommand, "node");
@@ -64,9 +89,17 @@ test("shipped local-agent configs include browser and desktop runtime smoke defa
assert.equal(cloudConfig.codexDesktopRefreshRetryDelayMs, 120);
});
test("repo ships browser and desktop smoke runtime scripts", async () => {
test("repo ships browser and desktop runtime scripts", async () => {
const browserSmoke = await readFile(path.join(repoRoot, "scripts", "browser-control-smoke.mjs"), "utf8");
const computerSmoke = await readFile(path.join(repoRoot, "scripts", "computer-use-smoke.mjs"), "utf8");
const cuaComputerUseRuntime = await readFile(
path.join(repoRoot, "scripts", "cua-driver-computer-use-runtime.mjs"),
"utf8",
);
const codexComputerUseRuntime = await readFile(
path.join(repoRoot, "scripts", "codex-computer-use-runtime.mjs"),
"utf8",
);
const codexDesktopRefreshHint = await readFile(
path.join(repoRoot, "scripts", "codex-desktop-refresh-hint.mjs"),
"utf8",
@@ -87,6 +120,18 @@ test("repo ships browser and desktop smoke runtime scripts", async () => {
path.join(repoRoot, "deployment", "launchd", "com.hyzq.boss.codex-desktop-bridge.plist"),
"utf8",
);
const bossAgentPackageScript = await readFile(
path.join(repoRoot, "scripts", "package-boss-agent-mac-runtime.sh"),
"utf8",
);
const startLocalAgent = await readFile(
path.join(repoRoot, "scripts", "start-local-agent.sh"),
"utf8",
);
const installLocalLaunchAgent = await readFile(
path.join(repoRoot, "scripts", "install-local-launchagent.sh"),
"utf8",
);
assert.match(browserSmoke, /status/);
assert.match(browserSmoke, /replyBody/);
@@ -96,6 +141,13 @@ test("repo ships browser and desktop smoke runtime scripts", async () => {
assert.match(computerSmoke, /resolveOpenAppPrefixArgs/);
assert.match(computerSmoke, /BOSS_COMPUTER_USE_MODE/);
assert.match(computerSmoke, /osascript/);
assert.match(cuaComputerUseRuntime, /runCuaDriverComputerUseTask/);
assert.match(cuaComputerUseRuntime, /BOSS_CUA_DRIVER_COMMAND/);
assert.match(cuaComputerUseRuntime, /launch_app/);
assert.match(cuaComputerUseRuntime, /get_window_state/);
assert.match(cuaComputerUseRuntime, /needs_user_action/);
assert.match(codexComputerUseRuntime, /Codex Computer Use/);
assert.match(codexComputerUseRuntime, /executeCodexAppServerTask/);
assert.match(codexDesktopRefreshHint, /codex_desktop_refresh_hint/);
assert.match(codexDesktopRefreshHint, /osascript/);
assert.match(codexDesktopRefreshHint, /activate/);
@@ -115,4 +167,22 @@ test("repo ships browser and desktop smoke runtime scripts", async () => {
assert.match(codexDesktopIntegrationProbe, /packagePatch/);
assert.match(codexDesktopBridgeLaunchAgent, /codex-desktop-refresh-bridge-daemon\.mjs/);
assert.match(codexDesktopBridgeLaunchAgent, /BOSS_CODEX_DESKTOP_BRIDGE_PORT/);
assert.match(bossAgentPackageScript, /boss-agent-mac-latest\.zip/);
assert.match(bossAgentPackageScript, /boss-agent-mac-latest\.json/);
assert.match(bossAgentPackageScript, /bossAgentVersion = version/);
assert.match(bossAgentPackageScript, /config\*\.json/);
assert.match(bossAgentPackageScript, /codexComputerUseArgs/);
assert.match(bossAgentPackageScript, /PlistBuddy/);
assert.match(bossAgentPackageScript, /ACTIVE_CONFIG_PATH/);
assert.match(bossAgentPackageScript, /CUSTOM_CONFIGS/);
assert.match(bossAgentPackageScript, /config\.installed\.json\|config\.cloud\.json\|config\.example\.json/);
assert.match(bossAgentPackageScript, /BOSS_AGENT_INSTALL_ROOT/);
assert.match(installLocalLaunchAgent, /resolve_default_config_source/);
assert.match(installLocalLaunchAgent, /PlistBuddy/);
assert.match(installLocalLaunchAgent, /ACTIVE_CONFIG_PATH/);
assert.match(installLocalLaunchAgent, /CONFIG_SOURCE_ARG/);
assert.match(installLocalLaunchAgent, /codexAppServerWorkdir/);
assert.match(installLocalLaunchAgent, /codexComputerUseWorkdir/);
assert.match(startLocalAgent, /BOSS_NODE_BIN/);
assert.match(startLocalAgent, /\.boss-runtime\/node-\*\/bin\/node/);
});

View File

@@ -186,6 +186,41 @@ test("browser smoke runtime emits target url when objective contains a website",
assert.match(result.executionSummary, /open_url/);
});
test("browser smoke runtime normalizes bare domains from app control text", async () => {
const result = await runRuntime(path.join(repoRoot, "scripts", "browser-control-smoke.mjs"), {
requestKind: "browser_control",
requestId: "browser-smoke-bare-domain",
objective: "打开Chrome浏览器访问 example.com完成后回复一句任务小结。",
context: {
riskLevel: "medium",
dryRun: true,
},
});
assert.equal(result.status, "completed");
assert.equal(result.targetUrl, "https://example.com");
assert.match(result.executionSummary, /open_url/);
});
test("browser smoke runtime derives a YouTube search url from natural language playback requests", async () => {
const result = await runRuntime(path.join(repoRoot, "scripts", "browser-control-smoke.mjs"), {
requestKind: "browser_control",
requestId: "browser-youtube-search",
objective: "打开浏览器用浏览器打开YouTube找一个蔡徐坤的MV播放",
context: {
riskLevel: "medium",
dryRun: true,
},
});
assert.equal(result.status, "completed");
assert.equal(
result.targetUrl,
"https://www.youtube.com/results?search_query=%E8%94%A1%E5%BE%90%E5%9D%A4%20MV",
);
assert.match(result.executionSummary, /open_url/);
});
test("browser smoke runtime can invoke configured browser automation command", async () => {
const markerDir = await fs.mkdtemp(path.join(os.tmpdir(), "boss-browser-automation-marker-"));
const markerFile = path.join(markerDir, "automation.log");
@@ -209,6 +244,7 @@ test("browser smoke runtime can invoke configured browser automation command", a
BOSS_BROWSER_AUTOMATION_COMMAND: process.execPath,
BOSS_BROWSER_AUTOMATION_ARGS_JSON: JSON.stringify([automationScript.scriptPath]),
BOSS_BROWSER_AUTOMATION_SESSION: "boss-browser-test",
BOSS_BROWSER_VISIBLE_OPEN_AFTER_AUTOMATION: "off",
},
},
);
@@ -226,6 +262,94 @@ test("browser smoke runtime can invoke configured browser automation command", a
}
});
test("browser smoke runtime still opens a visible browser after automation succeeds", async () => {
const markerDir = await fs.mkdtemp(path.join(os.tmpdir(), "boss-browser-visible-after-automation-"));
const automationLog = path.join(markerDir, "automation.log");
const openerMarker = path.join(markerDir, "visible-open.txt");
let automationScript;
let openerScript;
try {
automationScript = await writeBrowserAutomationScript(automationLog);
openerScript = await writeOpenMarkerScript(openerMarker);
const result = await runRuntimeWithServer(
path.join(repoRoot, "scripts", "browser-control-smoke.mjs"),
{
requestKind: "browser_control",
requestId: "browser-visible-after-automation",
objective: "打开 https://example.com 看一下首页",
context: {
riskLevel: "medium",
dryRun: false,
},
},
{
env: {
BOSS_BROWSER_AUTOMATION_MODE: "playwright",
BOSS_BROWSER_AUTOMATION_COMMAND: process.execPath,
BOSS_BROWSER_AUTOMATION_ARGS_JSON: JSON.stringify([automationScript.scriptPath]),
BOSS_BROWSER_OPEN_COMMAND: process.execPath,
BOSS_BROWSER_OPEN_ARGS_JSON: JSON.stringify([openerScript.scriptPath]),
},
},
);
assert.equal(result.status, "completed");
assert.match(result.replyBody, /Boss Automated Title/);
assert.match(result.executionSummary, /browser_automation_executed\+open_url_executed/);
assert.equal(await fs.readFile(openerMarker, "utf8"), "https://example.com");
} finally {
if (automationScript?.scriptDir) {
await fs.rm(automationScript.scriptDir, { recursive: true, force: true });
}
if (openerScript?.scriptDir) {
await fs.rm(openerScript.scriptDir, { recursive: true, force: true });
}
await fs.rm(markerDir, { recursive: true, force: true });
}
});
test("browser smoke runtime falls back to opener when browser automation fails", async () => {
const marker = await fs.mkdtemp(path.join(os.tmpdir(), "boss-browser-automation-fallback-"));
const openerMarker = path.join(marker, "opened.txt");
let openerScript;
try {
const failingAutomationPath = path.join(marker, "failing-automation.mjs");
await fs.writeFile(failingAutomationPath, "process.stderr.write('automation failed'); process.exit(1);", "utf8");
openerScript = await writeOpenMarkerScript(openerMarker);
const result = await runRuntime(
path.join(repoRoot, "scripts", "browser-control-smoke.mjs"),
{
requestKind: "browser_control",
requestId: "browser-automation-fallback",
objective: "打开 example.com 看一下首页",
context: {
riskLevel: "medium",
dryRun: false,
},
},
{
env: {
BOSS_BROWSER_AUTOMATION_MODE: "playwright",
BOSS_BROWSER_AUTOMATION_COMMAND: process.execPath,
BOSS_BROWSER_AUTOMATION_ARGS_JSON: JSON.stringify([failingAutomationPath]),
BOSS_BROWSER_OPEN_COMMAND: process.execPath,
BOSS_BROWSER_OPEN_ARGS_JSON: JSON.stringify([openerScript.scriptPath]),
},
},
);
assert.equal(result.status, "completed");
assert.equal(result.targetUrl, "https://example.com");
assert.match(result.executionSummary, /open_url_executed/);
assert.equal(await fs.readFile(openerMarker, "utf8"), "https://example.com");
} finally {
if (openerScript?.scriptDir) {
await fs.rm(openerScript.scriptDir, { recursive: true, force: true });
}
await fs.rm(marker, { recursive: true, force: true });
}
});
test("browser smoke runtime auto-detects bundled playwright wrapper and uses request id as session", async () => {
const tmpRoot = await fs.mkdtemp(path.join(os.tmpdir(), "boss-browser-autodetect-"));
const logFile = path.join(tmpRoot, "wrapper.log");
@@ -246,6 +370,7 @@ test("browser smoke runtime auto-detects bundled playwright wrapper and uses req
{
env: {
CODEX_HOME: codexHome,
BOSS_BROWSER_VISIBLE_OPEN_AFTER_AUTOMATION: "off",
},
},
);
@@ -560,6 +685,84 @@ test("browser smoke runtime can execute an injected url opener when not dry-run"
}
});
test("browser smoke runtime opens Chrome when app control text asks for Chrome", async () => {
const marker = await fs.mkdtemp(path.join(os.tmpdir(), "boss-browser-chrome-open-"));
const markerFile = path.join(marker, "open-args.json");
let openerCommand;
try {
openerCommand = await writeArgumentMarkerCommand(markerFile, "open");
const result = await runRuntime(
path.join(repoRoot, "scripts", "browser-control-smoke.mjs"),
{
requestKind: "browser_control",
requestId: "browser-open-chrome",
objective: "打开Chrome浏览器访问 example.com完成后回复一句任务小结。",
context: { dryRun: false },
},
{
env: {
BOSS_BROWSER_OPEN_COMMAND: openerCommand.scriptPath,
},
},
);
assert.equal(result.status, "completed");
assert.equal(result.targetUrl, "https://example.com");
assert.deepEqual(JSON.parse(await fs.readFile(markerFile, "utf8")), [
"-a",
"Google Chrome",
"https://example.com",
]);
} finally {
if (openerCommand?.scriptDir) {
await fs.rm(openerCommand.scriptDir, { recursive: true, force: true });
}
await fs.rm(marker, { recursive: true, force: true });
}
});
test("browser smoke runtime uses osascript for real Chrome URL opens on macOS", async () => {
const marker = await fs.mkdtemp(path.join(os.tmpdir(), "boss-browser-chrome-osascript-"));
const osascriptMarker = path.join(marker, "osascript-args.json");
let osascriptCommand;
try {
osascriptCommand = await writeArgumentMarkerCommand(osascriptMarker, "osascript");
const failingOpenPath = path.join(osascriptCommand.scriptDir, "open");
await fs.writeFile(
failingOpenPath,
"#!/usr/bin/env node\nprocess.stderr.write('open should not be used for Chrome URL opens'); process.exit(1);\n",
"utf8",
);
await fs.chmod(failingOpenPath, 0o755);
const result = await runRuntime(
path.join(repoRoot, "scripts", "browser-control-smoke.mjs"),
{
requestKind: "browser_control",
requestId: "browser-open-chrome-osascript",
objective: "打开Chrome浏览器访问 example.com完成后回复一句任务小结。",
context: { dryRun: false },
},
{
env: {
PATH: `${osascriptCommand.scriptDir}:${process.env.PATH}`,
},
},
);
assert.equal(result.status, "completed");
assert.equal(result.targetUrl, "https://example.com");
const args = JSON.parse(await fs.readFile(osascriptMarker, "utf8"));
assert.match(args.join(" "), /Google Chrome/);
assert.match(args.join(" "), /https:\/\/example\.com/);
} finally {
if (osascriptCommand?.scriptDir) {
await fs.rm(osascriptCommand.scriptDir, { recursive: true, force: true });
}
await fs.rm(marker, { recursive: true, force: true });
}
});
test("computer use smoke runtime can execute an injected app opener when not dry-run", async () => {
const marker = await fs.mkdtemp(path.join(os.tmpdir(), "boss-computer-open-"));
let openerScript;
@@ -687,6 +890,136 @@ test("computer use smoke runtime defaults to open -a style args for macOS opener
}
});
test("computer use smoke runtime maps Chrome to Google Chrome for macOS app opens", async () => {
const marker = await fs.mkdtemp(path.join(os.tmpdir(), "boss-computer-open-chrome-"));
let openerCommand;
const markerFile = path.join(marker, "argv.json");
try {
openerCommand = await writeArgumentMarkerCommand(markerFile, "open");
const result = await new Promise((resolve, reject) => {
const child = spawn(process.execPath, [path.join(repoRoot, "scripts", "computer-use-smoke.mjs")], {
cwd: repoRoot,
env: {
...process.env,
BOSS_COMPUTER_USE_MODE: "open",
BOSS_COMPUTER_USE_OPEN_APP_COMMAND: openerCommand.scriptPath,
},
stdio: ["pipe", "pipe", "pipe"],
});
let stdout = "";
let stderr = "";
child.stdout.setEncoding("utf8");
child.stderr.setEncoding("utf8");
child.stdout.on("data", (chunk) => {
stdout += chunk;
});
child.stderr.on("data", (chunk) => {
stderr += chunk;
});
child.on("error", reject);
child.on("close", (code) => {
if (code !== 0) {
reject(new Error(stderr.trim() || `exit code ${code}`));
return;
}
try {
resolve(JSON.parse(stdout.trim().split(/\r?\n/).at(-1) || ""));
} catch (error) {
reject(error);
}
});
child.stdin.write(
JSON.stringify({
requestKind: "desktop_control",
requestId: "computer-open-chrome",
objective: "打开Chrome浏览器",
context: { dryRun: false },
}),
);
child.stdin.end();
});
assert.equal(result.status, "completed");
assert.equal(result.targetApp, "Chrome");
assert.equal(result.automationTargetApp, "Google Chrome");
const argv = JSON.parse(await fs.readFile(markerFile, "utf8"));
assert.deepEqual(argv, ["-a", "Google Chrome"]);
} finally {
if (openerCommand?.scriptDir) {
await fs.rm(openerCommand.scriptDir, { recursive: true, force: true });
}
await fs.rm(marker, { recursive: true, force: true });
}
});
test("computer use smoke runtime opens browser urls directly without keystroke automation", async () => {
const marker = await fs.mkdtemp(path.join(os.tmpdir(), "boss-computer-open-browser-url-"));
let openerCommand;
const markerFile = path.join(marker, "argv.json");
try {
openerCommand = await writeArgumentMarkerCommand(markerFile, "open");
const result = await new Promise((resolve, reject) => {
const child = spawn(process.execPath, [path.join(repoRoot, "scripts", "computer-use-smoke.mjs")], {
cwd: repoRoot,
env: {
...process.env,
BOSS_COMPUTER_USE_MODE: "osascript",
BOSS_COMPUTER_USE_OPEN_APP_COMMAND: openerCommand.scriptPath,
},
stdio: ["pipe", "pipe", "pipe"],
});
let stdout = "";
let stderr = "";
child.stdout.setEncoding("utf8");
child.stderr.setEncoding("utf8");
child.stdout.on("data", (chunk) => {
stdout += chunk;
});
child.stderr.on("data", (chunk) => {
stderr += chunk;
});
child.on("error", reject);
child.on("close", (code) => {
if (code !== 0) {
reject(new Error(stderr.trim() || `exit code ${code}`));
return;
}
try {
resolve(JSON.parse(stdout.trim().split(/\r?\n/).at(-1) || ""));
} catch (error) {
reject(error);
}
});
child.stdin.write(
JSON.stringify({
requestKind: "desktop_control",
requestId: "computer-open-browser-url",
objective: "打开软件 Chrome输入“https://example.com”回车",
context: { dryRun: false },
}),
);
child.stdin.end();
});
assert.equal(result.status, "completed");
assert.equal(result.targetApp, "Chrome");
assert.equal(result.automationTargetApp, "Google Chrome");
assert.equal(result.targetUrl, "https://example.com");
assert.match(result.executionSummary, /open_app_url_executed/);
const argv = JSON.parse(await fs.readFile(markerFile, "utf8"));
assert.deepEqual(argv, ["-a", "Google Chrome", "https://example.com"]);
} finally {
if (openerCommand?.scriptDir) {
await fs.rm(openerCommand.scriptDir, { recursive: true, force: true });
}
await fs.rm(marker, { recursive: true, force: true });
}
});
test("computer use smoke runtime can prepend configured open -a style args", async () => {
const marker = await fs.mkdtemp(path.join(os.tmpdir(), "boss-computer-open-default-"));
let openerCommand;

View File

@@ -0,0 +1,280 @@
import test from "node:test";
import assert from "node:assert/strict";
import { mkdtemp, writeFile, chmod, mkdir, symlink } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
import {
buildCuaLaunchArgs,
detectCuaTargetApp,
isSubmitLikeObjective,
runCuaDriverComputerUseTask,
} from "../scripts/cua-driver-computer-use-runtime.mjs";
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
async function createFakeCuaDriver() {
const dir = await mkdtemp(path.join(os.tmpdir(), "boss-cua-driver-"));
const command = path.join(dir, "fake-cua-driver.mjs");
const logPath = path.join(dir, "calls.jsonl");
await writeFile(
command,
`#!/usr/bin/env node
import { appendFileSync } from "node:fs";
const logPath = process.env.FAKE_CUA_LOG_PATH;
const args = process.argv.slice(2);
const callIndex = args.indexOf("call");
const tool = callIndex >= 0 ? args[callIndex + 1] : args[0];
const rawJson = callIndex >= 0 ? args[callIndex + 2] : args[1];
let payload = {};
try { payload = rawJson ? JSON.parse(rawJson) : {}; } catch {}
appendFileSync(logPath, JSON.stringify({ tool, payload }) + "\\n");
if (tool === "launch_app") {
if (process.env.FAKE_CUA_FAIL_LAUNCH === "true") {
process.stdout.write(JSON.stringify({
content: [{ type: "text", text: "Launch failed: app not found" }],
isError: true,
}));
process.exit(1);
}
process.stdout.write(JSON.stringify({
structuredContent: {
pid: 2468,
name: payload.name || "Google Chrome",
bundle_id: payload.bundle_id || "com.google.Chrome",
windows: [
{ window_id: 1357, title: "Boss Cua Test", is_on_screen: true, on_current_space: true },
],
},
content: [{ type: "text", text: "✅ Launched app" }],
}));
process.exit(0);
}
if (tool === "list_apps") {
process.stdout.write(JSON.stringify({
structuredContent: {
apps: [
{ pid: 2468, name: "Google Chrome", bundle_id: "com.google.Chrome", running: true },
],
},
content: [{ type: "text", text: "✅ Found app" }],
}));
process.exit(0);
}
if (tool === "list_windows") {
process.stdout.write(JSON.stringify({
structuredContent: {
windows: [
{ window_id: 1357, title: "Boss Cua Test", is_on_screen: true, on_current_space: true },
],
},
content: [{ type: "text", text: "✅ Found window" }],
}));
process.exit(0);
}
if (tool === "get_window_state") {
process.stdout.write(JSON.stringify({
content: [{ type: "text", text: "✅ Google Chrome — 3 elements, turn 1 + screenshot\\n- [0] AXWindow Boss Cua Test" }],
structuredContent: { element_count: 3, has_screenshot: true },
}));
process.exit(0);
}
if (tool === "type_text") {
process.stdout.write(JSON.stringify({
content: [{ type: "text", text: "✅ Inserted text" }],
structuredContent: { ok: true },
}));
process.exit(0);
}
if (tool === "press_key") {
process.stdout.write(JSON.stringify({
content: [{ type: "text", text: "✅ Pressed key" }],
structuredContent: { ok: true },
}));
process.exit(0);
}
process.stderr.write("unknown tool " + tool);
process.exit(64);
`,
"utf8",
);
await chmod(command, 0o755);
return { command, logPath, cwd: dir };
}
test("cua runtime detects common macOS targets", () => {
assert.equal(detectCuaTargetApp("打开 Chrome 浏览器搜索测试")?.name, "Google Chrome");
assert.equal(detectCuaTargetApp("打开系统设置看屏幕录制")?.bundleId, "com.apple.systempreferences");
assert.equal(detectCuaTargetApp("打开 QQ 群 AI开发")?.name, "QQ");
});
test("cua runtime adds about:blank for browser launch when objective has no URL", () => {
const target = detectCuaTargetApp("打开 Safari");
const launchArgs = buildCuaLaunchArgs(target, "打开 Safari");
assert.equal(launchArgs.bundle_id, "com.apple.Safari");
assert.deepEqual(launchArgs.urls, ["about:blank"]);
});
test("cua runtime uses the current macOS system settings bundle id", () => {
const target = detectCuaTargetApp("打开系统设置");
const launchArgs = buildCuaLaunchArgs(target, "打开系统设置");
assert.equal(launchArgs.bundle_id, "com.apple.systempreferences");
});
test("cua runtime launches target app and observes window state through cua-driver", async () => {
const fake = await createFakeCuaDriver();
const result = await runCuaDriverComputerUseTask(
{
requestKind: "desktop_control",
requestId: "cua-task-1",
objective: "打开 Chrome 浏览器",
platform: "macos",
provider: "cua-driver-computer-use",
},
{
env: {
BOSS_CUA_DRIVER_COMMAND: fake.command,
BOSS_CUA_DRIVER_TIMEOUT_MS: "4000",
FAKE_CUA_LOG_PATH: fake.logPath,
},
cwd: repoRoot,
},
);
assert.equal(result.status, "completed");
assert.equal(result.requestId, "cua-task-1");
assert.equal(result.targetApp, "Google Chrome");
assert.match(result.replyBody, /已通过 Cua Driver/);
assert.match(result.executionSummary, /launch_app -> get_window_state/);
});
test("cua runtime falls back to running app windows when launch_app fails", async () => {
const fake = await createFakeCuaDriver();
const result = await runCuaDriverComputerUseTask(
{
requestKind: "desktop_control",
requestId: "cua-task-running-app",
objective: "打开 Chrome 浏览器",
platform: "macos",
provider: "cua-driver-computer-use",
},
{
env: {
BOSS_CUA_DRIVER_COMMAND: fake.command,
BOSS_CUA_DRIVER_TIMEOUT_MS: "4000",
FAKE_CUA_LOG_PATH: fake.logPath,
FAKE_CUA_FAIL_LAUNCH: "true",
},
cwd: repoRoot,
},
);
assert.equal(result.status, "completed");
assert.match(result.executionSummary, /launch_app_failed -> list_apps -> list_windows -> get_window_state/);
});
test("cua runtime can discover cua-driver from HOME local bin when PATH is empty", async () => {
const fake = await createFakeCuaDriver();
const localBin = path.join(fake.cwd, ".local", "bin");
await mkdir(localBin, { recursive: true });
await symlink(fake.command, path.join(localBin, "cua-driver"));
const result = await runCuaDriverComputerUseTask(
{
requestKind: "desktop_control",
requestId: "cua-task-path",
objective: "打开 Chrome 浏览器",
platform: "macos",
provider: "cua-driver-computer-use",
},
{
env: {
HOME: fake.cwd,
PATH: path.dirname(process.execPath),
BOSS_CUA_DRIVER_COMMAND: "cua-driver",
BOSS_CUA_DRIVER_TIMEOUT_MS: "4000",
FAKE_CUA_LOG_PATH: fake.logPath,
},
cwd: repoRoot,
},
);
assert.equal(result.status, "completed");
});
test("cua runtime types quoted text but does not submit without explicit submit allowance", async () => {
const fake = await createFakeCuaDriver();
const result = await runCuaDriverComputerUseTask(
{
requestKind: "desktop_control",
requestId: "cua-task-typing",
objective: "打开 QQ 输入“我是测试”",
platform: "macos",
provider: "cua-driver-computer-use",
},
{
env: {
BOSS_CUA_DRIVER_COMMAND: fake.command,
BOSS_CUA_DRIVER_TIMEOUT_MS: "4000",
FAKE_CUA_LOG_PATH: fake.logPath,
},
cwd: repoRoot,
},
);
assert.equal(result.status, "completed");
assert.match(result.executionSummary, /type_text/);
});
test("cua runtime asks for confirmation before send-like objectives", async () => {
assert.equal(isSubmitLikeObjective("在 QQ 里输入“你好”并发送"), true);
const fake = await createFakeCuaDriver();
const result = await runCuaDriverComputerUseTask(
{
requestKind: "desktop_control",
requestId: "cua-task-send",
objective: "在 QQ 里输入“你好”并发送",
platform: "macos",
provider: "cua-driver-computer-use",
},
{
env: {
BOSS_CUA_DRIVER_COMMAND: fake.command,
FAKE_CUA_LOG_PATH: fake.logPath,
},
cwd: repoRoot,
},
);
assert.equal(result.status, "needs_user_action");
assert.equal(result.kind, "desktop_submit_confirmation_required");
assert.deepEqual(result.availableActions, ["allow_once", "deny"]);
});
test("cua runtime rejects non-mac platforms", async () => {
const result = await runCuaDriverComputerUseTask(
{
requestId: "cua-task-windows",
objective: "打开系统设置",
platform: "windows",
provider: "cua-driver-computer-use",
},
{
env: {},
cwd: repoRoot,
},
);
assert.equal(result.status, "failed");
assert.equal(result.error, "UNSUPPORTED_CONTROL_PLATFORM");
});

View File

@@ -0,0 +1,193 @@
import test from "node:test";
import assert from "node:assert/strict";
import os from "node:os";
import path from "node:path";
import { mkdtemp, rm } from "node:fs/promises";
import { NextRequest } from "next/server";
let runtimeRoot = "";
let data: typeof import("../src/lib/boss-data");
let authCookie = "";
let deviceHeartbeatRoute: (typeof import("../src/app/api/device-heartbeat/route"))["POST"];
let claimMasterTaskRoute: (typeof import("../src/app/api/v1/master-agent/tasks/claim/route"))["POST"];
let getBossAgentOtaRoute: (typeof import("../src/app/api/v1/boss-agent/ota/route"))["GET"];
let postAdminAccessRoute: (typeof import("../src/app/api/v1/admin/access/route"))["POST"];
async function setup() {
if (runtimeRoot) return;
runtimeRoot = await mkdtemp(path.join(os.tmpdir(), "boss-device-revocation-"));
process.env.BOSS_RUNTIME_ROOT = runtimeRoot;
process.env.BOSS_STATE_FILE = path.join(runtimeRoot, "boss-state.json");
const [dataModule, authModule, heartbeatModule, claimModule, otaModule, adminAccessModule] =
await Promise.all([
import("../src/lib/boss-data.ts"),
import("../src/lib/boss-auth.ts"),
import("../src/app/api/device-heartbeat/route.ts"),
import("../src/app/api/v1/master-agent/tasks/claim/route.ts"),
import("../src/app/api/v1/boss-agent/ota/route.ts"),
import("../src/app/api/v1/admin/access/route.ts"),
]);
data = dataModule;
authCookie = authModule.AUTH_SESSION_COOKIE;
deviceHeartbeatRoute = heartbeatModule.POST;
claimMasterTaskRoute = claimModule.POST;
getBossAgentOtaRoute = otaModule.GET;
postAdminAccessRoute = adminAccessModule.POST;
}
test.after(async () => {
if (runtimeRoot) await rm(runtimeRoot, { recursive: true, force: true });
});
test.beforeEach(async () => {
await setup();
await rm(runtimeRoot, { recursive: true, force: true });
});
function heartbeatRequest(body: Record<string, unknown>) {
return new NextRequest("http://127.0.0.1:3000/api/device-heartbeat", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
deviceId: "mac-studio",
name: "Mac Studio",
avatar: "M",
account: "krisolo",
status: "online",
quota5h: 80,
quota7d: 90,
projects: ["Boss"],
...body,
}),
});
}
async function highestAdminRequest(body: Record<string, unknown>) {
const session = await data.createAuthSession({
account: "krisolo",
role: "highest_admin",
displayName: "Boss 超级管理员",
loginMethod: "password",
});
return new NextRequest("http://127.0.0.1:3000/api/v1/admin/access", {
method: "POST",
headers: {
"content-type": "application/json",
cookie: `${authCookie}=${session.sessionToken}`,
},
body: JSON.stringify(body),
});
}
test("existing device heartbeat requires its device token and cannot be kept alive by an empty token request", async () => {
const state = await data.readState();
const device = state.devices.find((item) => item.id === "mac-studio");
assert.ok(device);
device.status = "offline";
device.lastSeenAt = "2026-05-17T01:00:00.000Z";
device.projects = ["原项目"];
await data.writeState(state);
const response = await deviceHeartbeatRoute(heartbeatRequest({ token: undefined }));
assert.equal(response.status, 401);
const payload = await response.json();
assert.equal(payload.message, "DEVICE_TOKEN_REQUIRED");
const nextState = await data.readState();
const nextDevice = nextState.devices.find((item) => item.id === "mac-studio");
assert.equal(nextDevice?.status, "offline");
assert.equal(nextDevice?.lastSeenAt, "2026-05-17T01:00:00.000Z");
assert.deepEqual(nextDevice?.projects, ["原项目"]);
});
test("revoked device token is rejected by heartbeat, master task claim, and boss-agent ota", async () => {
const initial = await data.readState();
const device = initial.devices.find((item) => item.id === "mac-studio");
assert.ok(device?.token);
const oldToken = device.token;
const revokeResponse = await postAdminAccessRoute(
await highestAdminRequest({
action: "revoke_device",
deviceId: "mac-studio",
reason: "客户解绑设备",
}),
);
assert.equal(revokeResponse.status, 200);
const revokePayload = await revokeResponse.json();
assert.equal(revokePayload.device.id, "mac-studio");
assert.ok(revokePayload.device.revokedAt);
assert.equal(revokePayload.device.status, "offline");
assert.equal(await data.verifyDeviceToken("mac-studio", oldToken), false);
const queued = await data.queueMasterAgentTask({
taskId: "revoked-device-task",
requestMessageId: "message-revoked",
requestText: "打开浏览器",
executionPrompt: "打开浏览器",
requestedBy: "krisolo",
requestedByAccount: "krisolo",
deviceId: "mac-studio",
});
assert.equal(queued.status, "queued");
const claimResponse = await claimMasterTaskRoute(
new NextRequest("http://127.0.0.1:3000/api/v1/master-agent/tasks/claim", {
method: "POST",
headers: {
"content-type": "application/json",
"x-boss-device-token": oldToken,
},
body: JSON.stringify({ deviceId: "mac-studio" }),
}),
);
assert.equal(claimResponse.status, 401);
const heartbeatResponse = await deviceHeartbeatRoute(heartbeatRequest({ token: oldToken }));
assert.equal(heartbeatResponse.status, 403);
assert.equal((await heartbeatResponse.json()).message, "DEVICE_REVOKED");
const otaResponse = await getBossAgentOtaRoute(
new NextRequest("http://127.0.0.1:3000/api/v1/boss-agent/ota?deviceId=mac-studio", {
method: "GET",
headers: { "x-boss-device-token": oldToken },
}),
);
assert.equal(otaResponse.status, 401);
const nextState = await data.readState();
const nextTask = nextState.masterAgentTasks.find((item) => item.taskId === "revoked-device-task");
const audit = nextState.permissionAuditLogs.find((item) => item.action === "device.revoked");
assert.equal(nextTask?.status, "queued");
assert.equal(nextState.devices.find((item) => item.id === "mac-studio")?.status, "offline");
assert.equal(audit?.deviceId, "mac-studio");
assert.equal(audit?.actorAccount, "krisolo");
});
test("a new device cannot self-register through heartbeat without a prepared enrollment", async () => {
const response = await deviceHeartbeatRoute(
new NextRequest("http://127.0.0.1:3000/api/device-heartbeat", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
deviceId: "rogue-mac",
token: "rogue-token",
name: "Rogue Mac",
avatar: "R",
account: "krisolo",
status: "online",
quota5h: 1,
quota7d: 1,
projects: ["Boss"],
}),
}),
);
assert.equal(response.status, 401);
assert.equal((await response.json()).message, "DEVICE_ENROLLMENT_REQUIRED");
assert.equal((await data.readState()).devices.some((item) => item.id === "rogue-mac"), false);
});

View File

@@ -0,0 +1,101 @@
#!/usr/bin/env node
import readline from "node:readline";
const rl = readline.createInterface({ input: process.stdin });
const received = [];
function send(message) {
process.stdout.write(`${JSON.stringify(message)}\n`);
}
rl.on("line", (line) => {
const message = JSON.parse(line);
received.push(message);
if (message.method === "initialize") {
send({
id: message.id,
result: {
userAgent: "boss-test-codex-app-server",
platformFamily: "mac",
platformOs: "darwin",
},
});
return;
}
if (message.method === "initialized") {
return;
}
if (message.method === "thread/resume") {
send({
id: message.id,
result: {
thread: {
id: message.params?.threadId ?? "thread-fixture",
name: "fixture thread",
},
},
});
return;
}
if (message.method === "thread/start") {
send({
id: message.id,
result: {
thread: {
id: "thread-started-fixture",
name: "new fixture thread",
},
},
});
return;
}
if (message.method === "turn/start") {
const text = message.params?.input?.find?.((item) => item?.type === "text")?.text ?? "";
send({
id: message.id,
result: {
turn: {
id: "turn-fixture",
threadId: message.params?.threadId,
},
},
});
if (process.env.BOSS_CODEX_APP_SERVER_FIXTURE_EXIT_AFTER_TURN_START === "1") {
process.exit(0);
}
send({
method: "item/agentMessage/delta",
params: {
threadId: message.params?.threadId,
turnId: "turn-fixture",
delta: `APP_SERVER_REPLY:${text}`,
},
});
send({
method: "turn/completed",
params: {
threadId: message.params?.threadId,
turn: {
id: "turn-fixture",
status: "completed",
},
},
});
process.stderr.write(`${JSON.stringify({ received })}\n`);
return;
}
send({
id: message.id,
error: {
code: -32601,
message: `unknown method ${message.method}`,
},
});
});

View File

@@ -0,0 +1,31 @@
let input = "";
process.stdin.setEncoding("utf8");
process.stdin.on("data", (chunk) => {
input += chunk;
});
process.stdin.on("end", () => {
const payload = JSON.parse(input || "{}");
const status = String(process.env.BOSS_CODEX_COMPUTER_USE_FIXTURE_STATUS || "completed");
if (status === "failed") {
process.stdout.write(
`${JSON.stringify({
status: "failed",
requestId: payload.requestId,
error: "CODEX_COMPUTER_USE_UNAVAILABLE",
})}\n`,
);
return;
}
process.stdout.write(
`${JSON.stringify({
status: "completed",
requestId: payload.requestId,
replyBody: `Codex Computer Use 已执行:${payload.objective || "未提供目标"}`,
targetApp: "Codex",
executionSummary: "codex-computer-use-runtime-ok",
})}\n`,
);
});

View File

@@ -56,6 +56,8 @@ test("browser control runner builds normalized stdin payload", () => {
requestedByAccount: "17600001111",
confirmationScopeKey: "thread:1",
riskLevel: "medium",
controlPlatform: "macos",
computerUseProvider: "openai-computer-use",
},
);
@@ -65,10 +67,36 @@ test("browser control runner builds normalized stdin payload", () => {
assert.equal(execution.stdinPayload.requestKind, "browser_control");
assert.equal(execution.stdinPayload.requestId, "browser-task-1");
assert.equal(execution.stdinPayload.objective, "打开后台首页");
assert.equal(execution.stdinPayload.platform, "macos");
assert.equal(execution.stdinPayload.provider, "openai-computer-use");
assert.equal(execution.stdinPayload.context.projectId, "boss-console");
assert.equal(execution.stdinPayload.context.threadId, "thread-1");
assert.equal(execution.stdinPayload.context.confirmationScopeKey, "thread:1");
assert.equal(execution.stdinPayload.context.riskLevel, "medium");
assert.equal(execution.stdinPayload.context.controlPlatform, "macos");
assert.equal(execution.stdinPayload.context.computerUseProvider, "openai-computer-use");
});
test("browser control runner rejects non-mac control platforms", () => {
assert.throws(
() =>
buildBrowserControlTaskExecution(
{
enabled: true,
command: "node",
args: ["tests/fixtures/browser-control-runtime.mjs"],
cwd: repoRoot,
timeoutMs: 3000,
},
{
taskId: "browser-task-windows",
taskType: "browser_control",
requestText: "打开浏览器",
controlPlatform: "windows",
},
),
/UNSUPPORTED_CONTROL_PLATFORM/,
);
});
test("browser control runner parses completed runtime payload", () => {

View File

@@ -0,0 +1,88 @@
import test from "node:test";
import assert from "node:assert/strict";
import path from "node:path";
import { fileURLToPath } from "node:url";
import {
executeCodexAppServerTask,
getCodexAppServerRunnerConfig,
shouldUseCodexAppServerTaskRunner,
} from "../local-agent/codex-app-server-runner.mjs";
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
test("codex app-server runner resumes a thread and collects streamed agent text", async () => {
const runnerConfig = getCodexAppServerRunnerConfig(process.env, {
codexAppServerEnabled: true,
codexAppServerCommand: process.execPath,
codexAppServerArgs: ["tests/fixtures/codex-app-server-runtime.mjs"],
codexAppServerWorkdir: repoRoot,
codexAppServerTimeoutMs: 5000,
masterAgentModel: "gpt-5.4",
});
const task = {
taskId: "task-app-server-1",
taskType: "conversation_reply",
targetCodexThreadRef: "019d-app-server-thread",
targetCodexFolderRef: repoRoot,
executionPrompt: "继续开发并给出结果",
};
assert.equal(shouldUseCodexAppServerTaskRunner(runnerConfig, task), true);
const result = await executeCodexAppServerTask(runnerConfig, task);
assert.equal(result.status, "completed");
assert.equal(result.threadId, "019d-app-server-thread");
assert.equal(result.cwd, repoRoot);
assert.equal(result.replyBody, "APP_SERVER_REPLY:继续开发并给出结果");
assert.equal(result.transport, "stdio");
});
test("codex app-server runner stays disabled unless feature flag is explicit", () => {
const runnerConfig = getCodexAppServerRunnerConfig(process.env, {
codexAppServerCommand: process.execPath,
codexAppServerArgs: ["tests/fixtures/codex-app-server-runtime.mjs"],
});
assert.equal(
shouldUseCodexAppServerTaskRunner(runnerConfig, {
taskType: "conversation_reply",
targetCodexThreadRef: "thread-disabled",
executionPrompt: "不会执行",
}),
false,
);
});
test("codex app-server runner fails fast when the server exits before turn completion", async () => {
const previous = process.env.BOSS_CODEX_APP_SERVER_FIXTURE_EXIT_AFTER_TURN_START;
process.env.BOSS_CODEX_APP_SERVER_FIXTURE_EXIT_AFTER_TURN_START = "1";
try {
const runnerConfig = getCodexAppServerRunnerConfig(process.env, {
codexAppServerEnabled: true,
codexAppServerCommand: process.execPath,
codexAppServerArgs: ["tests/fixtures/codex-app-server-runtime.mjs"],
codexAppServerWorkdir: repoRoot,
codexAppServerTimeoutMs: 5000,
});
const result = await executeCodexAppServerTask(runnerConfig, {
taskId: "task-app-server-exit",
taskType: "conversation_reply",
targetCodexThreadRef: "019d-app-server-thread",
targetCodexFolderRef: repoRoot,
executionPrompt: "不要等到超时",
});
assert.equal(result.status, "failed");
assert.equal(result.canFallbackToCli, false);
assert.match(result.errorMessage, /CODEX_APP_SERVER_EXITED:0/);
} finally {
if (previous === undefined) {
delete process.env.BOSS_CODEX_APP_SERVER_FIXTURE_EXIT_AFTER_TURN_START;
} else {
process.env.BOSS_CODEX_APP_SERVER_FIXTURE_EXIT_AFTER_TURN_START = previous;
}
}
});

View File

@@ -34,6 +34,9 @@ test("computer use runner derives config from explicit values", () => {
dialogGuardPlatformAdapters: ["darwin", "win32"],
dialogGuardMacActionCommand: "/usr/local/bin/boss-mac-dialog-helper",
dialogGuardMacActionArgs: ["click-dialog"],
cuaDriverCommand: "/usr/local/bin/cua-driver",
cuaDriverArgs: ["--no-relaunch"],
cuaDriverTimeoutMs: 9000,
dialogGuardWindowsActionCommand: "powershell.exe",
dialogGuardWindowsActionArgs: ["-File", "C:/Boss/dialog-helper.ps1"],
});
@@ -45,11 +48,12 @@ test("computer use runner derives config from explicit values", () => {
assert.equal(config.timeoutMs, 12000);
assert.equal(config.dialogGuardEnabled, true);
assert.equal(config.dialogGuardConsentRequired, true);
assert.deepEqual(config.dialogGuardPlatformAdapters, ["darwin", "win32"]);
assert.deepEqual(config.dialogGuardPlatformAdapters, ["darwin"]);
assert.equal(config.dialogGuardMacActionCommand, "/usr/local/bin/boss-mac-dialog-helper");
assert.deepEqual(config.dialogGuardMacActionArgs, ["click-dialog"]);
assert.equal(config.dialogGuardWindowsActionCommand, "powershell.exe");
assert.deepEqual(config.dialogGuardWindowsActionArgs, ["-File", "C:/Boss/dialog-helper.ps1"]);
assert.equal(config.cuaDriverCommand, "/usr/local/bin/cua-driver");
assert.deepEqual(config.cuaDriverArgs, ["--no-relaunch"]);
assert.equal(config.cuaDriverTimeoutMs, 9000);
});
test("computer use runner builds normalized stdin payload", () => {
@@ -70,6 +74,8 @@ test("computer use runner builds normalized stdin payload", () => {
requestedByAccount: "17600001111",
confirmationScopeKey: "thread:desktop",
riskLevel: "high",
controlPlatform: "macos",
computerUseProvider: "cua-driver-computer-use",
},
);
@@ -79,13 +85,39 @@ test("computer use runner builds normalized stdin payload", () => {
assert.equal(execution.stdinPayload.requestKind, "desktop_control");
assert.equal(execution.stdinPayload.requestId, "desktop-task-1");
assert.equal(execution.stdinPayload.objective, "打开系统设置");
assert.equal(execution.stdinPayload.platform, "macos");
assert.equal(execution.stdinPayload.provider, "cua-driver-computer-use");
assert.equal(execution.stdinPayload.context.projectId, "boss-console");
assert.equal(execution.stdinPayload.context.threadId, "thread-desktop");
assert.equal(execution.stdinPayload.context.confirmationScopeKey, "thread:desktop");
assert.equal(execution.stdinPayload.context.riskLevel, "high");
assert.equal(execution.stdinPayload.context.controlPlatform, "macos");
assert.equal(execution.stdinPayload.context.computerUseProvider, "cua-driver-computer-use");
});
test("computer use runner passes dialog guard config to runtime env", () => {
test("computer use runner rejects non-mac control platforms", () => {
assert.throws(
() =>
buildComputerUseTaskExecution(
{
enabled: true,
command: "node",
args: ["tests/fixtures/computer-use-runtime.mjs"],
cwd: repoRoot,
timeoutMs: 3000,
},
{
taskId: "desktop-task-windows",
taskType: "desktop_control",
requestText: "打开系统设置",
controlPlatform: "windows",
},
),
/UNSUPPORTED_CONTROL_PLATFORM/,
);
});
test("computer use runner passes mac-only dialog guard config to runtime env", () => {
const execution = buildComputerUseTaskExecution(
{
enabled: true,
@@ -98,6 +130,9 @@ test("computer use runner passes dialog guard config to runtime env", () => {
dialogGuardPlatformAdapters: ["darwin", "win32"],
dialogGuardMacActionCommand: "/usr/local/bin/boss-mac-dialog-helper",
dialogGuardMacActionArgs: ["click-dialog"],
cuaDriverCommand: "/usr/local/bin/cua-driver",
cuaDriverArgs: ["--no-relaunch"],
cuaDriverTimeoutMs: 9000,
dialogGuardWindowsActionCommand: "powershell.exe",
dialogGuardWindowsActionArgs: ["-File", "C:/Boss/dialog-helper.ps1"],
},
@@ -105,19 +140,23 @@ test("computer use runner passes dialog guard config to runtime env", () => {
taskId: "desktop-dialog-env",
taskType: "desktop_control",
requestText: "打开 QQ",
controlPlatform: "macos",
computerUseProvider: "cua-driver-computer-use",
},
);
assert.equal(execution.env.BOSS_DIALOG_GUARD_ENABLED, "true");
assert.equal(execution.env.BOSS_DIALOG_GUARD_CONSENT_REQUIRED, "true");
assert.equal(execution.env.BOSS_DIALOG_GUARD_PLATFORM_ADAPTERS, "darwin,win32");
assert.equal(execution.env.BOSS_DIALOG_GUARD_PLATFORM_ADAPTERS, "darwin");
assert.equal(execution.env.BOSS_MAC_DIALOG_GUARD_ACTION_COMMAND, "/usr/local/bin/boss-mac-dialog-helper");
assert.equal(execution.env.BOSS_MAC_DIALOG_GUARD_ACTION_ARGS_JSON, JSON.stringify(["click-dialog"]));
assert.equal(execution.env.BOSS_WINDOWS_DIALOG_GUARD_ACTION_COMMAND, "powershell.exe");
assert.equal(
execution.env.BOSS_WINDOWS_DIALOG_GUARD_ACTION_ARGS_JSON,
JSON.stringify(["-File", "C:/Boss/dialog-helper.ps1"]),
);
assert.equal(execution.env.BOSS_CUA_DRIVER_COMMAND, "/usr/local/bin/cua-driver");
assert.equal(execution.env.BOSS_CUA_DRIVER_ARGS_JSON, JSON.stringify(["--no-relaunch"]));
assert.equal(execution.env.BOSS_CUA_DRIVER_TIMEOUT_MS, "9000");
assert.equal(execution.env.BOSS_CONTROL_PLATFORM, "macos");
assert.equal(execution.env.BOSS_COMPUTER_USE_PROVIDER, "cua-driver-computer-use");
assert.equal(execution.env.BOSS_WINDOWS_DIALOG_GUARD_ACTION_COMMAND, undefined);
assert.equal(execution.env.BOSS_WINDOWS_DIALOG_GUARD_ACTION_ARGS_JSON, undefined);
});
test("computer use runner parses completed runtime payload", () => {
@@ -186,6 +225,69 @@ test("computer use runner executes configured runtime command", async () => {
assert.match(result.replyBody ?? "", /打开飞书/);
});
test("computer use runner prefers Codex Computer Use and falls back to CUA runtime when Codex fails", async () => {
const previous = process.env.BOSS_CODEX_COMPUTER_USE_FIXTURE_STATUS;
process.env.BOSS_CODEX_COMPUTER_USE_FIXTURE_STATUS = "failed";
try {
const result = await executeComputerUseTask(
{
taskId: "desktop-task-codex-fallback",
taskType: "desktop_control",
requestText: "打开系统设置",
controlPlatform: "macos",
computerUseProvider: "codex-computer-use",
},
{
codexComputerUseEnabled: true,
codexComputerUseCommand: process.execPath,
codexComputerUseArgs: ["tests/fixtures/codex-computer-use-runtime.mjs"],
codexComputerUseWorkdir: repoRoot,
codexComputerUseTimeoutMs: 4000,
codexComputerUseFallbackToCua: true,
computerUseEnabled: true,
computerUseCommand: process.execPath,
computerUseArgs: ["tests/fixtures/computer-use-runtime.mjs"],
computerUseWorkdir: repoRoot,
computerUseTimeoutMs: 4000,
},
);
assert.equal(result.status, "completed");
assert.equal(result.computerUseProvider, "cua-driver-computer-use");
assert.match(result.replyBody ?? "", /桌面运行时已执行/);
} finally {
if (previous === undefined) {
delete process.env.BOSS_CODEX_COMPUTER_USE_FIXTURE_STATUS;
} else {
process.env.BOSS_CODEX_COMPUTER_USE_FIXTURE_STATUS = previous;
}
}
});
test("computer use runner uses Codex Computer Use without CUA when Codex completes", async () => {
const result = await executeComputerUseTask(
{
taskId: "desktop-task-codex-primary",
taskType: "desktop_control",
requestText: "打开系统设置",
controlPlatform: "macos",
computerUseProvider: "codex-computer-use",
},
{
codexComputerUseEnabled: true,
codexComputerUseCommand: process.execPath,
codexComputerUseArgs: ["tests/fixtures/codex-computer-use-runtime.mjs"],
codexComputerUseWorkdir: repoRoot,
codexComputerUseTimeoutMs: 4000,
computerUseEnabled: false,
},
);
assert.equal(result.status, "completed");
assert.equal(result.computerUseProvider, "codex-computer-use");
assert.match(result.replyBody ?? "", /Codex Computer Use 已执行/);
});
test("computer use runner reports disabled runtime instead of pretending desktop work completed", async () => {
const result = await executeComputerUseTask({
taskId: "task-desktop-control",

View File

@@ -71,6 +71,7 @@ test("local-agent heartbeat reports gui and cli capability state", async () => {
masterAgentPollIntervalMs: 60_000,
masterAgentEnabled: false,
codexSessionDiscoveryEnabled: false,
codexAppServerCommand: process.execPath,
projects: [],
projectCandidates: [],
skillsDir,
@@ -106,6 +107,7 @@ test("local-agent heartbeat reports gui and cli capability state", async () => {
assert.ok(payload.capabilities, "heartbeat payload should include device capabilities");
assert.equal(payload.capabilities.gui.connected, false);
assert.equal(payload.capabilities.cli.connected, true);
assert.equal(payload.capabilities.codexAppServer.connected, true);
assert.equal(payload.preferredExecutionMode, "cli");
} finally {
child.kill("SIGTERM");
@@ -118,3 +120,72 @@ test("local-agent heartbeat reports gui and cli capability state", async () => {
await rm(runtimeRoot, { recursive: true, force: true });
}
});
test("local-agent heartbeat reports Codex App Server capability only when enabled and executable", async () => {
const runtimeRoot = await mkdtemp(path.join(os.tmpdir(), "boss-local-agent-app-server-capability-"));
const skillsDir = path.join(runtimeRoot, "skills");
await mkdir(skillsDir, { recursive: true });
const mockControlPlane = await startMockControlPlane();
const exampleConfig = JSON.parse(
await readFile(path.join(repoRoot, "local-agent", "config.example.json"), "utf8"),
);
const configPath = path.join(runtimeRoot, "config.json");
await writeFile(
configPath,
JSON.stringify(
{
...exampleConfig,
bindHost: "127.0.0.1",
port: 0,
controlPlaneUrl: `http://127.0.0.1:${mockControlPlane.port}`,
heartbeatIntervalMs: 60_000,
masterAgentPollIntervalMs: 60_000,
masterAgentEnabled: false,
codexSessionDiscoveryEnabled: false,
codexAppServerEnabled: true,
codexAppServerCommand: process.execPath,
projects: [],
projectCandidates: [],
skillsDir,
},
null,
2,
),
"utf8",
);
const child = spawn(process.execPath, ["local-agent/server.mjs", configPath], {
cwd: repoRoot,
stdio: ["ignore", "pipe", "pipe"],
});
let stderr = "";
child.stderr.on("data", (chunk) => {
stderr += String(chunk);
});
try {
const heartbeatRequest = await Promise.race([
mockControlPlane.heartbeatReceived,
new Promise((_, reject) => {
setTimeout(() => {
reject(new Error(`timed out waiting for heartbeat\n${stderr}`));
}, 8000);
}),
]);
const payload = JSON.parse(heartbeatRequest.bodyText);
assert.equal(payload.capabilities.codexAppServer.connected, true);
} finally {
child.kill("SIGTERM");
await new Promise((resolve) => {
child.once("close", resolve);
}).catch(() => null);
await new Promise((resolve) => {
mockControlPlane.server.close(resolve);
});
await rm(runtimeRoot, { recursive: true, force: true });
}
});

Some files were not shown because too many files have changed in this diff Show More