fix(sandbox): canonicalize paths when detecting mount overlap - #83
Conversation
Mount planning mixed two path realms. Git paths come from `git rev-parse --git-dir`/`--git-common-dir`, which are symlink-resolved, while the worktree and `sourceProjectDir` go through `resolve()`, which never resolves symlinks. Overlap detection is string prefix matching, so on macOS (where /var and /tmp are symlinks to /private/...) it could not see that /var/X is an ancestor of /private/var/X/.git. The read-only source project mount was therefore not dropped, and the same directory was mounted twice with conflicting permissions. Canonicalize both operands inside `containerPathsOverlap`, the single choke point behind every overlap check, and apply the same comparison to the two containment guards in `detectGitMount`. Emitted hostDir and containerDir values are unchanged: sbx mounts at the identical host path and opencode reports unresolved host paths, while the git mounts must keep git's resolved paths because the linked worktree records them.
📝 WalkthroughWalkthroughSandbox path comparisons now canonicalize existing paths before normalization. Git mount filtering uses canonicalized descendant checks instead of raw string-prefix checks. Unresolvable paths retain their original values. ChangesSandbox path handling
Estimated code review effort: 2 (Simple) | ~10 minutes 🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Comment |
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@src/sandbox/manager.ts`:
- Around line 48-49: Update the workspace-boundary guards in
resolveToolOutputMount and resolveTempMount to canonicalize the configured paths
and use isSameOrDescendantPath instead of raw startsWith checks, ensuring
symlinked paths resolving outside workspaceDir are rejected before mounting.
Preserve the original configured paths in emitted mounts.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro Plus
Run ID: ec23a590-d549-40c3-8449-b57b91817bd1
📒 Files selected for processing (2)
src/sandbox/manager.tssrc/sandbox/path.ts
| const left = normalizeContainerPath(canonicalizePath(a)) | ||
| const right = normalizeContainerPath(canonicalizePath(b)) |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win
Use canonical paths for all workspace-boundary checks.
resolveToolOutputMount at Line [249] and resolveTempMount at Line [263] still use raw startsWith checks. If a configured path is a symlink under workspaceDir but resolves outside it, these guards skip the mount before the canonical overlap check runs. Reuse canonicalizePath and isSameOrDescendantPath in both guards. Keep the original path in emitted mounts.
Proposed fix
- if (resolved === workspaceDir || resolved.startsWith(workspaceDir + '/')) return undefined+ if (isSameOrDescendantPath(canonicalizePath(resolved), canonicalizePath(workspaceDir))) {+ return undefined+ }Apply the same change in both workspace-boundary guards.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@src/sandbox/manager.ts` around lines 48 - 49, Update the workspace-boundary
guards in resolveToolOutputMount and resolveTempMount to canonicalize the
configured paths and use isSameOrDescendantPath instead of raw startsWith
checks, ensuring symlinked paths resolving outside workspaceDir are rejected
before mounting. Preserve the original configured paths in emitted mounts.
Fixes the one test failing on
main:test/sandbox-manager.test.ts > SandboxManager > start > "git mounts inside sourceProjectDir survive read-write while the overlapping project workspace is dropped"Root cause
Mount planning mixed two path realms that were never normalized against each other:
git rev-parse --git-common-dir(detectGitMount)/private/var/.../main-project/.gitresolve(sourceProjectDir)from config/var/.../main-projectresolve()never touches symlinksOverlap detection is pure string prefix matching (
isSameOrDescendantPath), so/var/Xis not seen as an ancestor of/private/var/X/.git. On macOS/varand/tmpare symlinks to/private/..., which is what the test hits.Consequence: the read-only source project mount was not dropped, so the same directory was mounted twice — once read-only, once read-write — instead of the project mount yielding to the writable git mounts.
Latent in production rather than universal: it needs a project or worktree path that traverses a symlink. Forge's default worktree root is not symlinked, but a symlinked home, project path, or external volume triggers it.
Fix
Canonicalize comparisons only; emitted mount paths are unchanged.
canonicalizePathinsrc/sandbox/path.ts—realpathSyncwith fallback to the input on throw, sincesourceProjectDir, custom mounts, and tool-output/temp dirs are not guaranteed to exist. Never throws.containerPathsOverlapcanonicalizes both operands. It is the single choke point behind every overlap check (findContainerPathCollisionfor custom mounts, the workspace-dedupe loop, and thebuildMountPlancandidate loop), so one change covers all call sites.detectGitMountuse the same comparison instead of rawstartsWith.Why emitted paths are deliberately left alone
sbx mounts every workspace at its identical host path, and opencode reports absolute host paths in the unresolved realm — realpathing the worktree mount would desync the sandbox path from the path opencode uses and break file/search tools. Conversely the git mounts must keep git's resolved paths, because the linked worktree's
.gitfile records that exact path and git inside the sandbox has to find it there.Validation
pnpm typecheck— passpnpm lint— passpnpm test— 2902/2902 pass (was 2901/2902 onmain)Summary by CodeRabbit