* fix(sandbox): uphold /mnt/user-data contract at Sandbox API boundary (#2873)
LocalSandboxProvider used a process-wide singleton with no /mnt/user-data
mapping, forcing every caller to translate virtual paths via tools.py
before invoking the public Sandbox API. AIO already exposes /mnt/user-data
natively (per-thread bind mounts), so the same code path behaved
differently across implementations — and direct callers like
uploads.py:282 / feishu.py:389 only worked thanks to the
`uses_thread_data_mounts` workaround flag.
Switch the provider to a dual-track cache: keep the `"local"` singleton
for legacy acquire(None) callers (backward-compat for existing tests and
scripts), and create a per-thread LocalSandbox with id `"local:{tid}"`
for acquire(thread_id). Each per-thread instance carries PathMapping
entries for /mnt/user-data, its three subdirs, and /mnt/acp-workspace,
mirroring how AioSandboxProvider mounts those paths into its container.
is_local_sandbox() now recognises both id formats. `_agent_written_paths`
becomes per-thread (it was a process-wide set that leaked across
threads — a latent isolation bug also fixed by this change).
Verified via TDD: a new contract test suite hits the public Sandbox API
directly (write/read/list/exec/glob/grep/update + per-thread isolation +
lifecycle). 3212 backend tests still pass, ruff is clean.
* fix(sandbox): address Copilot review on #2881
Three follow-ups from Copilot's review of the LocalSandboxProvider refactor:
1. Synchronisation: ``acquire`` / ``get`` / ``reset`` mutated the cache without
any lock, so concurrent acquire of the same ``thread_id`` could create two
``LocalSandbox`` instances and lose one's ``_agent_written_paths`` state.
Add a provider-wide ``threading.Lock`` (matching ``AioSandboxProvider``) and
build per-thread mappings outside the lock to avoid holding it during the
``ensure_thread_dirs`` filesystem touch.
2. Memory bound: ``_thread_sandboxes`` grew monotonically. Replace the plain
dict with an ``OrderedDict`` LRU capped at
``DEFAULT_MAX_CACHED_THREAD_SANDBOXES`` (256, configurable per provider
instance). ``get`` promotes touched threads to the MRU end so an active
thread isn't evicted under load. Eviction is graceful: the next ``acquire``
rebuilds a fresh sandbox; only ``_agent_written_paths`` (reverse-resolve
hint) is lost.
3. Docs: update ``CLAUDE.md`` to reflect the new per-thread architecture, the
LRU cap, and that ``is_local_sandbox`` recognises both id formats.
New regression tests:
- Concurrent ``acquire("alpha")`` from 8 threads yields a single instance
(slow-init injection forces the race window wide open).
- Concurrent ``acquire`` of distinct thread_ids yields distinct instances.
- The cache evicts the least-recently-used thread once the cap is exceeded.
- ``get`` promotes recency so a polled thread survives a later acquire-storm.