Re-resolve symlinked tool paths on every ToolRegistry load (REPL-29758)
What
Resolve each custom tool file's real path with a fresh fs.realpath immediately before import()ing it in ToolRegistry, instead of importing the symlink path and letting the module loader's cached symlink resolution decide where it leads.
Why
Root cause of the 2026-07-24 production incident (REPL-29758). The Replo harness ships the sandbox tools dir as a versioned directory behind a symlink (tools → tools-<sha>); an auto-upgrade writes the new dir, flips the symlink, deletes the old target, then SIGHUP-reloads opencode in place.
The registry's Glob.scanSync follows the symlink fresh and sees the new file list — but import(pathToFileURL(match)) resolved through Bun's cached symlink resolution, which still pointed at the deleted old dir. Any tool file that was new in the flip (e.g. compile-email.js) then failed with Cannot find module .../tools-<oldsha>/compile-email.js, and because tool resolution is eager and all-or-nothing, every turn on the sandbox died until the process was restarted.
The fix also closes a quieter latent bug: previously, even a successful in-place reload kept serving old tool code from the module cache until the process happened to respawn. Because the versioned dir name is content-addressed, the freshly-resolved path is a natural cache key — a changed dir is a guaranteed cache miss (new code loads), an unchanged dir is a cache hit (no wasted work).
Testing
New regression test reproduces the incident in miniature: versioned dir + symlink, load registry, write v2 dir (changed tool + brand-new tool), flip symlink, delete v1, invalidate(), load again.
- Without the fix it fails with the exact production error shape:
FileNotFound while resolving '.../.opencode/tools/added.ts'. - With the fix:
bun test test/tool/registry.test.ts→ 14 pass / 0 fail.
Rollout
This complements andytown PR replohq/andytown#23769 (restart opencode on module-code converges), which protects the first upgrade on stale boxes — a window this fix structurally cannot cover, since the process receiving that upgrade predates the fixed binary. Once this fix is released, pinned in the upgrader's curlInstall row, and the fleet's on-disk binary floor includes it, the tools/plugins entries can optionally move back from restart-opencode to the in-place reload-opencode.
🤖 Generated with Claude Code
Summary by cubic
Always resolve each tool file’s real path before importing in ToolRegistry, using FSUtil.realPath on every load to handle symlinked versioned dirs. Fixes REPL-29758 where harness upgrades caused “Cannot find module …/tools-/…”, and ensures new tool code loads without a restart.
- Bug Fixes
- Resolve via
FSUtil.Service.realPath(match)right beforeimport(), fail fast (no fallback) to avoid stale cached symlink resolution. - Real path becomes the cache key, so
invalidate()loads updated code while unchanged dirs stay cached. - Added a regression test that flips the
toolssymlink and deletes the old target (harsher than prod) to verify new/changed tools load correctly.
- Resolve via