fix(infra): scope Windows path realpath caches#108073
Conversation
Move realpath cache boundary from process-wide to caller-owned scopes to prevent stale symlink/ancestor answers in security-sensitive containment checks while maintaining performance. Changes: - Add hostPathCache to fs-paths.ts mount resolution - Accept cache parameter in resolvePathViaExistingAncestorSync - Create per-operation caches in plugin hooks and skills resolution - Use scoped caches in sandbox host-path validation - Fix boundary-path.ts export conflict (remove duplicate export) - Add regression tests for stale symlink/junction detection Performance: 55% reduction in lstat time on Windows (21,376ms vs 47,834ms baseline) Fixes openclaw#85262
|
Codex review: needs maintainer review before merge. Reviewed July 15, 2026, 10:45 PM ET / July 16, 2026, 02:45 UTC. Summary PR surface: Source +77, Tests +127. Total +204 across 10 files. Reproducibility: yes. at the source-and-profile level: repeated uncached canonicalization is clear from the call paths and the contributor supplied baseline and after-fix Windows profiles, but this review did not independently reproduce the current-main slowdown. Review metrics: none identified. Stored data model Root-cause cluster Members:
Proposal only: this assessment does not dispatch repair, suppress jobs, mutate sibling items, close, or merge anything. Merge readiness Overall follows the weaker of proof and patch quality, so missing proof can cap an otherwise strong patch. Rank-up moves:
Risk before merge
Maintainer options:
Next step before merge
Maintainer decision needed
Security Review detailsBest possible solution: Land only an operation-owned cache design that is created and discarded within one synchronous resolution pass, preserves live filesystem reads between operations, and keeps stale-alias regression coverage around every security-sensitive caller. Do we have a high-confidence way to reproduce the issue? Yes at the source-and-profile level: repeated uncached canonicalization is clear from the call paths and the contributor supplied baseline and after-fix Windows profiles, but this review did not independently reproduce the current-main slowdown. Is this the best way to solve the issue? Yes, subject to rebase review: caller-owned per-operation caches are narrower and safer than the rejected process-wide approach while retaining measurable Windows performance benefit. AGENTS.md: found and applied where relevant. Codex review notes: model internal, reasoning high; reviewed against 04221fc91ef4. Label changesLabel changes:
Label justifications:
Evidence reviewedPR surface: Source +77, Tests +127. Total +204 across 10 files. View PR surface stats
What I checked:
Likely related people:
What the crustacean ranks mean
Shiny media proof means a screenshot, video, or linked artifact directly shows the changed behavior. Runtime, network, CSP, and security claims still need visible diagnostics. How this review workflow works
|
Summary
Windows
lstat/realpath calls are expensive enough that repeated path-boundary checks can dominate startup and agent-path flows. The original version tried to fix that with process-wide realpath caches, but those caches sit on plugin, skill, hook, and sandbox containment checks. That lifetime is too broad: stale symlink or ancestor answers can survive after the filesystem changes.This revision keeps the performance direction while moving the cache boundary to caller-owned operation scopes:
src/infra/path-guards.tsremains the direct@openclaw/fs-safe/pathexport surface; no process-wide realpath cache is installed there.src/infra/boundary-path.tsaccepts an optional caller-owned cache forresolvePathViaExistingAncestorSync.Closes #85262
Real behavior proof
Behavior addressed: reduces repeated Windows path canonicalization in plugin hook/skill and sandbox host-path operations without adding stale process-wide path answers to security-sensitive containment checks.
Real environment tested: local linked worktree for focused tests/lint/review; AWS Crabbox native Windows for focused runtime tests; AWS Crabbox Linux for
check:changed; WSL2 runner acquisition was attempted on both AWS and Azure but did not produce a usable WSL2 test lane.Exact steps or command run after this patch:
Evidence after fix:
b1d8191b27: 5 shards passed, 114 tests.b1d8191b27: passed.b1d8191b27: clean, no accepted/actionable findings.provider=aws,lease=cbx_dc20a584615f,run=run_b4c210ac8166, hydrate run https://github.com/openclaw/openclaw/actions/runs/26719083996. Focused Vitest passed 5 shards: 111 passed, 3 skipped.provider=aws,lease=cbx_ab9a2c3903d5,run=run_4137bbc8c85d,pnpm check:changedpassed.Observed result after fix: repeated path canonicalization inside a single plugin/sandbox operation can reuse one caller-owned cache, while a later operation re-reads current filesystem state and blocks stale symlink/junction escapes.
What was not tested: full end-to-end Windows startup profiling at the current rebased head
b1d8191b27(performance data was collected at prior heads; the rebase was conflict-resolution only with no logic changes). The manifest-registry-installed lstat hotspot is intentionally out of scope and tracked in #90362.Remaining performance gap
The largest remaining
lstatgap is inmanifest-registry-installed.ts, wherebuildInstalledManifestRegistryIndexKey()is called ~179 times per config validation, each time creating a new scopedMap(). This is intentionally left as a follow-up in #90362 — the current PR focuses on the plugin hook/skill and sandbox containment paths. The manifest-registry optimization can be addressed in a separate PR without changing the security-boundary cache lifetime design landed here.Runner notes
node_modules;pnpm install --frozen-lockfilehit WindowsEMFILEtwice near finalization. Vitest was nevertheless present and resolvable, and the focused native Windows test command passed.b1d8191b27;origin/maincontinued moving during validation, so the final broad gate is recorded against the rebased head before that later churn.Windows Performance Proof
Environment
Method
CPU profiling via Node.js inspector API, 120s coverage: startup → ready → idle (no webchat connection).
Scoped Cache vs Global Cache vs No Cache
lstattotalgateway-1779357547869.cpuprofilegateway-1779440772524.cpuprofilegateway-1780375536440.cpuprofileWhy scoped cache is slower than global cache (+13s)
The gap is not from "other callers not passing a cache" — it is from the largest single caller
manifest-registry-installed.ts:The scoped-cache approach still delivers a 55% reduction in lstat time and 3s faster startup compared to no cache, while avoiding stale process-wide cache answers in security-sensitive containment checks.
Cache Behavior (instrumented dist, global-cache version, call counting)
safeRealpathSynctotal callsisPathInsideWithRealpathcallsPer-phase breakdown:
Cache entries = 3 per plugin (root dir + package.json + entry file).
292 entries × ~200 bytes = ~58KB memory. Bounded by plugin count.
Cache growth test (after installing @openclaw/feishu)
+1 plugin = +3 cache entries (+~600 bytes). Cache is bounded.
Reproducing
Rebase note (2026-07-06): Rebased onto latest
origin/main(e5a605424e) — conflict-resolution only, no logic changes. New head:b1d8191b27.Note: This is a re-submission of #85264 which was auto-closed by the stale bot due to inactivity. The code changes are identical — only the Android camera logging file has been excluded from this PR.