[{"data":1,"prerenderedAt":2148},["ShallowReactive",2],{"\u002F2026-05-06":3,"\u002F2026-05-06-rel":396},{"id":4,"title":5,"body":6,"column":379,"date":380,"description":12,"extension":381,"hero_image":382,"meta":383,"navigation":384,"path":385,"seo":386,"series_id":382,"severity":382,"stem":387,"summary":388,"tags":389,"__hash__":395},"posts\u002F2026-05-06-更新包的信任链.md","更新包，能往客户机写任何文件",{"type":7,"value":8,"toc":368},"minimark",[9,13,18,21,24,31,39,42,52,75,78,82,85,89,92,95,102,105,112,119,158,161,164,186,189,192,196,199,214,284,287,290,345,348,361,364],[10,11,12],"p",{},"OTA 在客户机上解包并替换文件，这是一条高权限路径。如果源被投毒或包被构造，后果是任意文件写入或目录穿越。我采用三层独立的防御机制，每一层都针对不同的威胁模型，且它们互相不可替代。",[14,15,17],"h2",{"id":16},"第一层签名验证链","第一层：签名验证链",[10,19,20],{},"每次 OTA 的起点是 release manifest（描述什么版本、文件列表、发布时间）和一个 zip 包体。两者都用 minisign 签名，公钥内嵌在客户端里。验签失败立即终止，后续步骤都走不到。",[10,22,23],{},"这一层的目的是确保来源可信。即使后续步骤再严密，如果源头被投毒了，也挡不住。",[10,25,26,30],{},[27,28,29],"strong",{},"信任链的完整性","。签名验证看似简单，但细节决定能否拦住真实的攻击。曾在 dress rehearsal 时遇到这样的故障：",[10,32,33,34,38],{},"release manifest 的签名文件（release-manifest.sig）生成时缺少 ",[35,36,37],"code",{},"publishedAt"," 字段。launcher 的验证代码强制检查这个字段——它不仅验证签名本身有效，还校验签名的 trusted comment 包含 publishedAt 且与 manifest 内容一致。这样设计是为了确保签名的上下文完整：不只验证\"这个文件确实由 A 签的\"，还验证\"这个文件在时间 T 由 A 签的\"。",[10,40,41],{},"缺少 publishedAt 导致验签必失败：",[43,44,49],"pre",{"className":45,"code":47,"language":48},[46],"language-text","[launcher] Stage 6 OTA check error: trusted_comment missing publishedAt\n","text",[35,50,47],{"__ignoreMap":51},"",[10,53,54,55,58,59,62,63,66,67,70,71,74],{},"排查过程：看 sig 文件实际内容，trusted comment 只有 ",[35,56,57],{},"kind=release_manifest version=0.14.0 signedAt=...","，缺少 ",[35,60,61],{},"publishedAt=...","。跟踪 sign CLI 的代码，",[35,64,65],{},"release-manifest"," 子命令调用 ",[35,68,69],{},"sign::sign_file"," 时 extra_fields 传空数组，没有 publishedAt。修复是给 sign 命令加 ",[35,72,73],{},"--published-at \u003CRFC3339>"," 参数，从 manifest.json 的 publishedAt 字段抠出来。",[10,76,77],{},"这个故障说明了签名链的脆弱面：签名生成端和验证端的合约必须完全一致，否则合法的包也会被拒。反过来说，这个严格的检查正是设计想要的效果——它提高了被攻击的成本。如果攻击者改了包的内容，要伪造一个新签名，不仅要破解私钥，还要用 launcher 能接受的格式生成 trusted comment。",[14,79,81],{"id":80},"第二层解压边界","第二层：解压边界",[10,83,84],{},"即使签名合法，包的内容也可能被精心构造来攻击解压逻辑。这一层用四个独立的检查阻止两类常见攻击：zip bomb 和目录穿越。",[86,87,88],"h3",{"id":88},"解压大小限制",[10,90,91],{},"单个文件大小上限已经存在：4 GiB。但攻击者可以用多个小文件堆积，总解压体积溢出磁盘。N 个 sub-4GiB 的文件累积可以填满任意容量。这是 CVE-2003-1564 级别的威胁。",[10,93,94],{},"防御方法是加总解压上限：10 GiB。这个阈值的选择基于实际情况——完整的 release 包括 panel 应用、多个引擎、二进制工具、数据文件，当前实际体积远低于 10 GiB，留足了安全裕度。",[10,96,97,98,101],{},"大小检查的时机很关键：在解压前累加 entry 的 uncompressed size（从 zip header 读取），一旦超限立即 bail。这样不需要真正解压 10 GiB 的数据就能拦住攻击。当然，zip header 的 size 字段本身可能被伪造——攻击者可以声明 size=0 但实际写入数据。这靠第二个检查兜底：",[35,99,100],{},"io::copy"," 的实际写入字节数仍受 zip stream 的物理限制，不会让你写出超过压缩数据本身的内容。",[86,103,104],{"id":104},"拒绝符号链接",[10,106,107,108,111],{},"zip 格式可以记录条目的 unix 权限模式，其中包括 mode bit 0o120000 表示符号链接。当前代码会调用 ",[35,109,110],{},"File::create(&dest)"," 写入条目，对于 symlink-mode 条目，zip 库不会真创建符号链接，而是当作普通文件写出——这是\"意外安全\"。",[10,113,114,115,118],{},"问题在于这种安全不是明确的。zip 库升级或 API 改变都可能破坏这个假设。防御方法是显式 reject：检查 ",[35,116,117],{},"entry.unix_mode() & 0o170000 == 0o120000","，如果是 symlink mode 直接 bail。",[43,120,124],{"className":121,"code":122,"language":123,"meta":51,"style":51},"language-rust shiki shiki-themes github-light github-dark","if let Some(mode) = entry.unix_mode() {\n    if mode & 0o170000 == 0o120000 {\n        bail!(\"OTA_ZIP_SYMLINK_REJECTED: entry `{}` has symlink mode bit\", entry_name);\n    }\n}\n","rust",[35,125,126,134,140,146,152],{"__ignoreMap":51},[127,128,131],"span",{"class":129,"line":130},"line",1,[127,132,133],{},"if let Some(mode) = entry.unix_mode() {\n",[127,135,137],{"class":129,"line":136},2,[127,138,139],{},"    if mode & 0o170000 == 0o120000 {\n",[127,141,143],{"class":129,"line":142},3,[127,144,145],{},"        bail!(\"OTA_ZIP_SYMLINK_REJECTED: entry `{}` has symlink mode bit\", entry_name);\n",[127,147,149],{"class":129,"line":148},4,[127,150,151],{},"    }\n",[127,153,155],{"class":129,"line":154},5,[127,156,157],{},"}\n",[10,159,160],{},"这样即使 zip 库在未来支持真正创建符号链接，launcher 也会拒绝。当前生产的 OTA 包不应该包含 symlink，如果未来需要支持，那是 spec 变更后的事，明确决策即可。",[86,162,163],{"id":163},"路径越界检查",[10,165,166,167,170,171,174,175,174,178,181,182,185],{},"这个已经存在——",[35,168,169],{},"safe_join"," 函数拒绝 ",[35,172,173],{},"Component::ParentDir"," \u002F ",[35,176,177],{},"Component::Prefix",[35,179,180],{},"Component::RootDir","，防止路径遍历攻击。zip 的 entry name 比如 ",[35,183,184],{},"..\u002F..\u002Fetc\u002Fpasswd"," 会被拒绝。",[86,187,188],{"id":188},"这些检查为什么互补",[10,190,191],{},"大小检查防止资源耗尽（存储满、内存爆炸）；symlink 拒绝防止权限提升（比如创建指向系统敏感文件的链接）；路径越界检查防止文件覆盖（比如解出文件到安装目录外）。单有其中一个都不够——大小检查无法阻止精心放置的 symlink 攻击，路径检查也无法阻止大量小文件堆积。",[14,193,195],{"id":194},"第三层版本单调性","第三层：版本单调性",[10,197,198],{},"已安装的版本号不能倒退。这防止被强推旧的、已知有漏洞的版本。",[10,200,201,202,205,206,209,210,213],{},"检查逻辑在 ",[35,203,204],{},"version_ge"," 函数里：当前版本 >= 要求版本才允许降级逻辑执行。最初的实现用简单的数字拆分比较，后来改用 ",[35,207,208],{},"semver::Version::parse"," 以支持正式的语义版本号，包括预发布版本的顺序（",[35,211,212],{},"1.0.0-rc.2 > 1.0.0-rc.1","）和预发布 \u003C 正式版的规则。",[43,215,217],{"className":121,"code":216,"language":123,"meta":51,"style":51},"fn version_ge(current: &str, required: &str) -> bool {\n    let parse = |s: &str| {\n        semver::Version::parse(s.trim_start_matches('v'))\n    };\n    match (parse(current), parse(required)) {\n        (Ok(c), Ok(r)) => c >= r,\n        _ => {\n            eprintln!(\"[launcher] WARN: version_ge parse failed\");\n            false  \u002F\u002F 解析失败保守判定为 false，走 RunChain 重新 OTA\n        }\n    }\n}\n",[35,218,219,224,229,234,239,244,250,256,262,268,274,279],{"__ignoreMap":51},[127,220,221],{"class":129,"line":130},[127,222,223],{},"fn version_ge(current: &str, required: &str) -> bool {\n",[127,225,226],{"class":129,"line":136},[127,227,228],{},"    let parse = |s: &str| {\n",[127,230,231],{"class":129,"line":142},[127,232,233],{},"        semver::Version::parse(s.trim_start_matches('v'))\n",[127,235,236],{"class":129,"line":148},[127,237,238],{},"    };\n",[127,240,241],{"class":129,"line":154},[127,242,243],{},"    match (parse(current), parse(required)) {\n",[127,245,247],{"class":129,"line":246},6,[127,248,249],{},"        (Ok(c), Ok(r)) => c >= r,\n",[127,251,253],{"class":129,"line":252},7,[127,254,255],{},"        _ => {\n",[127,257,259],{"class":129,"line":258},8,[127,260,261],{},"            eprintln!(\"[launcher] WARN: version_ge parse failed\");\n",[127,263,265],{"class":129,"line":264},9,[127,266,267],{},"            false  \u002F\u002F 解析失败保守判定为 false，走 RunChain 重新 OTA\n",[127,269,271],{"class":129,"line":270},10,[127,272,273],{},"        }\n",[127,275,277],{"class":129,"line":276},11,[127,278,151],{},[127,280,282],{"class":129,"line":281},12,[127,283,157],{},[10,285,286],{},"这一层防的是一种很具体的操作事故：手误把已撤回的旧版本重新发布。如果没这一层，客户机会被自动推回旧版本；有了这一层，旧包虽然验签合法、解压也通过了，但版本号检查会拒绝它。",[14,288,289],{"id":289},"为什么三层都必要",[291,292,293,309],"table",{},[294,295,296],"thead",{},[297,298,299,303,306],"tr",{},[300,301,302],"th",{},"防御",[300,304,305],{},"什么时候失效",[300,307,308],{},"后果",[310,311,312,324,335],"tbody",{},[297,313,314,318,321],{},[315,316,317],"td",{},"签名验证",[315,319,320],{},"私钥泄露",[315,322,323],{},"任何内容都能合法签名，后续检查都白搭",[297,325,326,329,332],{},[315,327,328],{},"解压边界",[315,330,331],{},"被绕过",[315,333,334],{},"zip bomb 填满磁盘、symlink 提权、文件覆盖",[297,336,337,340,342],{},[315,338,339],{},"版本单调",[315,341,331],{},[315,343,344],{},"被推回已知有漏洞的旧版本",[10,346,347],{},"签名验证保证来源可信——这是前提。但\"来自信任方\"的包仍可能是：",[349,350,351,355,358],"ul",{},[352,353,354],"li",{},"意外生成的恶意内容（比如临时文件被打进 zip）",[352,356,357],{},"被中间人修改过（虽然验签会失败，但某些场景下可能有变量窗口）",[352,359,360],{},"通过合法渠道发布的有漏洞的旧版本（这时签名完全合法）",[10,362,363],{},"解压边界独立处理包内容的风险，不依赖于包的签名状态。版本单调性独立处理版本回退风险。三个不同的威胁模型需要三层不同的防御。",[365,366,367],"style",{},"html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}",{"title":51,"searchDepth":136,"depth":136,"links":369},[370,371,377,378],{"id":16,"depth":136,"text":17},{"id":80,"depth":136,"text":81,"children":372},[373,374,375,376],{"id":88,"depth":142,"text":88},{"id":104,"depth":142,"text":104},{"id":163,"depth":142,"text":163},{"id":188,"depth":142,"text":188},{"id":194,"depth":136,"text":195},{"id":289,"depth":136,"text":289},"交付与更新","2026-05-06","md",null,{},true,"\u002F2026-05-06",{"title":5,"description":12},"2026-05-06-更新包的信任链","构建三层防御阻止 OTA 恶意包注入：签名验证、解压边界、版本单调性。",[390,391,317,392,393,394],"OTA","minisign","zip bomb","解压安全","版本管理","NGp6JVDpg3UAbc1LQKtBhL5j8LjKoVKpxrzTxJEqd-8",[397,788,1655],{"id":398,"title":399,"body":400,"column":379,"date":773,"description":774,"extension":381,"hero_image":382,"meta":775,"navigation":384,"path":776,"seo":777,"series_id":382,"severity":382,"stem":778,"summary":779,"tags":780,"__hash__":787},"posts\u002F2026-08-16-让会话记住目录让回答能停下.md","让会话记住目录，让回答能停下",{"type":7,"value":401,"toc":764},[402,408,412,415,418,421,448,451,457,464,471,475,478,492,495,498,501,505,508,513,531,534,539,548,551,556,565,568,572,575,578,583,589,592,597,606,609,614,623,626,633,638,647,650,655,658,662,665,672,676,679,682,686,689,752,755,761],[10,403,404,405],{},"同一天做了两件事。表面上一个关于文件系统，一个关于流式生成，实际在解决同一个问题：",[27,406,407],{},"长动作要么在一开始就能定好条件，要么在过程中能叫停。",[14,409,411],{"id":410},"一工作目录原先只活在一次调用里","一、工作目录原先只活在一次调用里",[10,413,414],{},"工具调用要指定工作目录。原先的实现是每次调用各自带一个。",[10,416,417],{},"这在单次调用里没问题，但会话是连续的：用户在一个会话里说「看看这个项目」「改一下那个配置」「跑一下测试」，每次都指定一遍目录，重复且容易出错。",[10,419,420],{},"改成会话级能力。数据模型加两个字段：",[43,422,426],{"className":423,"code":424,"language":425,"meta":51,"style":51},"language-sql shiki shiki-themes github-light github-dark","-- 会话级工作文件夹：null 表示沿用设备上报的默认目录，对存量会话零影响。\nALTER TABLE \"Session\" ADD COLUMN \"workspaceDir\" TEXT;\n-- 桌面端注册时上报的本机默认工作文件夹；老版本客户端不上报则保持 null。\nALTER TABLE \"Device\" ADD COLUMN \"defaultWorkspaceDir\" TEXT;\n","sql",[35,427,428,433,438,443],{"__ignoreMap":51},[127,429,430],{"class":129,"line":130},[127,431,432],{},"-- 会话级工作文件夹：null 表示沿用设备上报的默认目录，对存量会话零影响。\n",[127,434,435],{"class":129,"line":136},[127,436,437],{},"ALTER TABLE \"Session\" ADD COLUMN \"workspaceDir\" TEXT;\n",[127,439,440],{"class":129,"line":142},[127,441,442],{},"-- 桌面端注册时上报的本机默认工作文件夹；老版本客户端不上报则保持 null。\n",[127,444,445],{"class":129,"line":148},[127,446,447],{},"ALTER TABLE \"Device\" ADD COLUMN \"defaultWorkspaceDir\" TEXT;\n",[10,449,450],{},"两个字段的分工是：设备上报它本机的默认目录，会话可以覆盖它。取值优先级：",[43,452,455],{"className":453,"code":454,"language":48},[46],"会话值 ?? 设备默认值\n",[35,456,454],{"__ignoreMap":51},[10,458,459,460,463],{},"迁移只用加列，",[35,461,462],{},"null"," 表示沿用设备默认值，存量会话不受影响。",[10,465,466,467,470],{},"长度上限 512 字符，写在协议包里：",[35,468,469],{},"WORKSPACE_DIR_MAX_LENGTH = 512","。",[14,472,474],{"id":473},"二四个执行入口共用同一个目录","二、四个执行入口共用同一个目录",[10,476,477],{},"目录信息要传到的位置比想象中多。同一件事在四个地方会被执行：",[349,479,480,483,486,489],{},[352,481,482],{},"工具调用",[352,484,485],{},"Agent 团队运行",[352,487,488],{},"设备协作",[352,490,491],{},"计划任务",[10,493,494],{},"如果只有工具调用拿到会话目录，其余三个各自回落到设备默认值，用户会遇到「我在会话里设了目录，跑计划任务却跑到了别的地方」。",[10,496,497],{},"所以目录跟着会话走，四个入口都从同一个地方取。",[10,499,500],{},"跨端还有一层：桌面端主进程、预加载层、网页端桥接、聊天界面。四处都要能读能写，用户才能在会话里查看或切换目录。",[14,502,504],{"id":503},"三路径校验的三个坑","三、路径校验的三个坑",[10,506,507],{},"工作目录涉及文件系统，三处安全处理值得单独写。",[10,509,510],{},[27,511,512],{},"相对路径要先解析成绝对路径。",[43,514,518],{"className":515,"code":516,"language":517,"meta":51,"style":51},"language-ts shiki shiki-themes github-light github-dark","\u002F\u002F 相对路径必须先解析成绝对路径再做高危校验，否则 ..\u002F..\u002F..\u002Fetc\u002Fpasswd\n\u002F\u002F 这类相对串匹配不上敏感路径正则会被直接放行\n","ts",[35,519,520,526],{"__ignoreMap":51},[127,521,522],{"class":129,"line":130},[127,523,525],{"class":524},"sJ8bj","\u002F\u002F 相对路径必须先解析成绝对路径再做高危校验，否则 ..\u002F..\u002F..\u002Fetc\u002Fpasswd\n",[127,527,528],{"class":129,"line":136},[127,529,530],{"class":524},"\u002F\u002F 这类相对串匹配不上敏感路径正则会被直接放行\n",[10,532,533],{},"先做正则匹配再解析，等于给相对路径开了一条绕过通道——它们看起来不像敏感路径。",[10,535,536],{},[27,537,538],{},"打开目录前先确认它是目录。",[43,540,542],{"className":515,"code":541,"language":517,"meta":51,"style":51},"\u002F\u002F open-workspace-dir 先 stat 确认是目录才放行，避免经 shell.openPath 执行可执行文件\n",[35,543,544],{"__ignoreMap":51},[127,545,546],{"class":129,"line":130},[127,547,541],{"class":524},[10,549,550],{},"用系统调用打开路径时，如果路径指向一个可执行文件，行为会变成执行它。",[10,552,553],{},[27,554,555],{},"目录不存在时要说明，不能静默回落。",[43,557,559],{"className":515,"code":558,"language":517,"meta":51,"style":51},"\u002F\u002F 换机时会话目录在本机不存在则回落设备默认目录，并在工具结果中明示，不静默。\n",[35,560,561],{"__ignoreMap":51},[127,562,563],{"class":129,"line":130},[127,564,558],{"class":524},[10,566,567],{},"这一条和一个功能细节有关：会话可以跨设备打开。用户在公司电脑上设的目录，回家打开时本机不存在。此时回落是唯一选择，但用户必须知道发生了回落——否则他会以为工具跑在他指定的目录里。",[14,569,571],{"id":570},"四停止生成","四、停止生成",[10,573,574],{},"第二个功能是让流式回答可以中途停止。",[10,576,577],{},"场景很具体：模型开始生成一段长回答，用户看前两段就知道方向不对。原先只能等它写完，或者刷新页面重新提问。",[10,579,580],{},[27,581,582],{},"停止信号走 Redis。",[43,584,587],{"className":585,"code":586,"language":48},[46],"POST \u002Fapi\u002Fchat\u002Fstop\n",[35,588,586],{"__ignoreMap":51},[10,590,591],{},"校验会话归属之后，往 Redis 写一个停止信号。选 Redis 而不是进程内变量，是因为生成可能发生在任意一个 pod 上——信号要跨 pod 生效。同一个会话重复调用是幂等的。",[10,593,594],{},[27,595,596],{},"生成侧轮询读信号。",[43,598,600],{"className":515,"code":599,"language":517,"meta":51,"style":51},"\u002F\u002F 生成侧用节流轮询（300ms）读信号，轮边界与流式回调两处检查点；在途工具跑完再收尾\n",[35,601,602],{"__ignoreMap":51},[127,603,604],{"class":129,"line":130},[127,605,599],{"class":524},[10,607,608],{},"两个检查点：一个是每轮对话的边界，一个是流式回调。工具执行中途不打断——一个已经在跑的工具调用停下来会留下半截状态，等它跑完再收尾。",[10,610,611],{},[27,612,613],{},"停止必须落一条消息。",[43,615,617],{"className":515,"code":616,"language":517,"meta":51,"style":51},"\u002F\u002F 停止一定落一条 assistant 消息并记 stoppedAt，否则前端会一直判成生成中\n",[35,618,619],{"__ignoreMap":51},[127,620,621],{"class":129,"line":130},[127,622,616],{"class":524},[10,624,625],{},"这是被前端状态机决定的。前端的「生成中」判定依据是「最后一条是不是用户消息」，如果停止之后不落一条 assistant 消息，界面会一直显示在生成。",[10,627,628,629,632],{},"数据库加一个字段：",[35,630,631],{},"Message.stoppedAt","。落这条消息时记下停止时刻——刷新、重进会话之后状态不该丢。",[10,634,635],{},[27,636,637],{},"结算按实际用量。",[43,639,641],{"className":515,"code":640,"language":517,"meta":51,"style":51},"\u002F\u002F 结算走原有 settle 路径按实际用量多退少补；被停止的半截回答不进长期记忆\n",[35,642,643],{"__ignoreMap":51},[127,644,645],{"class":129,"line":130},[127,646,640],{"class":524},[10,648,649],{},"半截回答不入长期记忆这一条是必要的。记忆提取的输入是完整的一轮对话，半截内容提取出来的「用户偏好」会是错的。",[10,651,652],{},[27,653,654],{},"继续写。",[10,656,657],{},"停止之后，末条消息上给一个「继续写」。它不是重新生成——从停止的位置接着往下，上下文里包含已经写好的部分。",[14,659,661],{"id":660},"五界面上只有一个按钮","五、界面上只有一个按钮",[10,663,664],{},"生成中，发送按钮变成停止按钮。桌面端和移动端两套布局都要改。",[10,666,667,668,671],{},"这个交互决定了一个细节：停止是一个",[27,669,670],{},"和发送同一位置","的操作。用户在等待时手指本来就停在那个位置，改文案比加一个新按钮更快。",[14,673,675],{"id":674},"六桌面端版本","六、桌面端版本",[10,677,678],{},"这两个功能里，工作目录要桌面端配合。桌面端从 1.0.32 升到 1.0.33。",[10,680,681],{},"顺序上有依赖：云端先上线，桌面端包发出后功能才对用户可见。所以版本号更新是单独一个提交，不是为了记录，是为了标记「从哪个包开始这个功能可用」。",[14,683,685],{"id":684},"七两个功能的共同点","七、两个功能的共同点",[10,687,688],{},"把它们放在一起看，都是「让用户在长动作里保留控制权」：",[291,690,691,703],{},[294,692,693],{},[297,694,695,697,700],{},[300,696],{},[300,698,699],{},"工作目录",[300,701,702],{},"停止生成",[310,704,705,716,730,741],{},[297,706,707,710,713],{},[315,708,709],{},"控制发生在",[315,711,712],{},"事前指定",[315,714,715],{},"事中叫停",[297,717,718,721,726],{},[315,719,720],{},"数据落点",[315,722,723],{},[35,724,725],{},"Session.workspaceDir",[315,727,728],{},[35,729,631],{},[297,731,732,735,738],{},[315,733,734],{},"跨端要求",[315,736,737],{},"主进程 \u002F 预加载 \u002F 桥接 \u002F 界面",[315,739,740],{},"桌面与移动两套布局",[297,742,743,746,749],{},[315,744,745],{},"不静默的地方",[315,747,748],{},"目录不存在时回落要明示",[315,750,751],{},"停止后必须落消息，界面不能一直显示生成中",[10,753,754],{},"四条对应关系里，最后一行是最容易做错的：两个功能都有「状态没落对，界面就一直显示在跑」的风险。工作目录那条如果静默回落，用户以为跑在 A 目录，实际跑在 B；停止那条如果不落消息，用户以为停了，界面说还在跑。",[10,756,757,758],{},"改法和预想的一样朴素：",[27,759,760],{},"把状态放在能读到的地方，并且让用户看见。",[365,762,763],{},"html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html pre.shiki code .sJ8bj, html code.shiki .sJ8bj{--shiki-default:#6A737D;--shiki-dark:#6A737D}",{"title":51,"searchDepth":136,"depth":136,"links":765},[766,767,768,769,770,771,772],{"id":410,"depth":136,"text":411},{"id":473,"depth":136,"text":474},{"id":503,"depth":136,"text":504},{"id":570,"depth":136,"text":571},{"id":660,"depth":136,"text":661},{"id":674,"depth":136,"text":675},{"id":684,"depth":136,"text":685},"2026-08-16","同一天做了两件事。表面上一个关于文件系统，一个关于流式生成，实际在解决同一个问题：长动作要么在一开始就能定好条件，要么在过程中能叫停。",{},"\u002F2026-08-16",{"title":399,"description":774},"2026-08-16-让会话记住目录让回答能停下","两个看似无关的功能：把工作目录从单次工具调用提升为会话级能力；让正在生成的回答可以中途停止并继续。共同点是给长动作加上用户的控制权。",[781,782,783,784,785,786],"桌面端","会话状态","文件系统","路径校验","流式生成","中断","jiUeJkYnicdLCfFjfh09Sim0_nWpiER2NhMrT2FeRKQ",{"id":789,"title":790,"body":791,"column":379,"date":1644,"description":795,"extension":381,"hero_image":382,"meta":1645,"navigation":384,"path":1646,"seo":1647,"series_id":382,"severity":382,"stem":1648,"summary":1649,"tags":1650,"__hash__":1654},"posts\u002F2026-07-07-桌面端只做壳不装页面.md","桌面端只做壳，不装页面",{"type":7,"value":792,"toc":1634},[793,796,799,806,810,813,823,956,962,981,995,1015,1029,1033,1039,1048,1054,1058,1064,1070,1073,1079,1154,1157,1160,1175,1178,1194,1201,1224,1227,1231,1234,1244,1251,1268,1271,1375,1381,1397,1400,1407,1411,1414,1441,1447,1457,1463,1532,1539,1549,1568,1571,1575,1582,1589,1592,1596,1599,1605,1608,1611,1615,1618,1621,1628,1631],[10,794,795],{},"Windows 客户端有两种做法。",[10,797,798],{},"一种是把前端打进安装包，装完之后本地有一份完整页面。另一种是桌面端只做壳，窗口加载远端地址。这条线选了后者。",[10,800,801,802,805],{},"选择的结果是：7 月 7 日一天发了两个版本（1.0.19、1.0.20），两次都只动了 ",[35,803,804],{},"apps\u002Fdesktop"," 自己的代码。",[14,807,809],{"id":808},"一壳里没有页面","一、壳里没有页面",[10,811,812],{},"最直接的证据是渲染进程的产物。",[10,814,815,818,819,822],{},[35,816,817],{},"electron-vite"," 的渲染进程配置指向 ",[35,820,821],{},"src\u002Frenderer\u002Findex.html","，而这个文件全部内容是：",[43,824,828],{"className":825,"code":826,"language":827,"meta":51,"style":51},"language-html shiki shiki-themes github-light github-dark","\u003C!DOCTYPE html>\n\u003Chtml>\n\u003Chead>\n  \u003Cmeta charset=\"UTF-8\" \u002F>\n  \u003Ctitle>创玩猩球\u003C\u002Ftitle>\n\u003C\u002Fhead>\n\u003Cbody>\n  \u003Cdiv id=\"app\">Loading...\u003C\u002Fdiv>\n\u003C\u002Fbody>\n\u003C\u002Fhtml>\n","html",[35,829,830,847,856,865,886,900,909,918,940,948],{"__ignoreMap":51},[127,831,832,836,840,844],{"class":129,"line":130},[127,833,835],{"class":834},"sVt8B","\u003C!",[127,837,839],{"class":838},"s9eBZ","DOCTYPE",[127,841,843],{"class":842},"sScJk"," html",[127,845,846],{"class":834},">\n",[127,848,849,852,854],{"class":129,"line":136},[127,850,851],{"class":834},"\u003C",[127,853,827],{"class":838},[127,855,846],{"class":834},[127,857,858,860,863],{"class":129,"line":142},[127,859,851],{"class":834},[127,861,862],{"class":838},"head",[127,864,846],{"class":834},[127,866,867,870,873,876,879,883],{"class":129,"line":148},[127,868,869],{"class":834},"  \u003C",[127,871,872],{"class":838},"meta",[127,874,875],{"class":842}," charset",[127,877,878],{"class":834},"=",[127,880,882],{"class":881},"sZZnC","\"UTF-8\"",[127,884,885],{"class":834}," \u002F>\n",[127,887,888,890,893,896,898],{"class":129,"line":154},[127,889,869],{"class":834},[127,891,892],{"class":838},"title",[127,894,895],{"class":834},">创玩猩球\u003C\u002F",[127,897,892],{"class":838},[127,899,846],{"class":834},[127,901,902,905,907],{"class":129,"line":246},[127,903,904],{"class":834},"\u003C\u002F",[127,906,862],{"class":838},[127,908,846],{"class":834},[127,910,911,913,916],{"class":129,"line":252},[127,912,851],{"class":834},[127,914,915],{"class":838},"body",[127,917,846],{"class":834},[127,919,920,922,925,928,930,933,936,938],{"class":129,"line":258},[127,921,869],{"class":834},[127,923,924],{"class":838},"div",[127,926,927],{"class":842}," id",[127,929,878],{"class":834},[127,931,932],{"class":881},"\"app\"",[127,934,935],{"class":834},">Loading...\u003C\u002F",[127,937,924],{"class":838},[127,939,846],{"class":834},[127,941,942,944,946],{"class":129,"line":264},[127,943,904],{"class":834},[127,945,915],{"class":838},[127,947,846],{"class":834},[127,949,950,952,954],{"class":129,"line":270},[127,951,904],{"class":834},[127,953,827],{"class":838},[127,955,846],{"class":834},[10,957,958,959,470],{},"没有 ",[35,960,961],{},"\u003Cscript>",[10,963,964,965,968,969,972,973,976,977,980],{},"构建产物三个文件：",[35,966,967],{},"out\u002Fmain\u002Findex.js","（884K）、",[35,970,971],{},"out\u002Fpreload\u002Findex.cjs","（4K）、",[35,974,975],{},"out\u002Frenderer\u002Findex.html","（",[27,978,979],{},"147 字节","）。",[10,982,983,984,174,987,990,991,994],{},"全仓搜索 ",[35,985,986],{},"loadFile",[35,988,989],{},"file:\u002F\u002F","，",[35,992,993],{},"apps\u002Fdesktop\u002Fsrc\u002F"," 下没有任何一处加载本地 renderer。主窗口创建完就一件事：",[43,996,998],{"className":515,"code":997,"language":517,"meta":51,"style":51},"void win.loadURL(cfg.webUrl);\n",[35,999,1000],{"__ignoreMap":51},[127,1001,1002,1006,1009,1012],{"class":129,"line":130},[127,1003,1005],{"class":1004},"szBVR","void",[127,1007,1008],{"class":834}," win.",[127,1010,1011],{"class":842},"loadURL",[127,1013,1014],{"class":834},"(cfg.webUrl);\n",[10,1016,1017,1018,1021,1022,1021,1025,1028],{},"打包配置里的文件白名单也只列了三项：",[35,1019,1020],{},"out\u002F**\u002F*","、",[35,1023,1024],{},"resources\u002F**\u002F*",[35,1026,1027],{},"package.json","。前端产物不在其中——不是通过某个「排除 web」的配置项排除掉的，是从来没被放进来过。",[14,1030,1032],{"id":1031},"二换来的东西","二、换来的东西",[10,1034,1035,1038],{},[27,1036,1037],{},"web 发版不动桌面端。"," 页面改动走云端发版，用户下次打开就是新版本，不需要装任何东西。这也是 7 月 7 日能一天发两版的原因：两次改动都只涉及桌面端自己的代码。",[10,1040,1041,1044,1045,1047],{},[27,1042,1043],{},"更新包小。"," OTA 只依赖 ",[35,1046,804],{}," 源码加一个协议包。更新包的内容就是主进程、预加载、几个本地二进制。",[10,1049,1050,1053],{},[27,1051,1052],{},"两端的版本可以独立走。"," 没有「前端必须等桌面端一起发」这种依赖。",[14,1055,1057],{"id":1056},"三代价","三、代价",[10,1059,1060,1063],{},[27,1061,1062],{},"离线完全不可用。"," 桌面端的 README 里，「暂未实现」清单下有一行：",[43,1065,1068],{"className":1066,"code":1067,"language":48},[46],"- 离线模式与本地回放\n",[35,1069,1067],{"__ignoreMap":51},[10,1071,1072],{},"没有本地缓存页、没有离线兜底。断网时窗口是空的。",[10,1074,1075,1078],{},[27,1076,1077],{},"首屏依赖网络。"," 主窗口的加载失败处理只做了一件事——标记这次启动是成功的：",[43,1080,1082],{"className":515,"code":1081,"language":517,"meta":51,"style":51},"win.webContents.on(\"did-fail-load\", (_e, _code, _desc, _url, isMainFrame) => {\n  if (isMainFrame) launchGuard.markLaunchSucceeded();\n});\n",[35,1083,1084,1135,1149],{"__ignoreMap":51},[127,1085,1086,1089,1092,1095,1098,1101,1105,1108,1111,1113,1116,1118,1121,1123,1126,1129,1132],{"class":129,"line":130},[127,1087,1088],{"class":834},"win.webContents.",[127,1090,1091],{"class":842},"on",[127,1093,1094],{"class":834},"(",[127,1096,1097],{"class":881},"\"did-fail-load\"",[127,1099,1100],{"class":834},", (",[127,1102,1104],{"class":1103},"s4XuR","_e",[127,1106,1107],{"class":834},", ",[127,1109,1110],{"class":1103},"_code",[127,1112,1107],{"class":834},[127,1114,1115],{"class":1103},"_desc",[127,1117,1107],{"class":834},[127,1119,1120],{"class":1103},"_url",[127,1122,1107],{"class":834},[127,1124,1125],{"class":1103},"isMainFrame",[127,1127,1128],{"class":834},") ",[127,1130,1131],{"class":1004},"=>",[127,1133,1134],{"class":834}," {\n",[127,1136,1137,1140,1143,1146],{"class":129,"line":136},[127,1138,1139],{"class":1004},"  if",[127,1141,1142],{"class":834}," (isMainFrame) launchGuard.",[127,1144,1145],{"class":842},"markLaunchSucceeded",[127,1147,1148],{"class":834},"();\n",[127,1150,1151],{"class":129,"line":142},[127,1152,1153],{"class":834},"});\n",[10,1155,1156],{},"这个设计的用意是区分「渲染进程起不来」和「网页加载失败」：网页加载失败说明沙箱正常，不该触发降级。但它也意味着主窗口没有错误页——加载失败时用户看到的是一块白窗口。",[10,1158,1159],{},"对比之下，agent 的内置浏览器有兜底页，注释写得很清楚：",[43,1161,1163],{"className":515,"code":1162,"language":517,"meta":51,"style":51},"\u002F\u002F 加载失败\u002F渲染崩溃时展示的兜底页：把失败原因和 URL 明明白白写出来，\n\u002F\u002F 取代过去那块「无提示纯白窗口」。\n",[35,1164,1165,1170],{"__ignoreMap":51},[127,1166,1167],{"class":129,"line":130},[127,1168,1169],{"class":524},"\u002F\u002F 加载失败\u002F渲染崩溃时展示的兜底页：把失败原因和 URL 明明白白写出来，\n",[127,1171,1172],{"class":129,"line":136},[127,1173,1174],{"class":524},"\u002F\u002F 取代过去那块「无提示纯白窗口」。\n",[10,1176,1177],{},"主窗口这一条还没补。",[10,1179,1180,1183,1184,1021,1187,1021,1190,1193],{},[27,1181,1182],{},"两端版本要各自维护兼容边界。"," 这条是最不确定的。目前桌面端和 web 之间只有三个地址契约（",[35,1185,1186],{},"webUrl",[35,1188,1189],{},"apiBase",[35,1191,1192],{},"wsUrl","），没有版本号交换，也没有「桌面端版本过低就拒绝服务」的门槛。",[10,1195,1196,1197,1200],{},"设备注册时会上报 ",[35,1198,1199],{},"appVersion","，云端落库——但它的用途是记录，不是校验。兼容靠的是「能力清单 + 可选字段」：老版本不带某个字段时，新接口按缺省处理。协议包里有对应的测试：",[43,1202,1204],{"className":515,"code":1203,"language":517,"meta":51,"style":51},"it(\"没有工作目录时不带该字段，老桌面端行为不变\", ...)\n",[35,1205,1206],{"__ignoreMap":51},[127,1207,1208,1211,1213,1216,1218,1221],{"class":129,"line":130},[127,1209,1210],{"class":842},"it",[127,1212,1094],{"class":834},[127,1214,1215],{"class":881},"\"没有工作目录时不带该字段，老桌面端行为不变\"",[127,1217,1107],{"class":834},[127,1219,1220],{"class":1004},"...",[127,1222,1223],{"class":834},")\n",[10,1225,1226],{},"测试清单里有一条 P1 是「旧桌面端连新后端不崩」。这条列在那里本身就说明它还没有被系统验证过。",[14,1228,1230],{"id":1229},"四壳里真正装的是什么","四、壳里真正装的是什么",[10,1232,1233],{},"如果桌面端什么都不装，它就没必要存在。装的是三类本地运行时。",[10,1235,1236,1239,1240,1243],{},[27,1237,1238],{},"Python 运行时。"," 打包一份 Python 3.12.7 的 embed 版本，让 AI 的 ",[35,1241,1242],{},"python xxx.py"," 在任何客户机器上都能跑，不需要客户自己装 Python。",[10,1245,1246,1247,1250],{},"它必须走 ",[35,1248,1249],{},"extraResources"," 而不是进 asar，配置里写了原因：",[43,1252,1256],{"className":1253,"code":1254,"language":1255,"meta":51,"style":51},"language-js shiki shiki-themes github-light github-dark","\u002F\u002F 自带 Python 运行时：二进制必须走 extraResources（解压到 app\u002Fresources 下、不进 asar 才能执行）。\n\u002F\u002F 由 scripts\u002Ffetch-pyruntime.mjs 预先下载到 resources\u002Fpyruntime\u002F\u003C平台>\u002F。\n","js",[35,1257,1258,1263],{"__ignoreMap":51},[127,1259,1260],{"class":129,"line":130},[127,1261,1262],{"class":524},"\u002F\u002F 自带 Python 运行时：二进制必须走 extraResources（解压到 app\u002Fresources 下、不进 asar 才能执行）。\n",[127,1264,1265],{"class":129,"line":136},[127,1266,1267],{"class":524},"\u002F\u002F 由 scripts\u002Ffetch-pyruntime.mjs 预先下载到 resources\u002Fpyruntime\u002F\u003C平台>\u002F。\n",[10,1269,1270],{},"运行时目录从安装目录解析，也不可能从网络取：",[43,1272,1274],{"className":515,"code":1273,"language":517,"meta":51,"style":51},"const base = join(resourcesPath, \"pyruntime\", sub);\nconst exe = process.platform === \"win32\" ? join(base, \"python.exe\") : join(base, \"bin\", \"python3\");\nif (!existsSync(exe)) return null;\n",[35,1275,1276,1300,1349],{"__ignoreMap":51},[127,1277,1278,1281,1285,1288,1291,1294,1297],{"class":129,"line":130},[127,1279,1280],{"class":1004},"const",[127,1282,1284],{"class":1283},"sj4cs"," base",[127,1286,1287],{"class":1004}," =",[127,1289,1290],{"class":842}," join",[127,1292,1293],{"class":834},"(resourcesPath, ",[127,1295,1296],{"class":881},"\"pyruntime\"",[127,1298,1299],{"class":834},", sub);\n",[127,1301,1302,1304,1307,1309,1312,1315,1318,1321,1323,1326,1329,1331,1334,1336,1338,1341,1343,1346],{"class":129,"line":136},[127,1303,1280],{"class":1004},[127,1305,1306],{"class":1283}," exe",[127,1308,1287],{"class":1004},[127,1310,1311],{"class":834}," process.platform ",[127,1313,1314],{"class":1004},"===",[127,1316,1317],{"class":881}," \"win32\"",[127,1319,1320],{"class":1004}," ?",[127,1322,1290],{"class":842},[127,1324,1325],{"class":834},"(base, ",[127,1327,1328],{"class":881},"\"python.exe\"",[127,1330,1128],{"class":834},[127,1332,1333],{"class":1004},":",[127,1335,1290],{"class":842},[127,1337,1325],{"class":834},[127,1339,1340],{"class":881},"\"bin\"",[127,1342,1107],{"class":834},[127,1344,1345],{"class":881},"\"python3\"",[127,1347,1348],{"class":834},");\n",[127,1350,1351,1354,1357,1360,1363,1366,1369,1372],{"class":129,"line":142},[127,1352,1353],{"class":1004},"if",[127,1355,1356],{"class":834}," (",[127,1358,1359],{"class":1004},"!",[127,1361,1362],{"class":842},"existsSync",[127,1364,1365],{"class":834},"(exe)) ",[127,1367,1368],{"class":1004},"return",[127,1370,1371],{"class":1283}," null",[127,1373,1374],{"class":834},";\n",[10,1376,1377,1380],{},[27,1378,1379],{},"VC++ 运行库。"," 安装期静默装，已装则跳过，避免多余的 UAC 弹窗。",[10,1382,1383,1386,1387,1021,1390,1021,1393,1396],{},[27,1384,1385],{},"Electron 自己的运行库。"," 有一个设计文档专门钉了一份「核心文件清单」：主程序 exe、",[35,1388,1389],{},"ffmpeg.dll",[35,1391,1392],{},"chrome_elf.dll",[35,1394,1395],{},"resources\u002Fapp.asar","，检查发生在安装包解包完成之后、启动新程序之前。",[10,1398,1399],{},"这份清单来自一次真实故障：用户从旧版自动更新之后，启动时报「找不到 ffmpeg.dll」。安装包里其实有这个文件——错误发生在 Electron 主程序加载阶段，那时应用自己的错误处理还没机会运行，所以只能靠安装器检查。",[10,1401,1402,1403,1406],{},"这三类东西的共同点是",[27,1404,1405],{},"它们不能从远端加载","。Python 解释器、运行库 dll、Electron 自身，都必须在本地文件系统上。这才是壳存在的理由，不是为了渲染页面。",[14,1408,1410],{"id":1409},"五发版链路","五、发版链路",[10,1412,1413],{},"四个步骤，脚本里编号就是四段：",[43,1415,1419],{"className":1416,"code":1417,"language":1418,"meta":51,"style":51},"language-bash shiki shiki-themes github-light github-dark","# [1\u002F4] 同步源码（只覆盖源码，保留 node_modules \u002F pyruntime \u002F dist）\n# [2\u002F4] 构建 dist:win（含 pyruntime、签名）\n# [3\u002F4] 校验 latest.yml 版本 == $VERSION\n# [4\u002F4] 上传 OTA\n","bash",[35,1420,1421,1426,1431,1436],{"__ignoreMap":51},[127,1422,1423],{"class":129,"line":130},[127,1424,1425],{"class":524},"# [1\u002F4] 同步源码（只覆盖源码，保留 node_modules \u002F pyruntime \u002F dist）\n",[127,1427,1428],{"class":129,"line":136},[127,1429,1430],{"class":524},"# [2\u002F4] 构建 dist:win（含 pyruntime、签名）\n",[127,1432,1433],{"class":129,"line":142},[127,1434,1435],{"class":524},"# [3\u002F4] 校验 latest.yml 版本 == $VERSION\n",[127,1437,1438],{"class":129,"line":148},[127,1439,1440],{"class":524},"# [4\u002F4] 上传 OTA\n",[10,1442,1443,1446],{},[27,1444,1445],{},"为什么是「scp 源码到构建机」而不是在构建机上 clone。"," 构建机无法非交互拉取私有仓库（HTTPS 没有 tty，SSH 公钥没授权），所以从工作机把源码推过去，在一个「依赖已装、运行时齐全、密钥在系统环境变量」的固定构建目录里出包。",[10,1448,1449,1452,1453,1456],{},[27,1450,1451],{},"为什么走镜像。"," electron-builder 在国内拉 GitHub releases 会超时，报的是 ",[35,1454,1455],{},"Timeout awaiting 'request' for 600000ms","。构建前设两个镜像环境变量绕开。",[10,1458,1459,1462],{},[27,1460,1461],{},"版本校验有两道。"," 第一道在脚本里，出包之后比对产物清单里的版本和目标版本，不符就中止，不上传：",[43,1464,1466],{"className":1416,"code":1465,"language":1418,"meta":51,"style":51},"[[ \"$ONVER\" == \"$VERSION\" ]] || { echo \"产物版本($ONVER) != 目标($VERSION)，中止上传。\" >&2; exit 1; }\n",[35,1467,1468],{"__ignoreMap":51},[127,1469,1470,1473,1476,1479,1481,1484,1487,1490,1492,1495,1498,1501,1504,1507,1509,1512,1514,1517,1520,1523,1526,1529],{"class":129,"line":130},[127,1471,1472],{"class":834},"[[ ",[127,1474,1475],{"class":881},"\"",[127,1477,1478],{"class":834},"$ONVER",[127,1480,1475],{"class":881},[127,1482,1483],{"class":1004}," ==",[127,1485,1486],{"class":881}," \"",[127,1488,1489],{"class":834},"$VERSION",[127,1491,1475],{"class":881},[127,1493,1494],{"class":834}," ]] ",[127,1496,1497],{"class":1004},"||",[127,1499,1500],{"class":834}," { ",[127,1502,1503],{"class":1283},"echo",[127,1505,1506],{"class":881}," \"产物版本(",[127,1508,1478],{"class":834},[127,1510,1511],{"class":881},") != 目标(",[127,1513,1489],{"class":834},[127,1515,1516],{"class":881},")，中止上传。\"",[127,1518,1519],{"class":1004}," >&2",[127,1521,1522],{"class":834},"; ",[127,1524,1525],{"class":1283},"exit",[127,1527,1528],{"class":1283}," 1",[127,1530,1531],{"class":834},"; }\n",[10,1533,1534,1535,1538],{},"第二道在产物选择里：只允许当前版本的三件套（安装包、blockmap、清单）上传，缺任何一个直接抛错。这一道是为了处理「",[35,1536,1537],{},"dist"," 目录不清、旧版安装包残留」的情况。",[10,1540,1541,1544,1545,1548],{},[27,1542,1543],{},"客户端侧。"," 更新走 ",[35,1546,1547],{},"electron-updater"," 的 generic provider，指向一个静态目录：",[43,1550,1552],{"className":1253,"code":1551,"language":1255,"meta":51,"style":51},"publish: [{ provider: \"generic\", url: updateUrl }],\n",[35,1553,1554],{"__ignoreMap":51},[127,1555,1556,1559,1562,1565],{"class":129,"line":130},[127,1557,1558],{"class":842},"publish",[127,1560,1561],{"class":834},": [{ provider: ",[127,1563,1564],{"class":881},"\"generic\"",[127,1566,1567],{"class":834},", url: updateUrl }],\n",[10,1569,1570],{},"启动时拉清单文件、比较版本、下载、静默安装。",[14,1572,1574],{"id":1573},"六版本号的唯一来源","六、版本号的唯一来源",[10,1576,1577,1578,1581],{},"版本号只在 ",[35,1579,1580],{},"apps\u002Fdesktop\u002Fpackage.json"," 一处。产物名和清单里的版本都由它决定。",[10,1583,1584,1585,1588],{},"与之配套的一条纪律：",[27,1586,1587],{},"绝不能复用旧版本号","。客户端判断「要不要更新」靠的是版本比较，版本号相同就等于没有新版本，用户那边什么都不会发生——而这种失败是静默的，服务端看到的是上传成功。",[10,1590,1591],{},"脚本的这条判断还有一个前置的护栏：工作区有未提交的已跟踪改动时拒绝发版。因为发出去的是工作区源码，不是某个 commit。",[14,1593,1595],{"id":1594},"七一处没对齐的地方","七、一处没对齐的地方",[10,1597,1598],{},"签名。桌面端 README 写的是：",[43,1600,1603],{"className":1601,"code":1602,"language":48},[46],"当前 Windows 安装器未接入 CA 代码签名，用户首次安装可能看到 SmartScreen 提示。\n拿到证书后再通过 WIN_CSC_LINK \u002F WIN_CSC_KEY_PASSWORD 接入签名流程。\n",[35,1604,1602],{"__ignoreMap":51},[10,1606,1607],{},"而发版 skill 里写的是构建机已配 signtool、产出无 SmartScreen 强拦。两处说法不一致，仓库里也搜不到任何签名实现——那两个环境变量只出现在这一行文档里。",[10,1609,1610],{},"这两句话都写在 7 月上旬，中间没有改动过配置。真实状态是：安装包没有 CA 签名，首次安装会出现 SmartScreen 提示。文档里那句「无强拦」应该改掉。",[14,1612,1614],{"id":1613},"八这条路线适合什么","八、这条路线适合什么",[10,1616,1617],{},"选它之前要接受三个前提：用户始终在线、接受首次打开有网络等待、接受两端各自演进。",[10,1619,1620],{},"换来的是发版节奏的自由：改一个页面不需要用户装新版本，改壳里的东西不需要等前端一起走。前者让迭代变快，后者让迭代变危险——OTA 是推给所有人的，很难回滚。",[10,1622,1623,1624,1627],{},"所以这条路线上的纪律集中在一点：",[27,1625,1626],{},"发版前的校验要做足","。两道版本门禁、一条工作区护栏、一个只构建不上传的选项。四道关卡守的都是同一件事：不要把一个身份不明的包推给全部用户。",[10,1629,1630],{},"有一处边界想清楚过：安装包必须包含哪些文件。这份清单不是设计出来的，是被一次「找不到 ffmpeg.dll」的故障逼出来的。清单上的文件缺任何一个，安装器就在启动新程序之前停下来——因为在那个时刻，应用自己的任何补救代码都还没机会运行。",[365,1632,1633],{},"html pre.shiki code .sVt8B, html code.shiki .sVt8B{--shiki-default:#24292E;--shiki-dark:#E1E4E8}html pre.shiki code .s9eBZ, html code.shiki .s9eBZ{--shiki-default:#22863A;--shiki-dark:#85E89D}html pre.shiki code .sScJk, html code.shiki .sScJk{--shiki-default:#6F42C1;--shiki-dark:#B392F0}html pre.shiki code .sZZnC, html code.shiki .sZZnC{--shiki-default:#032F62;--shiki-dark:#9ECBFF}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html pre.shiki code .szBVR, html code.shiki .szBVR{--shiki-default:#D73A49;--shiki-dark:#F97583}html pre.shiki code .s4XuR, html code.shiki .s4XuR{--shiki-default:#E36209;--shiki-dark:#FFAB70}html pre.shiki code .sJ8bj, html code.shiki .sJ8bj{--shiki-default:#6A737D;--shiki-dark:#6A737D}html pre.shiki code .sj4cs, html code.shiki .sj4cs{--shiki-default:#005CC5;--shiki-dark:#79B8FF}",{"title":51,"searchDepth":136,"depth":136,"links":1635},[1636,1637,1638,1639,1640,1641,1642,1643],{"id":808,"depth":136,"text":809},{"id":1031,"depth":136,"text":1032},{"id":1056,"depth":136,"text":1057},{"id":1229,"depth":136,"text":1230},{"id":1409,"depth":136,"text":1410},{"id":1573,"depth":136,"text":1574},{"id":1594,"depth":136,"text":1595},{"id":1613,"depth":136,"text":1614},"2026-07-07",{},"\u002F2026-07-07",{"title":790,"description":795},"2026-07-07-桌面端只做壳不装页面","桌面端只加载远端地址，不把前端打进安装包。这条路线换来了独立的发版节奏，代价是离线不可用、首屏依赖网络，以及壳里必须自带的那部分本地运行时。",[1651,781,390,1652,1653,394],"Electron","electron-builder","打包","ElNTxouudoGHuSXPhaMwM-u23sOVeK4HA3GAGBnmito",{"id":1656,"title":1657,"body":1658,"column":379,"date":2136,"description":1662,"extension":381,"hero_image":382,"meta":2137,"navigation":384,"path":2138,"seo":2139,"series_id":382,"severity":382,"stem":2140,"summary":2141,"tags":2142,"__hash__":2147},"posts\u002F2026-06-16-把高风险发版固化成skill.md","不让 AI 碰发版，不等于靠人记住每一步",{"type":7,"value":1659,"toc":2130},[1660,1663,1667,1670,1673,1692,1703,1706,1710,1713,1716,1836,1843,1846,1853,1883,1886,1893,1897,1900,1903,1910,1980,1991,2052,2055,2070,2077,2080,2097,2104,2107,2110,2121,2124,2127],[10,1661,1662],{},"发版从来不应该由人工记忆驱动。5 月那一轮 OTA 系列故障（FA-005 到 FA-008）和跨版本步进导致的漏 DLL 事故反复说明了这一点：发版是不可逆的、影响全部用户的、细节繁琐的操作，任何一个环节遗漏或顺序错误就导致客户端卡死、文件不一致、激活页死循环。我现在不是让 AI 执行发版——那是禁地——而是把 5 个已经踏过所有坑的发版流程全部固化成 skill，让执行者用最小脑力成本跑完完整链路，而不是依赖文档翻译和记忆。",[14,1664,1666],{"id":1665},"触发词不该触发时绝对不触发","触发词：不该触发时绝对不触发",[10,1668,1669],{},"一个 skill 最危险的时刻是被错误调用。\"现在是什么版本\"听起来像发版，\"改一下版本号\"听起来像发版，但它们完全不是。",[10,1671,1672],{},"Wakou 发版 skill 的触发词清单（必读触发词段）就是为了卡死这条线：",[349,1674,1675,1681],{},[352,1676,1677,1678],{},"触发：",[35,1679,1680],{},"发版 \u002F 发布 \u002F release \u002F 打个新版本 \u002F OTA \u002F OTA 死循环 \u002F 版本 bump \u002F \u002Fwakou-release",[352,1682,1683,1684,1687,1688,1691],{},"不触发：",[35,1685,1686],{},"改一下 package.json 版本号","（用户只想改文件），",[35,1689,1690],{},"看看现在是什么版本","（只查询）",[10,1693,1694,1695,1698,1699,1702],{},"反面教材我都经历过。改一下配置文件、改一个环境变量、查一个版本号，因为没有明确的触发词界限，最后莫名其妙走到了发版脚本的某一步。Connector OTA skill 同样列明了不触发场景：",[35,1696,1697],{},"\"Connector 现在是什么版本\"(只查询)"," ",[35,1700,1701],{},"\"改 tauri.conf 版本号\"(用户只想改文件)"," — 这些都不触发，即使命令里含了 version、release 这类关键字。",[10,1704,1705],{},"这条最反直觉但最重要：不该触发时被触发，等于主动送一次误操作机会给自动化流程。",[14,1707,1709],{"id":1708},"步骤不可跳过验证链条硬卡","步骤不可跳过：验证链条硬卡",[10,1711,1712],{},"一旦 skill 确定要执行，每一步都必须有验证关卡，前一步没通过绝不进下一步。我用 Wakou 全自动发版流程作例子。",[10,1714,1715],{},"预检（Step 1）是整个流程的闸门。跑这些 Bash 检查：",[43,1717,1719],{"className":1416,"code":1718,"language":1418,"meta":51,"style":51},"cd \u002FUsers\u002Fxingye\u002FAi\u002Fnew-openclaw\ngit status --short                                          # 工作区干净\ntest -x \u002Fopt\u002Fhomebrew\u002Fbin\u002Fsshpass                           # ssh 工具\ntest -x \u002Fopt\u002Fhomebrew\u002Fbin\u002Fminisign                          # 签名工具\ntest -x \u003C签名 CLI 路径>                                      # 内部签名工具就位\ntest -f \u003C发版签名私钥路径>                                   # 私钥就位\ntest -f \u003CCDN 上传凭据路径>                                   # CDN 凭据就位\n",[35,1720,1721,1729,1743,1757,1769,1796,1815],{"__ignoreMap":51},[127,1722,1723,1726],{"class":129,"line":130},[127,1724,1725],{"class":1283},"cd",[127,1727,1728],{"class":881}," \u002FUsers\u002Fxingye\u002FAi\u002Fnew-openclaw\n",[127,1730,1731,1734,1737,1740],{"class":129,"line":136},[127,1732,1733],{"class":842},"git",[127,1735,1736],{"class":881}," status",[127,1738,1739],{"class":1283}," --short",[127,1741,1742],{"class":524},"                                          # 工作区干净\n",[127,1744,1745,1748,1751,1754],{"class":129,"line":142},[127,1746,1747],{"class":1283},"test",[127,1749,1750],{"class":1283}," -x",[127,1752,1753],{"class":881}," \u002Fopt\u002Fhomebrew\u002Fbin\u002Fsshpass",[127,1755,1756],{"class":524},"                           # ssh 工具\n",[127,1758,1759,1761,1763,1766],{"class":129,"line":148},[127,1760,1747],{"class":1283},[127,1762,1750],{"class":1283},[127,1764,1765],{"class":881}," \u002Fopt\u002Fhomebrew\u002Fbin\u002Fminisign",[127,1767,1768],{"class":524},"                          # 签名工具\n",[127,1770,1771,1773,1775,1778,1781,1784,1787,1790,1793],{"class":129,"line":154},[127,1772,1747],{"class":1283},[127,1774,1750],{"class":1283},[127,1776,1777],{"class":1004}," \u003C",[127,1779,1780],{"class":881},"签名",[127,1782,1783],{"class":881}," CLI",[127,1785,1786],{"class":881}," 路",[127,1788,1789],{"class":834},"径",[127,1791,1792],{"class":1004},">",[127,1794,1795],{"class":524},"                                      # 内部签名工具就位\n",[127,1797,1798,1800,1803,1805,1808,1810,1812],{"class":129,"line":246},[127,1799,1747],{"class":1283},[127,1801,1802],{"class":1283}," -f",[127,1804,1777],{"class":1004},[127,1806,1807],{"class":881},"发版签名私钥路",[127,1809,1789],{"class":834},[127,1811,1792],{"class":1004},[127,1813,1814],{"class":524},"                                   # 私钥就位\n",[127,1816,1817,1819,1821,1823,1826,1829,1831,1833],{"class":129,"line":252},[127,1818,1747],{"class":1283},[127,1820,1802],{"class":1283},[127,1822,1777],{"class":1004},[127,1824,1825],{"class":881},"CDN",[127,1827,1828],{"class":881}," 上传凭据路",[127,1830,1789],{"class":834},[127,1832,1792],{"class":1004},[127,1834,1835],{"class":524},"                                   # CDN 凭据就位\n",[10,1837,1838,1839,1842],{},"任何一条 fail，流程停止并告诉用户修什么。如果 git 工作区脏，不是自动 stash，而是问用户：工作区有未提交改动，要先 commit 还是 stash？发版会自动 bump 三个版本文件并 commit。这样做的目的很明确——让用户",[27,1840,1841],{},"清晰意识到","发版会改动工作区，而不是闭着眼睛走入自动流程。",[10,1844,1845],{},"收三个密码（Step 2）也有安全约定。不要把密码记到内存或 commit 进文件，只读 shell 的 export。如果用户在过去几分钟内说过这些密码（在 conversation context 里能直接拿到），可以直接传 env 跑；否则必须用户主动导入。",[10,1847,1848,1849,1852],{},"跑 release.sh（Step 3）用 ",[35,1850,1851],{},"run_in_background=true"," 避免 5-15 分钟阻塞主线，把输出 tee 到日志。然后（Step 4）用 Monitor 工具监控 10 个 stage 的进度：",[1854,1855,1856,1859,1862,1865,1868,1871,1874,1877,1880],"ol",{},[352,1857,1858],{},"preflight",[352,1860,1861],{},"detect FROM_VERSION",[352,1863,1864],{},"tar + scp source to Win build host",[352,1866,1867],{},"build on Win",[352,1869,1870],{},"stage + diff-pack OTA full.zip",[352,1872,1873],{},"minisign full.zip + release-manifest.json",[352,1875,1876],{},"upload to CDN",[352,1878,1879],{},"admin: create release + PATCH rollout",[352,1881,1882],{},"git tag + commit version bump",[10,1884,1885],{},"每个 stage 完成给用户报一句。出现 ✗ FAILED 立刻 surface 错误。没有\"继续试试能不能救\"，就是暴露失败。",[10,1887,1888,1889,1892],{},"这整套链条的目的是",[27,1890,1891],{},"让人工审核点布满全流程","。不是机器自动发版，而是 AI 陪着用户一步步走完发版，每步都看得清、验得出。",[14,1894,1896],{"id":1895},"故障处置内联把坑写在流程里","故障处置内联：把坑写在流程里",[10,1898,1899],{},"OTA 系列故障的教训不应该沉在 bug 复盘里。我把它们直接铺进 skill 的执行逻辑。",[10,1901,1902],{},"Wakou 发版 skill 有整整一章叫「🚨 阻塞性 bug 处置 SOP」。列出了从 G19 到 G31 的 11 个已知 bug：",[10,1904,1905,1906,1909],{},"G19 是 OTA modal 反复弹。原因是 state base 残留 stale ",[35,1907,1908],{},"download.zip.minisig","，当时的修复是：",[43,1911,1913],{"className":1416,"code":1912,"language":1418,"meta":51,"style":51},"# 预检 sanity#3: data\u002F 不删\nsanity#3 强制阻断 deletes 命中 data\u002F。某次发版失败提示 sanity#3 一定是 build 步骤搞坏了 data\u002F 目录的同步，不要绕过 sanity check 去强发，先 debug 为什么 data\u002F 出现在 deletes 列表。\n",[35,1914,1915,1920],{"__ignoreMap":51},[127,1916,1917],{"class":129,"line":130},[127,1918,1919],{"class":524},"# 预检 sanity#3: data\u002F 不删\n",[127,1921,1922,1925,1928,1931,1934,1937,1940,1943,1946,1949,1952,1955,1958,1961,1964,1967,1970,1972,1975,1977],{"class":129,"line":136},[127,1923,1924],{"class":842},"sanity#3",[127,1926,1927],{"class":881}," 强制阻断",[127,1929,1930],{"class":881}," deletes",[127,1932,1933],{"class":881}," 命中",[127,1935,1936],{"class":881}," data\u002F。某次发版失败提示",[127,1938,1939],{"class":881}," sanity#3",[127,1941,1942],{"class":881}," 一定是",[127,1944,1945],{"class":881}," build",[127,1947,1948],{"class":881}," 步骤搞坏了",[127,1950,1951],{"class":881}," data\u002F",[127,1953,1954],{"class":881}," 目录的同步，不要绕过",[127,1956,1957],{"class":881}," sanity",[127,1959,1960],{"class":881}," check",[127,1962,1963],{"class":881}," 去强发，先",[127,1965,1966],{"class":881}," debug",[127,1968,1969],{"class":881}," 为什么",[127,1971,1951],{"class":881},[127,1973,1974],{"class":881}," 出现在",[127,1976,1930],{"class":881},[127,1978,1979],{"class":881}," 列表。\n",[10,1981,1982,1983,1986,1987,1990],{},"G25 是跨用户机器后 chat 权限错。根因是 ",[35,1984,1985],{},"sessions\u002Faudit"," 含绝对路径，guardian sync 扩散。这不是\"修了以后的故事\"，而是",[27,1988,1989],{},"发版前防御 checklist"," 的一部分：",[43,1992,1994],{"className":1416,"code":1993,"language":1418,"meta":51,"style":51},"# 发版前防御 checklist (每次发版前在 build host 做完整新用户回归)\n# 3. dist 内 grep C:\\Users\\ 应该 0 命中（防 G25 类绝对路径泄露）\nssh CHANCHING@\u003C构建机> 'powershell -Command \"\n  Get-ChildItem D:\\wakou-build\\dist-X.Y.Z\\WakouPanelPortable -Recurse -File -Include *.json,*.jsonl |\n    Where-Object { $_.Length -lt 200KB } |\n    Select-String C:\\\\Users\\\\Administrator -SimpleMatch -List |\n    Select-Object FullName\n\"'\n",[35,1995,1996,2001,2006,2027,2032,2037,2042,2047],{"__ignoreMap":51},[127,1997,1998],{"class":129,"line":130},[127,1999,2000],{"class":524},"# 发版前防御 checklist (每次发版前在 build host 做完整新用户回归)\n",[127,2002,2003],{"class":129,"line":136},[127,2004,2005],{"class":524},"# 3. dist 内 grep C:\\Users\\ 应该 0 命中（防 G25 类绝对路径泄露）\n",[127,2007,2008,2011,2014,2016,2019,2022,2024],{"class":129,"line":142},[127,2009,2010],{"class":842},"ssh",[127,2012,2013],{"class":881}," CHANCHING@",[127,2015,851],{"class":1004},[127,2017,2018],{"class":881},"构建",[127,2020,2021],{"class":834},"机",[127,2023,1792],{"class":1004},[127,2025,2026],{"class":881}," 'powershell -Command \"\n",[127,2028,2029],{"class":129,"line":148},[127,2030,2031],{"class":881},"  Get-ChildItem D:\\wakou-build\\dist-X.Y.Z\\WakouPanelPortable -Recurse -File -Include *.json,*.jsonl |\n",[127,2033,2034],{"class":129,"line":154},[127,2035,2036],{"class":881},"    Where-Object { $_.Length -lt 200KB } |\n",[127,2038,2039],{"class":129,"line":246},[127,2040,2041],{"class":881},"    Select-String C:\\\\Users\\\\Administrator -SimpleMatch -List |\n",[127,2043,2044],{"class":129,"line":252},[127,2045,2046],{"class":881},"    Select-Object FullName\n",[127,2048,2049],{"class":129,"line":258},[127,2050,2051],{"class":881},"\"'\n",[10,2053,2054],{},"任何一项 fail → 不要 GA，修了再 bump 一个 patch 版本。",[10,2056,2057,2058,2061,2062,2065,2066,2069],{},"Connector OTA skill 的故障字典（§4）则是一张病症与病因的对照表。\"signature verification failed\" 对应的处置是检查 ",[35,2059,2060],{},"TAURI_SIGNING_PRIVATE_KEY"," 是不是 .sec 文件",[27,2063,2064],{},"base64 编码后","的内容。\"OTA 不跨级但用户期望直跳\" 的处置是调整 ",[35,2067,2068],{},"findNextVersion"," 逻辑或在灰度模式放上一个 manifest。",[10,2071,2072,2073,2076],{},"关键在",[27,2074,2075],{},"处置是写死在 skill 里","，不是留在事后复盘让人去翻。OpenClaw Runtime 发版 skill 的故障字典甚至铺了 10 条常见陷阱：BuildKit context 缓存不清导致改动没编进去、gwbridge IP 池满导致 service 无法起、孤儿 docker-proxy 占着 host port、磁盘满导致 openclaw.json 被写成 0 字节。",[10,2078,2079],{},"每一条都带着：\"现象是什么、根因是什么、怎么修\"。拿 BuildKit 缓存那条：",[2081,2082,2083],"blockquote",{},[10,2084,2085,2086,2089,2090,2093,2094,2096],{},"症状：rsync\u002FSFTP 把新源推上 prod repo，源文件 grep 确认是新的，但 docker build 出来的镜像里还是旧 bundle。根因：",[35,2087,2088],{},"--no-cache-filter"," 不可靠，BuildKit 的 build context 层仍可能命中缓存。修法：clawpanel 源改动后，必须用整体 ",[35,2091,2092],{},"--no-cache","，不要用 ",[35,2095,2088],{},"。代价：单次 ~20-30 分钟。但这是唯一能确保改动真编进去的方式。",[10,2098,2099,2100,2103],{},"这不是\"高级技巧\"或\"经验之谈\"。这是",[27,2101,2102],{},"发版的必读须知","，写进 skill 里才能确保每次都不遗漏。",[14,2105,2106],{"id":2106},"为什么这样做有效",[10,2108,2109],{},"高风险操作的安全性来自流程的确定性，不来自执行者的谨慎。人会忘、会打错、会跳步。但固化的流程不会：",[349,2111,2112,2115,2118],{},[352,2113,2114],{},"触发词明确化杀死了\"我不是故意调用发版\"的那类误操作。",[352,2116,2117],{},"步骤验证链条把每一步的前置条件和验收标准写成 Bash 检查，没法绕过。",[352,2119,2120],{},"故障处置内联把一个多月里积累的坑提前封死在流程里，新一轮发版不会重蹈覆辙。",[10,2122,2123],{},"5 个 skill 现在覆盖了：Wakou panel OTA、Connector 桌面端 OTA、OpenClaw runtime 容器全量发布、Codex 助手静默更新、加上客服远程调试的风险边界。它们没有一个是让 AI 执行发版的——都是让 AI 陪着用户，一步步走完一个铺满护栏的流程。",[10,2125,2126],{},"这是把\"不要让 AI 碰发版\"这条禁区，变成了\"AI 执行发版的框架，确保每一步都验证、每个坑都已知、每种故障都有救法\"的可行路径。",[365,2128,2129],{},"html pre.shiki code .sj4cs, html code.shiki .sj4cs{--shiki-default:#005CC5;--shiki-dark:#79B8FF}html pre.shiki code .sZZnC, html code.shiki .sZZnC{--shiki-default:#032F62;--shiki-dark:#9ECBFF}html pre.shiki code .sScJk, html code.shiki .sScJk{--shiki-default:#6F42C1;--shiki-dark:#B392F0}html pre.shiki code .sJ8bj, html code.shiki .sJ8bj{--shiki-default:#6A737D;--shiki-dark:#6A737D}html pre.shiki code .szBVR, html code.shiki .szBVR{--shiki-default:#D73A49;--shiki-dark:#F97583}html pre.shiki code .sVt8B, html code.shiki .sVt8B{--shiki-default:#24292E;--shiki-dark:#E1E4E8}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}",{"title":51,"searchDepth":136,"depth":136,"links":2131},[2132,2133,2134,2135],{"id":1665,"depth":136,"text":1666},{"id":1708,"depth":136,"text":1709},{"id":1895,"depth":136,"text":1896},{"id":2106,"depth":136,"text":2106},"2026-06-16",{},"\u002F2026-06-16-skill",{"title":1657,"description":1662},"2026-06-16-把高风险发版固化成skill","发版是明确不交给 AI 自主执行的操作。通过触发词精确化、步骤验证链条、故障处置内联，把流程固化到 skill，让高风险操作失败概率趋零。",[2143,390,2144,2145,2146],"发版流程","自动化","Tauri","Docker Swarm","WbjJuf61wSEx4d5Iov2KhPdG1N6hik51RAl8rAGgug8",1789212573559]