fix(containers): compose profile and Git mounts safely

Stage profile sources per instance and compose overlapping bind mounts so Docker cannot mask Git content or leave writable files root-owned.\n\n- preserve shared Git clones while applying profile overlays\n- add mount composition and ownership regression coverage\n- update OpenSpec tracking
This commit is contained in:
2026-07-21 20:49:03 +02:00
parent fc52353b2e
commit 16984b7cf6
4 changed files with 340 additions and 144 deletions
+180 -27
View File
@@ -127,11 +127,11 @@ def _chown_staged_mounts(
uid: int,
gid: int,
) -> None:
"""Recursively chown staged mount sources to the container user.
"""Recursively chown instance-local mount sources to the container user.
Config-profile mounts, git mounts, and SSH key mounts are staged under
instance_dir by the API process (root). Without this, the container
user cannot write into bind-mounted directories such as ~/.config.
Profile copies, profile/Git composites, and SSH key mounts are created by
the API process. Their sources must be owned by the target container user
before Docker bind-mounts them into writable paths.
"""
for vol in extra_volumes:
source = vol.get("source", "")
@@ -156,27 +156,180 @@ def _relative_under(parent: str, child: str) -> str | None:
return None
def _stage_profile_mounts(profile_mounts: list[dict], instance_dir: str) -> list[dict]:
"""Copy profile bind sources into an instance-local, writable staging area."""
staged_mounts: list[dict] = []
staging_root = os.path.join(instance_dir, "mounts", "profiles")
for mount in profile_mounts:
source = mount.get("source", "")
target = mount.get("target", "")
if not source or not target:
continue
if not os.path.exists(source):
logger.warning("Skipping missing config profile mount source: %s", source)
continue
digest = hashlib.sha256(f"{source}\0{target}".encode()).hexdigest()[:16]
staged_source = os.path.join(staging_root, digest)
try:
if os.path.lexists(staged_source):
if os.path.isdir(staged_source):
shutil.rmtree(staged_source)
else:
os.unlink(staged_source)
os.makedirs(os.path.dirname(staged_source), exist_ok=True)
if os.path.isdir(source):
shutil.copytree(source, staged_source, symlinks=True)
else:
shutil.copy2(source, staged_source, follow_symlinks=False)
except OSError as exc:
logger.error("Failed to stage config profile mount %s: %s", source, exc)
continue
staged_mount = dict(mount)
staged_mount["source"] = staged_source
staged_mounts.append(staged_mount)
return staged_mounts
def _mounts_overlap(first_target: str, second_target: str) -> bool:
"""Return whether two normalized container mount targets intersect."""
return (
_relative_under(first_target, second_target) is not None
or _relative_under(second_target, first_target) is not None
)
def _copy_mount_source(source: str, destination: str) -> None:
"""Copy a bind-mount source into its destination in a composite tree."""
try:
if os.path.isdir(source):
os.makedirs(destination, exist_ok=True)
for entry in os.listdir(source):
source_entry = os.path.join(source, entry)
destination_entry = os.path.join(destination, entry)
if os.path.isdir(source_entry):
shutil.copytree(
source_entry,
destination_entry,
dirs_exist_ok=True,
symlinks=True,
)
else:
os.makedirs(os.path.dirname(destination_entry), exist_ok=True)
shutil.copy2(source_entry, destination_entry, follow_symlinks=False)
return
os.makedirs(os.path.dirname(destination), exist_ok=True)
shutil.copy2(source, destination, follow_symlinks=False)
except OSError as exc:
raise RuntimeError(f"Unable to compose mount source {source}: {exc}") from exc
def _stack_profile_mounts_with_git_mounts(
profile_mounts: list[dict],
git_mount_volumes: list[dict],
instance_dir: str,
) -> list[dict]:
"""Leave profile and Git sources isolated.
"""Build instance-local composite mounts for overlapping profile and Git paths.
Git mount sources are shared, read-only canonical checkouts. Profile
content must never be copied into one because doing so dirties the
checkout and leaks one profile's content to every instance using it.
Docker applies one bind mount per target; it never merges their contents.
A composite therefore copies shared Git content first and profile content
second, so profile files extend (and intentionally override) the Git tree
without dirtying the shared clone.
"""
for profile_mount in profile_mounts:
profile_target = profile_mount.get("target", "")
for git_mount in git_mount_volumes:
if _relative_under(git_mount.get("target", ""), profile_target) is not None:
logger.warning(
"Config profile mount %s overlaps Git mount %s; keeping sources isolated",
profile_target,
git_mount.get("target"),
records: list[tuple[str, dict]] = [
("profile", mount) for mount in profile_mounts
] + [("git", mount) for mount in git_mount_volumes]
components: list[list[int]] = []
remaining: set[int] = set(range(len(records)))
while remaining:
component_indices: set[int] = {min(remaining)}
remaining.difference_update(component_indices)
pending = list(component_indices)
while pending:
current_index = pending.pop()
current_target = records[current_index][1].get("target", "")
for candidate_index in list(remaining):
candidate_target = records[candidate_index][1].get("target", "")
if _mounts_overlap(current_target, candidate_target):
remaining.remove(candidate_index)
component_indices.add(candidate_index)
pending.append(candidate_index)
components.append(sorted(component_indices))
result: list[dict] = []
for component_indexes in components:
component_records = [records[index] for index in component_indexes]
kinds = {kind for kind, _mount in component_records}
if kinds != {"profile", "git"}:
result.extend(mount for _kind, mount in component_records)
continue
targets = [
os.path.normpath(mount["target"]) for _kind, mount in component_records
]
composite_target = os.path.commonpath(targets)
if any(
not os.path.isdir(mount["source"])
and os.path.normpath(mount["target"]) == composite_target
for _kind, mount in component_records
):
composite_target = os.path.dirname(composite_target)
digest_input = "\0".join(
f"{kind}:{mount['source']}:{mount['target']}"
for kind, mount in component_records
)
composite_source = os.path.join(
instance_dir,
"mounts",
"composites",
hashlib.sha256(digest_input.encode()).hexdigest()[:16],
)
try:
if os.path.lexists(composite_source):
shutil.rmtree(composite_source)
os.makedirs(composite_source, exist_ok=True)
except OSError as exc:
raise RuntimeError(
f"Unable to prepare composite mount directory {composite_source}: {exc}"
) from exc
for kind in ("git", "profile"):
for record_kind, mount in component_records:
if record_kind != kind:
continue
relative_target = _relative_under(composite_target, mount["target"])
destination = (
composite_source
if relative_target == ""
else os.path.join(composite_source, relative_target or "")
)
break
return profile_mounts
_copy_mount_source(mount["source"], destination)
result.append(
{
"source": composite_source,
"target": composite_target,
"type": "bind",
"readonly": all(
mount.get("readonly", False) for _kind, mount in component_records
),
}
)
logger.info(
"Composed %d profile/Git mounts at %s into %s",
len(component_records),
composite_target,
composite_source,
)
return result
async def resolve_git_mounts(
@@ -1435,22 +1588,22 @@ async def start_tool_instance(
git_mount_volumes = await resolve_git_mounts(
session, resolved, instance_dir, working_directory, home_dir
)
# Stack static file mounts on top of git repo mounts so they do
# not mask each other when they target the same directory.
stacked_profile_mounts = _stack_profile_mounts_with_git_mounts(
profile_mounts, git_mount_volumes
staged_profile_mounts = _stage_profile_mounts(profile_mounts, instance_dir)
composed_mounts = _stack_profile_mounts_with_git_mounts(
staged_profile_mounts,
git_mount_volumes,
instance_dir,
)
extra_volumes.extend(stacked_profile_mounts)
extra_volumes.extend(git_mount_volumes)
extra_volumes.extend(composed_mounts)
logger.debug(
"Applied config profile %s to instance %s (env=%d, files=%d, mounts=%d, git_mounts=%d, stacked=%d)",
"Applied config profile %s to instance %s (env=%d, files=%d, profile_mounts=%d, git_mounts=%d, composed_mounts=%d)",
resolved.profile_name,
instance.id,
len(profile_env),
len(profile_files),
len(profile_mounts),
len(staged_profile_mounts),
len(git_mount_volumes),
len(profile_mounts) - len(stacked_profile_mounts),
len(composed_mounts),
)
except ConfigProfileCycleError as exc:
logger.error(