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