Harden read-only thread handling and refresh Android releases

This commit is contained in:
kris
2026-04-06 13:26:48 +08:00
parent 9d7d2f4d17
commit 3564aeaf2e
18 changed files with 346 additions and 27 deletions

View File

@@ -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`,提示用户先重新整理群成员

View File

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

View File

@@ -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<CandidateConversation> candidates = new ArrayList<>();
private final Set<String> selectedProjectIds = new LinkedHashSet<>();
private final Set<String> lastCandidateProjectIds = new LinkedHashSet<>();
private static final Set<String> 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", "未命名会话")),

View File

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

View File

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

View File

@@ -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 <targetCodexThreadRef>`,把任务恢复到真实 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`

View File

@@ -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` 并行写入风险已接入项目/文件夹级冲突控制:默认阻断,用户只能对当前异常项目/文件夹选择 `禁止 / 允许本次 / 永久放行`
本地已知运行方式:

View File

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

View File

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

View File

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

View File

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

Binary file not shown.

Binary file not shown.

View File

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

View File

@@ -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 可写线程");
});

View File

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