Skip to content

fix: Node 26.10 skill installs, Homebrew Node upgrades, stripped config markers, and Windows uninstall - #926

Closed
lunamoth7533 wants to merge 12 commits into
duolahypercho:mainfrom
lunamoth7533:claude/happy-turing-917lz3
Closed

lunamoth7533 wants to merge 12 commits into
duolahypercho:mainfrom
lunamoth7533:claude/happy-turing-917lz3

Conversation

@lunamoth7533

@lunamoth7533 lunamoth7533 commented Sep 26, 2026 •

Copy link
Copy Markdown

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.

  1. Skill install fails on Node 26.10 (fix(skills)).
    • Node 26.10 shipped fs: throw on existing dir in cpSync with errorOnExist nodejs/node#64124: fs.cpSync with errorOnExist now refuses an existing destination directory rather than merging into it.
    • publishStagedSkill claims its target with a no-replace mkdir, then copied the staged tree onto that directory. Every skill install and refresh failed with ERR_FS_CP_EEXIST, naming the directory it had just created; 31 of 43 skills-install tests fail on 26.10.
    • The tree is now copied entry by entry, so every copy lands on a new path.
    • The regression test emulates the 26.10 rule in-process, so it also fails on CI's Node 24.
  2. A skill failure fails the POSIX install and update (fix(install)).
    • bin/install ran the skill refresh bare under set -eu, so update treated a skill error as a failed update and rolled back.
    • The rollback re-ran the same step, so users got "Update failed and automatic rollback also failed".
    • The hunks are byte-identical to fix: keep POSIX router updates after skill refresh failure #891; this PR adds its changelog fragment.
  3. Windows uninstall leaves the managed skills behind (fix(windows)).
    • codex-router.ps1 uninstall now removes them, best effort, like bin/uninstall.
    • disable keeps them, like bin/disable.
  4. Homebrew Node upgrades break login-free routing and strand repair (fix(node), hardened in the review commit).
    • process.execPath is the versioned keg that brew upgrade node deletes. The config manager wrote it into Codex's caller auth command. The launchers fell back to it, and a service installed without CODEX_ROUTER_NODE_BIN recorded it too.
    • Login-free ownership matched the rendered command literally, so after an upgrade enable, update, and doctor --fix all refused with "lost ownership".
    • New src/stable-node.mjs maps a keg to its formula's opt link, the path the Homebrew formula itself pins.
    • The auth command keeps a working, non-keg Node the config already names. Otherwise it uses the router's own Node binary, never a launcher or a version-manager shim.
    • The command's path is no longer ownership evidence, since its arguments still are. This also recovers a command an older Control Center recorded as its Electron app.
    • doctor fails on a dead or non-Node auth command and warns on a keg path; --fix rewrites it.
    • The service restart and client-disconnect spawns flagged in the owner's review of fix: keep router refreshes on stable node #907 use childNodeBinary(). It keeps the process's own Node and differs only when that keg is gone.
  5. A comment-stripped config.toml strands the router (fix(config), hardened in the review commit).
    • A writer that re-serializes the file keeps the tables but drops the comments, and the router's markers are comments.
    • update/doctor --fix then 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.
    • The router re-adopts its provider table, realtime endpoints, and multi-agent feature, but only on the evidence the markers carried: its own port and private catalog, or the protected v3 state.
    • It also requires every value to decode to exactly what it writes, including the padded and multi-line arrays that Python's toml and tomli_w write back.
    • A comment inside or between its tables still disqualifies them. A comment above the next table is never removed.
    • disable retires everything it took back.
    • Use Router with ChatGPT was stuck in both directions, which the review commit had not covered (follow-up 3cf3a546).
      • From the built-in OpenAI provider, the switch owns [model_providers.codex-router-signed], and the router recognized that table by its markers alone.
      • Once they were stripped, off refused with "Signed routing lost ownership of model_providers.codex-router-signed", on said to turn it off first, and update/doctor --fix refused as well.
      • The table is now re-marked at read time on the same kind of evidence: the v1 signed state, model_provider = "codex-router-signed", the router's own root base URL, and an exact rendering.

Review fixes (c92aabd9):

  • Blocking, Windows. The catalog path is stored escaped, so recovery kept the unmarked openai_base_url and 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.
  • Pre-existing: a restored secret could change. A saved provider section containing $'/$&/$$ (for example a bearer token) was restored altered by String#replace. Replacer functions now return it verbatim.

Relation to open PRs.

Known limitations, deliberately out of scope.

  • Rolling back to a pre-fix release after a keg path was rewritten leaves that release unable to match a v3 login-free block. It compares its own keg path literally, as it already does after any Node upgrade.
  • The Windows service still records process.execPath; Windows has no kegs.
  • CI pins Node 24, so the 26.10 rule is exercised by emulation. A Node 26 job could be added separately.

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.md now say uninstall removes the skills on every platform.

Verification

  • npm run check reports: syntax checks passed, changelog fragments valid (22), v2-agent applications valid.

  • git diff --check origin/main...HEAD is clean.

  • Two independent read-only reviews, run under the repo-maintainer skill's "critical" tier:

    • ownership-boundary correctness;
    • integration/platform parity.

    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 test on 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-only chmod tests (model-router reports failed skill commands, an unreadable approved tree becomes stale instead of throwing). They fail identically on untouched origin/main when 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_EEXIST through 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 before 3cf3a546.
    • router-restart: reverting to process.execPath fails.
    • 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.mjs hard-links the Node binary, because Node resolves a symlinked execPath.

  • Real Node 26.10.0: skills-install goes from 32/43 failing to all 44 passing as a normal user. The single failure left as root is a chmod test 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 toml 0.10.2, and tomli_w 1.2.0, with and without a Windows-style escaped state path. Every run ends as valid TOML (checked with tomllib) with exactly one openai_base_url and correct status. After disable there is zero router residue, and v3's Bearer ab$'cd header is restored exactly.

  • After 3cf3a546, every test file that touches signed routing passes: 459 tests, 456 pass, 3 skipped, across 12 files including config-manager, control, refresh-catalog, doctor-routing-mode, and routing.

  • codex-router.ps1: every Windows script parses under real PowerShell 7.6.6. The dispatcher was driven against a stand-in node, 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.md
  • changelog.d/posix-install-skill-refresh-best-effort.md
  • changelog.d/windows-uninstall-skill-removal.md
  • changelog.d/stable-node-path.md
  • changelog.d/stripped-config-markers.md

🤖 Generated with Claude Code

https://claude.ai/code/session_018S4qLJNvsusgNp7Yi7MEAo

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

Copy link
Copy Markdown
Author

Thanks for approving the workflows. The run on 3cf3a546 had four red jobs, and the new head c84d1c75 addresses all of them:

  • test (macos-latest): this PR's own bug, fixed in 7209d107. The new keg fixture built its paths under /var/..., but Node reports process.execPath with symlinks resolved, and on macOS that is /private/var/.... So config-manager.test.mjs:837 and router-restart.test.mjs:49 compared two spellings of the same file. The fixture now resolves its root first. I reproduced the failure on Linux with a symlinked TMPDIR; after the fix, 85 of 85 pass with both the symlinked and the plain temp directory.
  • Electron control center (Linux), Electron control center (Windows), Unified native macOS app: not caused by this PR. All three fail the same test, apps/control-center/test/renderer.test.mjs › "fallback-only splits…". It is a date time-bomb: its fixture buckets sit on 2026-08-27/28, and the 30-day Usage window left them behind on 2026-09-27. Every branch fails it from today, main included, and this PR does not touch apps/control-center. The standalone fix is test(control-center): pin the fallback usage test's clock inside its fixture window #927, and the same change is carried here as c84d1c75, so this PR does not have to wait for test(control-center): pin the fallback usage test's clock inside its fixture window #927. With it, the renderer suite passes 16 of 16 locally.

The new head will need workflow approval again.


Generated by Claude Code

…ide its fixture window"

This reverts commit c84d1c7. main now fixes the same time-bomb in #929 by building the fixture's dates from today, and a pinned page clock would push those dates out of its own window again.

Copy link
Copy Markdown
Author

Update: main now carries #929, which fixes the renderer time-bomb by building the fixture's dates from today. The clock pin ported in c84d1c75 would have conflicted with that fix, pushing the fixture's dates out of its own window again, so dd071746 reverts it. #927 is closed as superseded.

The PR no longer touches apps/control-center. This PR merged with current main (8dbd65fb) passes the full npm test locally: 4,724 tests, 4,685 pass, 37 skipped. The only 2 failures are root-only chmod tests, which fail the same way on untouched main when run as root. The new head needs workflow approval again.


Generated by Claude Code

…into claude/happy-turing-917lz3

# Conflicts:
#	src/router-restart.mjs

Copy link
Copy Markdown
Author

Merged current main (cc8adfcc) in a067fd5a. #931 changed the same restart spawn in src/router-restart.mjs that this PR changed. I resolved the conflict in #931's favour:

  • The restart keeps the configured runtime from routerNodeBinary(env). This PR only wraps it: homebrewStableNodePath(routerNodeBinary(env)).
  • An explicit CODEX_ROUTER_NODE_BIN passes through unchanged.
  • With nothing configured, a Homebrew keg execPath is named by its formula's opt link instead of the Cellar path an upgrade deletes. Non-Homebrew Node is unchanged.
  • Test changes:

The full npm test on this exact tree: 4,727 tests, 4,688 pass, 37 skipped. The only 2 failures are the root-only chmod tests, which fail the same way on main as root.


Generated by Claude Code

@lunamoth7533 lunamoth7533 closed this by deleting the head repository Sep 30, 2026
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