Skip to content

refactor(schedule): 定时任务存储按 bot 拆分到 BOT_HOME——修复沙盒内锁 EPERM、消除跨 bot 任务泄漏 - #611

Merged
deepcoldy merged 4 commits into
deepcoldy:masterfrom
xu4wang:refactor/per-bot-schedules
Jul 27, 2026
Merged

refactor(schedule): 定时任务存储按 bot 拆分到 BOT_HOME——修复沙盒内锁 EPERM、消除跨 bot 任务泄漏#611
deepcoldy merged 4 commits into
deepcoldy:masterfrom
xu4wang:refactor/per-bot-schedules

Conversation

@xu4wang

@xu4wang xu4wang commented Jul 26, 2026

Copy link
Copy Markdown
Contributor

问题

共享的 data/schedules.json 有两个结构性问题:

  1. 沙盒内定时任务只能查不能改(实测证实):文件本体有单文件 readWrite 放行,但 schedule-store 的所有变更走 withFileLockSync,需要创建兄弟文件 schedules.json.lock——单文件规则盖不住兄弟路径,沙盒内 botmux schedule add/rm/pause 一律 EPERM。macOS 一行放行能修,但 Linux bwrap 无法绑定"时有时无"的瞬态锁文件,共享文件模型下无干净解。
  2. 跨 bot 任务泄漏:为避免"读拒→读到空表→整体覆写清空全员任务",共享文件被迫整体 readWrite,每个沙盒 bot 都能读到所有 bot 的任务 prompt/routing(当时 owner 明确接受的代价)。

方案:存储按 bot 拆分进 BOT_HOME

<botmuxHome>/bots/<appId>/schedules.json。BOT_HOME 对 owner 本就整目录 readWrite、对兄弟构造性 deny——沙盒策略零新增规则,RMW 兄弟锁随文件进入 rw 目录,两平台同时修复;跨 bot 泄漏面随共享文件一起消失;fs-policy 里那条特殊放行直接删除。

架构契合:任务本就带 larkAppId 归属,每个 daemon 本就只执行自己 bot 的任务(setOwnerFilter)——"共享文件+各自过滤"变"各读各的文件",调度侧不变。

改动

  • schedule-store:per-bot scope 绑定(daemon 启动绑自己、CLI 绑会话 bot / --lark-app-id)+ per-file 状态机;createTaskparams.larkAppId 路由到属主文件;新增 listTasksForBots/findTaskAcrossBots(非沙盒终端聚合 list、跨库 id 寻址)与 importTasks(迁移用)
  • 启动迁移(新 schedule-split-migration.ts):daemon 启动时把 legacy 共享文件按 task.larkAppId 拆分(无归属/未知归属→bot-0,与 owner-filter 旧语义一致);多 daemon 并发 boot 由 legacy 文件锁串行化、锁内复查存在性;幂等;原文件保留为 schedules.json.bak-split-v1(降级改名即回);malformed 文件保留原地不 rename(不吞任务、不 brick 启动)
  • CLI:scope 解析链 --lark-app-id → session marker → BOTMUX_LARK_APP_ID env,任务 owner 用同一解析结果;bots.json 读取改为惰性+容错(沙盒内 loadBotsJson 会 process.exit);沙盒内跨 bot 写给出明确错误
  • v3 workflowbotmux-schedule reconciler 从冻结 input 的 larkAppId 寻址(非 daemon 恢复路径无全局 scope)
  • worker/fs-policy:删共享文件预建与特殊放行
  • probe:新增「自己 BOT_HOME 锁文件可建」(本缺口的精确探针)+「兄弟 store 拒读」(仅在文件真实存在时断言)

测试与实测

  • 全量套件与 master 基线按失败文件名对照零回归(仅有的 3 个新失败是测试自身适配,已修:fs-policy 断言、host-executor/dashboard-ipc 绑 scope);schedule 相关 192 用例全过;新增迁移专项 7 用例(拆分/幂等/冲突保留/malformed 保护/独立性)
  • 真机 Seatbelt 实测:真实沙盒 profile 内完整跑通 schedule add → list → rm,落点 per-bot 文件——旧模型下第一步就 EPERM
  • sandbox-probe 全绿(含新断言)
  • codex 定向 review 两轮:抓出 2 个 P1(v3 reconciler 非 daemon 路径炸 scope、沙盒会话 marker 缺失时任务 ownerless 永不执行)均已修复并复验 FIX-OK;迁移锁序(legacy→dest 单向)、watcher 闭包一致性、daemon 零参调用时序均复核无问题

兼容性

  • 已有任务:启动迁移自动拆分,调度行为不变(含 legacy ownerless→bot-0)
  • 降级:mv schedules.json.bak-split-v1 schedules.json 即回(新写入的任务需手工并回,PR 说明即此一条)
  • 非沙盒裸终端:schedule list 聚合所有 bot、id 操作跨库寻址,管理体验与之前一致

xu4wang added 2 commits July 27, 2026 01:24
…任务泄漏

存储从共享 data/schedules.json 迁至 <botmuxHome>/bots/<appId>/schedules.json:
- 沙盒策略零新增规则:own BOT_HOME 本就 readWrite、兄弟构造性 deny——RMW
  兄弟锁(schedules.json.lock)随文件进入 rw 目录,两平台的沙盒内
  schedule 增删改同时修复(旧单文件放行盖不住兄弟锁路径,EPERM)
- 消除旧共享文件被迫接受的泄漏面:任务 prompt/routing 不再对全体沙盒 bot 可读
- fs-policy 删除共享文件特殊放行;worker 删除预建

store 增加 per-bot scope(daemon 绑自己、CLI 绑会话 bot/--lark-app-id)与
per-file 状态机;createTask 按 params.larkAppId 路由到属主文件;新增
listTasksForBots/findTaskAcrossBots(非沙盒聚合/跨库寻址)与 importTasks。
daemon 启动时把 legacy 共享文件按 task.larkAppId 幂等拆分(无归属→bot-0,
同 owner-filter 旧语义;并发 boot 由 legacy 文件锁串行化;原文件保留为
.bak-split-v1,降级手工改名即回)。

codex 两处 P1 已修:v3 reconciler 从冻结 input 取 larkAppId 寻址(非 daemon
运行无全局 scope);CLI add 的 owner 回退链对齐 scope 解析(flag→marker→env),
防止沙盒会话产出 ownerless 任务永不执行。

真机验证:sandbox-probe 新增锁文件断言全绿;真实 Seatbelt profile 内
schedule add/list/rm 端到端通过,落点 per-bot 文件。
@xu4wang
xu4wang requested a review from deepcoldy as a code owner July 26, 2026 18:54
@deepcoldy

Copy link
Copy Markdown
Owner

首次 review(Claude)— 结论:改动逻辑正确、无阻塞项,可合(前置:@申晗 确认 + @codex 独立复审)

按仓库「影响范围评估」逐条核过承重事实,本地实跑验证如下。

这个 PR 在解决什么

共享的 data/schedules.json 有两个结构病:

  1. 沙盒内定时任务只能读不能写:文件本体有单文件 readWrite 放行,但 schedule-store 每次改都要走 withFileLockSync兄弟锁文件 schedules.json.lock——单文件规则盖不住兄弟路径,Linux bwrap 又没法绑定「时有时无」的瞬态锁,共享文件模型下无干净解。
  2. 跨 bot 泄漏:为避免「读拒→读空→整体覆写清空全员任务」,共享文件被迫整体 readWrite,每个沙盒 bot 都能读到别的 bot 的任务 prompt/routing。

怎么解的

存储从一个共享文件拆成 per-bot:<botmuxHome>/bots/<appId>/schedules.json。BOT_HOME 对 owner 本就整目录 readWrite、对兄弟构造性 deny——所以 RMW 兄弟锁随文件进入 rw 目录(两平台同时修好),跨 bot 泄漏随共享文件消失,fs-policy 那条特殊放行直接删掉。架构上契合:任务本就带 larkAppId 归属、每个 daemon 本就只执行自己 bot 的任务,「共享文件+运行时过滤」变成「各读各的文件」。

我核过的承重事实(全部成立)

  1. 路径一致性:store 用 botHomePath(dirname(config.session.dataDir), appId),与 worker.ts 给 fs-policy 的 ownBotHome(同公式)一致;SESSION_DATA_DIR 原样透传给沙盒子进程(child-env.ts),willRedirectCliData 只重定向 CLI 配置目录(<botHome>/claude)不动 SESSION_DATA_DIR → 沙盒里算出的 store 路径正好落在被授权 rw 的 BOT_HOME。
  2. 进程模型startDaemon(botIndex) 一个 daemon 进程只服务一个 bot → 模块级全局 scopeAppId 安全,不存在两 bot 共进程互踩。
  3. 迁移竞态schedule-split-migration.ts 在 legacy 文件锁内跑、锁内复查存在性、importTasks 全部完成才 renameSync.bak-split-v1;多 daemon 并发 boot 只有一个真拆、其余进锁后见文件已消失即 no-op;malformed 文件保留原地不 rename(不吞任务、不 brick 启动);幂等。锁嵌套是跨文件(legacy data/schedules.json vs dest bots/<appId>/schedules.json),不违反 file-lock「同路径不可重入」约束。
  4. owner-filter 一致setOwnerFilter/taskBelongsToThisDaemon 现在是物理分区之上的双保险,语义不变(ownerless→primary bot-0,bot-0 执行 ownerless)。
  5. 零参调用方全覆盖scheduler.ts + dashboard-ipc-server.ts 都是 daemon 侧(启动时已 setScheduleScope),cli.tscmdSchedule 先绑 scope,botmux-schedule.ts executor 显式传 appId。daemon 启动到 setScheduleScope 之间无任何 store 访问。
  6. dashboard 零回归dashboard-ipc-server.tslistTasks().filter(belongsToOwner)——改前读共享文件(全量)再过滤,改后读自己 store(已只本 bot)再过滤(冗余但无害)→ 同一观察输出。

本地验证

  • 环境:本机 root,node_modules 从同 lockfile-hash 的 canonical checkout 复用。
  • PR 直接改动的 8 个测试套件实跑 208/208 全绿(含新增迁移专项 7 用例、fs-policy 断言更新、idempotency 跨 store 路由)。
  • 全量 unit:10808 passed / 12 failed。12 个失败全部是环境基线、且所在 5 个文件均非本 PR 改动:TZ 断言(本机非 +8)、model-runner(需网络/凭证)、真实 bubblewrap e2e(root CAP_DAC_OVERRIDE 读穿 mode-000 mask)。逐条核对失败输出requireScope 泄漏——即没有任何调用方因为「没绑 scope」而炸。唯一被 PR 改到的 fs-policy.test.ts 在 208 绿里。

一个非阻塞观察点(P3,供 @codex 独立复核)

botmux-schedule.tsreconciler.readOnlyLookup 已显式从 frozen input 取 larkAppId 来寻址(注释点明「非 daemon 的 v3 run 不绑全局 scope」),但同文件的 invoke() 没有做同样的防御式取值——它完全依赖 input.larkAppId 存在,否则 createTask 会走到 requireScope() 抛错。今天这是安全的:runtime.tsassertHostExecutionIdentity(1800 & 1877 两条 prepare 路径都会先 assert)保证任何到达 invoke 的 schedule 输入都带非空 larkAppId。但这个保证隔了约 250 行、跨文件,属于隐式耦合。建议(不阻塞):在 invoke() 加一行注释指向该不变量,或镜像 reconciler 的防御式取值,降低未来改 runtime 时踩坑的概率。


以上为首审。@codex 请独立复核 diff / 测试 / 跨 bot·跨后端影响面。未经 @申晗 确认不合码。

@chatgpt-codex-connector

Copy link
Copy Markdown

To use Codex here, create a Codex account and connect to github.

@deepcoldy deepcoldy left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Codex 独立复审 — 结论:请求修改

核心方向成立:per-bot store 与 BOT_HOME 的授权公式一致,RMW sibling lock 在 macOS/Linux 都进入 owner 的可写目录;PTY/Tmux/普通/restore/adopt 的 CLI 子进程都能拿到 BOTMUX_LARK_APP_ID,daemon/dashboard 的零参调用也在启动时绑定了 scope;迁移的 legacy-file lock、锁内复查和“全部 import 完才 rename”可应对并发启动与部分失败。未发现跨 CLI 或跨后端的架构性回归。

但独立复核发现 2 个需要先修的缺口:

1. 阻塞:未知 owner 被写入 primary store 后,实际上没有任何 daemon 会执行

src/services/schedule-split-migration.ts:71 对“不在当前 knownAppIdstask.larkAppId”选择 primaryAppId 作为目标文件,但 importTasks() 保留任务原来的 larkAppId。因此迁移后形成:

  • 文件位置:bots/<primary>/schedules.json
  • 任务 owner:仍为旧的/暂未配置的 appId
  • primary scheduler:taskBelongsToThisDaemon() 看到非空 owner 且不等于 primary,返回 false
  • 将来重新加入原 bot:它只读 bots/<原 appId>/schedules.json,那里又是空的

隔离目录实测结果:

{"primaryStoreHasTask":true,"taskOwner":"cli_removed","primaryWouldExecute":false,"ownerStoreExists":false}

这不是注释所说的“unknown owners go to primary”执行兜底,而是把任务永久搁置;backup 虽在,但正常运行路径已不可达。建议明确语义后二选一:安全 appId 仍落到自己的 store 以保留未来恢复,或回退 primary 时同步清掉/改写 owner,使 primary 真的接管。迁移测试不能只断言 key 在 primary 文件里,还应断言 owner filter 的实际执行归属以及 bot 恢复后的可见性。

2. 阻塞:浏览器 E2E 的定时任务清理器没有绑定新 scope,所有兜底清理都失效

schedule-store 的零参 removeTask/listTasks 会经 requireScope() 抛错,但 test/e2e-browser/schedule-cleanup.ts:41-43,65-80,93-110 仍直接导入 store 后零参调用。隔离临时目录复现:

{"removed":[],"warnings":["removeTask(missing-id) threw: [schedule-store] no bot scope bound — call setScheduleScope(<larkAppId>) before using the schedule store","listTasks() threw: [schedule-store] no bot scope bound — call setScheduleScope(<larkAppId>) before using the schedule store"]}

后果是 feishu-schedule.e2e.ts 创建的真实任务在 UI 清理失败时无法兜底删除,global setup 的 orphan sweep 也固定空跑,可能持续往真实群发消息。这里不能只绑定 primary:E2E 可能由任意 bot 创建任务。建议让该 unsandboxed helper 枚举配置中的 appId,显式按 store 查找/删除,并补一个 temp per-bot store 回归测试。

验证记录

  • pnpm build:通过
  • PR 直接相关 8 套:208/208 通过
  • pnpm test10816 passed / 4 failed / 5 skipped;4 个失败在未含 PR 的基线可完全复现(3 个 host TZ、1 个 root 下真实 bwrap mode-000),与本 PR 无关
  • 当前 origin/master=b30e8949,PR 基于 945c332agit merge-tree --write-tree origin/master HEAD 无冲突;合成 merge tree pnpm build 通过,相关 8 套串行 209/209 通过
  • worktree 保持干净;未改代码、未重启 daemon、未合码

修完这两点后请再 @ 我复审。未经申晗确认仍不要合码。

…tore

codex 独立复审抓出 2 个阻塞缺口,均已修复并补回归测试(变异测试证对实现敏感):

1. 迁移「未知/未配置 owner」搁置(finding 1,已 runtime 复现)
   - 病因:schedule-split-migration 把 larkAppId 不在 bots.json 的任务写进 primary
     的 store 却保留原 larkAppId;primary 的 owner filter 因 appId 不匹配拒绝执行,
     而该 owner 自己的 store 又是空的 → 任务永不触发。
   - 修法(按 codex 建议区分三种归属,而非一律塞 primary):
     · 真 ownerless(无 larkAppId)→ primary 的 store,保持 ownerless(primary daemon
       本就执行 ownerless,等同拆分前行为;不 stamp primary appId)
     · owner 在 bots.json → 自己的 BOT_HOME store(不变)
     · owner 合法但当前未配置(bot 被删/配置漂移)→ 落自己的 dormant store(非 primary),
       保留 larkAppId,bot 将来恢复即原样可见(塞 primary 要么搁置、要么以错误 bot 身份执行)
     · larkAppId 不安全(无法做路径段)→ fail-safe:import 前中止整次拆分、保留 legacy
       文件交人工,绝不静默吞行

2. 浏览器 E2E 清理器全部失效(finding 2)
   - 病因:test/e2e-browser/schedule-cleanup.ts 仍零参调用 removeTask/listTasks,
     per-bot store 固定抛 no bot scope bound;UI 清理失败后真实测试任务无法兜底删除,
     orphan sweep 也空跑。
   - 修法:候选 ID 删除、按 label fallback、orphan sweep 三条路径全部枚举
     <botmuxHome>/bots/<appId> 下的 store 并显式传 appId(findTaskAcrossBots /
     listTasksForBots / removeTask(id, appId)),不再依赖任何 bound scope。

测试:
- 新增 test/schedule-cleanup-per-bot.test.ts(4 用例:跨 store 删候选 id / label fallback /
  orphan sweep / 空 store no-op)
- 更新 schedule-split-migration.test.ts:未配置-但安全 owner → 自己 store 保留 appId、
  ownerless → primary 保持 ownerless、不安全 appId → 中止且保留 legacy
- 变异测试:还原旧 buggy 路由 → 迁移 2 用例红;还原零参调用 → 清理 2 用例抛 no bot scope
- pnpm build 绿;PR 相关 9 套件 213/213;全量 unit 10815 passed / 10 failed,
  10 个失败全环境基线(TZ 非 +8 / model-runner 需网络 / root DAC_OVERRIDE 读穿真 bwrap),
  全非本次改动文件,零 no-bot-scope 泄漏

Co-Authored-By: Riff <noreply@riff.dev>
@deepcoldy

Copy link
Copy Markdown
Owner

代作者修复:采纳 Codex 复审的 2 个 blocker(新 head 01e7ff58

Codex 独立复审抓出 2 个阻塞缺口,我都独立核实(finding 1 还跑了 runtime 复现),确认成立——且两处正好是我首审的盲点(finding 2 我把调用方 grep 限定在 src/ 漏了 test/;finding 1 我把「未知 owner」和「无 owner」混为一谈)。现已修复并补回归测试。

Finding 1 — 迁移「未配置 owner」任务永久搁置(runtime 已复现)

病因schedule-split-migrationlarkAppId 不在 bots.json 的任务写进 primary 的 store 却保留原 larkAppId;primary 的 owner filter 因 appId 不匹配拒绝执行,而该 owner 自己的 store 又是空的 → 任务永不触发。隔离复现:primaryStoreHasTask=trueprimaryWouldExecute=falseownerStoreExists=false

修法(按 Codex 建议区分归属,而非一律塞 primary):

  • 真 ownerless(无 larkAppId)→ primary 的 store,保持 ownerless(primary daemon 本就执行 ownerless,等同拆分前行为;不 stamp primary appId,否则会破坏 legacy ownerless 语义)
  • owner 在 bots.json → 自己的 BOT_HOME store(不变)
  • owner 合法但当前未配置(bot 被删 / 配置漂移)→ 落自己的 dormant store(非 primary),保留 larkAppId,bot 将来恢复即原样可见。塞 primary 要么搁置(外来 appId 过不了 primary filter),要么若 strip 掉 appId 就以错误 bot 身份执行
  • larkAppId 不安全(无法做路径段)→ fail-safe:import 前中止整次拆分、保留 legacy 文件交人工,绝不静默吞行

Finding 2 — 浏览器 E2E 清理器全部失效

病因test/e2e-browser/schedule-cleanup.ts零参调用 removeTask/listTasks,per-bot store 固定抛 no bot scope bound;UI 清理失败后真实测试任务无法兜底删除,orphan sweep 也空跑。

修法:候选 ID 删除、按 label fallback、orphan sweep 三条路径全部枚举 <botmuxHome>/bots/<appId> 下的 store 并显式传 appIdfindTaskAcrossBots / listTasksForBots / removeTask(id, appId)),不再依赖任何 bound scope。

测试

  • 新增 test/schedule-cleanup-per-bot.test.ts(4 用例:跨 store 删候选 id / label fallback / orphan sweep 保留 fresh+非匹配 / 空 store no-op)
  • 更新 schedule-split-migration.test.ts:未配置-但安全 owner→自己 store 保留 appId、ownerless→primary 保持 ownerless、不安全 appId→中止且保留 legacy
  • 变异测试(证测试有判别力):还原旧 buggy 路由 → 迁移 2 用例转红;还原零参调用 → 清理 2 用例抛 no bot scope
  • pnpm build 绿;PR 相关 9 套件 213/213;全量 unit 10815 passed / 10 failed——10 个失败全环境基线(TZ 非 +8 / model-runner 需网络 / root DAC_OVERRIDE 读穿真 bwrap),全非本次改动文件,no bot scope 泄漏

变更文件:src/services/schedule-split-migration.tstest/e2e-browser/schedule-cleanup.tstest/schedule-split-migration.test.tstest/schedule-cleanup-per-bot.test.ts(+239/-25)。

@codex 请从新 commit 01e7ff58 独立复核。未经 @申晗 确认不合码。

@chatgpt-codex-connector

Copy link
Copy Markdown

To use Codex here, create a Codex account and connect to github.

…only + 非字符串一律 fail-safe

codex 二轮复审在迁移 fail-safe 边界又抓出两个真实缺口(均已 runtime 复现):

- finding 3:truthy 非字符串 larkAppId(最小例 boolean `true`)——`assertSafeAppId`
  的 RegExp.test 把它隐式转成 "true" 通过,任务被迁进 bots/true/、legacy 被删,
  而恢复 appId="true" 的 bot 时 owner filter 严格比较 true !== "true" 仍不执行。
- finding 4:falsy 非字符串(`false`/`0`/`null`)——原 `if (!task.larkAppId)` 把它
  当 ownerless 塞进 primary,且 primaryWouldExecute=true,会用**错误 bot 身份执行**,
  比 finding 3 的搁置更危险。

根因:owner 判定既没区分「字段缺失(真 ownerless)」与「字段存在但类型错」,又在
路径安全校验(assertSafeAppId)之外漏了类型校验。

修法(按 codex 建议一次收紧,覆盖全部 falsy/truthy 非字符串 + 空串):
- 仅 `larkAppId === undefined` 才算 ownerless → primary 保持 ownerless
- 其余先要求 `typeof === 'string'`,非字符串(bool/number/null/object,truthy 或
  falsy)一律 fail-safe:中止整次拆分、保留 legacy、import 前零写入
- 再对字符串跑 assertSafeAppId(空串/路径穿越同样 fail-safe)
- 配置内→自己 store,合法未配置→自己 dormant store(保 appId,不变)

测试:
- schedule-split-migration.test.ts 的 fail-safe 用例表扩到
  true/false/123/0/null/''/object 七种,断言中止+保留 legacy+无任何 coerced 名
  store 目录(bots/ 下零 sibling)
- 变异测试:把判定还原成 `!task.larkAppId` → false/0/null/'' 四例转红,
  证明 undefined-only 判定正是 finding 4 的修复点
- pnpm build 绿;PR 相关 9 套件 220/220;全量 unit 10822 passed / 10 failed,
  10 个失败全环境基线(TZ 非 +8 / model-runner 需网络 / root DAC_OVERRIDE 读穿真
  bwrap),全非本次改动文件,零 no-bot-scope 泄漏

Co-Authored-By: Riff <noreply@riff.dev>
@deepcoldy

Copy link
Copy Markdown
Owner

代作者修复(第 2 轮):采纳 Codex 二轮复审的迁移 fail-safe 边界缺口(新 head 5114a79d

Codex 二轮复审在迁移 fail-safe 边界又抓出两个真实缺口,我都独立 runtime 复现确认成立,一次性收紧修复。

Finding 3 — truthy 非字符串 larkAppId 绕过 fail-safe

复现larkAppId: true{"legacyExists":false,"backupExists":true,"dormantStoreExists":true,"storedOwner":true,"storedOwnerType":"boolean","restoredBotWouldExecute":false}
assertSafeAppIdRegExp.test 把 boolean true 隐式转成 "true" 通过 → 任务迁进 bots/true/、legacy 被删,而恢复 appId="true" 的 bot 时 owner filter 严格比较 true !== "true" 仍不执行。

Finding 4 — falsy 非字符串更危险(错误身份执行)

复现larkAppId: false{"legacyExists":false,"backupExists":true,"primaryStoreExists":true,"storedOwner":false,"storedOwnerType":"boolean","primaryWouldExecute":true}
我上一版的 if (!task.larkAppId)false/0/null 当 ownerless 塞进 primary,且 primaryWouldExecute=true → 用错误 bot 身份执行,比 finding 3 的搁置更危险。

根因 & 修法(按 Codex 建议一次收紧)

owner 判定既没区分「字段缺失(真 ownerless)」与「字段存在但类型错」,又在路径安全校验之外漏了类型校验。改为:

  • larkAppId === undefined 才算 ownerless → primary 保持 ownerless(不变)
  • 其余先要求 typeof === 'string',非字符串(bool/number/null/object,truthy 或 falsy)一律 fail-safe:中止整次拆分、保留 legacy、import 前零写入
  • 再对字符串跑 assertSafeAppId(空串 '' / 路径穿越同样 fail-safe)
  • 配置内 → 自己 store,合法未配置 → 自己 dormant store(保 appId,不变)

测试

  • schedule-split-migration.test.ts fail-safe 用例表扩到 true/false/123/0/null/''/object 七种,断言中止 + 保留 legacy + bots/ 下零 sibling 目录(无任何 coerced 名 store)
  • 变异测试:把判定还原成 !task.larkAppIdfalse/0/null/'' 四例转红,证明 undefined-only 判定正是 finding 4 的修复点
  • pnpm build 绿;PR 相关 9 套件 220/220;全量 unit 10822 passed / 10 failed(全环境基线:TZ 非 +8 / model-runner 需网络 / root DAC_OVERRIDE 读穿真 bwrap,全非本次改动文件),no bot scope 泄漏

变更文件:src/services/schedule-split-migration.tstest/schedule-split-migration.test.ts(+84/-20)。

@codex 请从 5114a79d 继续复核。未经 @申晗 确认不合码。

@chatgpt-codex-connector

Copy link
Copy Markdown

To use Codex here, create a Codex account and connect to github.

@deepcoldy deepcoldy left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

复审结论:APPROVE,当前无阻塞项。本次批准只表示代码复审通过;按群内约定,未经申晗明确确认仍不得合码

复核 head:5114a79d9a716650d937522ff6bf6132d407858c

原先四个阻塞点均已关闭:

  1. 未配置但合法的 owner 不再折叠进 primary,而是进入自己的 dormant store 并保留原 larkAppId;ownerless 仍只由 primary 承接且不 stamp。
  2. E2E 清理的候选 ID、label fallback、orphan sweep 三条路径均枚举所有 per-bot store,并显式携带 _storeAppId/appId;无零参 store 调用。
  3. truthy 非字符串 owner 不再被 RegExp.test 隐式转成路径后静默搁置。
  4. falsy 非字符串 owner 不再被误判为 ownerless、落入 primary 并以错误 bot 身份执行。现在只有 larkAppId === undefined 才算 ownerless;其余先做字符串类型校验,再做路径安全校验,失败均在 import 前中止并保留 legacy。

独立复现验证:旧 head 上 larkAppId:true 会落入 bots/true/ 且恢复后仍不执行;larkAppId:false 会进入 primary 且 primaryWouldExecute=true。新 head 上 false 最小复现变为 legacyExists=true / backupExists=false / primaryStoreExists=false,符合 fail-safe。

独立测试:

  • pnpm build:通过。
  • PR 相关 9 套件:220/220 通过(迁移 15 例,含 true/false/123/0/null/空串/object;E2E per-bot cleanup 4 例)。
  • pnpm test10828 passed / 4 failed / 5 skipped。4 个失败均为本机既有环境基线:3 个宿主时区非 +8,1 个 root/DAC_OVERRIDE 下真 bwrap 可读穿 000 mask;没有 no bot scope,失败文件均非本 PR 修改文件。
  • 与最新 origin/master b30e8949 合成树 a7e6376c:无冲突,pnpm build 通过,相关 9 套件 221/221 通过(master 新增 1 个 dashboard 测试)。

影响面结论:per-bot 路径公式与 BOT_HOME 隔离一致;共享 sandbox 特判删除后 macOS/Linux 均回归到 bot 自有目录授权;调度 owner 语义、dashboard 观察结果、普通/v3 host 调用及 E2E 管理清理均有对应覆盖。没有发现新的跨 bot、跨后端或迁移数据安全问题。

@deepcoldy
deepcoldy merged commit fdb105a into deepcoldy:master Jul 27, 2026
@deepcoldy

Copy link
Copy Markdown
Owner

✅ 已合并(申晗授权 admin-merge)

  • 合并方式:admin-merge(fork PR 无 CI,本地已充分验证)
  • merge commit:fdb105a8b5cf1d7a5c6c3b562dc8c5c6d9f69e29
  • base parent:459251f4(合并时的最新 master)
  • mergedAt:2026-07-27T18:23:14Z

合并前的最终校验(master 自 codex 复审基线 b30e894 已前移到 459251f,故重验)

master 期间并入了 #588#621(含 worker/dashboard 改动,与本 PR 的 worker.ts/cli.ts/daemon.ts 有文件级重叠):

未执行发版(tag);未切换/重启 live daemon——按约定二者需另行授权。

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants