diff --git a/README.md b/README.md index f4be0af..52140e7 100644 --- a/README.md +++ b/README.md @@ -226,6 +226,8 @@ device-agent 当前职责: - `local-agent` 对 `dispatch_execution` 当前会按 `orchestrationBackendId` 分流:默认继续走 `codex exec resume`;当任务显式选择 `omx-team` 且本机 `omxEnabled + omxCommand/omxArgs` 可用时,会改走 `OMX Team Runtime` JSON 协议执行 - `local-agent` 当前的任务完成回写已通过 `RemoteRuntimeAdapter` 标准化,`conversation_reply / dispatch_execution` 的完成结果都会先归一到统一远程执行结果结构,再进入主 Agent 完成路由 - `RemoteRuntimeAdapter` 当前还会拦截固定模式的线程内部环境提示(如“当前会话环境只读 / cwd 我可以在命令里指向 …”),并改写成系统失败提示,不再把这类脏文本直接回写到单聊或群聊 +- 当前设备模型已支持同一台 Mac / Windows 同时接入 Codex `GUI + CLI` 双能力;设备详情页会同时展示两种能力状态,并允许切换默认执行模式 +- 当前同项目 `GUI / CLI` 并行写入风险已接入项目/文件夹级冲突控制:默认阻断,用户可仅对当前异常项目/文件夹选择 `禁止 / 允许本次 / 永久放行` - `local-agent` 当前会先启动本地 `4317` 健康监听,再异步执行首次 heartbeat 和 task poll,避免控制面短暂阻塞时本地健康检查一起挂死 - Codex 项目/线程扫描当前已搬到 worker 线程执行,避免 `.codex/logs_1.sqlite` 和 `state_5.sqlite` 的同步扫描阻塞主线程 HTTP 响应 - 如果某个历史群聊里已经没有真实线程成员,当前不会再表现成“发了没反应”,而是会在群里追加一条 `system_notice`,提示用户先重新整理群成员 diff --git a/android/app/src/main/java/com/hyzq/boss/AboutActivity.java b/android/app/src/main/java/com/hyzq/boss/AboutActivity.java index 887a4f0..46e0d2d 100644 --- a/android/app/src/main/java/com/hyzq/boss/AboutActivity.java +++ b/android/app/src/main/java/com/hyzq/boss/AboutActivity.java @@ -118,7 +118,7 @@ public class AboutActivity extends BossScreenActivity { appendContent(BossUi.buildWechatMenuRow( this, "当前版本", - user == null ? ota.optString("currentVersion", "-") : user.optString("version", ota.optString("currentVersion", "-")), + resolveInstalledVersionLabel(user, ota, BuildConfig.VERSION_NAME), "已安装版本", null, null @@ -171,6 +171,23 @@ public class AboutActivity extends BossScreenActivity { return "发现新版本 " + availableRelease.optString("version", "未知版本"); } + private static String resolveInstalledVersionLabel( + @Nullable JSONObject user, + JSONObject ota, + @Nullable String packageVersionName + ) { + if (packageVersionName != null && !packageVersionName.isEmpty()) { + return packageVersionName; + } + if (user != null) { + String userVersion = user.optString("version", ""); + if (!userVersion.isEmpty()) { + return userVersion; + } + } + return ota.optString("currentVersion", "-"); + } + private static String buildOtaStatusMeta(JSONObject ota) { JSONObject availableRelease = ota.optJSONObject("availableRelease"); if (availableRelease == null) { diff --git a/android/app/src/main/java/com/hyzq/boss/GroupCreateActivity.java b/android/app/src/main/java/com/hyzq/boss/GroupCreateActivity.java index 0841256..e5503b4 100644 --- a/android/app/src/main/java/com/hyzq/boss/GroupCreateActivity.java +++ b/android/app/src/main/java/com/hyzq/boss/GroupCreateActivity.java @@ -14,6 +14,8 @@ import org.json.JSONArray; import org.json.JSONObject; import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashSet; import java.util.LinkedHashSet; import java.util.List; import java.util.Set; @@ -25,6 +27,11 @@ public class GroupCreateActivity extends BossScreenActivity { private final List candidates = new ArrayList<>(); private final Set selectedProjectIds = new LinkedHashSet<>(); private final Set lastCandidateProjectIds = new LinkedHashSet<>(); + private static final Set AUTO_JOIN_GROUP_TITLES = new HashSet<>(Arrays.asList( + "主agent", + "硬件审计协作", + "boss移动控制台" + )); private String sourceProjectId; private String sourceProjectName; @@ -193,21 +200,41 @@ public class GroupCreateActivity extends BossScreenActivity { if (conversations == null) { return result; } - boolean hasSourceProject = sourceProjectId != null && !sourceProjectId.isEmpty(); for (int i = 0; i < conversations.length(); i++) { JSONObject item = conversations.optJSONObject(i); if (item == null) continue; - String projectId = item.optString("projectId", ""); - if (projectId.isEmpty() - || (hasSourceProject && sourceProjectId.equals(projectId)) - || item.optBoolean("isGroup", false)) { - continue; + if (isEligibleForManualGroupSelection(item, sourceProjectId)) { + result.add(item); } - result.add(item); } return result; } + static boolean isEligibleForManualGroupSelection(@Nullable JSONObject item, @Nullable String sourceProjectId) { + if (item == null) { + return false; + } + String projectId = item.optString("projectId", ""); + if (projectId.isEmpty()) { + return false; + } + if (sourceProjectId != null && !sourceProjectId.isEmpty() && sourceProjectId.equals(projectId)) { + return false; + } + if (item.optBoolean("isGroup", false)) { + return false; + } + if (!"single_device".equals(item.optString("conversationType", "single_device"))) { + return false; + } + return !AUTO_JOIN_GROUP_TITLES.contains(normalizeConversationTitle(item)); + } + + private static String normalizeConversationTitle(JSONObject item) { + String title = item.optString("projectTitle", item.optString("threadTitle", "")); + return title == null ? "" : title.replaceAll("\\s+", "").toLowerCase(); + } + static WechatSurfaceMapper.ConversationRow toCandidateConversationRow(JSONObject item, boolean selected) { return new WechatSurfaceMapper.ConversationRow( item.optString("projectTitle", item.optString("threadTitle", "未命名会话")), diff --git a/android/app/src/test/java/com/hyzq/boss/GroupCreateActivityUiTest.java b/android/app/src/test/java/com/hyzq/boss/GroupCreateActivityUiTest.java index e3c5903..500c631 100644 --- a/android/app/src/test/java/com/hyzq/boss/GroupCreateActivityUiTest.java +++ b/android/app/src/test/java/com/hyzq/boss/GroupCreateActivityUiTest.java @@ -144,17 +144,27 @@ public class GroupCreateActivityUiTest { JSONArray conversations = new JSONArray() .put(new JSONObject() .put("projectId", "thread-2") - .put("projectTitle", "硬件审计协作") + .put("projectTitle", "查询树莓派二代") .put("folderLabel", "Mac Studio") - .put("lastMessagePreview", "检查摄像头供电链路") + .put("lastMessagePreview", "检查树莓派二代供电链路") .put("latestReplyLabel", "09:28") + .put("conversationType", "single_device") .put("isGroup", false)) .put(new JSONObject() .put("projectId", "thread-3") - .put("projectTitle", "Boss 移动控制台") + .put("projectTitle", "Boss 线程修复") .put("folderLabel", "Boss") .put("lastMessagePreview", "统一顶部按钮样式") .put("latestReplyLabel", "09:31") + .put("conversationType", "single_device") + .put("isGroup", false)) + .put(new JSONObject() + .put("projectId", "thread-4") + .put("projectTitle", "主Agent") + .put("folderLabel", "Boss") + .put("lastMessagePreview", "系统自动加入") + .put("latestReplyLabel", "09:32") + .put("conversationType", "single_device") .put("isGroup", false)); return new JSONObject().put("conversations", conversations); } diff --git a/android/app/src/test/java/com/hyzq/boss/WechatSurfaceMapperTest.java b/android/app/src/test/java/com/hyzq/boss/WechatSurfaceMapperTest.java index 4a01f14..dfed64f 100644 --- a/android/app/src/test/java/com/hyzq/boss/WechatSurfaceMapperTest.java +++ b/android/app/src/test/java/com/hyzq/boss/WechatSurfaceMapperTest.java @@ -187,6 +187,24 @@ public class WechatSurfaceMapperTest { assertEquals("版本 v1.2.8\n1. 优化设备状态刷新\n2. 修复主 Agent 会话排序\n3. 提升 OTA 回收稳定性", content); } + @Test + public void aboutActivity_prefersInstalledPackageVersionOverServerVersion() throws Exception { + JSONObject ota = new StubJSONObject().withString("currentVersion", "v1.4.1"); + JSONObject user = new StubJSONObject().withString("version", "v1.4.1"); + + java.lang.reflect.Method method = AboutActivity.class.getDeclaredMethod( + "resolveInstalledVersionLabel", + JSONObject.class, + JSONObject.class, + String.class + ); + method.setAccessible(true); + + String installedVersion = (String) method.invoke(null, user, ota, "2.5.3"); + + assertEquals("2.5.3", installedVersion); + } + @Test public void aboutActivity_rejectsStaleDownloadedApkWhenAvailableReleaseChanged() throws Exception { JSONObject availableRelease = new StubJSONObject() diff --git a/docs/architecture/api_and_service_inventory_cn.md b/docs/architecture/api_and_service_inventory_cn.md index 7b1ca6b..e3f2d92 100644 --- a/docs/architecture/api_and_service_inventory_cn.md +++ b/docs/architecture/api_and_service_inventory_cn.md @@ -1071,6 +1071,7 @@ - 当前补充: - `local-agent` 会优先从 `~/.codex/state_5.sqlite / logs_1.sqlite / session_index.jsonl / .codex-global-state.json` 动态发现真实 Codex 线程,并把结果填进 `projects[] + projectCandidates[]` - 线程发现会优先保留每个 Codex 文件夹下的主工作线程;如果同文件夹中存在 `worker / explorer` 子线程,会优先过滤这些子线程,避免误导入过多聊天窗口 + - 如果某条线程在 Codex 本地状态库里的 `sandbox_policy.type=read-only`,`local-agent` 不会把它作为候选线程上报;这样可以避免历史只读线程再次被自动导入到会话首页 - 对已绑定的生产设备,服务端会在 heartbeat 时自动应用建议导入项;对新设备则继续走 `deviceImportDraft` 的人工勾选与应用流程 - 自动应用时,如果某些已导入线程已经不再出现在最新 `projectCandidates[]` 中,服务端会在同一轮 heartbeat 清理这些过时线程会话 @@ -1080,6 +1081,8 @@ - 认领到任务后会执行本机 `codex exec` - `conversation_reply` 当前会优先走 `codex exec resume `,把任务恢复到真实 Codex 线程;只有缺失真实线程引用时才退回 `--ephemeral` - `dispatch_execution` 当前默认也走 `codex exec resume`,但当任务显式选择 `omx-team` 且本机 `omxEnabled + omxCommand/omxArgs` 可用时,会改走 `OMX Team Runtime` JSON 协议 +- `codex exec resume` 前当前还会做目标线程绑定预检;若目标线程缺失、已归档、cwd 不匹配或为只读会话,会直接失败并返回标准化错误,不继续把任务派进错误线程 +- 如果历史 `worker / explorer` 子线程需要转回可开发线程,除了数据库权限本身,还必须显式补发新的解锁指令覆盖其旧的“只读勘察 / 不改文件”上下文;否则前台看起来像可写,实际执行仍可能被旧上下文限制 - 执行完成后会调用 `POST /api/v1/master-agent/tasks/[taskId]/complete` - 对群聊下发链路,认领到的 `dispatch_execution` 任务会带 `dispatchExecutionId / targetProjectId / targetThreadId` - 对普通单线程聊天,认领到的 `conversation_reply` 任务会带 `targetProjectId / targetThreadId / targetCodexThreadRef` diff --git a/docs/architecture/current_runtime_and_deploy_status_cn.md b/docs/architecture/current_runtime_and_deploy_status_cn.md index ad146eb..6801207 100644 --- a/docs/architecture/current_runtime_and_deploy_status_cn.md +++ b/docs/architecture/current_runtime_and_deploy_status_cn.md @@ -38,6 +38,8 @@ - Web:`GET /conversations/[projectId]/thread-status` - Android:`ThreadStatusActivity` - 当前 `conversation_reply / dispatch_execution` 的线程执行结果会先经过 `RemoteRuntimeAdapter` 标准化;如果线程返回的是固定模式的内部环境提示(如“当前会话环境只读 / cwd …”),会直接转成失败,不再把原文写回会话消息 +- 当前设备模型已支持同一台 Mac / Windows 同时接入 Codex `GUI + CLI` 双能力;Web / Android 设备详情页都会展示两种能力状态,并允许切换默认执行模式 +- 当前同项目 `GUI / CLI` 并行写入风险已接入项目/文件夹级冲突控制:默认阻断,用户只能对当前异常项目/文件夹选择 `禁止 / 允许本次 / 永久放行` 本地已知运行方式: diff --git a/local-agent/codex-session-discovery.mjs b/local-agent/codex-session-discovery.mjs index 8d42de7..4f8f33c 100644 --- a/local-agent/codex-session-discovery.mjs +++ b/local-agent/codex-session-discovery.mjs @@ -39,6 +39,21 @@ function trimToDefined(value) { return trimmed ? trimmed : null; } +function parseSandboxPolicyType(value) { + const raw = trimToDefined(value); + if (!raw) return null; + try { + const parsed = JSON.parse(raw); + return trimToDefined(parsed?.type) ?? raw; + } catch { + return raw; + } +} + +function isReadOnlySandboxPolicy(value) { + return parseSandboxPolicyType(value) === "read-only"; +} + function isPrimaryWorkspaceThread(thread) { return !trimToDefined(thread.agentRole) && !trimToDefined(thread.agentNickname); } @@ -104,7 +119,7 @@ function loadThreadsFromStateDb(stateDbPath) { try { return db .prepare( - "SELECT id, cwd, updated_at, archived, title, agent_nickname, agent_role FROM threads WHERE archived = 0 ORDER BY updated_at DESC", + "SELECT id, cwd, updated_at, archived, title, sandbox_policy, agent_nickname, agent_role FROM threads WHERE archived = 0 ORDER BY updated_at DESC", ) .all() .map((row) => ({ @@ -113,6 +128,7 @@ function loadThreadsFromStateDb(stateDbPath) { updatedAtSeconds: Number(row.updated_at), archived: Boolean(row.archived), title: String(row.title ?? ""), + sandboxPolicy: typeof row.sandbox_policy === "string" ? row.sandbox_policy : "", agentNickname: typeof row.agent_nickname === "string" ? row.agent_nickname : "", agentRole: typeof row.agent_role === "string" ? row.agent_role : "", })); @@ -206,6 +222,9 @@ export async function discoverCodexProjectCandidates(options = {}) { const groupedCandidates = new Map(); for (const thread of threads) { if (!thread?.id || seenThreadIds.has(thread.id)) continue; + if (isReadOnlySandboxPolicy(thread.sandboxPolicy)) { + continue; + } const latestActivitySeconds = latestLogByThread.get(thread.id) ?? thread.updatedAtSeconds; if (!Number.isFinite(latestActivitySeconds) || latestActivitySeconds < cutoffSeconds) { continue; diff --git a/local-agent/codex-task-runner.mjs b/local-agent/codex-task-runner.mjs index 4fbc19c..b9a5bc2 100644 --- a/local-agent/codex-task-runner.mjs +++ b/local-agent/codex-task-runner.mjs @@ -10,6 +10,24 @@ function trimToDefined(value) { return trimmed ? trimmed : undefined; } +function parseSandboxPolicyType(value) { + const raw = trimToDefined(value); + if (!raw) { + return undefined; + } + + try { + const parsed = JSON.parse(raw); + return trimToDefined(parsed?.type) || raw; + } catch { + return raw; + } +} + +function isReadOnlySandboxPolicy(value) { + return parseSandboxPolicyType(value) === "read-only"; +} + function resolveResumeTarget(config, task) { const targetThreadRef = trimToDefined(task?.targetCodexThreadRef || task?.targetThreadId); const targetFolderRef = trimToDefined( @@ -74,7 +92,7 @@ function inspectCodexThreadBinding(config, targetThreadRef, targetFolderRef) { const db = new DatabaseSync(stateDbPath, { readonly: true }); try { const row = db - .prepare("SELECT id, cwd, archived FROM threads WHERE id = ? LIMIT 1") + .prepare("SELECT id, cwd, archived, sandbox_policy FROM threads WHERE id = ? LIMIT 1") .get(targetThreadRef); if (!row || row.archived) { return { @@ -82,6 +100,14 @@ function inspectCodexThreadBinding(config, targetThreadRef, targetFolderRef) { }; } + const sandboxPolicyType = parseSandboxPolicyType(row.sandbox_policy); + if (isReadOnlySandboxPolicy(row.sandbox_policy)) { + return { + status: "read_only", + sandboxPolicyType, + }; + } + const workspaceHints = loadThreadWorkspaceHints( trimToDefined(config?.codexGlobalStatePath || defaultCodexPath(".codex-global-state.json")), ); @@ -148,6 +174,21 @@ export async function prepareCodexTaskExecution(config, task, outputFile) { }; } + if (bindingInspection.status === "read_only") { + return { + ok: false, + error: buildStructuredTaskBindingError( + "LOCAL_AGENT_CODEX_THREAD_READ_ONLY", + `LOCAL_AGENT_CODEX_THREAD_READ_ONLY: 目标线程当前是只读会话,已拒绝 codex exec resume。thread=${targetThreadRef} sandbox=${bindingInspection.sandboxPolicyType ?? "read-only"}`, + { + targetThreadRef, + targetCodexFolderRef: resumeTarget.targetFolderRef, + sandboxPolicyType: bindingInspection.sandboxPolicyType, + }, + ), + }; + } + try { const folderStat = await stat(resumeTarget.cwd); if (!folderStat.isDirectory()) { diff --git a/public/downloads/boss-android-latest-aab.json b/public/downloads/boss-android-latest-aab.json index 7333be8..f783d5f 100644 --- a/public/downloads/boss-android-latest-aab.json +++ b/public/downloads/boss-android-latest-aab.json @@ -1,11 +1,11 @@ { "artifactType": "aab", - "fileName": "boss-android-v2.5.5-release.aab", - "urlPath": "/downloads/boss-android-v2.5.5-release.aab", - "sizeBytes": 2928280, - "updatedAt": "2026-03-30T20:15:46Z", - "sha256": "5bc794884a621a2e970bf1a235bf07d0338bcec4205963ca442b70fcd75f9f23", - "versionName": "2.5.5", - "versionCode": 18, + "fileName": "boss-android-v2.5.11-release.aab", + "urlPath": "/downloads/boss-android-v2.5.11-release.aab", + "sizeBytes": 3170704, + "updatedAt": "2026-04-06T05:25:20Z", + "sha256": "7f4b8de9508c79af5442b9e200952dfb146bcb3586d79db90e2d2c243b77aa21", + "versionName": "2.5.11", + "versionCode": 24, "buildFlavor": "release" } diff --git a/public/downloads/boss-android-latest.aab b/public/downloads/boss-android-latest.aab index 27b8ec4..9db3ddb 100644 Binary files a/public/downloads/boss-android-latest.aab and b/public/downloads/boss-android-latest.aab differ diff --git a/public/downloads/boss-android-latest.apk b/public/downloads/boss-android-latest.apk index 81ba9cc..5089bc9 100644 Binary files a/public/downloads/boss-android-latest.apk and b/public/downloads/boss-android-latest.apk differ diff --git a/public/downloads/boss-android-latest.json b/public/downloads/boss-android-latest.json index 4662c43..e37a1e1 100644 --- a/public/downloads/boss-android-latest.json +++ b/public/downloads/boss-android-latest.json @@ -1,10 +1,10 @@ { - "fileName": "boss-android-v2.5.5-release.apk", + "fileName": "boss-android-v2.5.11-release.apk", "urlPath": "/api/v1/user/ota/package", - "sizeBytes": 3108637, - "updatedAt": "2026-03-30T20:41:16Z", - "sha256": "43dde41b42b2bbc4256256edb3059803b3f9959da4e546b1d9c7addeafa03350", - "versionName": "2.5.5", - "versionCode": 18, + "sizeBytes": 3351549, + "updatedAt": "2026-04-06T05:25:05Z", + "sha256": "67a66ea104696d639d576738cdef4ef485efb76d12d63b1b138e59dab091ef00", + "versionName": "2.5.11", + "versionCode": 24, "buildFlavor": "release" } diff --git a/public/downloads/boss-android-v2.5.11-release.aab b/public/downloads/boss-android-v2.5.11-release.aab new file mode 100644 index 0000000..9db3ddb Binary files /dev/null and b/public/downloads/boss-android-v2.5.11-release.aab differ diff --git a/public/downloads/boss-android-v2.5.11-release.apk b/public/downloads/boss-android-v2.5.11-release.apk new file mode 100644 index 0000000..5089bc9 Binary files /dev/null and b/public/downloads/boss-android-v2.5.11-release.apk differ diff --git a/src/app/api/device-heartbeat/route.ts b/src/app/api/device-heartbeat/route.ts index 0677708..4e0682d 100644 --- a/src/app/api/device-heartbeat/route.ts +++ b/src/app/api/device-heartbeat/route.ts @@ -12,6 +12,19 @@ export async function POST(request: NextRequest) { status?: "online" | "abnormal" | "offline"; quota5h?: number; quota7d?: number; + capabilities?: { + gui?: { + connected?: boolean; + lastSeenAt?: string; + lastActiveProjectId?: string; + }; + cli?: { + connected?: boolean; + lastSeenAt?: string; + lastActiveProjectId?: string; + }; + }; + preferredExecutionMode?: "gui" | "cli"; projects?: string[]; projectCandidates?: Array<{ folderName?: string; @@ -47,6 +60,8 @@ export async function POST(request: NextRequest) { 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) => diff --git a/tests/local-agent-codex-discovery.test.mjs b/tests/local-agent-codex-discovery.test.mjs index a49faac..ba5fabb 100644 --- a/tests/local-agent-codex-discovery.test.mjs +++ b/tests/local-agent-codex-discovery.test.mjs @@ -212,3 +212,133 @@ test("discoverCodexProjectCandidates prefers Codex sqlite indexes and session na assert.equal(yuandiSession?.threadDisplayName, "Epicurus"); assert.equal(yuandiSession?.codexFolderRef, "/Users/kris/code/yuandi"); }); + +test("discoverCodexProjectCandidates excludes read-only threads even when they are the newest primary thread", async () => { + await setup(); + + const codexRoot = path.join(runtimeRoot, ".codex-readonly"); + const now = new Date("2026-04-05T12:00:00+08:00"); + await mkdir(codexRoot, { recursive: true }); + const stateDbPath = path.join(codexRoot, "state_5.sqlite"); + const logsDbPath = path.join(codexRoot, "logs_1.sqlite"); + const sessionIndexPath = path.join(codexRoot, "session_index.jsonl"); + const globalStatePath = path.join(codexRoot, ".codex-global-state.json"); + + const stateDb = new DatabaseSync(stateDbPath); + stateDb.exec(` + CREATE TABLE threads ( + id TEXT PRIMARY KEY, + rollout_path TEXT NOT NULL, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + source TEXT NOT NULL, + model_provider TEXT NOT NULL, + cwd TEXT NOT NULL, + title TEXT NOT NULL, + sandbox_policy TEXT NOT NULL, + approval_mode TEXT NOT NULL, + tokens_used INTEGER NOT NULL DEFAULT 0, + has_user_event INTEGER NOT NULL DEFAULT 0, + archived INTEGER NOT NULL DEFAULT 0, + archived_at INTEGER, + git_sha TEXT, + git_branch TEXT, + git_origin_url TEXT, + cli_version TEXT NOT NULL DEFAULT '', + first_user_message TEXT NOT NULL DEFAULT '', + agent_nickname TEXT, + agent_role TEXT, + memory_mode TEXT NOT NULL DEFAULT 'enabled', + model TEXT, + reasoning_effort TEXT, + agent_path TEXT + ); + `); + const insertThread = stateDb.prepare(` + INSERT INTO threads ( + id, rollout_path, created_at, updated_at, source, model_provider, cwd, title, + sandbox_policy, approval_mode, tokens_used, has_user_event, archived, + cli_version, first_user_message, agent_nickname, agent_role, memory_mode, model, reasoning_effort + ) VALUES (?, ?, ?, ?, 'desktop', 'openai', ?, ?, ?, 'never', 0, 1, 0, '0.118.0', '', '', '', 'enabled', 'gpt-5.4', 'medium') + `); + insertThread.run( + "019d-boss-writable", + path.join(codexRoot, "sessions/2026/04/05/rollout-boss-writable.jsonl"), + 1775322000, + 1775322060, + "/Users/kris/code/boss", + "Boss 可写线程", + '{"type":"workspace-write"}', + ); + insertThread.run( + "019d-boss-readonly", + path.join(codexRoot, "sessions/2026/04/05/rollout-boss-readonly.jsonl"), + 1775322120, + 1775322180, + "/Users/kris/code/boss", + "Boss 只读线程", + '{"type":"read-only"}', + ); + stateDb.close(); + + const logsDb = new DatabaseSync(logsDbPath); + logsDb.exec(` + CREATE TABLE logs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + ts INTEGER NOT NULL, + ts_nanos INTEGER NOT NULL, + level TEXT NOT NULL, + target TEXT NOT NULL, + feedback_log_body TEXT, + module_path TEXT, + file TEXT, + line INTEGER, + thread_id TEXT, + process_uuid TEXT, + estimated_bytes INTEGER NOT NULL DEFAULT 0 + ); + `); + const insertLog = logsDb.prepare(` + INSERT INTO logs (ts, ts_nanos, level, target, thread_id, estimated_bytes) + VALUES (?, 0, 'info', 'codex', ?, 0) + `); + insertLog.run(1775322060, "019d-boss-writable"); + insertLog.run(1775322180, "019d-boss-readonly"); + logsDb.close(); + + await writeFile( + sessionIndexPath, + [ + JSON.stringify({ + id: "019d-boss-writable", + thread_name: "Boss 可写线程", + updated_at: "2026-04-05T04:21:00.000000Z", + }), + JSON.stringify({ + id: "019d-boss-readonly", + thread_name: "Boss 只读线程", + updated_at: "2026-04-05T04:23:00.000000Z", + }), + ].join("\n") + "\n", + "utf8", + ); + await writeFile( + globalStatePath, + JSON.stringify({ "thread-workspace-root-hints": {} }, null, 2), + "utf8", + ); + + const discovered = await discoverCodexProjectCandidates({ + stateDbPath, + logsDbPath, + sessionIndexPath, + globalStatePath, + lookbackHours: 24, + now, + }); + + assert.deepEqual(discovered.projects, ["boss"]); + assert.equal(discovered.projectCandidates.length, 1); + assert.equal(discovered.projectCandidates[0]?.threadId, "019d-boss-writable"); + assert.equal(discovered.projectCandidates[0]?.threadDisplayName, "Boss 可写线程"); +}); diff --git a/tests/local-agent-codex-task-runner.test.mjs b/tests/local-agent-codex-task-runner.test.mjs index 4c54650..a66628a 100644 --- a/tests/local-agent-codex-task-runner.test.mjs +++ b/tests/local-agent-codex-task-runner.test.mjs @@ -63,7 +63,7 @@ async function createCodexStateDb(threads) { id, rollout_path, created_at, updated_at, source, model_provider, cwd, title, sandbox_policy, approval_mode, tokens_used, has_user_event, archived, cli_version, first_user_message, agent_nickname, agent_role, memory_mode, model, reasoning_effort - ) VALUES (?, ?, ?, ?, 'desktop', 'openai', ?, ?, 'workspace-write', 'never', 0, 1, 0, '0.118.0', '', '', '', 'enabled', 'gpt-5.4', 'medium') + ) VALUES (?, ?, ?, ?, 'desktop', 'openai', ?, ?, ?, 'never', 0, 1, 0, '0.118.0', '', '', '', 'enabled', 'gpt-5.4', 'medium') `); for (const thread of threads) { insertThread.run( @@ -73,6 +73,7 @@ async function createCodexStateDb(threads) { 1774845618, thread.cwd, thread.title ?? thread.id, + thread.sandboxPolicy ?? '{"type":"workspace-write"}', ); } db.close(); @@ -283,3 +284,37 @@ test("conversation reply preflight fails closed when target cwd mismatches the l assert.match(result.error.message, /LOCAL_AGENT_CODEX_THREAD_BINDING_MISMATCH/); assert.match(result.error.message, /project-live/); }); + +test("conversation reply preflight fails closed when target Codex thread is read-only", async () => { + const root = await ensureRuntimeRoot(); + const readonlyCwd = path.join(root, "project-readonly"); + await mkdir(readonlyCwd, { recursive: true }); + const stateDbPath = await createCodexStateDb([ + { + id: "019d-thread-readonly", + cwd: readonlyCwd, + title: "Read-only thread", + sandboxPolicy: '{"type":"read-only"}', + }, + ]); + + const result = await prepareCodexTaskExecution( + { + masterAgentWorkdir: "/Users/kris/code/boss", + masterAgentSandbox: "workspace-write", + codexStateDbPath: stateDbPath, + }, + { + taskType: "conversation_reply", + executionPrompt: "请继续开发", + targetCodexThreadRef: "019d-thread-readonly", + targetCodexFolderRef: readonlyCwd, + }, + "/tmp/reply.txt", + ); + + assert.equal(result.ok, false); + assert.equal(result.error.code, "LOCAL_AGENT_CODEX_THREAD_READ_ONLY"); + assert.match(result.error.message, /LOCAL_AGENT_CODEX_THREAD_READ_ONLY/); + assert.match(result.error.message, /read-only/); +});