Skip to content

Windows: bili omp 覆盖层 symlink 全部 EPERM 被裸 catch{} 吞掉 → 每次启动都是全新未配置(即 #289 的 Windows 真因) #381

Closed

Description

@wosnxn123

症状

Windows 上每次 bili omp 启动的 oh-my-pi 都是全新未配置状态:无 config.yml、无凭据、无会话、无扩展——正是 #289 描述的症状(Linux 复现不出,因为根因是 Windows 特有的)。

根因:覆盖层符号链接全部 EPERM,被裸 catch {} 静默吞掉

bili omp 的隔离机制(src/launcher.ts runLaunch omp 分支,dist/index.js:62133-62140)是把 PI_CODING_AGENT_DIR 指向覆盖层 ~/.omp/agent-bili,并用 fs.symlinkSync 逐条目把真实目录 ~/.omp/agent 链接进覆盖层(refreshOverlayHome,HEAD launcher.ts:703-708,dist:61487-61496)。

问题链:

  1. Windows 上非特权进程创建 symlink 一律 EPERM(见下节"为什么与开发人员模式有关")
  2. 每次 symlinkSync 失败被裸 catch {} 吞掉,refreshOverlayHome 结尾仍无条件 return true(launcher.ts:710)——无告警、无回退
  3. 于是覆盖层里只有 bili 自己生成的 models.yml + .bili-launch.pid,零个符号链接(实测 Get-ChildItem -Force 无一个 ReparsePoint)
  4. omp 忠实地从 PI_CODING_AGENT_DIR 解析一切(config.yml / agent.db / sessions/ / extensions/ 全在该目录下),拿到空目录 → 每次都是全新实例。plain omp 完全正常(真实 ~/.omp/agent/config.yml 里 setupVersion: 2 健在),只有经 bili 启动才受害

上游 HEAD(550da62)仍有此问题:launcher.ts 最近 10 次提交无一涉及 Windows/symlink/EPERM。

为什么与开发人员模式有关

Windows 的符号链接创建需要 token 持有 SeCreateSymbolicLinkPrivilege。普通用户账号只有开启「开发人员模式」(或提权/内置管理员)才持有该权限——这与 git-for-windows/WSL 文档里"无特权 symlink"是同一约束,不是 bili 配错了环境。三种链接方式在受害机器上的实测(bili 用的正是第一种):

fs.symlinkSync(file) → EPERM: operation not permitted
fs.symlinkSync(dir)  → EPERM: operation not permitted
junction (mklink /J) → OK    ← 无需任何特权

这台机器上:非提权 token、whoami /priv 无 SeCreateSymbolicLinkPrivilege、注册表 AppModelUnlock 键不存在。开了开发人员模式后症状即消失——但默认安装 bili 的用户不应该需要知道这个前提,而且失败被静默吞掉意味着用户永远无从诊断。

次生危害:merge-back 对 SQLite 三件套非原子改名,且竞态活库

用户在覆盖层会话里写出的 agent.db* / history.db / models.db,会在下一次启动被 mergeOverlayEntry(launcher.ts:713-793)按 mtime 逐个独立 rename 回真实目录,输家改名 *.bili-conflict。实测观察到:

  • db / -wal / -shm 三件套分头搬迁,可能不同代次混拼(SQLite 损坏风险)
  • 真实目录里躺着 agent.db.bili-conflict(299KB)、models.db.bili-conflict(1.4MB)、config.yml.bili-conflict 等坟场
  • 14:23:48-14:23:50 一轮 merge 发生时,普通 omp 会话正开着这些库(16252/18188 存活)——改名进行中的 SQLite 文件
  • 覆盖层会话因此从不加载扩展(agent-bili 无 config.yml),bili 的压缩注入对 bili omp 会话实际全程失效

环境

  • billion-context 0.1.66(npm 全局,dist 内无 "junction" 字样,无任何回退)
  • Windows 11 Enterprise 29648,非提权,开发人员模式关,Node v26.7.0
  • oh-my-pi(omp.exe,AppData\Local\omp)——omp 侧已排除:ACP/TUI 与 bili 共用同一个 getAgentDir() 解析,忠实读 PI_CODING_AGENT_DIR,无独立故障

建议修复

  1. win32 回退链:symlinkSync 抛 EPERM/EINVAL 时,目录条目退 fs.symlinkSync(target, link, 'junction')(已实测无需特权即可用),文件条目退 linkSync 硬链接(同卷)→ copy
  2. 失败要响:全部链接失败时不要 return true——至少 warn 一行"overlay 空心化,omp 将以空配置启动(Windows 需开发人员模式或改用 junction 回退)",或直接中止
  3. SQLite 三件套整体搬迁:merge 时把 x / x-wal / x-shm 作为一组处理;真实文件被打开持锁时(rename 失败)跳过本轮 merge 而非半搬
  4. 可选:检测到 SeCreateSymbolicLinkPrivilege 缺失时在 bili start 阶段就提示,而不是等到启动客户端时静默劣化

最小复现

任何未开开发人员模式的 Windows 10/11:

npm i -g billion-context
bili omp          # → 进入的是全新未配置 omp
Get-ChildItem -Force $env:USERPROFILE\.omp\agent-bili |
  Select Name, LinkType   # → LinkType 全空,只有 models.yml + .bili-launch.pid
node -e "require('fs').symlinkSync(process.env.USERPROFILE+'\\.omp\\agent\\config.yml', require('path').join(require('os').tmpdir(),'l1'))"  # → EPERM

开启开发人员模式 + 注销重登后,同一条 bili omp 的覆盖层即正常挂链,症状消失。


补充说明:此 issue 由机器上的完整取证(进程/日志/session 文件对账)与上游源码逐行核对得出;与 #289(同症状、Linux 无法复现)、#367(persist 目录 rename 被 AV 锁,另一类 EPERM)均不重复。

Activity

  1. ranxianglei commented on Aug 31, 2026

    @ranxianglei
    Owner

    ework 接单

  2. ranxianglei commented on Aug 31, 2026

    @ranxianglei
    Owner

    🤖 ework agent · vllm-qwen/qwen3.8-27b

    [bot] 🏷 已修复,PR: #382

    根因:refreshOverlayHome(src/launcher.ts)里每个 fs.symlinkSync 都被裸 catch {} 吞掉,且函数无条件 return true。Windows 非特权进程没有 SeCreateSymbolicLinkPrivilege,symlink 全部 EPERM → 覆盖层只剩 models.yml + pid 标记、零链接 → omp 读到空目录 → 每次全新未配置(即 #289 的 Windows 真因)。

    修复(src/launcher.ts):

    1. linkOverlayEntry — symlinkSync 抛 EPERM/EACCES/EINVAL 时按条目类型降级:目录 → symlink 'junction'(无需特权),文件 → 硬链接(同卷)→ 拷贝。无需开启开发者模式。
    2. refreshOverlayHome — 逐条统计链接成功/失败。覆盖层全空(无任何条目可解析)时打一条醒目警告并 return false,让 launcher 中止而不是把客户端指向空目录;部分失败也报出来。顺带修了一个 EEXIST bug(per-entry 循环已正确链接、或 SQLite 回滚残留的条目若被重复链接会误报为失败)。
    3. mergeSqliteSet — SQLite 三件套(.db/.db-wal/.db-shm)作为整体迁移 + 两阶段回滚,锁住/半迁移的集合不会再损坏真实 home 的 db。
    4. 写穿硬链接(nlink>1 且 dev+ino 与真实文件相同)刷新时重新指向,绝不合并回去(防数据丢失)。

    测试(tests/launcher.test.ts,+6):在 Linux 上通过禁止非 junction 的 fs.symlinkSync 来模拟 Windows EPERM——目录→junction、文件→硬链接、文件→拷贝、全失败→空层→undefined、SQLite 整体迁移、SQLite 回滚、写穿硬链接重指向。

    前置检查:typecheck ✅ / test 868 通过 0 失败 ✅ / build ✅

    注意:本环境无法跑真实 Windows,降级路径靠模拟 EPERM/EACCES 的单测覆盖;建议发布前在真实 Windows 上对 bili omp 做一次冒烟。

  3. ranxianglei commented on Aug 31, 2026

    @ranxianglei
    Owner

    合并了 ,提交一个新版本pr

  4. ranxianglei commented on Aug 31, 2026

    @ranxianglei
    Owner

    🤖 ework agent · vllm-qwen/qwen3.8-27b

    [bot] 🏷 已提交新版本 release PR: #383(v0.1.67)

    • 分支:2026-08-31_release-v0.1.67,仅改 package.json 的 version(0.1.66 → 0.1.67),独立 release commit release v0.1.67。
    • 前置检查(与 CI 一致):typecheck ✅ / test 868 通过 0 失败 ✅ / build ✅
    • 合并后 CI 会自动 npm publish + 打 tag v0.1.67 + 创建 GitHub Release。

    本次发布内容即 #382(Windows 覆盖层 symlink EPERM 降级 + 空层显式失败 + SQLite 三件套整体迁移)。合并请由你操作。

  5. wosnxn123 commented on Aug 31, 2026

    @wosnxn123
    Author

    对已合并修复 PR #382 的复审意见(基于 issue 正文同一台受害机器的实证)

    先感谢:四条建议全部落实,PR #382 已合并,junction/hardlink/copy 回退链 + fail-loudly + SQLite 集合合并的方向完全正确。以下是复审 src/launcher.ts(PR head 497c131)时发现的 2 个残余边缘缺陷与 1 个待验证点,按可自动修复的格式给出。

    缺陷 1:mergeSqliteSet 的成员级 mtime 裁决仍可能拆散 WAL 与主库(违背本函数自身的存在目的)

    mergeSqliteSet 注释声明"A WAL is only valid against its exact main db, so the set must move as a unit",但实现是每个成员独立比较 mtime、新者胜。可构造出跨代次混拼:

    构造场景(覆盖层会话未 checkpoint 时,真实 home 里同一库被并行更新):

    • real/agent.db mtime = T2(裸 omp 会话刚写过)
    • overlay/agent.db mtime = T1(T1 < T2)
    • overlay/agent.db-wal mtime = T3(T3 > real 侧 wal,覆盖层会话写入的 WAL 帧)

    逐成员裁决结果:db 真实侧胜(overlay 版改名 .bili-conflict 留在真实目录)→ wal 覆盖层侧胜(搬入真实目录,替换真实 wal)。最终真实 home = T2 主库 + 覆盖层会话的 wal——两者 salt/页号互不匹配。SQLite 恢复时会校验 salt 并丢弃错配 WAL,即覆盖层会话的全部未 checkpoint 写入静默丢失;而"输家"overlay db 被改名成冲突文件躺在真实目录,进一步诱导用户以为已合并。

    建议修复:按主库(base x.db)的 mtime 裁决一次胜方;胜方的整组(x、x-wal、x-shm,各自存在才动)一起移动,败方成员统一改名 .bili-conflict。成员级比较可以完全去掉。
    (注:主库不存在、仅 sidecar 存在的孤儿集合应整体作为陈旧残渣处理——直接丢弃或统一入 .bili-conflict,不要单独搬进真实目录。)

    验收标准:

    • 新增测试:real db 新于 overlay db、overlay wal 新于 real wal 时,合并后真实目录中 x.db 与 x.db-wal 必须来自同一侧(同代次)
    • 败方所有成员都带 .bili-conflict 后缀,不出现跨侧混拼

    缺陷 2:sqliteSetMembers 不覆盖 -journal;既有冲突文件被静默覆盖

    a) sqliteSetMembers 只列 [base, base-wal, base-shm]。回滚日志模式下 SQLite 会生成 base-journal,它仍走逐条目 merge 老路,与主库可以拆开移动。建议:journal 加入集合成员(或至少在集合检测的 "some sidecar present" 判定里考虑它)。

    b) mergeSqliteSet 与 mergeOverlayEntry 的失败者处理都是 renameSync(src, dst.bili-conflict);renameSync 在 Windows/POSIX 都会静默覆盖已存在的同名目标。跨启动重试(缺陷链:上一轮 merge 失败留在 overlay → 本轮又失败)时,上一轮的败方数据无声消失。建议:目标已存在时加时间戳后缀(如 .bili-conflict-<n> 或 .1),或在覆盖前至少 warn 一行。

    待验证点:Windows 真机上首选分支创建的是 file-tag 符号链接(目录条目)

    linkOverlayEntry 首选 fs.symlinkSync(target, link) 不带 type 参数;libuv 在 Windows 上 type 省略时按 'file' 创建 reparse point,对目录目标即产生 file-tag 符号链接。多数 Win32 API 能跟随它,但 readdir(withFileTypes)、部分备份/索引工具的行为不保证,且这正是 PR 描述里自述"未能在真 Windows 冒烟"的路径。

    建议在 win32 分支显式化:先 lstatSync(target),目录直接 symlinkSync(target, link, 'junction')(免特权、语义无歧义),文件用 'file';这样也省掉首试失败再回退的一次 syscall。真机验证数据点(本 issue 受害机,Win11 Enterprise 29648,开发人员模式开):可对照 Get-ChildItem $env:USERPROFILE\.omp\agent-bili | Select Name, LinkType, Target 确认 sessions/、extensions/ 等目录条目是否以 LinkType=SymbolicLink 出现且内容可正常枚举——若是,此点降级为代码清晰度建议;若有异常,junction 优先方案是正解。


    环境注:以上分析基于 issue 正文中的同一台机器(Windows 11、Node v26.7.0、billion-context 0.1.66)与 PR #382 diff 逐行复审;复现数据(agent.db.bili-conflict 299KB / models.db.bili-conflict 1.4MB / WAL 三件套分头搬迁的时间戳对账)见本 issue 取证记录。

  6. ranxianglei commented on Aug 31, 2026

    @ranxianglei
    Owner

    ework继续跟进

  7. ranxianglei commented on Aug 31, 2026

    @ranxianglei
    Owner

    🤖 ework agent · vllm-qwen/qwen3.8-27b

    [bot] 🏷 已按 review 意见修复,PR: #396

    针对 FLOOR 10 的 review,4 处修改(src/launcher.ts + 3 个新测试):

    1. mergeSqliteSet 按主库整体裁决(Defect 1)
    原逐成员 mtime 裁决可能把"较新的主库 + 另一侧较新的 WAL"拼在一起(salt/page 不匹配 → SQLite 丢弃 WAL → overlay 会话未 checkpoint 的写入丢失)。现改为:以主库 mtime 一次性裁决,赢家整组迁移,输家成员全部保留为 .bili-conflict。孤儿集合(无主库、只有 sidecar)视为陈旧残留,统一丢弃,绝不逐个迁移。

    2. sqliteSetMembers 补上 -journal(Defect 2a)
    rollback-journal 模式的 x.db-journal 之前不在集合里,会被单独合并(同样的跨侧风险)。

    3. 冲突重命名不覆盖(Defect 2b)
    renameSync(src, dst.bili-conflict) 会静默覆盖同名目标,重试轮次会冲掉上一轮保留的输家。mergeSqliteSet 与 mergeOverlayEntry 现都用 freeConflictName(.bili-conflict、.1、.2…)。

    4. linkOverlayEntry 显式 symlink 类型(验证点)
    首次尝试用无类型的 fs.symlinkSync(target, link),在 Windows 上对目录目标会创建 file 标记的重解析点(libuv 无类型默认 'file'),readdir(withFileTypes)/部分备份索引器可能不跟随。现显式:win32 目录 → 'junction'(无需权限、无歧义),文件 → 'file'。

    测试:新增 3 个(主库裁决不拼接、-journal 成组、冲突重命名不覆盖)+ 原有 6 个 #381 测试;871 通过,typecheck + build 绿。

    发布顺序:建议在 v0.1.67 release PR(#383)合并之前合并本 PR,让 release 带上这些修复;若 v0.1.67 已发布,可作 v0.1.68。

  8. added 2 commits that reference this issue on Sep 3, 2026
    a2b2822
    09ef208
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions