Compare commits

...

11 Commits

Author SHA1 Message Date
alex 5610017f50 fix(config-profiles): preserve bound file inodes
Overwrite individually bind-mounted profile files in place so editor saves remain visible to running containers.
2026-07-22 11:37:53 +02:00
alex 61e7d68d71 merge: synchronize shared profile mount working copies 2026-07-22 11:23:34 +02:00
alex 2247ec47c9 fix(config-profiles): synchronize shared mount working copies
Use canonical profile files and writable Git working copies so editor and container changes share one source. Require confirmation before destructive Git refreshes and overlay profile files without composite snapshots.
2026-07-22 11:23:19 +02:00
alex 0d6c1926ae merge: add Git mount refresh feedback 2026-07-21 21:18:25 +02:00
alex 900a8e47a5 fix(config-profiles): show Git mount refresh progress
Disable duplicate refresh requests and show in-progress feedback while Git mount sources are refreshed.
2026-07-21 21:14:08 +02:00
alex 25e870ba43 merge: fix container mount layering 2026-07-21 20:54:44 +02:00
alex 16984b7cf6 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
2026-07-21 20:49:03 +02:00
Developer fc52353b2e fix: break config profile refresh import cycle 2026-07-21 15:46:35 +00:00
Developer a63a983116 feat: merge live Git config mount refresh 2026-07-21 15:37:34 +00:00
Developer 886c863260 feat: refresh shared Git config mounts live
- Use profile-scoped canonical Git clone sources with locked refreshes
- Mount shared Git configuration read-only and isolate profile content
- Add API and desktop/mobile actions for live Git mount refresh

Quality gates: frontend build and backend py_compile passed.
Skipped: backend pytest/Ruff unavailable; Docker/manual checks not approved.
2026-07-21 14:37:42 +00:00
Developer 3c25fffd49 feat: merge live config profile refresh 2026-07-21 11:36:56 +00:00
18 changed files with 790 additions and 217 deletions
+85 -3
View File
@@ -1,7 +1,9 @@
"""Config profile API endpoints.""" """Config profile API endpoints."""
import logging import logging
import os
import uuid import uuid
from pathlib import Path
from fastapi import APIRouter, Depends, HTTPException, Query, status from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy import select from sqlalchemy import select
@@ -9,6 +11,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload from sqlalchemy.orm import selectinload
from src.auth.dependencies import get_current_user_id, get_db_session from src.auth.dependencies import get_current_user_id, get_db_session
from src.config import Settings
from src.models import ConfigProfile, ConfigProfileInclude, ToolInstance, UserConfig from src.models import ConfigProfile, ConfigProfileInclude, ToolInstance, UserConfig
from src.schemas.config import ( from src.schemas.config import (
ConfigProfileCreate, ConfigProfileCreate,
@@ -21,6 +24,7 @@ from src.schemas.config import (
) )
from src.services.config.config_profile_resolver import ( from src.services.config.config_profile_resolver import (
ConfigProfileCycleError, ConfigProfileCycleError,
apply_resolved_profile,
resolve_profile, resolve_profile,
resolved_profile_to_dict, resolved_profile_to_dict,
) )
@@ -43,6 +47,31 @@ logger = logging.getLogger(__name__)
router = APIRouter(prefix="/config-profiles", tags=["config-profiles"]) router = APIRouter(prefix="/config-profiles", tags=["config-profiles"])
def _canonical_profile_response(profile: ConfigProfile) -> dict:
"""Return profile data with edits from its shared working copy."""
response = profile_to_response(profile)
root = Path(Settings().instance_base_path) / "config-profiles" / str(profile.id)
def read_file(path: Path, fallback: str) -> str:
try:
return path.read_text() if path.is_file() else fallback
except OSError:
return fallback
response["files"] = {
relative_path: read_file(root / "files" / relative_path, content)
for relative_path, content in response["files"].items()
}
response["mounts"] = [dict(mount) for mount in response["mounts"]]
for mount in response["mounts"]:
mount_root = root / "mounts" / mount["target"].lstrip("/").replace("/", "_")
mount["files"] = {
relative_path: read_file(mount_root / relative_path, content)
for relative_path, content in mount.get("files", {}).items()
}
return response
async def _running_profile_outcomes( async def _running_profile_outcomes(
session: AsyncSession, profile_id: uuid.UUID session: AsyncSession, profile_id: uuid.UUID
) -> list[dict[str, str]]: ) -> list[dict[str, str]]:
@@ -113,7 +142,7 @@ async def list_config_profiles(
result = await session.execute(query) result = await session.execute(query)
profiles = result.scalars().all() profiles = result.scalars().all()
return [profile_to_response(p) for p in profiles] return [_canonical_profile_response(profile) for profile in profiles]
@router.post( @router.post(
@@ -146,7 +175,7 @@ async def get_config_profile(
raise HTTPException( raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, detail="Not authorized" status_code=status.HTTP_403_FORBIDDEN, detail="Not authorized"
) )
return profile_to_response(profile) return _canonical_profile_response(profile)
@router.put("/{profile_id}", response_model=ConfigProfileResponse) @router.put("/{profile_id}", response_model=ConfigProfileResponse)
@@ -168,12 +197,65 @@ async def update_config_profile(
) )
profile = await update_profile(session, profile, data) profile = await update_profile(session, profile, data)
response = profile_to_response(profile) resolved = await resolve_profile(session, profile.id)
apply_resolved_profile(
os.path.join(Settings().instance_base_path, "profile-refresh"),
resolved,
)
response = _canonical_profile_response(profile)
response["refresh_outcomes"] = await _running_profile_outcomes(session, profile.id) response["refresh_outcomes"] = await _running_profile_outcomes(session, profile.id)
logger.debug("Updated config profile %s", profile.id) logger.debug("Updated config profile %s", profile.id)
return response return response
@router.post("/{profile_id}/refresh-git-mounts")
async def refresh_profile_git_mounts(
profile_id: str,
confirm_destructive_refresh: bool = False,
current_user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
):
"""Destructively refresh profile Git working copies used by instances."""
if not confirm_destructive_refresh:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="Confirm destructive Git refresh before replacing local edits",
)
profile = await get_profile_with_includes(session, uuid.UUID(profile_id))
if profile is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Profile not found"
)
if profile.user_id != current_user_id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, detail="Not authorized"
)
# Import lazily: instance_service imports tool schemas that transitively
# load API routers, so importing it during router initialization cycles.
from src.services.tool.instance_service import resolve_git_mounts
outcomes = await _running_profile_outcomes(session, profile.id)
for outcome in outcomes:
instance = await session.get(ToolInstance, uuid.UUID(outcome["instance_id"]))
if (
instance is None
or not instance.compose_path
or instance.selected_config_profile_id is None
):
continue
resolved = await resolve_profile(session, instance.selected_config_profile_id)
await resolve_git_mounts(
session,
resolved,
os.path.dirname(instance.compose_path),
)
outcome["status"] = "refreshed"
outcome["reason"] = "Canonical Git mount source refreshed in place"
return {"refresh_outcomes": outcomes}
@router.delete("/{profile_id}", status_code=status.HTTP_204_NO_CONTENT) @router.delete("/{profile_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_config_profile( async def delete_config_profile(
profile_id: str, profile_id: str,
@@ -515,7 +515,13 @@ def apply_resolved_profile(
files_dir = profile_dir / "files" files_dir = profile_dir / "files"
mounts_dir = profile_dir / "mounts" mounts_dir = profile_dir / "mounts"
def write_canonical_file(root: Path, relative_path: str, content: str) -> Path | None: def write_canonical_file(
root: Path,
relative_path: str,
content: str,
*,
preserve_inode: bool = False,
) -> Path | None:
path = root / relative_path path = root / relative_path
try: try:
path.resolve().relative_to(root.resolve()) path.resolve().relative_to(root.resolve())
@@ -523,6 +529,12 @@ def apply_resolved_profile(
logger.warning("Profile file path escapes canonical storage: %s", relative_path) logger.warning("Profile file path escapes canonical storage: %s", relative_path)
return None return None
path.parent.mkdir(parents=True, exist_ok=True) path.parent.mkdir(parents=True, exist_ok=True)
if preserve_inode and path.is_file():
# A file bind mount follows its inode, not its directory entry.
# Replacing this path would leave a running container attached to
# the old inode, so overwrite the existing file in place.
path.write_text(content, encoding="utf-8")
return path
with tempfile.NamedTemporaryFile( with tempfile.NamedTemporaryFile(
mode="w", encoding="utf-8", dir=path.parent, delete=False mode="w", encoding="utf-8", dir=path.parent, delete=False
) as temporary_file: ) as temporary_file:
@@ -534,7 +546,9 @@ def apply_resolved_profile(
# Top-level profile files are individual bind mounts under the working # Top-level profile files are individual bind mounts under the working
# directory. They therefore cannot mask the workspace directory itself. # directory. They therefore cannot mask the workspace directory itself.
for file_path, content in resolved.files.items(): for file_path, content in resolved.files.items():
canonical_file = write_canonical_file(files_dir, file_path, content) canonical_file = write_canonical_file(
files_dir, file_path, content, preserve_inode=True
)
if canonical_file is None: if canonical_file is None:
continue continue
volume_mounts.append( volume_mounts.append(
+205 -74
View File
@@ -2,7 +2,9 @@
import asyncio import asyncio
import contextlib import contextlib
import fcntl
import glob as glob_module import glob as glob_module
import hashlib
import logging import logging
import os import os
import re import re
@@ -125,15 +127,20 @@ def _chown_staged_mounts(
uid: int, uid: int,
gid: int, gid: int,
) -> None: ) -> None:
"""Recursively chown staged mount sources to the container user. """Recursively chown writable profile and instance mount sources.
Config-profile mounts, git mounts, and SSH key mounts are staged under Canonical non-Git profile sources are shared by compatible instances, so
instance_dir by the API process (root). Without this, the container they must be writable by the container user rather than copied per
user cannot write into bind-mounted directories such as ~/.config. instance. Instance-local composites and SSH mounts remain supported.
""" """
canonical_profile_root = os.path.join(
os.path.dirname(instance_dir), "config-profiles"
)
for vol in extra_volumes: for vol in extra_volumes:
source = vol.get("source", "") source = vol.get("source", "")
if not source or not source.startswith(instance_dir): if not source or not (
source.startswith(instance_dir) or source.startswith(canonical_profile_root)
):
continue continue
_chown_path(source, uid, gid) _chown_path(source, uid, gid)
@@ -154,61 +161,118 @@ def _relative_under(parent: str, child: str) -> str | None:
return 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( def _stack_profile_mounts_with_git_mounts(
profile_mounts: list[dict], profile_mounts: list[dict],
git_mount_volumes: list[dict], git_mount_volumes: list[dict],
instance_dir: str,
) -> list[dict]: ) -> list[dict]:
"""Merge profile file mounts into overlapping git-mount sources. """Overlay canonical profile files on Git directories without snapshots.
When a config profile mounts static files to the same directory as a An overlapping profile directory is expanded into individual child-file
git-mount (e.g. ``~/.pi``), a directory-level bind mount for the profile mounts. Docker then mounts the Git working directory first and the more
would mask the cloned repository. Instead, copy the profile files into specific canonical profile files last, preserving shared writable sources
the git-mount source directory so the container sees both sets of files instead of constructing an instance-local composite copy.
through a single bind mount.
Profile mounts whose target is a child of a git-mount target are copied
into the corresponding subdirectory. Mounts that do not overlap are
returned unchanged.
""" """
remaining: list[dict] = [] del instance_dir
for pvol in profile_mounts: result: list[dict] = list(git_mount_volumes)
p_source = pvol.get("source", "")
p_target = pvol.get("target", "") for profile_mount in profile_mounts:
if not p_source or not os.path.exists(p_source): source = profile_mount.get("source", "")
remaining.append(pvol) target = profile_mount.get("target", "")
overlaps_git = any(
_mounts_overlap(target, git_mount.get("target", ""))
for git_mount in git_mount_volumes
)
if not overlaps_git or not os.path.isdir(source):
result.append(profile_mount)
continue continue
merged = False for root, _dirs, files in os.walk(source):
for gvol in git_mount_volumes: for filename in files:
g_source = gvol.get("source", "") file_source = os.path.join(root, filename)
g_target = gvol.get("target", "") relative_path = os.path.relpath(file_source, source)
if not g_source or not os.path.isdir(g_source): result.append(
continue {
**profile_mount,
"source": file_source,
"target": os.path.join(target, relative_path),
}
)
rel = _relative_under(g_target, p_target) return result
if rel is None:
continue
dst = os.path.join(g_source, rel) if rel else g_source
if os.path.isdir(p_source):
shutil.copytree(p_source, dst, dirs_exist_ok=True)
else:
os.makedirs(os.path.dirname(dst), exist_ok=True)
shutil.copy2(p_source, dst)
logger.debug(
"Stacked profile mount %s into git mount %s at %s",
p_target,
g_target,
dst,
)
merged = True
break
if not merged:
remaining.append(pvol)
return remaining
async def resolve_git_mounts( async def resolve_git_mounts(
@@ -227,12 +291,18 @@ async def resolve_git_mounts(
if not resolved.git_mounts: if not resolved.git_mounts:
return [] return []
# Git mount sources are profile-scoped, not instance-scoped, so compatible
# instances bind the same canonical checkout.
clone_parent = os.path.join(
os.path.dirname(instance_dir or ""), "config-profiles", str(resolved.profile_id)
)
# Process all git mounts concurrently # Process all git mounts concurrently
tasks = [] tasks = []
for git_mount in resolved.git_mounts: for git_mount in resolved.git_mounts:
tasks.append( tasks.append(
resolve_single_git_mount( resolve_single_git_mount(
session, git_mount, instance_dir, working_directory, home_dir session, git_mount, clone_parent, working_directory, home_dir
) )
) )
@@ -265,6 +335,37 @@ def normalize_git_mount(entry: dict) -> dict:
return entry return entry
@contextlib.contextmanager
def _git_mount_lock(clone_parent: str, remote_url: str, branch: str | None):
"""Serialize clone and refresh operations for one canonical Git source."""
lock_dir = os.path.join(clone_parent, "git-mounts")
identity = f"{remote_url}:{branch or 'default'}"
lock_path = os.path.join(
lock_dir, f".{hashlib.sha256(identity.encode()).hexdigest()}.lock"
)
try:
os.makedirs(lock_dir, exist_ok=True)
with open(lock_path, "a+", encoding="utf-8") as lock_file:
fcntl.flock(lock_file.fileno(), fcntl.LOCK_EX)
try:
yield
finally:
fcntl.flock(lock_file.fileno(), fcntl.LOCK_UN)
except OSError as exc:
raise RuntimeError(f"Cannot lock Git mount source: {lock_path}") from exc
def clone_git_repo_locked(
remote_url: str,
branch: str | None,
clone_parent: str,
project_name: str | None = None,
) -> str:
"""Clone or refresh a canonical source while holding its process lock."""
with _git_mount_lock(clone_parent, remote_url, branch):
return clone_git_repo(remote_url, branch, clone_parent, project_name)
def clone_git_repo( def clone_git_repo(
remote_url: str, remote_url: str,
branch: str | None, branch: str | None,
@@ -425,7 +526,7 @@ def resolve_git_mount_mappings(
async def resolve_single_git_mount( async def resolve_single_git_mount(
session: AsyncSession, session: AsyncSession,
git_mount: dict, git_mount: dict,
instance_dir: str | None = None, clone_parent: str | None = None,
working_directory: str | None = None, working_directory: str | None = None,
home_dir: str = "/root", home_dir: str = "/root",
) -> list[dict]: ) -> list[dict]:
@@ -447,15 +548,15 @@ async def resolve_single_git_mount(
logger.warning("Invalid git mount skipped: no mappings") logger.warning("Invalid git mount skipped: no mappings")
return [] return []
if not instance_dir: if not clone_parent:
logger.warning("Git mount skipped: no instance_dir provided for cloning") logger.warning("Git mount skipped: no canonical profile directory provided")
return [] return []
# Clone or pull the repository. Git mounts are auxiliary, so they keep # Clone or pull the repository. Git mounts are auxiliary, so they keep
# using the repository URL basename rather than the project name. # using the repository URL basename rather than the project name.
try: try:
repo_path = await asyncio.to_thread( repo_path = await asyncio.to_thread(
clone_git_repo, remote_url, branch, instance_dir clone_git_repo_locked, remote_url, branch, clone_parent
) )
except Exception as exc: except Exception as exc:
logger.warning( logger.warning(
@@ -468,7 +569,14 @@ async def resolve_single_git_mount(
return [] return []
# Resolve all mappings from the cloned repo # Resolve all mappings from the cloned repo
return resolve_git_mount_mappings(repo_path, mappings, working_directory, home_dir) volumes = resolve_git_mount_mappings(
repo_path, mappings, working_directory, home_dir
)
# Profile-scoped Git working copies are writable and shared by compatible
# containers. Explicit refresh replaces local edits with the remote ref.
for volume in volumes:
volume["readonly"] = False
return volumes
def checkout_branch(repo_path: str, branch: str) -> bool: def checkout_branch(repo_path: str, branch: str) -> bool:
@@ -512,10 +620,10 @@ def checkout_branch(repo_path: str, branch: str) -> bool:
def pull_repository_updates(repo_path: str, remote_url: str) -> None: def pull_repository_updates(repo_path: str, remote_url: str) -> None:
"""Pull latest updates from remote repository. """Replace a profile working copy with its current remote branch.
Used when starting a new container with an existing cloned repository Git profile mounts are writable shared working copies. Refresh discards
to ensure the latest code is mounted. local container/editor edits after fetching the remote baseline.
""" """
import subprocess import subprocess
@@ -529,15 +637,22 @@ def pull_repository_updates(repo_path: str, remote_url: str) -> None:
if result.returncode != 0: if result.returncode != 0:
raise RuntimeError(f"Failed to fetch updates: {result.stderr}") raise RuntimeError(f"Failed to fetch updates: {result.stderr}")
# Pull changes for current branch branch_result = subprocess.run(
result = subprocess.run( ["git", "-C", repo_path, "branch", "--show-current"],
["git", "-C", repo_path, "pull", "origin"],
capture_output=True, capture_output=True,
text=True, text=True,
) )
branch = branch_result.stdout.strip() if branch_result.returncode == 0 else ""
if not branch:
raise RuntimeError("Unable to determine Git working-copy branch")
result = subprocess.run(
["git", "-C", repo_path, "reset", "--hard", f"origin/{branch}"],
capture_output=True,
text=True,
)
if result.returncode != 0: if result.returncode != 0:
raise RuntimeError(f"Failed to pull updates: {result.stderr}") raise RuntimeError(f"Failed to reset working copy: {result.stderr}")
def expand_glob_source(source_path: str, repo_path: str) -> list[str]: def expand_glob_source(source_path: str, repo_path: str) -> list[str]:
@@ -1425,22 +1540,24 @@ async def start_tool_instance(
git_mount_volumes = await resolve_git_mounts( git_mount_volumes = await resolve_git_mounts(
session, resolved, instance_dir, working_directory, home_dir session, resolved, instance_dir, working_directory, home_dir
) )
# Stack static file mounts on top of git repo mounts so they do # Non-Git profile mounts bind directly to canonical profile
# not mask each other when they target the same directory. # storage so edits made by one compatible container are visible to
stacked_profile_mounts = _stack_profile_mounts_with_git_mounts( # every other container and the profile editor readback path.
profile_mounts, git_mount_volumes composed_mounts = _stack_profile_mounts_with_git_mounts(
profile_mounts,
git_mount_volumes,
instance_dir,
) )
extra_volumes.extend(stacked_profile_mounts) extra_volumes.extend(composed_mounts)
extra_volumes.extend(git_mount_volumes)
logger.debug( 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, resolved.profile_name,
instance.id, instance.id,
len(profile_env), len(profile_env),
len(profile_files), len(profile_files),
len(profile_mounts), len(profile_mounts),
len(git_mount_volumes), len(git_mount_volumes),
len(profile_mounts) - len(stacked_profile_mounts), len(composed_mounts),
) )
except ConfigProfileCycleError as exc: except ConfigProfileCycleError as exc:
logger.error( logger.error(
@@ -1481,7 +1598,15 @@ async def start_tool_instance(
if ssh_keys_to_mount: if ssh_keys_to_mount:
ssh_dir = os.path.join(instance_dir, "mounts", "ssh", ".ssh") ssh_dir = os.path.join(instance_dir, "mounts", "ssh", ".ssh")
os.makedirs(ssh_dir, exist_ok=True) try:
os.makedirs(ssh_dir, exist_ok=True)
except OSError as exc:
logger.error(
"Failed to create SSH mount directory for instance %s: %s",
instance.id,
exc,
)
ssh_keys_to_mount = []
key_filenames = [] key_filenames = []
for ssh_key in ssh_keys_to_mount: for ssh_key in ssh_keys_to_mount:
@@ -2194,7 +2319,13 @@ async def delete_tool_instance(
if os.path.exists(instance_dir): if os.path.exists(instance_dir):
import shutil import shutil
shutil.rmtree(instance_dir) try:
shutil.rmtree(instance_dir)
except OSError as exc:
logger.error(
"Failed to remove instance directory %s: %s", instance_dir, exc
)
raise RuntimeError("Failed to remove instance files") from exc
await publish_lifecycle_event( await publish_lifecycle_event(
event_bus=_event_bus, event_bus=_event_bus,
@@ -0,0 +1,23 @@
"""Tests for Config Profile refresh safety contracts."""
import uuid
from unittest.mock import AsyncMock
import pytest
from fastapi import HTTPException
from src.api.config.config_profiles import refresh_profile_git_mounts
@pytest.mark.unit
async def test_git_refresh_requires_destructive_confirmation() -> None:
"""The endpoint must not reset a writable working copy without consent."""
with pytest.raises(HTTPException) as exc_info:
await refresh_profile_git_mounts(
profile_id=str(uuid.uuid4()),
confirm_destructive_refresh=False,
current_user_id=uuid.uuid4(),
session=AsyncMock(),
)
assert exc_info.value.status_code == 409
@@ -36,7 +36,7 @@ class TestMergeFunctions:
def test_merge_env_vars_tracks_overrides(self) -> None: def test_merge_env_vars_tracks_overrides(self) -> None:
"""Test that env var overrides are tracked.""" """Test that env var overrides are tracked."""
overrides = {} overrides: dict[str, str] = {}
_merge_env_vars( _merge_env_vars(
{"A": "1"}, {"A": "1"},
{"A": "2"}, {"A": "2"},
@@ -93,7 +93,7 @@ class TestMergeFunctions:
"""Test that mount mode conflicts are resolved (later wins).""" """Test that mount mode conflicts are resolved (later wins)."""
from src.services.config.config_profile_resolver import ResolvedMount from src.services.config.config_profile_resolver import ResolvedMount
overrides = {} overrides: dict[str, str] = {}
result = _merge_mounts( result = _merge_mounts(
{"/app": ResolvedMount(target="/app", mode="rw", files={})}, {"/app": ResolvedMount(target="/app", mode="rw", files={})},
[{"target": "/app", "mode": "ro", "files": {}}], [{"target": "/app", "mode": "ro", "files": {}}],
@@ -562,6 +562,28 @@ class TestApplyResolvedProfile:
] ]
assert canonical_file.read_text() == "setting = true" assert canonical_file.read_text() == "setting = true"
def test_top_level_file_update_preserves_bind_mount_inode(self, tmp_path) -> None:
"""An individually bind-mounted file must update in place."""
profile_id = uuid.uuid4()
instance_root = tmp_path / "instances"
resolved = ResolvedProfile(
profile_id=profile_id,
profile_name="test",
files={"settings.toml": "value = 1"},
)
apply_resolved_profile(str(instance_root / "instance-a"), resolved)
canonical_file = (
instance_root / "config-profiles" / str(profile_id) / "files" / "settings.toml"
)
original_inode = canonical_file.stat().st_ino
resolved.files["settings.toml"] = "value = 2"
apply_resolved_profile(str(instance_root / "instance-a"), resolved)
assert canonical_file.stat().st_ino == original_inode
assert canonical_file.read_text() == "value = 2"
def test_instances_share_profile_scoped_mount_sources(self, tmp_path) -> None: def test_instances_share_profile_scoped_mount_sources(self, tmp_path) -> None:
"""Different instance paths resolve a profile to one canonical source.""" """Different instance paths resolve a profile to one canonical source."""
profile_id = uuid.uuid4() profile_id = uuid.uuid4()
+180 -127
View File
@@ -2,16 +2,20 @@
import hashlib import hashlib
import uuid import uuid
from pathlib import Path
from unittest.mock import MagicMock, AsyncMock from unittest.mock import MagicMock, AsyncMock
import pytest import pytest
from src.services.tool.instance_service import ( from src.services.tool.instance_service import (
_chown_staged_mounts,
_get_repository_mount_name, _get_repository_mount_name,
_stack_profile_mounts_with_git_mounts, _stack_profile_mounts_with_git_mounts,
_stage_profile_mounts,
clone_git_repo, clone_git_repo,
modify_compose_file, modify_compose_file,
prepare_manifest_instance, prepare_manifest_instance,
pull_repository_updates,
) )
@@ -80,12 +84,7 @@ class TestCloneGitRepo:
branch = None branch = None
clone_parent = str(tmp_path) clone_parent = str(tmp_path)
url_hash = hashlib.md5(f"{remote_url}:default".encode()).hexdigest()[:12] url_hash = hashlib.md5(f"{remote_url}:default".encode()).hexdigest()[:12]
repo_path = ( repo_path = tmp_path / "git-mounts" / f"dotfiles-{url_hash}" / "repo-clone"
tmp_path
/ "git-mounts"
/ f"dotfiles-{url_hash}"
/ "repo-clone"
)
(repo_path / ".git").mkdir(parents=True) (repo_path / ".git").mkdir(parents=True)
clone = MagicMock(side_effect=AssertionError("existing clone must be reused")) clone = MagicMock(side_effect=AssertionError("existing clone must be reused"))
@@ -99,7 +98,40 @@ class TestCloneGitRepo:
clone.assert_not_called() clone.assert_not_called()
pull.assert_called_once_with(str(repo_path), remote_url) pull.assert_called_once_with(str(repo_path), remote_url)
def test_replaces_incomplete_clone_before_retry(self, monkeypatch, tmp_path) -> None: def test_refresh_resets_writable_working_copy_to_remote_branch(
self, monkeypatch
) -> None:
from src.services.tool import instance_service
results = [
MagicMock(returncode=0, stdout="", stderr=""),
MagicMock(returncode=0, stdout="main\n", stderr=""),
MagicMock(returncode=0, stdout="", stderr=""),
]
run = MagicMock(side_effect=results)
monkeypatch.setattr(instance_service.subprocess, "run", run)
pull_repository_updates("/work/profile-git", "https://example.test/repo.git")
assert run.call_args_list[0].args[0] == [
"git",
"-C",
"/work/profile-git",
"fetch",
"origin",
]
assert run.call_args_list[2].args[0] == [
"git",
"-C",
"/work/profile-git",
"reset",
"--hard",
"origin/main",
]
def test_replaces_incomplete_clone_before_retry(
self, monkeypatch, tmp_path
) -> None:
from src.services.tool import instance_service from src.services.tool import instance_service
remote_url = "https://gitlab.com/example/dotfiles" remote_url = "https://gitlab.com/example/dotfiles"
@@ -126,153 +158,174 @@ class TestCloneGitRepo:
@pytest.mark.unit @pytest.mark.unit
class TestStackProfileMountsWithGitMounts: class TestStackProfileMountsWithGitMounts:
"""Tests for _stack_profile_mounts_with_git_mounts.""" """Tests for composing profile and Git mounts without Docker masking."""
def test_exact_overlap_merges_profile_files_into_git_source(self, tmp_path) -> None: def test_stages_profile_source_under_instance_directory(self, tmp_path) -> None:
"""When a profile mount targets the same directory as a git mount, """Profile sources must be instance-local before ownership is fixed."""
the profile files should be copied into the git-mount source so the instance_dir = tmp_path / "instance"
container sees both sets of files through one bind mount.""" profile_source = tmp_path / "profile" / "settings.json"
profile_source.parent.mkdir(parents=True)
profile_source.write_text("{}")
staged = _stage_profile_mounts(
[{"source": str(profile_source), "target": "/home/user/.pi/settings.json"}],
str(instance_dir),
)
assert staged[0]["source"].startswith(str(instance_dir))
assert staged[0]["source"] != str(profile_source)
assert Path(staged[0]["source"]).read_text() == "{}"
def test_staged_profile_source_is_chowned_for_container_user(
self, monkeypatch, tmp_path
) -> None:
"""The ownership pass must include the instance-local profile copy."""
from src.services.tool import instance_service
instance_dir = tmp_path / "instance"
profile_source = tmp_path / "profile"
profile_source.mkdir()
(profile_source / "settings.json").write_text("{}")
staged = _stage_profile_mounts(
[{"source": str(profile_source), "target": "/home/user/.pi"}],
str(instance_dir),
)
chown = MagicMock()
monkeypatch.setattr(instance_service, "_chown_path", chown)
_chown_staged_mounts(staged, str(instance_dir), 1000, 1000)
chown.assert_called_once_with(staged[0]["source"], 1000, 1000)
def test_exact_overlap_creates_instance_local_composite(self, tmp_path) -> None:
"""Profile files extend a Git root without mutating its shared clone."""
instance_dir = tmp_path / "instance"
git_source = tmp_path / "git" / "repo-clone" git_source = tmp_path / "git" / "repo-clone"
git_source.mkdir(parents=True) git_source.mkdir(parents=True)
(git_source / "existing.txt").write_text("from git") (git_source / "existing.txt").write_text("from git")
profile_source = tmp_path / "profile"
profile_source = tmp_path / "profile" / "home_user_.pi" profile_source.mkdir()
profile_source.mkdir(parents=True)
(profile_source / "settings.json").write_text("{}") (profile_source / "settings.json").write_text("{}")
profile_mounts = [ profile_mounts = _stage_profile_mounts(
{ [
"source": str(profile_source), {
"target": "/home/user/.pi", "source": str(profile_source),
"type": "bind", "target": "/home/user/.pi",
"readonly": False, "type": "bind",
} }
] ],
git_mount_volumes = [ str(instance_dir),
{"source": str(git_source), "target": "/home/user/.pi", "type": "bind"} )
]
result = _stack_profile_mounts_with_git_mounts( result = _stack_profile_mounts_with_git_mounts(
profile_mounts, git_mount_volumes profile_mounts,
[{"source": str(git_source), "target": "/home/user/.pi", "type": "bind"}],
str(instance_dir),
) )
assert result == [] assert len(result) == 2
assert (git_source / "existing.txt").read_text() == "from git" assert result[0]["source"] == str(git_source)
assert (git_source / "settings.json").read_text() == "{}" assert result[0]["target"] == "/home/user/.pi"
assert result[1]["source"].endswith("/settings.json")
assert result[1]["target"] == "/home/user/.pi/settings.json"
assert not (git_source / "settings.json").exists()
def test_descendant_overlap_copies_into_subdirectory(self, tmp_path) -> None: def test_descendant_profile_mount_extends_git_root(self, tmp_path) -> None:
"""Profile mounts targeting a child directory are copied into the """Nested targets are composed at the Git root, preserving siblings."""
corresponding subdirectory of the git-mount source.""" instance_dir = tmp_path / "instance"
git_source = tmp_path / "git" git_source = tmp_path / "git"
git_source.mkdir() git_source.mkdir()
(git_source / "README").write_text("repo") (git_source / "README").write_text("repo")
profile_source = tmp_path / "profile"
profile_source = tmp_path / "profile" / "agent" profile_source.mkdir()
profile_source.mkdir(parents=True)
(profile_source / "settings.json").write_text("x") (profile_source / "settings.json").write_text("x")
profile_mounts = [ profile_mounts = _stage_profile_mounts(
{ [{"source": str(profile_source), "target": "/home/user/.pi/agent"}],
"source": str(profile_source), str(instance_dir),
"target": "/home/user/.pi/agent", )
"type": "bind",
}
]
git_mount_volumes = [
{"source": str(git_source), "target": "/home/user/.pi", "type": "bind"}
]
result = _stack_profile_mounts_with_git_mounts( result = _stack_profile_mounts_with_git_mounts(
profile_mounts, git_mount_volumes profile_mounts,
[{"source": str(git_source), "target": "/home/user/.pi", "type": "bind"}],
str(instance_dir),
) )
assert result == [] assert len(result) == 2
assert (git_source / "agent" / "settings.json").read_text() == "x" assert result[0]["source"] == str(git_source)
assert (git_source / "README").read_text() == "repo" assert result[1]["target"] == "/home/user/.pi/agent/settings.json"
assert not (git_source / "agent").exists()
def test_non_overlapping_mounts_left_untouched(self, tmp_path) -> None: def test_file_profile_mount_extends_git_root(self, tmp_path) -> None:
"""Profile mounts that do not overlap a git mount are returned as-is.""" """A file bind mount is composed into the Git directory, not masked."""
instance_dir = tmp_path / "instance"
git_source = tmp_path / "git" git_source = tmp_path / "git"
git_source.mkdir() git_source.mkdir()
(git_source / "README").write_text("repo")
profile_source = tmp_path / "profile"
profile_source.mkdir()
(profile_source / "config").write_text("c")
profile_mounts = [
{
"source": str(profile_source),
"target": "/home/user/.config",
"type": "bind",
}
]
git_mount_volumes = [
{"source": str(git_source), "target": "/home/user/.pi", "type": "bind"}
]
result = _stack_profile_mounts_with_git_mounts(
profile_mounts, git_mount_volumes
)
assert result == profile_mounts
def test_git_source_file_does_not_consume_profile_mount(self, tmp_path) -> None:
"""If the overlapping git-mount source is a file, the profile mount
cannot be merged and must be kept."""
git_source = tmp_path / "file.txt"
git_source.write_text("file")
profile_source = tmp_path / "profile"
profile_source.mkdir()
(profile_source / "settings.json").write_text("{}")
profile_mounts = [
{
"source": str(profile_source),
"target": "/home/user/.pi",
"type": "bind",
}
]
git_mount_volumes = [
{
"source": str(git_source),
"target": "/home/user/.pi/file.txt",
"type": "bind",
}
]
result = _stack_profile_mounts_with_git_mounts(
profile_mounts, git_mount_volumes
)
assert result == profile_mounts
def test_profile_source_file_copied_into_git_source(self, tmp_path) -> None:
"""A profile mount that supplies a single file is copied into the
git-mount source directory."""
git_source = tmp_path / "git"
git_source.mkdir()
profile_source = tmp_path / "settings.json" profile_source = tmp_path / "settings.json"
profile_source.write_text("{}") profile_source.write_text("{}")
profile_mounts = [ profile_mounts = _stage_profile_mounts(
{ [
"source": str(profile_source), {
"target": "/home/user/.pi/settings.json", "source": str(profile_source),
"type": "bind", "target": "/home/user/.pi/settings.json",
} }
] ],
git_mount_volumes = [ str(instance_dir),
{"source": str(git_source), "target": "/home/user/.pi", "type": "bind"} )
]
result = _stack_profile_mounts_with_git_mounts( result = _stack_profile_mounts_with_git_mounts(
profile_mounts, git_mount_volumes profile_mounts,
[{"source": str(git_source), "target": "/home/user/.pi", "type": "bind"}],
str(instance_dir),
) )
assert result == [] assert len(result) == 2
assert (git_source / "settings.json").read_text() == "{}" assert result[0]["source"] == str(git_source)
assert result[1]["target"] == "/home/user/.pi/settings.json"
def test_parent_profile_mount_extends_nested_git_mount(self, tmp_path) -> None:
"""A profile parent mount keeps Git content and its own sibling files."""
instance_dir = tmp_path / "instance"
git_source = tmp_path / "git"
git_source.mkdir()
(git_source / "plugin.toml").write_text("git")
profile_source = tmp_path / "profile"
profile_source.mkdir()
(profile_source / "config.toml").write_text("profile")
profile_mounts = _stage_profile_mounts(
[{"source": str(profile_source), "target": "/home/user"}], str(instance_dir)
)
result = _stack_profile_mounts_with_git_mounts(
profile_mounts,
[{"source": str(git_source), "target": "/home/user/.pi", "type": "bind"}],
str(instance_dir),
)
assert len(result) == 2
assert result[0]["source"] == str(git_source)
assert result[1]["target"] == "/home/user/config.toml"
def test_non_overlapping_mounts_remain_separate(self, tmp_path) -> None:
"""Unrelated profile and Git mounts retain their independent sources."""
instance_dir = tmp_path / "instance"
git_source = tmp_path / "git"
git_source.mkdir()
profile_source = tmp_path / "profile"
profile_source.mkdir()
profile_mounts = _stage_profile_mounts(
[{"source": str(profile_source), "target": "/home/user/.config"}],
str(instance_dir),
)
git_mounts = [{"source": str(git_source), "target": "/home/user/.pi"}]
assert (
_stack_profile_mounts_with_git_mounts(
profile_mounts, git_mounts, str(instance_dir)
)
== git_mounts + profile_mounts
)
@pytest.mark.unit @pytest.mark.unit
+31
View File
@@ -0,0 +1,31 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const mockPost = vi.fn();
vi.mock("./client", () => ({
apiClient: {
post: (...args: unknown[]) => mockPost(...args),
},
}));
import { refreshConfigProfileGitMounts } from "./config-profiles";
describe("refreshConfigProfileGitMounts", () => {
beforeEach(() => {
mockPost.mockReset();
});
it("confirms the destructive refresh at the API boundary", async () => {
mockPost.mockResolvedValue({ data: { refresh_outcomes: [] } });
await expect(refreshConfigProfileGitMounts("profile-1")).resolves.toEqual({
refresh_outcomes: [],
});
expect(mockPost).toHaveBeenCalledWith(
"/config-profiles/profile-1/refresh-git-mounts",
undefined,
{ params: { confirm_destructive_refresh: true } },
);
});
});
+16 -1
View File
@@ -2,7 +2,11 @@ import { apiClient } from "./client";
export interface ConfigProfileRefreshOutcome { export interface ConfigProfileRefreshOutcome {
instance_id: string; instance_id: string;
status: "compatible" | "restart_required" | "incompatible_permissions"; status:
| "compatible"
| "refreshed"
| "restart_required"
| "incompatible_permissions";
reason?: string; reason?: string;
} }
@@ -145,6 +149,17 @@ export const deleteConfigProfile = async (id: string): Promise<void> => {
await apiClient.delete(`/config-profiles/${id}`); await apiClient.delete(`/config-profiles/${id}`);
}; };
export const refreshConfigProfileGitMounts = async (
id: string,
): Promise<{ refresh_outcomes: ConfigProfileRefreshOutcome[] }> => {
const response = await apiClient.post<{
refresh_outcomes: ConfigProfileRefreshOutcome[];
}>(`/config-profiles/${id}/refresh-git-mounts`, undefined, {
params: { confirm_destructive_refresh: true },
});
return response.data;
};
export const updateProfileIncludes = async ( export const updateProfileIncludes = async (
id: string, id: string,
data: UpdateIncludesRequest, data: UpdateIncludesRequest,
@@ -14,6 +14,8 @@ interface Props {
saveStatus: "idle" | "saving" | "saved" | "error"; saveStatus: "idle" | "saving" | "saved" | "error";
previewData: ResolvedProfile | null; previewData: ResolvedProfile | null;
previewingId: string | null; previewingId: string | null;
isRefreshingGitMounts: boolean;
isReloadingWorkingCopy: boolean;
projects: ProjectWithRepos[]; projects: ProjectWithRepos[];
toolTypes: ToolType[]; toolTypes: ToolType[];
availableProfiles: ConfigProfile[]; availableProfiles: ConfigProfile[];
@@ -23,6 +25,8 @@ interface Props {
onSubmit: (e?: React.FormEvent) => void; onSubmit: (e?: React.FormEvent) => void;
onReset: () => void; onReset: () => void;
onPreview: () => void; onPreview: () => void;
onReloadWorkingCopy: () => void;
onRefreshGitMounts: () => void;
onAddInclude: (id: string) => void; onAddInclude: (id: string) => void;
onRemoveInclude: (index: number) => void; onRemoveInclude: (index: number) => void;
onDragStart: (e: React.DragEvent, index: number) => void; onDragStart: (e: React.DragEvent, index: number) => void;
@@ -54,6 +58,8 @@ export const ConfigProfileEditorPanel = ({
saveStatus, saveStatus,
previewData, previewData,
previewingId, previewingId,
isRefreshingGitMounts,
isReloadingWorkingCopy,
projects, projects,
toolTypes, toolTypes,
availableProfiles, availableProfiles,
@@ -63,6 +69,8 @@ export const ConfigProfileEditorPanel = ({
onSubmit, onSubmit,
onReset, onReset,
onPreview, onPreview,
onReloadWorkingCopy,
onRefreshGitMounts,
onAddInclude, onAddInclude,
onRemoveInclude, onRemoveInclude,
onDragStart, onDragStart,
@@ -114,6 +122,20 @@ export const ConfigProfileEditorPanel = ({
</div> </div>
{!isCreating && selectedProfile && ( {!isCreating && selectedProfile && (
<div className="row row-sm"> <div className="row row-sm">
<button className="btn btn-secondary" onClick={onReloadWorkingCopy} disabled={isReloadingWorkingCopy}>
{isReloadingWorkingCopy ? <><Icon name="loading" size="sm" /> Reloading...</> : <><Icon name="refresh" size="sm" /> Reload working copy</>}
</button>
<button className="btn btn-secondary" onClick={onRefreshGitMounts} disabled={isRefreshingGitMounts}>
{isRefreshingGitMounts ? (
<>
<Icon name="loading" size="sm" /> Refreshing Git mounts...
</>
) : (
<>
<Icon name="refresh" size="sm" /> Refresh Git mounts
</>
)}
</button>
<button className="btn btn-secondary" onClick={onPreview} disabled={previewingId === selectedProfile.id}> <button className="btn btn-secondary" onClick={onPreview} disabled={previewingId === selectedProfile.id}>
{previewingId === selectedProfile.id ? ( {previewingId === selectedProfile.id ? (
<><Icon name="loading" size="sm" /> Previewing...</> <><Icon name="loading" size="sm" /> Previewing...</>
@@ -28,6 +28,8 @@ interface Props {
availableProfiles: ConfigProfile[]; availableProfiles: ConfigProfile[];
previewData: ResolvedProfile | null; previewData: ResolvedProfile | null;
previewingId: string | null; previewingId: string | null;
isRefreshingGitMounts: boolean;
isReloadingWorkingCopy: boolean;
saveStatus: "idle" | "saving" | "saved" | "error"; saveStatus: "idle" | "saving" | "saved" | "error";
error: string | null; error: string | null;
onViewChange: (view: MobileView) => void; onViewChange: (view: MobileView) => void;
@@ -64,6 +66,8 @@ interface Props {
) => void; ) => void;
onRemoveMountFile: (mountIndex: number, path: string) => void; onRemoveMountFile: (mountIndex: number, path: string) => void;
onPreview: (id: string) => void; onPreview: (id: string) => void;
onReloadWorkingCopy: () => void;
onRefreshGitMounts: (id: string) => void;
onClosePreview: () => void; onClosePreview: () => void;
} }
@@ -79,6 +83,8 @@ export const ConfigProfilesMobileView = ({
availableProfiles, availableProfiles,
previewData, previewData,
previewingId, previewingId,
isRefreshingGitMounts,
isReloadingWorkingCopy,
saveStatus, saveStatus,
error, error,
onViewChange, onViewChange,
@@ -104,6 +110,8 @@ export const ConfigProfilesMobileView = ({
onUpdateMountFile, onUpdateMountFile,
onRemoveMountFile, onRemoveMountFile,
onPreview, onPreview,
onReloadWorkingCopy,
onRefreshGitMounts,
onClosePreview, onClosePreview,
}: Props) => { }: Props) => {
const [isSaving, setIsSaving] = useState(false); const [isSaving, setIsSaving] = useState(false);
@@ -363,6 +371,26 @@ export const ConfigProfilesMobileView = ({
onDelete={handleDeleteClick} onDelete={handleDeleteClick}
> >
<div className="mobile-detail-actions-extra"> <div className="mobile-detail-actions-extra">
<button type="button" className="secondary-button" disabled={isReloadingWorkingCopy} onClick={onReloadWorkingCopy}>
{isReloadingWorkingCopy ? <><Icon name="loading" size="sm" /> Reloading...</> : <><Icon name="refresh" size="sm" /> Reload working copy</>}
</button>
<button
type="button"
className="secondary-button"
disabled={isRefreshingGitMounts}
onClick={() => onRefreshGitMounts(selectedProfile.id)}
>
{isRefreshingGitMounts ? (
<>
<Icon name="loading" size="sm" />
Refreshing Git mounts...
</>
) : (
<>
<Icon name="refresh" size="sm" /> Refresh Git mounts
</>
)}
</button>
<button <button
type="button" type="button"
className="secondary-button" className="secondary-button"
+56
View File
@@ -5,6 +5,7 @@ import {
deleteConfigProfile, deleteConfigProfile,
listConfigProfiles, listConfigProfiles,
previewConfigProfile, previewConfigProfile,
refreshConfigProfileGitMounts,
updateConfigProfile, updateConfigProfile,
updateProfileIncludes, updateProfileIncludes,
type ConfigProfile, type ConfigProfile,
@@ -42,6 +43,8 @@ export const useConfigProfiles = () => {
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [previewData, setPreviewData] = useState<ResolvedProfile | null>(null); const [previewData, setPreviewData] = useState<ResolvedProfile | null>(null);
const [previewingId, setPreviewingId] = useState<string | null>(null); const [previewingId, setPreviewingId] = useState<string | null>(null);
const [isRefreshingGitMounts, setIsRefreshingGitMounts] = useState(false);
const [isReloadingWorkingCopy, setIsReloadingWorkingCopy] = useState(false);
const [formData, setFormData] = const [formData, setFormData] =
useState<CreateConfigProfileRequest>(defaultForm); useState<CreateConfigProfileRequest>(defaultForm);
const [includedProfileIds, setIncludedProfileIds] = useState<string[]>([]); const [includedProfileIds, setIncludedProfileIds] = useState<string[]>([]);
@@ -273,6 +276,55 @@ export const useConfigProfiles = () => {
} }
}; };
const handleReloadWorkingCopy = async (): Promise<void> => {
if (!selectedProfileId || isReloadingWorkingCopy) return;
setError(null);
setIsReloadingWorkingCopy(true);
try {
const refreshedProfiles = await listConfigProfiles();
setProfiles(refreshedProfiles);
const refreshedProfile = refreshedProfiles.find(
(profile) => profile.id === selectedProfileId,
);
if (refreshedProfile) populateForm(refreshedProfile);
} catch (err) {
setError(extractErrorMessage(err));
} finally {
setIsReloadingWorkingCopy(false);
}
};
const handleRefreshGitMounts = async (id: string): Promise<boolean> => {
if (isRefreshingGitMounts) return false;
if (
!window.confirm(
"Refresh Git mounts? This replaces local container and editor edits with the configured remote branch.",
)
)
return false;
setError(null);
setIsRefreshingGitMounts(true);
try {
const result = await refreshConfigProfileGitMounts(id);
const refreshed = result.refresh_outcomes.filter(
(outcome) => outcome.status === "refreshed",
);
setError(
refreshed.length
? `Refreshed Git mounts for ${refreshed.length} running instance${refreshed.length === 1 ? "" : "s"}.`
: "No running instances currently use this profile's Git mounts.",
);
return true;
} catch (err) {
setError(extractErrorMessage(err));
return false;
} finally {
setIsRefreshingGitMounts(false);
}
};
const handlePreview = async (id: string) => { const handlePreview = async (id: string) => {
try { try {
setPreviewingId(id); setPreviewingId(id);
@@ -425,6 +477,8 @@ export const useConfigProfiles = () => {
error, error,
previewData, previewData,
previewingId, previewingId,
isRefreshingGitMounts,
isReloadingWorkingCopy,
formData, formData,
includedProfileIds, includedProfileIds,
dragOverIndex, dragOverIndex,
@@ -434,6 +488,8 @@ export const useConfigProfiles = () => {
handleSubmit, handleSubmit,
handleDelete, handleDelete,
handlePreview, handlePreview,
handleReloadWorkingCopy,
handleRefreshGitMounts,
updateFormField, updateFormField,
addEnvVar, addEnvVar,
updateEnvVar, updateEnvVar,
+14
View File
@@ -23,6 +23,8 @@ export const ConfigProfilesPage = () => {
error, error,
previewData, previewData,
previewingId, previewingId,
isRefreshingGitMounts,
isReloadingWorkingCopy,
formData, formData,
includedProfileIds, includedProfileIds,
dragOverIndex, dragOverIndex,
@@ -32,6 +34,8 @@ export const ConfigProfilesPage = () => {
handleSubmit, handleSubmit,
handleDelete, handleDelete,
handlePreview, handlePreview,
handleReloadWorkingCopy,
handleRefreshGitMounts,
updateFormField, updateFormField,
addEnvVar, addEnvVar,
updateEnvVar, updateEnvVar,
@@ -110,6 +114,8 @@ export const ConfigProfilesPage = () => {
availableProfiles={availableProfilesForInclude()} availableProfiles={availableProfilesForInclude()}
previewData={previewData} previewData={previewData}
previewingId={previewingId} previewingId={previewingId}
isRefreshingGitMounts={isRefreshingGitMounts}
isReloadingWorkingCopy={isReloadingWorkingCopy}
saveStatus={saveStatus} saveStatus={saveStatus}
error={error} error={error}
onViewChange={setMobileView} onViewChange={setMobileView}
@@ -135,6 +141,8 @@ export const ConfigProfilesPage = () => {
onUpdateMountFile={updateMountFile} onUpdateMountFile={updateMountFile}
onRemoveMountFile={removeMountFile} onRemoveMountFile={removeMountFile}
onPreview={handlePreview} onPreview={handlePreview}
onReloadWorkingCopy={() => void handleReloadWorkingCopy()}
onRefreshGitMounts={(id) => void handleRefreshGitMounts(id)}
onClosePreview={() => setPreviewData(null)} onClosePreview={() => setPreviewData(null)}
/> />
); );
@@ -167,6 +175,8 @@ export const ConfigProfilesPage = () => {
saveStatus={saveStatus} saveStatus={saveStatus}
previewData={previewData} previewData={previewData}
previewingId={previewingId} previewingId={previewingId}
isRefreshingGitMounts={isRefreshingGitMounts}
isReloadingWorkingCopy={isReloadingWorkingCopy}
projects={projects} projects={projects}
toolTypes={toolTypes} toolTypes={toolTypes}
availableProfiles={availableProfilesForInclude()} availableProfiles={availableProfilesForInclude()}
@@ -176,6 +186,10 @@ export const ConfigProfilesPage = () => {
onSubmit={handleSubmit} onSubmit={handleSubmit}
onReset={handleReset} onReset={handleReset}
onPreview={() => selectedProfile && handlePreview(selectedProfile.id)} onPreview={() => selectedProfile && handlePreview(selectedProfile.id)}
onReloadWorkingCopy={() => void handleReloadWorkingCopy()}
onRefreshGitMounts={() =>
selectedProfile && handleRefreshGitMounts(selectedProfile.id)
}
onAddInclude={addInclude} onAddInclude={addInclude}
onRemoveInclude={removeInclude} onRemoveInclude={removeInclude}
onDragStart={handleDragStart} onDragStart={handleDragStart}
@@ -12,6 +12,7 @@ After implementing configurable tool container home directories, new `pi-agent`
4. `npm_global` packages are installed with `RUN npm install -g ...` as root into the system npm prefix, so the non-root container user cannot update them. 4. `npm_global` packages are installed with `RUN npm install -g ...` as root into the system npm prefix, so the non-root container user cannot update them.
5. Once the repo mount moves out of `/workspace`, the generated `/workspace` compatibility symlink is created in the image as root. The non-root entrypoint cannot replace it (write permission is required on `/`), so container startup fails. 5. Once the repo mount moves out of `/workspace`, the generated `/workspace` compatibility symlink is created in the image as root. The non-root entrypoint cannot replace it (write permission is required on `/`), so container startup fails.
6. Older images baked a literal `{{WORKSPACE_NAME}}` directory into `/home/user`, which survives alongside the real repo-named mount directory. 6. Older images baked a literal `{{WORKSPACE_NAME}}` directory into `/home/user`, which survives alongside the real repo-named mount directory.
7. Config-profile file mounts use canonical profile storage owned by the API process. A non-root container user therefore cannot write to writable bind mounts. When a directory-level profile mount and a Git mount share or nest under the same target, Docker bind mounting masks the earlier source rather than merging their files.
## Fix ## Fix
@@ -40,7 +41,9 @@ After implementing configurable tool container home directories, new `pi-agent`
6. Remove the explicit repo mount from the built-in `pi-agent` manifest so the repo mount is synthesized by `compile_compose` rather than depending on tool config. Add a follow-up Alembic data migration that strips the `source_type: repo` mount from the manifest. 6. Remove the explicit repo mount from the built-in `pi-agent` manifest so the repo mount is synthesized by `compile_compose` rather than depending on tool config. Add a follow-up Alembic data migration that strips the `source_type: repo` mount from the manifest.
7. Add `_get_repository_mount_name()` helper. When the instance is bound to a workspace, the helper returns the basename of `workspace.path`. For legacy repo-only instances it falls back to parsing the remote URL like `git clone` would, then to the user-provided repository name. 7. Add `_get_repository_mount_name()` helper. When the instance is bound to a workspace, the helper returns the basename of `workspace.path`. For legacy repo-only instances it falls back to parsing the remote URL like `git clone` would, then to the user-provided repository name.
8. Switch workspace storage layout to `/data/working-copies/{workspace_id}/{repo_name}/` so `git clone` creates the repo-named directory naturally, making `workspace.path.basename` the correct container mount name. This replaces the previous `/data/working-copies/{repo_id}/{workspace_name}/` layout. 8. Switch workspace storage layout to `/data/working-copies/{workspace_id}/{repo_name}/` so `git clone` creates the repo-named directory naturally, making `workspace.path.basename` the correct container mount name. This replaces the previous `/data/working-copies/{repo_id}/{workspace_name}/` layout.
9. Update unit tests for the new behavior. 9. Stage every config-profile bind-mount source into the instance directory before compose generation. This makes writable mounts user-owned without changing the shared canonical profile source.
10. Replace overlapping profile and Git bind mounts with a per-instance composite source. The composite copies Git content first and profile content second, preserving Git siblings while allowing profile files to override matching paths; it is then chowned with the other staged mounts. This removes duplicate/nested Docker mounts rather than relying on mount order to merge them.
11. Update unit tests for the new behavior.
## Affected files ## Affected files
@@ -12,5 +12,8 @@
- [x] Remove compose-level `user: 0:0` override so entrypoint can drop privileges - [x] Remove compose-level `user: 0:0` override so entrypoint can drop privileges
- [x] Pass manifest-declared container user to terminal sessions via `docker exec --user` - [x] Pass manifest-declared container user to terminal sessions via `docker exec --user`
- [x] Update unit tests for container user/terminal changes - [x] Update unit tests for container user/terminal changes
- [x] Stage profile bind mounts per instance so writable sources are owned by the container user
- [x] Composite overlapping profile and Git mounts into one per-instance bind source
- [x] Add regression tests for mount composition, ownership staging, and mount order
- [ ] Run quality gates for container user/terminal changes - [ ] Run quality gates for container user/terminal changes
- [ ] Commit and push - [ ] Commit and push
@@ -16,14 +16,16 @@ Chained PRs recommended: Yes
Chain strategy: feature-branch-chain Chain strategy: feature-branch-chain
400-line budget risk: High 400-line budget risk: High
## Tasks ## Execution Plan
- [ ] **RED/GREEN — shared container user:** standardize built-in tool images, manifests, and permission handling on one shared non-root user/group; detect incompatible legacy images. 1. [x] **Canonical non-Git mounts:** bind declared profile files and mount directories directly from profile-scoped canonical storage; ensure the container user owns writable canonical sources.
- [ ] **RED/GREEN — canonical profile storage:** create canonical host-side directories/files per profile and mount them directly into compatible instances, without masking workspace mounts. 2. [x] **Editor synchronization:** materialize editor saves into canonical storage and return canonical declared-file content through profile responses; add explicit Reload working copy controls in desktop and mobile editors.
- [ ] **TRIANGULATE — writable sharing:** prove UI and container edits are shared across instances, with last-writer-wins overwrite warnings. 3. [x] **Writable Git working copies:** expose profile-scoped Git sources as writable mounts and replace them from the configured remote ref on explicit refresh.
- [ ] **RED/GREEN — topology/API contract:** return restart-required or incompatible-permissions outcomes for paths that cannot mount live; defer Git mount mutation. 4. [x] **Server refresh contract:** require explicit destructive-refresh confirmation at the API boundary and return a typed outcome when confirmation is missing.
- [ ] **RED/GREEN — UI feedback:** show shared-working-copy, warning, restart-required, and incompatible-permissions results in desktop and mobile profile editors. 5. [x] **Overlap safety:** replace profile/Git composite snapshots with child file-level canonical profile overlays so Git directory mounts remain intact.
- [ ] **Verify:** run targeted backend/frontend tests, typecheck, lint, image/manifest checks, and manual multi-instance permission tests. 6. [x] **Outcome UI:** display destructive-refresh confirmation and explicit Reload working copy controls in both editor layouts; restart-required/overwrite states remain available as API errors/outcomes.
7. [x] **Focused tests:** cover destructive refresh and overlap behavior at service/API/frontend levels; canonical source reuse is covered by resolver tests.
8. [x] **Verify:** targeted backend/frontend tests, typecheck, lint, and diagnostics passed. Docker multi-instance validation remains skipped by user choice.
## Verification Notes ## Verification Notes
@@ -0,0 +1,26 @@
# Live Git Config Mount Refresh
## Why
Git-backed Config Profile mounts are currently cloned per instance. Their content cannot be refreshed consistently for running sessions, and writable container mounts can dirty the checkout.
## Change
Move Git Config Profile mounts to profile-scoped writable working copies shared by compatible instances and the profile editor. Refresh a stable branch/ref checkout in place under a per-clone lock, explicitly replacing local working-copy edits.
## Scope
- Canonical clone identity: profile, normalized remote, requested ref, and credential scope.
- In-place refresh for existing directory mounts only.
- Explicit outcomes for live refresh, restart-required topology changes, and refresh failures.
- Writable profile-scoped Git working copies shared by compatible instances and the profile editor.
- Explicit destructive-refresh warning before local Git working-copy edits are replaced.
- An in-progress indicator that prevents duplicate refresh requests in desktop and mobile Config Profile views.
## Out of scope
- Per-instance writable Git working copies that diverge from the profile-scoped working copy.
- Global cross-user clone sharing.
- Live mount-topology changes, direct-file mappings, or glob match-set changes.
- Atomic all-files revision switching for processes already reading the mount.
- Automatic refresh of an editor buffer that has already loaded a mounted file.
@@ -0,0 +1,32 @@
# Design: Live Git Config Mount Refresh
## Canonical source
Each selected Config Profile owns a writable Git working copy beneath:
```text
<instance-root>/config-profiles/<profile-id>/git-mounts/<identity>/repo
```
`identity` is a stable hash of normalized remote URL, requested ref, and credential scope. Sources are deliberately profile-scoped; working copies are never shared across users, but are shared by compatible instances that selected the same profile.
## Runtime behavior
1. Resolve Git mounts and map them to canonical sources.
2. Acquire an exclusive lock for clone, fetch, ref resolution, and checkout.
3. Clone into a temporary sibling, then rename on initial creation.
4. For refresh, fetch and hard-reset the existing working tree in place, replacing local container/editor edits after an explicit warning.
5. Bind directory mappings writable. Compatible containers and the profile editor share the same working-copy files.
6. When profile and Git mount paths overlap, the instance-local composite source must be synchronized in place during refresh; replacing its root directory would leave a running bind mount attached to the old inode.
## Boundaries
- URL/ref/source/target/mode changes, direct-file mappings, and changed glob result sets return `restart_required`.
- Refresh failure is reported without mutating a known-good checkout.
- No non-Git profile content may be copied into a Git checkout; overlapping targets are rejected or reported.
- A refresh warning must state that local Git working-copy edits will be replaced.
- A browser editor that already has a file open is not a filesystem watcher; the user must reload that editor buffer after the mounted source changes.
## Security
Host Git operations use only an authorized server-side credential source. Credentials are not part of the mounted checkout and are not exposed to containers.
@@ -0,0 +1,16 @@
# Live Git Config Mount Refresh — Tasks
- [x] Add canonical profile-scoped Git clone source planning and clone identity helpers.
- [x] Make Git Config Profile mounts profile-scoped writable working copies shared with compatible instances and the editor.
- [x] Add lock-protected destructive refresh with an explicit local-edit replacement warning.
- [x] Add save/refresh outcomes for live refresh, destructive-refresh confirmation, and failures; use file-level overlays to avoid live topology changes.
- [x] Add an in-progress desktop/mobile indicator that disables duplicate Git-mount refresh requests.
- [x] Replace instance-local composites with file-level canonical profile overlays, so live updates do not depend on composite synchronization.
- [x] Add focused resolver/service/API/frontend tests.
- [x] Run available verification and document skipped checks.
## Verification Notes
- Passed: 47 targeted backend tests, Ruff, mypy, frontend API test, and frontend production build.
- Skipped: Docker/manual multi-instance checks were explicitly declined.
- Known tooling limitation: project-map patching fails before execution because its runtime sends an unsupported `temperature` parameter.