Skip to content

fix(desktop): ask before replacing a different CLI runtime - #4508

Merged
huangruiteng merged 5 commits into
mainfrom
codex/desktop-runtime-pairing-choice
Sep 16, 2026
Merged

huangruiteng merged 5 commits into
mainfrom
codex/desktop-runtime-pairing-choice

Conversation

@huangruiteng

Copy link
Copy Markdown
Collaborator

动机

打开桌面 App 时,如果本机默认 CLI 的运行时修订与 App 自带快照不同,App 会直接安装自带快照。当 CLI 是更新的那一层时,这一步会把 CLI 静默回退,并且只在 loopx doctor 里表现为一条配对告警。

改动

启动时的运行时判定改成显式三态(RuntimeStep):

  • 没有安装运行时 → 仍自动安装自带快照,新机器保持一次引导;
  • 已安装同一快照 → 不做任何事;
  • 已安装不同修订 → 发布 runtime_pairing_required(带两个修订),并在启动服务前停下,不再替换。

首屏渲染这个决策:「升级:更新 App 与运行时」检查当前通道并在有更新时继续走签名校验+安装,「回退 CLI:改用本 App 自带运行时」安装自带快照并在同一窗口重连;决策态不会被启动失败投影覆盖,也不会升级成错误态。已批准的更新 journal 仍自动完成安装,不会被该闸门拦住;显式 LOOPX_BIN 覆盖仍然永不自动替换。

行为变化

默认行为变化(已披露):此前"不同修订即自动替换"变为"先询问"。受影响的是 App 启动路径,不涉及评分、任务语义、权限边界或制品。

验证

  • cargo test --locked 68 passed;cargo clippy --all-targets --locked -- -D warnings 干净;
  • 真实浏览器 smoke(Playwright 驱动本仓库 boot.js/index.html):决策态、两个选择、390px 布局、升级 ["check","apply"]、回退 ["align_runtime"]、连接后收起;
  • pytest -k "doctor or desktop" 81 passed / 1 skipped;tests/desktop 12 passed;dashboard tsc --noEmit 干净;
  • loopx canary premerge --from-git-diff 通过(catalog canaries / risk-profile smokes / public-boundary / direct checks 全部 0 失败)。

@huangruiteng huangruiteng left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Approval conclusion (author-owned PR; GitHub blocks formal self-approval)

Exact head reviewed: add03e6197cbf6c9ff3f067494ef18be78e54f29 on codex/desktop-runtime-pairing-choice (base main at dd9a95ce6; review policy revision 5).

动机

macOS App 启动时若发现本机 CLI 运行时与 App 自带快照的 source_revision 不同,会直接安装自带快照(resume_runtime → prepare_runtime(matches=false),最多 3 次、间隔 ≥30s),无论哪一层更新。对「CLI 是更新那一层」的机器,这一步是把 CLI 静默回退,而且除 loopx doctor 的可选配对行之外没有任何提示。改动目标只有一个:去掉这个静默默认,同时保留新机器一次启动即可引导的行为。

改动思路

把原来的布尔「matches」换成显式三态 RuntimeStep { AlreadyPaired, InstallBundled, AskOperator },由同一个 owner(App 进程)在启动前分类:

  • 没有安装运行时 → InstallBundled,新机引导不变;
  • 已安装同一快照 → AlreadyPaired,什么都不做;
  • 已安装不同修订 → AskOperator,发布 runtime_pairing_required(带两个修订)并在启动服务前停下;
  • LOOPX_BIN 显式覆盖 → 仍拒绝替换(runtime_identity_mismatch)。

复用了既有的一切:同一个 bundled_runtime::install、同一份版本范围 journal、同一个 RuntimeRetry 预算、同一个 boot 恢复面板;align_runtime 与 repair 共用抽出的 reinstall_bundled_runtime,没有新增安装路径。中途修掉了一个反例:App 升级后重启时那条「journal 版本 == 运行中 App 版本」属于操作者已批准的同意,必须继续完成安装而不是再次询问,因此分类器把 approved journal 归入 InstallBundled。

具体改动

  • apps/desktop/loopx-control-plane/src-tauri/src/maintenance.rs(+331/-72,其中约一半是测试):新增 RuntimeStep、classify_runtime_step(精确比较 40 位 source_revision)、pairing_details(固定 6 个键,只含两个修订与 App 版本)、reinstall_bundled_runtime;prepare_runtime 改为按 step 决策,AskOperator 分支不消耗 RuntimeRetry;resume_runtime 一次性读取身份与 journal 并传入 approved_journal;require_paired_runtime 对「不同修订」也改为发布决策;reconcile_services 与 publish 把新阶段视为运行时阻塞态而非服务失败。
  • apps/desktop/loopx-control-plane/src-tauri/src/lib.rs(+19):boot_failure_message 对 runtime_pairing_required 给出「选择升级或回退 CLI」的可执行文案,而不是通用启动失败;boot 表面测试新增首屏断言。
  • apps/desktop/loopx-control-plane/static/index.html / boot.js / boot.css(+111/-7):首屏新增决策块(标题、两个修订、两个按钮、状态与说明),renderPairing 负责显隐与状态、upgradeBoth 把「升级」实现为 check→apply 一次意图、align_runtime 作为回退动作;loopxBootFailed/loopxBootRetrying 在该阶段提前返回,escalateFromSnapshot 跳过它,确保 native 重试循环不会把它重画成错误态。
  • apps/presentation/dashboard/src/features/personal-workspace/desktop-update.tsx(+2/-1):工作区更新面板的 Phase 联合类型与消息表补上新阶段,保持穷尽。
  • loopx/desktop_installation.py(+1/-1)与 tests/test_desktop_installation.py:loopx doctor 的配对建议改为同时点名两个选择,测试断言相应改写,CLI 报告与 App 行为不会各说一套。
  • examples/desktop-update-browser-smoke.mjs(+85/-2)与 examples/desktop-recovery-diagnostics-test.mjs(+52):真实浏览器 smoke 覆盖决策面(不选不装、两按钮可用、390px 布局、["check","apply"]、无更新构建只 ["check"]、["align_runtime"]、连接后收起、native 回调无法覆盖、五轮不升级为错误态);单测覆盖配对证据的边界与隐私。
  • apps/desktop/loopx-control-plane/README.md(+15/-8)与 skills/loopx-project/SKILL.md(+3/-1):行为变更披露——自动安装只在没有运行时发生,不同运行时要由操作者选择;skill 对旧构建的告警保留。

对主干的风险

最强回归有两个方向。其一,操作者不回答时 App 停在首屏、服务不启动——这是有意的 fail closed,代价是「不静默改动 CLI」,恢复路径是修复/回退/换通道或 revert 提交。其二,App 升级后重启必须仍自动装配套运行时,这条若被闸门拦住会让每次 App 更新都卡在询问;分类器把 approved journal 视为既有同意,并直接测试了该分支。影响半径仅限 macOS App 启动路径:CLI、终端流程、HTTP 服务、Goal 数据、权限边界、评分与任务语义都不变,也没有新增持久状态或需要迁移的字段(决策每次启动从两个身份推导)。可观测点是发布的 runtime_pairing_required 阶段(含两个修订)与 loopx doctor 的配对行;回退方式是 revert 该提交。负面走查:LOOPX_BIN 覆盖下仍拒绝替换;开发与非 macOS 构建仍短路;未知 IPC action 仍被 ACL 拒绝。

验证:cargo test --locked 68 passed / 2 ignored、cargo clippy --all-targets --locked -- -D warnings 干净;node examples/desktop-update-browser-smoke.mjs 通过(驱动生产 boot 表面);pytest tests/ -k "doctor or desktop" 81 passed / 1 skipped;python -m unittest discover -s tests/desktop 12 passed;dashboard tsc --noEmit 干净;两个 desktop workflow smoke 通过;scripts/loopx canary premerge --from-git-diff 19 项 0 失败。两处本机环境差异已用未改动的 base 工作区对照证明与本次改动无关(根 node_modules 缺失导致语义 drift smoke 无 TS parser;Node 25 的 vm sandbox 缺 performance 全局),补齐后分别通过。尚未覆盖:真实签名 App 包在物理机上跑完整决策——本机安装已被先行对齐到同一修订,这一步计划在合并并安装新 App 构建后补收据;Windows 仍由既有的 platform_update_not_supported 短路。

我的整体评价

改动量与问题成正比:一个三态分类器、一个共享安装 helper、一个启动分支和一个首屏决策块,删除的是「不同修订即替换」这条默认规则,没有新能力、新 schema、新持久状态或新 CLI 选项。证据覆盖了配对/缺失/不同/已批准/覆盖五条启动分支与两个操作者动作,且负面走查盯的是这套设计最可能出错的两处(静默安装回归、App 更新被卡死)。结论:APPROVE。残余风险是真机端到端收据尚未产出,以及「不回答就停住」这一有意选择的接受度需要发布说明讲清楚。

English verdict: APPROVE for add03e6197cbf6c9ff3f067494ef18be78e54f29 — 12 files, +623/-93. The macOS App no longer installs its bundled runtime over a different installed CLI runtime at startup: a typed RuntimeStep classifier keeps fresh-machine bootstrap automatic (InstallBundled when nothing is installed, including an operator-approved update journal) and turns a genuine revision difference into an operator decision (runtime_pairing_required with both revisions, services held back, no install), with the first screen offering "update App and runtime" (check then verified install) or "use this App's runtime" (align_runtime, sharing the existing bounded reinstall with repair). No new capability, schema, CLI option or persisted state; LOOPX_BIN overrides are still never replaced. Authorization naming, typed state (exact 40-hex comparison, no substring heuristics), default-change disclosure (README, skill, PR body, commit messages, CLI advice text and its test) and domain neutrality were checked on the full diff. Validation at the exact head: cargo test 68 passed, clippy clean, production boot surface driven in a real browser through the decision (no install before a choice, both choices, upgrade chain, channel with nothing newer, align then reconnect), pytest 81 passed/1 skipped, tests/desktop 12 passed, dashboard tsc clean, canary premerge 19 checks 0 failures. Residual risk: the decision has not yet been observed in a real signed App bundle on a physical machine (planned receipt after merge and a new App build), and a pending decision intentionally leaves services stopped.

Opening the App while the default CLI runtime was a different revision
silently installed the App's bundled snapshot, which could move a
separately updated CLI backwards.

A start now classifies the installed runtime first:

- no installed runtime: install the bundled snapshot, so a fresh machine
  still bootstraps in one launch;
- the bundled snapshot already installed: nothing to do;
- a different runtime installed: publish `runtime_pairing_required` with
  both revisions and stop before services, instead of replacing it.

The boot surface renders that decision on the first screen - "update App
and runtime" checks this App's channel and continues into the verified
install, "use this App's runtime" installs the bundled snapshot and
reconnects the same window - and no longer escalates a decision into the
startup error projection. An approved update journal still finishes its
installation without asking again, and an explicit `LOOPX_BIN` override is
still never replaced automatically.

Signed-off-by: huangruiteng <14976749+huangruiteng@users.noreply.github.com>
`loopx doctor` told operators that a mismatched App "may replace the CLI
runtime" without saying what to do about it. The reported advice now names
the decision the App asks for, and the contract test asserts that wording
instead of a promise of silent replacement.

Signed-off-by: huangruiteng <14976749+huangruiteng@users.noreply.github.com>
Record that automatic runtime preparation now applies only when nothing is
installed, that a different installed runtime is the operator's decision,
and what each choice does to the two layers. The LoopX project skill keeps
its warning for older builds while naming the current behavior.

Signed-off-by: huangruiteng <14976749+huangruiteng@users.noreply.github.com>
The browser smoke drives the boot surface through the decision: both
choices stay available, nothing installs before one is chosen, the native
boot-failure callback cannot overwrite it, escalation never relabels it as
an error, the layout holds at phone width, and "update" checks then installs
while "use this App's runtime" issues the align action and retires the
chooser once services connect. The unit test keeps the pairing evidence
bounded and free of private detail.

Signed-off-by: huangruiteng <14976749+huangruiteng@users.noreply.github.com>
The workspace update panel carries the new App phase, so the packaged chat
assets must match a clean source build. Adds the new generation, keeps the
previous one in the retention manifest, and repoints index.html.

Signed-off-by: huangruiteng <14976749+huangruiteng@users.noreply.github.com>
@huangruiteng
huangruiteng force-pushed the codex/desktop-runtime-pairing-choice branch from add03e6 to 826def0 Compare September 16, 2026 07:54

@huangruiteng huangruiteng left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Approval conclusion (author-owned PR; GitHub blocks formal self-approval)

Exact head reviewed: 826def0f93cfeaf95998befc5b262eda635a7095 on codex/desktop-runtime-pairing-choice (re-read immediately before this verdict; base main at dd584fab4; review policy revision 5). Rebased onto the current main and re-verified end to end at this head.

动机

macOS App 启动时若发现本机 CLI 运行时与 App 自带快照的 source_revision 不同,会直接安装自带快照(resume_runtime → prepare_runtime(matches=false),最多 3 次、间隔 ≥30s),无论哪一层更新。对「CLI 是更新那一层」的机器,这一步是把 CLI 静默回退,而且除 loopx doctor 的可选配对行之外没有任何提示。改动目标只有一个:去掉这个静默默认,同时保留新机器一次启动即可引导的行为。

改动思路

把原来的布尔「matches」换成显式三态 RuntimeStep { AlreadyPaired, InstallBundled, AskOperator },由同一个 owner(App 进程)在启动前分类:

  • 没有安装运行时 → InstallBundled,新机引导不变;
  • 已安装同一快照 → AlreadyPaired,什么都不做;
  • 已安装不同修订 → AskOperator,发布 runtime_pairing_required(带两个修订)并在启动服务前停下;
  • LOOPX_BIN 显式覆盖 → 仍拒绝替换(runtime_identity_mismatch)。

复用了既有的一切:同一个 bundled_runtime::install、同一份版本范围 journal、同一个 RuntimeRetry 预算、同一个 boot 恢复面板;align_runtime 与 repair 共用抽出的 reinstall_bundled_runtime,没有新增安装路径。中途修掉了一个反例:App 升级后重启时那条「journal 版本 == 运行中 App 版本」属于操作者已批准的同意,必须继续完成安装而不是再次询问,因此分类器把 approved journal 归入 InstallBundled。

具体改动

  • apps/desktop/loopx-control-plane/src-tauri/src/maintenance.rs(+331/-72,其中约一半是测试):新增 RuntimeStep、classify_runtime_step(精确比较 40 位 source_revision)、pairing_details(固定 6 个键,只含两个修订与 App 版本)、reinstall_bundled_runtime;prepare_runtime 改为按 step 决策,AskOperator 分支不消耗 RuntimeRetry;resume_runtime 一次性读取身份与 journal 并传入 approved_journal;require_paired_runtime 对「不同修订」也改为发布决策;reconcile_services 与 publish 把新阶段视为运行时阻塞态而非服务失败。
  • apps/desktop/loopx-control-plane/src-tauri/src/lib.rs(+19):boot_failure_message 对 runtime_pairing_required 给出「选择升级或回退 CLI」的可执行文案,而不是通用启动失败;boot 表面测试新增首屏断言。
  • apps/desktop/loopx-control-plane/static/index.html / boot.js / boot.css(+111/-7):首屏新增决策块(标题、两个修订、两个按钮、状态与说明),renderPairing 负责显隐与状态、upgradeBoth 把「升级」实现为 check→apply 一次意图、align_runtime 作为回退动作;loopxBootFailed/loopxBootRetrying 在该阶段提前返回,escalateFromSnapshot 跳过它,确保 native 重试循环不会把它重画成错误态。
  • apps/presentation/dashboard/src/features/personal-workspace/desktop-update.tsx(+2/-1):工作区更新面板的 Phase 联合类型与消息表补上新阶段,保持穷尽。
  • loopx/desktop_installation.py(+1/-1)与 tests/test_desktop_installation.py:loopx doctor 的配对建议改为同时点名两个选择,测试断言相应改写,CLI 报告与 App 行为不会各说一套。
  • examples/desktop-update-browser-smoke.mjs(+85/-2)与 examples/desktop-recovery-diagnostics-test.mjs(+52):真实浏览器 smoke 覆盖决策面(不选不装、两按钮可用、390px 布局、["check","apply"]、无更新构建只 ["check"]、["align_runtime"]、连接后收起、native 回调无法覆盖、五轮不升级为错误态);单测覆盖配对证据的边界与隐私。
  • apps/desktop/loopx-control-plane/README.md(+15/-8)与 skills/loopx-project/SKILL.md(+3/-1):行为变更披露——自动安装只在没有运行时发生,不同运行时要由操作者选择;skill 对旧构建的告警保留。
  • loopx/web/chat/(+145/-1,生成产物):工作区面板的阶段联合类型变化后,重新构建打包的 chat 工作区,使 npm run build:chat 在干净源码上不再产生差异(新增一代 bundle、retention manifest 保留上一代、index.html 指向新 bundle)。

对主干的风险

最强回归有两个方向。其一,操作者不回答时 App 停在首屏、服务不启动——这是有意的 fail closed,代价是「不静默改动 CLI」,恢复路径是修复/回退/换通道或 revert 提交。其二,App 升级后重启必须仍自动装配套运行时,这条若被闸门拦住会让每次 App 更新都卡在询问;分类器把 approved journal 视为既有同意,并直接测试了该分支。影响半径仅限 macOS App 启动路径:CLI、终端流程、HTTP 服务、Goal 数据、权限边界、评分与任务语义都不变,也没有新增持久状态或需要迁移的字段(决策每次启动从两个身份推导)。可观测点是发布的 runtime_pairing_required 阶段(含两个修订)与 loopx doctor 的配对行;回退方式是 revert 该提交。负面走查:LOOPX_BIN 覆盖下仍拒绝替换;开发与非 macOS 构建仍短路;未知 IPC action 仍被 ACL 拒绝。

验证(全部在本次 rebase 后的 head 上重跑):cargo test --locked 68 passed / 2 ignored、cargo clippy --all-targets --locked -- -D warnings 干净;node examples/desktop-update-browser-smoke.mjs 通过(驱动生产 boot 表面);pytest tests/ -k "doctor or desktop" 81 passed / 1 skipped;python -m unittest discover -s tests/desktop 12 passed;dashboard tsc --noEmit 干净;两个 desktop workflow smoke 通过;scripts/loopx canary premerge --from-git-diff 19 项 0 失败;npm run build:chat 后 loopx/web/chat 无差异(打包资源齐平检查,前一轮 CI 的 “packaged Dashboard assets differ from a clean source build” 即由此修复)。两处本机环境差异已用未改动的 base 工作区对照证明与本次改动无关(根 node_modules 缺失导致语义 drift smoke 无 TS parser;Node 25 的 vm sandbox 缺 performance 全局),补齐后分别通过。尚未覆盖:真实签名 App 包在物理机上跑完整决策——本机安装已被先行对齐到同一修订,这一步计划在合并并安装新 App 构建后补收据;Windows 仍由既有的 platform_update_not_supported 短路。

我的整体评价

改动量与问题成正比:一个三态分类器、一个共享安装 helper、一个启动分支和一个首屏决策块,删除的是「不同修订即替换」这条默认规则,没有新能力、新 schema、新持久状态或新 CLI 选项。证据覆盖了配对/缺失/不同/已批准/覆盖五条启动分支与两个操作者动作,且负面走查盯的是这套设计最可能出错的两处(静默安装回归、App 更新被卡死)。结论:APPROVE。残余风险是真机端到端收据尚未产出,以及「不回答就停住」这一有意选择的接受度需要发布说明讲清楚。

English verdict: APPROVE for 826def0f93cfeaf95998befc5b262eda635a7095 — 15 files, +768/-94. The macOS App no longer installs its bundled runtime over a different installed CLI runtime at startup: a typed RuntimeStep classifier keeps fresh-machine bootstrap automatic (InstallBundled when nothing is installed, including an operator-approved update journal) and turns a genuine revision difference into an operator decision (runtime_pairing_required with both revisions, services held back, no install), with the first screen offering "update App and runtime" (check then verified install) or "use this App's runtime" (align_runtime, sharing the existing bounded reinstall with repair). No new capability, schema, CLI option or persisted state; LOOPX_BIN overrides are still never replaced. Authorization naming, typed state (exact 40-hex comparison, no substring heuristics), default-change disclosure (README, skill, PR body, commit messages, CLI advice text and its test) and domain neutrality were checked on the full diff. Validation at the rebased exact head: cargo test 68 passed, clippy clean, production boot surface driven in a real browser through the decision (no install before a choice, both choices, upgrade chain, channel with nothing newer, align then reconnect), pytest 81 passed/1 skipped, tests/desktop 12 passed, dashboard tsc clean, canary premerge 19 checks 0 failures, and a clean npm run build:chat leaving the packaged chat workspace unchanged. Residual risk: the decision has not yet been observed in a real signed App bundle on a physical machine (planned receipt after merge and a new App build), and a pending decision intentionally leaves services stopped.

@huangruiteng
huangruiteng merged commit 2e1bcb8 into main Sep 16, 2026
29 of 34 checks passed
@huangruiteng
huangruiteng deleted the codex/desktop-runtime-pairing-choice branch September 16, 2026 09:25
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.

1 participant