Repository navigation
fix: Node 26.10 skill installs, Homebrew Node upgrades, stripped config markers, and Windows uninstall - #926
lunamoth7533 wants to merge 12 commits into
Conversation
Node 26.10 made fs.cpSync refuse an existing destination directory under errorOnExist instead of merging into it (nodejs/node#64124). Publication claims its target with a no-replace mkdir and then copied the staged tree onto that directory, so every skill install and refresh failed with ERR_FS_CP_EEXIST naming the directory it had just created: 31 of the 43 skills-install tests fail on 26.10. Copy the staged tree into the claimed directory one top-level entry at a time, so every cpSync destination is new on every Node version while a pre-existing entry is still refused rather than overwritten. The new test emulates the 26.10 rule in-process, so CI's older Node catches a publication that hands its claimed directory to cpSync. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018S4qLJNvsusgNp7Yi7MEAo
bin/install ran the managed skill refresh bare under set -eu, so any skill
error exited the installer non-zero after the router was installed. update
read that as a failed update and rolled back, then re-ran the same failing
step while restoring ("Update failed and automatic rollback also failed"
on Node 26.10). Warn and continue, as install.ps1 and bin/uninstall do.
The hunks match #891 byte for byte so either
change merges cleanly after the other.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018S4qLJNvsusgNp7Yi7MEAo
install.ps1 installs the skill pack into the Codex skills directory, but codex-router.ps1 uninstall only disabled the Codex configuration, so the skills outlived the router; bin/uninstall has always removed them. Only uninstall asks for the removal (disable keeps them, like bin/disable), and it is best effort so a failure still retires the shared service. The new behavioral test drives the real dispatcher under pwsh (shipped on GitHub's Linux and macOS runners) against a stand-in node. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018S4qLJNvsusgNp7Yi7MEAo
process.execPath is the running binary's resolved path, which on Homebrew is the versioned keg that `brew upgrade node` deletes. The config manager wrote it into Codex's caller auth command, nodeRuntimePath() fell back to it for the claude-router and cursor-router-agent launchers, and a service installed without CODEX_ROUTER_NODE_BIN recorded it too. After an upgrade Codex could not run its auth command on any routed turn. Worse, login-free ownership matched the rendered block literally, auth command included, so every upgrade made the router's own block look user-edited: enable, update, and doctor --fix all refused with "Login-free mode lost ownership" and left the dead command in place. Add src/stable-node.mjs: stableNodeBinary() prefers an existing explicit CODEX_ROUTER_NODE_BIN, maps a keg to its formula's opt link (the path the Homebrew formula pins), and never names an Electron host; childNodeBinary() is the non-throwing variant for spawns. Use them for the auth command, the launchers, the macOS/Linux service definitions, the service restart, and the client-disconnect spawns flagged in review of #907. Normalize an auth command that is still a Node executable before the ownership comparison, report it in config status, and add a doctor check that fails on a missing Node and warns on a keg path. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018S4qLJNvsusgNp7Yi7MEAo
The config manager recognizes its own tables by comment markers. A writer that re-serializes config.toml (a TOML library round trip, a formatter) keeps every table but drops every comment, so the router's provider table read as user-owned: update, enable, and doctor --fix all refused with "Refusing to replace user-owned model provider codex-router". Login-free mode on the user's own provider table lost its markers and slot comments too, so every command refused with "lost ownership" and the mode could not even be turned off. Re-adopt on the evidence the markers carried, and only that: strippedManagedRouterProvider() takes the codex-router table (and its auth subtable) when the root keys still name this router's own port and private merged catalog; withRestoredProviderTableMarkers() puts the markers and slots back in memory when the protected v3 state still names the table. Both require every field to be exactly what this router renders, the auth command's Node path aside, and status reports the command the file names. disable also retires a stripped provider table, so the orphan cannot refuse a later reinstall. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018S4qLJNvsusgNp7Yi7MEAo
Two read-only reviews of the Node-path and stripped-marker changes found: - Windows: the catalog path is stored escaped, so clean() never saw it once markers were gone, kept the unmarked openai_base_url, and enable appended a second one -- a config Codex refuses to load. Decide from the decoded root value, and never emit a duplicate base URL. - `$'`, `$&`, `$$` in a path or a saved provider section are replacement patterns to String#replace: a restored bearer token came back altered (pre-existing), and a reconstituted block could become invalid TOML. Use replacer functions. - Recovery deleted user comments: ranges now end before a comment that heads the next table, and a comment inside or between the router's own tables disqualifies them, as it does inside a marked block. - Real TOML libraries write `[ "a", "b",]` or one element per line; values are now compared decoded, so their round trips are recovered too. - The auth command no longer takes an explicit CODEX_ROUTER_NODE_BIN, which could be a launcher or a directory-dependent shim: it keeps a working non-keg Node already named, else the router's own Node (keg to opt). Its absolute path is not ownership evidence, which also recovers a command an older Control Center recorded as its Electron app; doctor fails on a command that is not Node. - The router's realtime endpoints and multi-agent feature are taken back on the same evidence, so disable after recovery leaves nothing behind. - childNodeBinary() keeps the process's own Node first (unchanged except when its keg is gone); router-restart honors its env; only an executable regular file counts as a Node binary. New tests run config-manager and router-restart from a real keg via a hard-linked fixture, because Node resolves a symlinked execPath. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018S4qLJNvsusgNp7Yi7MEAo
README, the docs-site skills guide, and docs/INSTALL.md credited only bin/uninstall with removing the managed Codex skills. codex-router.ps1 uninstall now does the same, and disable keeps them on both. Also correct a test comment that said nothing off Windows can run PowerShell. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018S4qLJNvsusgNp7Yi7MEAo
…d markers From the built-in OpenAI provider, Use Router with ChatGPT switches Codex to the router's own [model_providers.codex-router-signed] table, which the config manager recognized by its comment markers alone. Once a writer that re-serializes config.toml dropped them, the switch could be moved in neither direction: off refused with "Signed routing lost ownership of model_providers.codex-router-signed", on said to turn it off first, and every update and doctor --fix refused as well. Put the markers back at read time when the protected v1 signed state records the switch, Codex still selects codex-router-signed, the root base URL is the router's own, and the unmarked table decodes to exactly one of the router's renderings. A changed field, a note inside the table, or a subtable still leaves it the user's, and a refusal writes nothing. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018S4qLJNvsusgNp7Yi7MEAo
macOS temp directories live under /var, a link to /private/var. Node reports process.execPath with links resolved, so a keg started from the fixture mapped to an opt link under /private/var while the tests expected the same path under /var. The config-manager and router-restart keg tests failed on the macOS runner only. Resolve the root before building the prefix; a symlinked TMPDIR reproduces the failure on Linux. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018S4qLJNvsusgNp7Yi7MEAo
…fixture window "fallback-only splits do not claim account breakdown or a complete range mix" has failed on every branch since 2026-09-27. Its fixture puts the account bucket on 2026-08-27 and the router fallback on 2026-08-28, and Usage walks its 30-day window back from new Date(). Once today passed 2026-09-26 the fallback date fell out of the window, no fallback bar was drawn, and the wait for `.us-chart-token-bars rect.router-fallback` timed out on the Linux, Windows, and unified macOS Control Center jobs. Pin the page clock with page.clock.setFixedTime inside the fixture's month. Date is fixed and timers keep running, so polling still works. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018S4qLJNvsusgNp7Yi7MEAo
|
Thanks for approving the workflows. The run on
The new head will need workflow approval again. Generated by Claude Code |
|
Update: The PR no longer touches Generated by Claude Code |
…into claude/happy-turing-917lz3 # Conflicts: # src/router-restart.mjs
|
Merged current
The full Generated by Claude Code |
Summary
Five fixes for problems found by Claude sessions on one Mac installation. Those sessions never pushed anything, so each fix was re-derived from their findings and reproduced against current
main. Two independent review passes followed, and a hardening commit and a docs commit come after the fixes. Every user-visible change has its own changelog fragment.fix(skills)).fs.cpSyncwitherrorOnExistnow refuses an existing destination directory rather than merging into it.publishStagedSkillclaims its target with a no-replacemkdir, then copied the staged tree onto that directory. Every skill install and refresh failed withERR_FS_CP_EEXIST, naming the directory it had just created; 31 of 43skills-installtests fail on 26.10.fix(install)).bin/installran the skill refresh bare underset -eu, soupdatetreated a skill error as a failed update and rolled back.fix(windows)).codex-router.ps1 uninstallnow removes them, best effort, likebin/uninstall.disablekeeps them, likebin/disable.fix(node), hardened in the review commit).process.execPathis the versioned keg thatbrew upgrade nodedeletes. The config manager wrote it into Codex's caller auth command. The launchers fell back to it, and a service installed withoutCODEX_ROUTER_NODE_BINrecorded it too.enable,update, anddoctor --fixall refused with "lost ownership".src/stable-node.mjsmaps a keg to its formula'soptlink, the path the Homebrew formula itself pins.doctorfails on a dead or non-Node auth command and warns on a keg path;--fixrewrites it.childNodeBinary(). It keeps the process's own Node and differs only when that keg is gone.config.tomlstrands the router (fix(config), hardened in the review commit).update/doctor --fixthen refused with "Refusing to replace user-owned model provider codex-router". Login-free mode on the user's own table could not even be turned off.tomlandtomli_wwrite back.disableretires everything it took back.3cf3a546).[model_providers.codex-router-signed], and the router recognized that table by its markers alone.update/doctor --fixrefused as well.model_provider = "codex-router-signed", the router's own root base URL, and an exact rendering.Review fixes (
c92aabd9):openai_base_urland appended a second one, which Codex refuses to load. Ownership is now decided from the decoded value, and a duplicate base URL is never written.$'/$&/$$(for example a bearer token) was restored altered byString#replace. Replacer functions now return it verbatim.Relation to open PRs.
bin/installhunks, which merge cleanly in either order.git merge-treeshows no textual conflict with this PR, but both add a Node-path helper.routerNodeBinary()could delegate tochildNodeBinary({ environment })so there is one policy. fix: keep router refreshes on stable node #907's remainingprocess.execPathspawns (publish, control, refresh-catalog, subagent-verify) stay in its scope.Known limitations, deliberately out of scope.
process.execPath; Windows has no kegs.AGENTS.md gains a "recorded Node path" section and a safety-boundary bullet on stripped markers. The README, the docs-site skills guide, and
docs/INSTALL.mdnow say uninstall removes the skills on every platform.Verification
npm run checkreports: syntax checks passed, changelog fragments valid (22), v2-agent applications valid.git diff --check origin/main...HEADis clean.Two independent read-only reviews, run under the repo-maintainer skill's "critical" tier:
They found no blocking issue in the ownership logic and one blocking Windows defect. That defect and every should-fix finding are addressed in
c92aabd9, each with a regression test.Full
npm teston Linux (Node 22.22.2, with PowerShell 7.6.6 on PATH): 4,701 tests: 4,664 pass, 35 skipped, 2 fail. Both failures are root-onlychmodtests (model-router reports failed skill commands,an unreadable approved tree becomes stale instead of throwing). They fail identically on untouchedorigin/mainwhen run as root and pass on this branch as an unprivileged user; CI does not run as root.Each new regression test fails on the code before its fix:
skills-install:ERR_FS_CP_EEXISTthrough the emulation.installer-scripts: the POSIX skill step.windows-operations: two cases.config-manager: the keg case plus three stripped-marker cases, all four failing on the pre-review code. A fourth stripped-marker case, the ChatGPT switch table, fails before3cf3a546.router-restart: reverting toprocess.execPathfails.doctor-routing-mode: the auth-command case.The keg tests run config-manager and router-restart from a real keg:
test/fixtures/homebrew-keg-node.mjshard-links the Node binary, because Node resolves a symlinkedexecPath.Real Node 26.10.0:
skills-installgoes from 32/43 failing to all 44 passing as a normal user. The single failure left as root is achmodtest that fails on every Node version.Stripped-config matrix, 18 of 18 pass. It covers plain, login-free v1, and login-free v3, each round-tripped by a comment filter, Python
toml0.10.2, andtomli_w1.2.0, with and without a Windows-style escaped state path. Every run ends as valid TOML (checked withtomllib) with exactly oneopenai_base_urland correct status. After disable there is zero router residue, and v3'sBearer ab$'cdheader is restored exactly.After
3cf3a546, every test file that touches signed routing passes: 459 tests, 456 pass, 3 skipped, across 12 files includingconfig-manager,control,refresh-catalog,doctor-routing-mode, androuting.codex-router.ps1: every Windows script parses under real PowerShell 7.6.6. The dispatcher was driven against a stand-innode, and that test runs on CI's Linux and macOS runners.Not run: live provider requests, and the macOS tray/Swift suites (no macOS host here). These changes touch neither.
Changelog
changelog.d/skills-install-node-26-10.mdchangelog.d/posix-install-skill-refresh-best-effort.mdchangelog.d/windows-uninstall-skill-removal.mdchangelog.d/stable-node-path.mdchangelog.d/stripped-config-markers.md🤖 Generated with Claude Code
https://claude.ai/code/session_018S4qLJNvsusgNp7Yi7MEAo