oh-my-openagent:boulder.json 中 worktree_path 的类型清洗与防御式守卫方案
【免费下载链接】oh-my-openagentOmO: Drop your tokens. Ultrawork. Done.项目地址: https://gitcode.com/gh_mirrors/oh/oh-my-openagent
本篇指南围绕 oh-my-openagent 仓库中一份针对 Boulder 状态文件(boulder.json)的代码变更方案展开:当boulder.json中worktree_path字段被手工编辑、外部工具或损坏状态写成null时,readBoulderState()的无校验类型断言会让运行时值与 TypeScript 类型契约(string | undefined)不一致,进而可能把非法值泄漏进 Atlas 空闲续跑的提示词。读完后,你将掌握这份变更方案中“读取层清洗 + 调用层守卫 + 测试锁定”三层防御的完整设计,并能结合仓库当前源码定位到每个改动点对应的实际实现位置。
一、背景:boulder.json 与 BoulderState 类型契约
oh-my-openagent 用.omo/boulder.json状态文件追踪进行中的计划(plan)执行进度,包括活动计划路径、会话列表、计划名,以及可选的执行代理和 worktree 路径。该文件的核心类型定义在共享包 types.ts 中:
export interface BoulderState { schema_version?: 2 active_work_id?: string works?: Record<string, BoulderWorkState> active_plan: string started_at: string ended_at?: string elapsed_ms?: number status?: BoulderWorkStatus updated_at?: string session_ids: string[] session_origins?: Record<string, "direct" | "appended"> plan_name: string agent?: string worktree_path?: string // ← 本文档关注点:类型契约为 string | undefined task_sessions?: Record<string, TaskSessionState> }注意worktree_path?: string:类型系统承诺“要么不存在,要么是字符串”,null不在合法取值范围内。
为什么null会混进来?关键在于写入路径天然规避了它。从 createBoulderState 的实现看,状态构造时对可选字段采用“键省略”策略:
return { schema_version: 2, active_work_id: workId, works: { [workId]: work }, active_plan: planPath, // ... ...(agent !== undefined ? { agent } : {}), ...(worktreePath !== undefined ? { worktree_path: worktreePath } : {}), }也就是说,仓库自身的写入逻辑(createBoulderState、addBoulderWork等)只会在worktreePath为undefined时省略该键,永远不会写出worktree_path: null。但boulder.json是一个落在磁盘上的普通 JSON 文件,用户手工编辑、状态文件损坏或第三方工具写入都可能产生完全合法的 JSON:
{ "active_plan": "/path/to/plan.md", "plan_name": "test-plan", "worktree_path": null }这正是变更方案要防御的输入。
二、问题定位:readBoulderState 的无校验类型断言
读取入口readBoulderState()位于共享包 read-state.ts(在omo-opencode包中经由 storage.ts 再导出)。其核心流程是:
const content = readFileSync(filePath, "utf-8") const parsed = JSON.parse(content) // ... 空对象/数组等顶层结构校验 ... normalizeState(parsed) const state = parsed as BoulderState原始方案文档指出的问题在于:JSON.parse()的产物被直接as BoulderState断言,除顶层“是否为非空对象”之外,不对worktree_path等具体字段做运行时校验。当文件里写的是null时,state.worktree_path的运行时类型是null,而类型签名声称是string | undefined。
这个“类型契约与运行时事实脱节”的下游代价体现在两处:
- 计划路径解析:resolveBoulderPlanPath 会用
state.worktree_path?.trim()判断是否需要把计划路径重定位到 worktree 下。可选链对null恰好安全,但如果字段是其他非字符串类型(例如数字),.trim()会直接抛TypeError。 - Atlas 空闲续跑注入:Atlas 钩子在会话空闲时读取 boulder 状态并调用
injectContinuation,把worktreePath一路传入续跑提示词构造器。提示词中会拼出[Worktree: ${worktreePath}]文本(见 boulder-continuation-injector.ts),null/非字符串值一旦泄漏,轻则在续跑提示词里渲染出[Worktree: null]之类的噪声上下文,重则引发类型误用。
三、变更一:在 readBoulderState 中清洗 worktree_path
方案的第一层防御放在读取层。变更文档给出的 BEFORE/AFTER 对比(原方案中对应src/features/boulder-state/storage.ts,即当前仓库的 packages/omo-opencode/src/features/boulder-state/storage.ts)如下:
// BEFORE (lines 29-32): if (!Array.isArray(parsed.session_ids)) { parsed.session_ids = [] } return parsed as BoulderState // AFTER: if (!Array.isArray(parsed.session_ids)) { parsed.session_ids = [] } if (parsed.worktree_path !== undefined && typeof parsed.worktree_path !== "string") { parsed.worktree_path = undefined } return parsed as BoulderState设计动机(引自方案文档):readBoulderState把JSON.parse()的原始输出直接断言为BoulderState,不校验具体字段。当boulder.json里是"worktree_path": null(合法 JSON,来源可能是手工编辑、损坏状态或外部工具)时,运行时类型是null而 TypeScript 类型是string | undefined。这段清洗确保下游代码拿到的永远符合类型契约。
值得注意的边界条件:守卫条件是!== undefined && typeof !== "string",即“字段存在但不是字符串”时才置为undefined,合法的字符串值原样保留、缺失的字段不引入新键。这与session_ids已有的“非数组则重置为空数组”的归一化手法一脉相承——当前仓库的 normalizeState 正是以同样的思路对session_ids、session_origins、task_sessions等字段做逐字段归一化的。
四、变更二:Atlas 空闲钩子的防御式类型守卫
方案的第二层防御是“belt-and-suspenders”(双保险)。即便读取层已经清洗,仓库其他位置的writeBoulderState调用仍可能直接产生非法状态,因此在把worktree_path传给续跑函数之前再做一次零成本的typeof检查。
变更文档涉及 Atlas 空闲钩子中两处injectContinuation调用点,当前仓库对应实现位于 idle-event.ts(空闲主路径)与 idle-continuation.ts(injectContinuation/scheduleRetry定义处):
位置 1:scheduleRetry 内的续跑注入(方案文档标注原文件 lines 83-88)
// BEFORE: await injectContinuation({ ctx, sessionID, sessionState, options, planName: currentBoulder.plan_name, progress: currentProgress, agent: currentBoulder.agent, worktreePath: currentBoulder.worktree_path, }) // AFTER: await injectContinuation({ ctx, sessionID, sessionState, options, planName: currentBoulder.plan_name, progress: currentProgress, agent: currentBoulder.agent, worktreePath: typeof currentBoulder.worktree_path === "string" ? currentBoulder.worktree_path : undefined, })位置 2:handleAtlasSessionIdle 内的续跑注入(方案文档标注原文件 lines 184-188)
// BEFORE: await injectContinuation({ ctx, sessionID, sessionState, options, planName: boulderState.plan_name, progress, agent: boulderState.agent, worktreePath: boulderState.worktree_path, }) // AFTER: await injectContinuation({ ctx, sessionID, sessionState, options, planName: boulderState.plan_name, progress, agent: boulderState.agent, worktreePath: typeof boulderState.worktree_path === "string" ? boulderState.worktree_path : undefined, })设计动机(引自方案文档):双保险防御。即使readBoulderState已经做了清洗,别处的直接writeBoulderState调用仍可能产生非法状态。typeof检查零成本,能彻底杜绝null或其他非字符串值泄漏进续跑链路。
从当前源码结构看,这条链路的完整调用关系是:handleAtlasSessionIdle(idle-event.ts)先经resolveActiveBoulderSession取得boulderState,再做完成度、停滞、冷却、后台任务等一连串前置判断,最终把boulderState.worktree_path透传给injectContinuation→injectBoulderContinuation,在构造续跑提示词时决定是否追加[Worktree: ...]上下文。守卫点选在injectContinuation的入参处,恰好卡在“状态值”与“提示词文本”的交界处。
五、变更三与四:用测试锁定两类损坏状态
方案文档同时给出了两组测试用例,分别锁定“字段缺失”与“字段为 null”两种场景。
5.1 Atlas 钩子层测试(index.test.ts)
在既有session.idle handler的 describe 块中新增两个用例:
test("should inject continuation when boulder.json has no worktree_path field", async () => { // given - boulder state WITHOUT worktree_path const planPath = join(TEST_DIR, "test-plan.md") writeFileSync(planPath, "# Plan\n- [ ] Task 1\n- [x] Task 2") const state: BoulderState = { active_plan: planPath, started_at: "2026-01-02T10:00:00Z", session_ids: [MAIN_SESSION_ID], plan_name: "test-plan", } writeBoulderState(TEST_DIR, state) const readState = readBoulderState(TEST_DIR) expect(readState?.worktree_path).toBeUndefined() const mockInput = createMockPluginInput() const hook = createAtlasHook(mockInput) // when await hook.handler({ event: { type: "session.idle", properties: { sessionID: MAIN_SESSION_ID }, }, }) // then - continuation injected, no worktree context in prompt expect(mockInput._promptMock).toHaveBeenCalled() const callArgs = mockInput._promptMock.mock.calls[0][0] expect(callArgs.body.parts[0].text).not.toContain("[Worktree:") expect(callArgs.body.parts[0].text).toContain("1 remaining") }) test("should handle boulder.json with worktree_path: null without crashing", async () => { // given - manually write boulder.json with worktree_path: null (corrupted state) const planPath = join(TEST_DIR, "test-plan.md") writeFileSync(planPath, "# Plan\n- [ ] Task 1\n- [x] Task 2") const boulderPath = join(SISYPHUS_DIR, "boulder.json") writeFileSync(boulderPath, JSON.stringify({ active_plan: planPath, started_at: "2026-01-02T10:00:00Z", session_ids: [MAIN_SESSION_ID], plan_name: "test-plan", worktree_path: null, }, null, 2)) const mockInput = createMockPluginInput() const hook = createAtlasHook(mockInput) // when await hook.handler({ event: { type: "session.idle", properties: { sessionID: MAIN_SESSION_ID }, }, }) // then - should inject continuation without crash, no "[Worktree: null]" expect(mockInput._promptMock).toHaveBeenCalled() const callArgs = mockInput._promptMock.mock.calls[0][0] expect(callArgs.body.parts[0].text).not.toContain("[Worktree: null]") expect(callArgs.body.parts[0].text).not.toContain("[Worktree: undefined]") })这两个用例的断言设计各有侧重:
- 缺失字段用例:验证正常写入路径下
readBoulderState读回的worktree_path是undefined,续跑提示词中不出现任何[Worktree:上下文,同时计划进度(1 remaining,对应- [ ] Task 1一条未完成任务)仍然被正确注入——证明清洗逻辑没有误伤正常流程。 - null 字段用例:绕过
writeBoulderState直接手写boulder.json注入worktree_path: null(模拟损坏状态),验证空闲处理器不崩溃,且提示词中既不渲染[Worktree: null]也不渲染[Worktree: undefined]——即null被规范化成了“无 worktree 上下文”。
测试文件在仓库中的对应位置是 packages/omo-opencode/src/hooks/atlas/index.test.ts。
5.2 存储层测试(storage.test.ts 增补)
describe("#given boulder.json with worktree_path: null", () => { test("#then readBoulderState should sanitize null to undefined", () => { // given const boulderPath = join(TEST_DIR, ".sisyphus", "boulder.json") writeFileSync(boulderPath, JSON.stringify({ active_plan: "/path/to/plan.md", started_at: "2026-01-02T10:00:00Z", session_ids: ["session-1"], plan_name: "test-plan", worktree_path: null, }, null, 2)) // when const state = readBoulderState(TEST_DIR) // then expect(state).not.toBeNull() expect(state!.worktree_path).toBeUndefined() }) test("#then readBoulderState should preserve valid worktree_path string", () => { // given const boulderPath = join(TEST_DIR, ".sisyphus", "boulder.json") writeFileSync(boulderPath, JSON.stringify({ active_plan: "/path/to/plan.md", started_at: "2026-01-02T10:00:00Z", session_ids: ["session-1"], plan_name: "test-plan", worktree_path: "/valid/worktree/path", }, null, 2)) // when const state = readBoulderState(TEST_DIR) // then expect(state?.worktree_path).toBe("/valid/worktree/path") }) })存储层测试成对出现:一个证明null被清洗为undefined,另一个证明合法字符串(/valid/worktree/path)被原样保留,防止清洗逻辑写成“一刀切”地把字段直接删掉。该测试增补的落点是 packages/omo-opencode/src/features/boulder-state/storage.test.ts,而共享包侧已有的读取测试可参见 packages/boulder-state/src/read-state.test.ts——其中包含了“文件不存在返回 null”“malformed JSON 返回 null”等既有边界场景,新增用例与它们共同构成readBoulderState的完整测试面。
六、结合当前仓库源码结构的补充说明
阅读这份方案时,建议对照当前仓库的实际代码组织做三点校准:
- 实现位置的演进。方案文档中的路径
src/features/boulder-state/storage.ts对应当前仓库的 packages/omo-opencode/src/features/boulder-state/storage.ts,该文件目前是一个再导出 shim,把readBoulderState、writeBoulderState等函数统一转出自共享包@oh-my-opencode/boulder-state(实现位于 packages/boulder-state/src/storage/read-state.ts 与 write-state.ts)。若在当前仓库落地同类改动,清洗逻辑应加在共享包的readBoulderState/normalizeState中,而不是 shim 里。 - 状态目录的命名。方案文档的测试夹具使用
SISYPHUS_DIR(.sisyphus/boulder.json)指代状态目录;当前仓库测试中状态文件已落在.omo/boulder.json(见 read-state.test.ts 中join(directory, ".omo")的夹具写法)。迁移用例时目录名需同步更新,断言逻辑本身不受影响。 - 当前实现的可对照点。当前 read-state.ts 的
normalizeState已对session_ids、session_origins、task_sessions等字段做了逐字段归一化,但尚未包含worktree_path的字符串类型清洗;当前 idle-event.ts 透传给injectContinuation的仍是boulderState.worktree_path原始值。也就是说,方案文档所描述的 AFTER 形态与当前仓库实现之间仍存在明确的落地空间,这也解释了为何该方案的三层结构(读取层清洗、调用层守卫、双向测试锁定)值得作为一个整体来参考。
七、验证方式与适用前提
- 验证:在仓库根目录下运行相关包的测试即可验证改动点。钩子层用 packages/omo-opencode/src/hooks/atlas/index.test.ts 中的
session.idle handler用例覆盖端到端行为(手写损坏 JSON → 触发session.idle→ 断言提示词文本);存储层用readBoulderState的清洗用例覆盖“null 归 undefined、字符串保留”两条契约。 - 适用前提:本方案假设
boulder.json可能被仓库自身之外的途径写入(手工编辑、外部工具),因此不信任磁盘上的任何字段类型;所有清洗都遵循“非法即置 undefined”的宽松降级策略,不做拒绝或抛错,保证损坏状态下的可用性优先。 - 限制:
typeof守卫只保证值要么是字符串要么是undefined,不校验路径本身是否合法、是否存在——路径有效性由下游resolveBoulderPlanPath(path.ts)通过existsSync回退逻辑兜底。
小结:这份变更方案的核心价值不在于两行代码本身,而在于它演示了处理“外部可写 JSON 状态文件”的标准姿势——在读取边界把运行时值收敛回类型契约,在关键消费边界再叠加零成本守卫,最后用“缺失字段”和“null 字段”两组测试把契约钉死。对 oh-my-openagent 这类以计划文件 + 状态文件驱动长任务续跑的系统而言,这类防御直接决定了损坏状态下代理是继续安静工作,还是把[Worktree: null]之类的脏数据推进模型上下文。
【免费下载链接】oh-my-openagentOmO: Drop your tokens. Ultrawork. Done.项目地址: https://gitcode.com/gh_mirrors/oh/oh-my-openagent
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考