Compare commits

...

8 Commits

Author SHA1 Message Date
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
Developer add7c1b500 feat: add live config profile refresh
- Standardize built-in tool users for shared writable profile mounts
- Mount canonical non-Git profile sources across compatible instances
- Report restart-required outcomes and guard active profile deletion
- Surface restart feedback in config profile editing

Quality gates: frontend build passed; backend py_compile and LSP passed.
Skipped: backend pytest/Ruff unavailable; Docker/manual checks not approved.
2026-07-21 11:12:41 +00:00
28 changed files with 1149 additions and 352 deletions
+84 -2
View File
@@ -1,6 +1,7 @@
"""Config profile API endpoints.""" """Config profile API endpoints."""
import logging import logging
import os
import uuid import uuid
from fastapi import APIRouter, Depends, HTTPException, Query, status from fastapi import APIRouter, Depends, HTTPException, Query, status
@@ -9,7 +10,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.models import ConfigProfile, ConfigProfileInclude, UserConfig from src.models import ConfigProfile, ConfigProfileInclude, ToolInstance, UserConfig
from src.schemas.config import ( from src.schemas.config import (
ConfigProfileCreate, ConfigProfileCreate,
ConfigProfileIncludeUpdate, ConfigProfileIncludeUpdate,
@@ -43,6 +44,34 @@ logger = logging.getLogger(__name__)
router = APIRouter(prefix="/config-profiles", tags=["config-profiles"]) router = APIRouter(prefix="/config-profiles", tags=["config-profiles"])
async def _running_profile_outcomes(
session: AsyncSession, profile_id: uuid.UUID
) -> list[dict[str, str]]:
"""Report running instances that must restart to adopt a profile revision."""
result = await session.execute(
select(ToolInstance).where(ToolInstance.status == "running")
)
outcomes: list[dict[str, str]] = []
for instance in result.scalars().all():
if instance.selected_config_profile_id is None:
continue
resolved = await resolve_profile(session, instance.selected_config_profile_id)
dependencies = {resolved.profile_id} | {
uuid.UUID(item["id"])
for item in resolved.included_profiles
if item.get("id")
}
if profile_id in dependencies:
outcomes.append(
{
"instance_id": str(instance.id),
"status": "restart_required",
"reason": "Existing instance must restart to adopt shared profile mounts",
}
)
return outcomes
@router.get("", response_model=list[ConfigProfileResponse]) @router.get("", response_model=list[ConfigProfileResponse])
async def list_config_profiles( async def list_config_profiles(
project_id: str | None = Query(None, description="Filter by project compatibility"), project_id: str | None = Query(None, description="Filter by project compatibility"),
@@ -140,8 +169,51 @@ async def update_config_profile(
) )
profile = await update_profile(session, profile, data) profile = await update_profile(session, profile, data)
response = profile_to_response(profile)
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 profile_to_response(profile) return response
@router.post("/{profile_id}/refresh-git-mounts")
async def refresh_profile_git_mounts(
profile_id: str,
current_user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
):
"""Refresh canonical Git mount sources used by running profile instances."""
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)
@@ -161,6 +233,16 @@ async def delete_config_profile(
status_code=status.HTTP_403_FORBIDDEN, detail="Not authorized" status_code=status.HTTP_403_FORBIDDEN, detail="Not authorized"
) )
outcomes = await _running_profile_outcomes(session, profile.id)
if outcomes:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail={
"message": "Profile is still used by running instances",
"outcomes": outcomes,
},
)
await session.delete(profile) await session.delete(profile)
await session.commit() await session.commit()
@@ -240,6 +240,12 @@ class ConfigProfileIncludeUpdate(BaseModel):
return v return v
class ConfigProfileRefreshOutcome(BaseModel):
instance_id: str
status: str
reason: str | None = None
class ConfigProfileResponse(BaseModel): class ConfigProfileResponse(BaseModel):
id: str id: str
user_id: str user_id: str
@@ -256,6 +262,7 @@ class ConfigProfileResponse(BaseModel):
includes: list[dict] includes: list[dict]
created_at: str created_at: str
updated_at: str updated_at: str
refresh_outcomes: list[ConfigProfileRefreshOutcome] = Field(default_factory=list)
class DefaultProfilesUpdate(BaseModel): class DefaultProfilesUpdate(BaseModel):
+88 -52
View File
@@ -5,47 +5,17 @@ import logging
from sqlalchemy import select, text from sqlalchemy import select, text
from src.database import SessionLocal from src.database import SessionLocal
from src.models import ToolType from src.models import ToolDefinitionManifest, ToolType
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# All supported built-in tools run their long-lived process with these IDs.
# Canonical writable Config Profile mounts can therefore be shared without
# per-instance ownership changes.
BUILTIN_USER_UID = 1000
BUILTIN_USER_GID = 1000
async def _table_exists(session, table_name: str) -> bool: BUILTIN_TOOL_TYPES = [
"""Check if a table exists in the database."""
try:
result = await session.execute(
text(
"""
SELECT EXISTS (
SELECT FROM information_schema.tables
WHERE table_schema = 'public'
AND table_name = :table_name
)
"""
),
{"table_name": table_name},
)
return result.scalar() or False
except Exception:
return False
async def seed_builtin_tool_types():
"""Create or update built-in tool types in the database.
Built-in tool types have no creator (created_by_id=None) and provide
out-of-the-box tools for users without requiring manual tool creation.
"""
async with SessionLocal() as session:
# Check if tool_types table exists before attempting to seed
if not await _table_exists(session, "tool_types"):
logger.warning(
"tool_types table does not exist. Skipping seeding. "
"Migrations may not have run yet."
)
return
builtin_types = [
{ {
"name": "code-server", "name": "code-server",
"display_name": "VS Code Server", "display_name": "VS Code Server",
@@ -83,6 +53,8 @@ services:
container_name: {{TOOL_NAME}} container_name: {{TOOL_NAME}}
environment: environment:
- JUPYTER_ENABLE_LAB=yes - JUPYTER_ENABLE_LAB=yes
- NB_UID=1000
- NB_GID=1000
volumes: volumes:
- {{REPO_PATH}}:/home/jovyan/work - {{REPO_PATH}}:/home/jovyan/work
ports: ports:
@@ -102,31 +74,95 @@ services:
opencode: opencode:
image: node:20-slim image: node:20-slim
container_name: {{TOOL_NAME}} container_name: {{TOOL_NAME}}
working_dir: /home/user/{{WORKSPACE_NAME}} working_dir: /home/node/{{WORKSPACE_NAME}}
volumes: volumes:
- {{REPO_PATH}}:/home/user/{{WORKSPACE_NAME}} - {{REPO_PATH}}:/home/node/{{WORKSPACE_NAME}}
ports: ports:
- "3000:3000" - "3000:3000"
command: > command: >
sh -c "set -x && sh -ec "apt-get update && apt-get install -y git ca-certificates &&
apt-get update && apt-get install -y git ca-certificates && npm install -g opencode-ai &&
echo 'Installing opencode...' && exec setpriv --reuid=node --regid=node --init-groups opencode server"
npm install -g opencode-ai 2>&1 || echo 'ERROR: npm install failed' &&
which opencode || echo 'ERROR: opencode not in PATH' &&
npm bin -g &&
ls -la $(npm bin -g) || echo 'ERROR: global bin dir not found' &&
echo 'export PATH=\"$(npm bin -g):\\$PATH\"' >> /root/.bashrc &&
echo 'cd /home/user/{{WORKSPACE_NAME}}' >> /root/.bashrc &&
echo 'OpenCode installation complete' &&
exec tail -f /dev/null"
stdin_open: true stdin_open: true
tty: true tty: true
restart: 'no'""", restart: 'no'""",
"required_variables": ["REPO_PATH", "TOOL_NAME"], "required_variables": ["REPO_PATH", "TOOL_NAME"],
}, },
] ]
for tool_data in builtin_types:
def _standardize_builtin_manifest_user(manifest: dict) -> bool:
"""Set the built-in manifest user to the shared UID/GID in place.
The helper deliberately recognizes only Headquarter's conventional
``user`` account so it cannot rewrite a future custom tool definition.
"""
user = manifest.get("user")
if not isinstance(user, dict) or user.get("name") != "user":
return False
changed = user.get("uid") != BUILTIN_USER_UID or user.get("gid") != BUILTIN_USER_GID
if changed:
user["uid"] = BUILTIN_USER_UID
user["gid"] = BUILTIN_USER_GID
return changed
async def _standardize_pi_agent_manifest(session) -> None:
"""Bring the built-in Pi Agent manifest in line with shared mount IDs."""
manifest_definition = await session.scalar(
select(ToolDefinitionManifest).where(
ToolDefinitionManifest.name == "pi-agent",
ToolDefinitionManifest.created_by_id.is_(None),
)
)
if manifest_definition is None:
return
manifest = dict(manifest_definition.manifest)
if _standardize_builtin_manifest_user(manifest):
manifest_definition.manifest = manifest
logger.info("Standardized built-in Pi Agent user to 1000:1000")
async def _table_exists(session, table_name: str) -> bool:
"""Check if a table exists in the database."""
try:
result = await session.execute(
text(
"""
SELECT EXISTS (
SELECT FROM information_schema.tables
WHERE table_schema = 'public'
AND table_name = :table_name
)
"""
),
{"table_name": table_name},
)
return result.scalar() or False
except Exception:
return False
async def seed_builtin_tool_types():
"""Create or update built-in tool types in the database.
Built-in tool types have no creator (created_by_id=None) and provide
out-of-the-box tools for users without requiring manual tool creation.
"""
async with SessionLocal() as session:
# Check if tool_types table exists before attempting to seed
if not await _table_exists(session, "tool_types"):
logger.warning(
"tool_types table does not exist. Skipping seeding. "
"Migrations may not have run yet."
)
return
await _standardize_pi_agent_manifest(session)
for tool_data in BUILTIN_TOOL_TYPES:
existing = await session.scalar( existing = await session.scalar(
select(ToolType).where(ToolType.name == tool_data["name"]) select(ToolType).where(ToolType.name == tool_data["name"])
) )
@@ -6,6 +6,7 @@ and cycle protection.
import logging import logging
import os import os
import tempfile
import uuid import uuid
from dataclasses import dataclass, field from dataclasses import dataclass, field
from typing import Any from typing import Any
@@ -481,6 +482,7 @@ def apply_resolved_profile(
instance_dir: str, instance_dir: str,
resolved: ResolvedProfile, resolved: ResolvedProfile,
home_dir: str = "/root", home_dir: str = "/root",
working_dir: str | None = None,
) -> tuple[dict[str, str], dict[str, str], list[dict], dict[str, Any]]: ) -> tuple[dict[str, str], dict[str, str], list[dict], dict[str, Any]]:
"""Apply a resolved profile to an instance directory. """Apply a resolved profile to an instance directory.
@@ -489,6 +491,8 @@ def apply_resolved_profile(
Args: Args:
instance_dir: Path to the instance directory. instance_dir: Path to the instance directory.
resolved: The resolved profile. resolved: The resolved profile.
home_dir: Container home directory used for path expansion.
working_dir: Container working directory for top-level profile files.
Returns: Returns:
Tuple of (env_vars, files, volume_mounts, runtime_hints). Tuple of (env_vars, files, volume_mounts, runtime_hints).
@@ -501,49 +505,57 @@ def apply_resolved_profile(
instance_path = Path(instance_dir) instance_path = Path(instance_dir)
env_vars = dict(resolved.env_vars) env_vars = dict(resolved.env_vars)
files = dict(resolved.files) working_dir = working_dir or home_dir
volume_mounts = [] volume_mounts = []
# Write profile files to instance directory # Profile content belongs to the profile, not an individual tool instance.
for file_path, content in files.items(): # Keeping it beside the instance root gives every compatible instance the
full_path = instance_path / file_path # same host source while retaining the existing instance storage setting.
try: profile_dir = instance_path.parent / "config-profiles" / str(resolved.profile_id)
full_path.resolve().relative_to(instance_path.resolve()) files_dir = profile_dir / "files"
except ValueError: mounts_dir = profile_dir / "mounts"
logger.warning(
"Profile file path escapes instance directory: %s", file_path
)
continue
full_path.parent.mkdir(parents=True, exist_ok=True)
full_path.write_text(content)
# Stage mount directories and prepare directory-level volume mounts. def write_canonical_file(root: Path, relative_path: str, content: str) -> Path | None:
# Each ResolvedMount targets a container directory; we stage all of its path = root / relative_path
# files under a single host directory and bind-mount that directory. This try:
# keeps the target directory writable by the container user, instead of path.resolve().relative_to(root.resolve())
# having Docker create a root-owned parent directory when only individual except ValueError:
# files are mounted. logger.warning("Profile file path escapes canonical storage: %s", relative_path)
return None
path.parent.mkdir(parents=True, exist_ok=True)
with tempfile.NamedTemporaryFile(
mode="w", encoding="utf-8", dir=path.parent, delete=False
) as temporary_file:
temporary_file.write(content)
temporary_path = Path(temporary_file.name)
temporary_path.replace(path)
return path
# Top-level profile files are individual bind mounts under the working
# directory. They therefore cannot mask the workspace directory itself.
for file_path, content in resolved.files.items():
canonical_file = write_canonical_file(files_dir, file_path, content)
if canonical_file is None:
continue
volume_mounts.append(
{
"source": str(canonical_file),
"target": os.path.normpath(os.path.join(working_dir, file_path)),
"type": "bind",
"readonly": False,
}
)
# Explicit profile mounts remain directory-level bind mounts, but use the
# same profile-scoped canonical source for every instance.
for mount in resolved.mounts.values(): for mount in resolved.mounts.values():
if not mount.files: if not mount.files:
continue continue
expanded_target = os.path.normpath( expanded_target = os.path.normpath(expand_container_path(mount.target, home_dir))
expand_container_path(mount.target, home_dir) mount_dir = mounts_dir / expanded_target.lstrip("/").replace("/", "_")
)
mount_dir = (
instance_path / "mounts" / expanded_target.lstrip("/").replace("/", "_")
)
mount_dir.mkdir(parents=True, exist_ok=True)
for file_path, content in mount.files.items(): for file_path, content in mount.files.items():
full_path = mount_dir / file_path write_canonical_file(mount_dir, file_path, content)
try:
full_path.resolve().relative_to(mount_dir.resolve())
except ValueError:
logger.warning("Mount file path escapes mount directory: %s", file_path)
continue
full_path.parent.mkdir(parents=True, exist_ok=True)
full_path.write_text(content)
volume_mounts.append( volume_mounts.append(
{ {
@@ -554,7 +566,9 @@ def apply_resolved_profile(
} }
) )
return env_vars, files, volume_mounts, resolved.runtime_hints # Files are now mounted directly from canonical storage, not copied into
# the instance directory for write_config_files().
return env_vars, {}, volume_mounts, resolved.runtime_hints
def expand_container_path(path: str, home_dir: str) -> str: def expand_container_path(path: str, home_dir: str) -> str:
+249 -67
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,11 +127,11 @@ 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 instance-local mount sources to the container user.
Config-profile mounts, git mounts, and SSH key mounts are staged under Profile copies, profile/Git composites, and SSH key mounts are created by
instance_dir by the API process (root). Without this, the container the API process. Their sources must be owned by the target container user
user cannot write into bind-mounted directories such as ~/.config. before Docker bind-mounts them into writable paths.
""" """
for vol in extra_volumes: for vol in extra_volumes:
source = vol.get("source", "") source = vol.get("source", "")
@@ -154,61 +156,180 @@ 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. """Build instance-local composite mounts for overlapping profile and Git paths.
When a config profile mounts static files to the same directory as a Docker applies one bind mount per target; it never merges their contents.
git-mount (e.g. ``~/.pi``), a directory-level bind mount for the profile A composite therefore copies shared Git content first and profile content
would mask the cloned repository. Instead, copy the profile files into second, so profile files extend (and intentionally override) the Git tree
the git-mount source directory so the container sees both sets of files without dirtying the shared clone.
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] = [] records: list[tuple[str, dict]] = [
for pvol in profile_mounts: ("profile", mount) for mount in profile_mounts
p_source = pvol.get("source", "") ] + [("git", mount) for mount in git_mount_volumes]
p_target = pvol.get("target", "") components: list[list[int]] = []
if not p_source or not os.path.exists(p_source): remaining: set[int] = set(range(len(records)))
remaining.append(pvol)
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 continue
merged = False targets = [
for gvol in git_mount_volumes: os.path.normpath(mount["target"]) for _kind, mount in component_records
g_source = gvol.get("source", "") ]
g_target = gvol.get("target", "") composite_target = os.path.commonpath(targets)
if not g_source or not os.path.isdir(g_source): if any(
continue 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)
rel = _relative_under(g_target, p_target) digest_input = "\0".join(
if rel is None: f"{kind}:{mount['source']}:{mount['target']}"
continue for kind, mount in component_records
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 composite_source = os.path.join(
break 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
if not merged: for kind in ("git", "profile"):
remaining.append(pvol) 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 "")
)
_copy_mount_source(mount["source"], destination)
return remaining 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( async def resolve_git_mounts(
@@ -227,12 +348,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 +392,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 +583,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 +605,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 +626,12 @@ 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
)
for volume in volumes:
volume["readonly"] = True
return volumes
def checkout_branch(repo_path: str, branch: str) -> bool: def checkout_branch(repo_path: str, branch: str) -> bool:
@@ -1402,17 +1565,22 @@ async def start_tool_instance(
resolved = await resolve_profile( resolved = await resolve_profile(
session, instance.selected_config_profile_id session, instance.selected_config_profile_id
) )
# Profile working-directory hints determine where individual
# canonical profile files are bind-mounted at container creation.
profile_hints = resolved.runtime_hints
if profile_hints.get("working_directory"):
working_directory = expand_container_path(
profile_hints["working_directory"], home_dir
)
profile_env, profile_files, profile_mounts, profile_hints = ( profile_env, profile_files, profile_mounts, profile_hints = (
apply_resolved_profile(instance_dir, resolved, home_dir) apply_resolved_profile(
instance_dir, resolved, home_dir, working_directory
)
) )
# Profile hints override the manifest/tool defaults, and git mounts # Profile hints override the manifest/tool defaults, and git mounts
# need the final working directory to resolve relative target paths. # need the final working directory to resolve relative target paths.
if profile_hints.get("start_command"): if profile_hints.get("start_command"):
start_command = profile_hints["start_command"] start_command = profile_hints["start_command"]
if profile_hints.get("working_directory"):
working_directory = expand_container_path(
profile_hints["working_directory"], home_dir
)
if profile_hints.get("port_override"): if profile_hints.get("port_override"):
port_override = profile_hints["port_override"] port_override = profile_hints["port_override"]
env_vars.update(profile_env) env_vars.update(profile_env)
@@ -1420,22 +1588,22 @@ 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 staged_profile_mounts = _stage_profile_mounts(profile_mounts, instance_dir)
# not mask each other when they target the same directory. composed_mounts = _stack_profile_mounts_with_git_mounts(
stacked_profile_mounts = _stack_profile_mounts_with_git_mounts( staged_profile_mounts,
profile_mounts, git_mount_volumes 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(staged_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(
@@ -1476,7 +1644,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")
try:
os.makedirs(ssh_dir, exist_ok=True) 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:
@@ -2189,7 +2365,13 @@ async def delete_tool_instance(
if os.path.exists(instance_dir): if os.path.exists(instance_dir):
import shutil import shutil
try:
shutil.rmtree(instance_dir) 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,62 @@
"""Tests for the shared non-root user used by built-in tools."""
from pathlib import Path
from src.seeds.builtin_tool_types import (
BUILTIN_TOOL_TYPES,
BUILTIN_USER_GID,
BUILTIN_USER_UID,
_standardize_builtin_manifest_user,
)
def test_builtin_compose_templates_use_shared_runtime_ids() -> None:
"""Every legacy built-in Compose tool declares the shared UID/GID."""
templates = {
str(tool["name"]): str(tool["compose_template"]) for tool in BUILTIN_TOOL_TYPES
}
assert "- PUID=1000" in templates["code-server"]
assert "- PGID=1000" in templates["code-server"]
assert "- NB_UID=1000" in templates["jupyter-notebook"]
assert "- NB_GID=1000" in templates["jupyter-notebook"]
assert "setpriv --reuid=node --regid=node --init-groups" in templates["opencode"]
def test_only_builtin_user_manifest_is_standardized() -> None:
"""The startup migration cannot rewrite a future custom tool user."""
builtin_manifest = {"user": {"name": "user", "uid": 1001, "gid": 1001}}
custom_manifest = {"user": {"name": "custom", "uid": 2000, "gid": 2000}}
assert _standardize_builtin_manifest_user(builtin_manifest)
assert builtin_manifest["user"] == {
"name": "user",
"uid": BUILTIN_USER_UID,
"gid": BUILTIN_USER_GID,
}
assert not _standardize_builtin_manifest_user(custom_manifest)
assert custom_manifest["user"] == {"name": "custom", "uid": 2000, "gid": 2000}
def test_tool_image_templates_define_shared_ids() -> None:
"""Project-owned image templates explicitly create or map UID/GID 1000."""
root = Path(__file__).resolve().parents[4]
sources = {
name: (root / "tool-images" / name).read_text()
for name in (
"base.dockerfile",
"opencode.dockerfile",
"pi-agent.dockerfile",
"code-server.dockerfile",
"jupyter.dockerfile",
)
}
for name in ("base.dockerfile", "opencode.dockerfile", "pi-agent.dockerfile"):
assert "groupadd -g 1000 user" in sources[name]
assert "useradd -m -u 1000 -g 1000" in sources[name]
assert "PUID=1000" in sources["code-server.dockerfile"]
assert "PGID=1000" in sources["code-server.dockerfile"]
assert "NB_UID=1000" in sources["jupyter.dockerfile"]
assert "NB_GID=1000" in sources["jupyter.dockerfile"]
@@ -532,6 +532,54 @@ class TestApplyResolvedProfile:
assert Path(volumes[0]["source"]).name == "workspace_x_y" assert Path(volumes[0]["source"]).name == "workspace_x_y"
assert (Path(volumes[0]["source"]) / "z.json").exists() assert (Path(volumes[0]["source"]) / "z.json").exists()
def test_top_level_files_use_profile_scoped_direct_bind_mounts(self, tmp_path) -> None:
"""Top-level files are shared safely without mounting over a workspace."""
profile_id = uuid.uuid4()
resolved = ResolvedProfile(
profile_id=profile_id,
profile_name="test",
files={".tool/config.toml": "setting = true"},
)
instance_root = tmp_path / "instances"
_, files, volumes, _ = apply_resolved_profile(
str(instance_root / "instance-a"),
resolved,
working_dir="/workspace/project",
)
canonical_file = (
instance_root / "config-profiles" / str(profile_id) / "files" / ".tool" / "config.toml"
)
assert files == {}
assert volumes == [
{
"source": str(canonical_file),
"target": "/workspace/project/.tool/config.toml",
"type": "bind",
"readonly": False,
}
]
assert canonical_file.read_text() == "setting = true"
def test_instances_share_profile_scoped_mount_sources(self, tmp_path) -> None:
"""Different instance paths resolve a profile to one canonical source."""
profile_id = uuid.uuid4()
resolved = ResolvedProfile(
profile_id=profile_id,
profile_name="test",
mounts={"/app": ResolvedMount(target="/app", mode="rw", files={"config.ini": "x"})},
)
instance_root = tmp_path / "instances"
_, _, first_volumes, _ = apply_resolved_profile(str(instance_root / "instance-a"), resolved)
_, _, second_volumes, _ = apply_resolved_profile(str(instance_root / "instance-b"), resolved)
assert first_volumes[0]["source"] == second_volumes[0]["source"]
assert first_volumes[0]["source"] == str(
instance_root / "config-profiles" / str(profile_id) / "mounts" / "app"
)
def test_empty_mount_produces_no_volumes(self, tmp_path) -> None: def test_empty_mount_produces_no_volumes(self, tmp_path) -> None:
"""A mount with no files should not produce any volume entries.""" """A mount with no files should not produce any volume entries."""
resolved = ResolvedProfile( resolved = ResolvedProfile(
@@ -579,7 +627,7 @@ class TestApplyResolvedProfile:
assert len(volumes) == 1 assert len(volumes) == 1
assert volumes[0]["target"] == "/etc/app" assert volumes[0]["target"] == "/etc/app"
assert volumes[0].get("readonly") is True assert volumes[0].get("readonly")
def test_writable_mount_does_not_set_readonly_flag(self, tmp_path) -> None: def test_writable_mount_does_not_set_readonly_flag(self, tmp_path) -> None:
"""A mount with mode 'rw' should not set readonly on the volume entry.""" """A mount with mode 'rw' should not set readonly on the volume entry."""
@@ -598,7 +646,7 @@ class TestApplyResolvedProfile:
assert len(volumes) == 1 assert len(volumes) == 1
assert volumes[0]["target"] == "/app" assert volumes[0]["target"] == "/app"
assert volumes[0].get("readonly") is False assert not volumes[0].get("readonly")
class TestCheckIncludeCycle: class TestCheckIncludeCycle:
+150 -118
View File
@@ -2,13 +2,16 @@
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,
@@ -80,12 +83,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 +97,9 @@ 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_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 +126,185 @@ 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), "source": str(profile_source),
"target": "/home/user/.pi", "target": "/home/user/.pi",
"type": "bind", "type": "bind",
"readonly": False,
} }
] ],
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) == 1
assert (git_source / "existing.txt").read_text() == "from git" composite = result[0]
assert (git_source / "settings.json").read_text() == "{}" assert composite["target"] == "/home/user/.pi"
assert composite["source"].startswith(str(instance_dir))
assert not (tmp_path / "git" / "repo-clone" / "settings.json").exists()
assert (
tmp_path / "git" / "repo-clone" / "existing.txt"
).read_text() == "from git"
assert (tmp_path / "instance" / "mounts" / "composites").exists()
assert (Path(composite["source"]) / "existing.txt").read_text() == "from git"
assert (Path(composite["source"]) / "settings.json").read_text() == "{}"
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) == 1
assert (git_source / "agent" / "settings.json").read_text() == "x" composite = Path(result[0]["source"])
assert (git_source / "README").read_text() == "repo" assert result[0]["target"] == "/home/user/.pi"
assert (composite / "README").read_text() == "repo"
assert (composite / "agent" / "settings.json").read_text() == "x"
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), "source": str(profile_source),
"target": "/home/user/.pi/settings.json", "target": "/home/user/.pi/settings.json",
"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) == 1
assert (git_source / "settings.json").read_text() == "{}" composite = Path(result[0]["source"])
assert result[0]["target"] == "/home/user/.pi"
assert (composite / "README").read_text() == "repo"
assert (composite / "settings.json").read_text() == "{}"
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) == 1
composite = Path(result[0]["source"])
assert result[0]["target"] == "/home/user"
assert (composite / "config.toml").read_text() == "profile"
assert (composite / ".pi" / "plugin.toml").read_text() == "git"
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)
)
== profile_mounts + git_mounts
)
@pytest.mark.unit @pytest.mark.unit
+16
View File
@@ -1,5 +1,11 @@
import { apiClient } from "./client"; import { apiClient } from "./client";
export interface ConfigProfileRefreshOutcome {
instance_id: string;
status: "compatible" | "refreshed" | "restart_required" | "incompatible_permissions";
reason?: string;
}
export interface ConfigProfile { export interface ConfigProfile {
id: string; id: string;
user_id: string; user_id: string;
@@ -16,6 +22,7 @@ export interface ConfigProfile {
includes: ConfigProfileInclude[]; includes: ConfigProfileInclude[];
created_at: string; created_at: string;
updated_at: string; updated_at: string;
refresh_outcomes?: ConfigProfileRefreshOutcome[];
} }
export interface ConfigProfileMount { export interface ConfigProfileMount {
@@ -138,6 +145,15 @@ 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`);
return response.data;
};
export const updateProfileIncludes = async ( export const updateProfileIncludes = async (
id: string, id: string,
data: UpdateIncludesRequest, data: UpdateIncludesRequest,
@@ -14,6 +14,7 @@ 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;
projects: ProjectWithRepos[]; projects: ProjectWithRepos[];
toolTypes: ToolType[]; toolTypes: ToolType[];
availableProfiles: ConfigProfile[]; availableProfiles: ConfigProfile[];
@@ -23,6 +24,7 @@ interface Props {
onSubmit: (e?: React.FormEvent) => void; onSubmit: (e?: React.FormEvent) => void;
onReset: () => void; onReset: () => void;
onPreview: () => void; onPreview: () => 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 +56,7 @@ export const ConfigProfileEditorPanel = ({
saveStatus, saveStatus,
previewData, previewData,
previewingId, previewingId,
isRefreshingGitMounts,
projects, projects,
toolTypes, toolTypes,
availableProfiles, availableProfiles,
@@ -63,6 +66,7 @@ export const ConfigProfileEditorPanel = ({
onSubmit, onSubmit,
onReset, onReset,
onPreview, onPreview,
onRefreshGitMounts,
onAddInclude, onAddInclude,
onRemoveInclude, onRemoveInclude,
onDragStart, onDragStart,
@@ -114,6 +118,17 @@ 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={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,7 @@ interface Props {
availableProfiles: ConfigProfile[]; availableProfiles: ConfigProfile[];
previewData: ResolvedProfile | null; previewData: ResolvedProfile | null;
previewingId: string | null; previewingId: string | null;
isRefreshingGitMounts: 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 +65,7 @@ interface Props {
) => void; ) => void;
onRemoveMountFile: (mountIndex: number, path: string) => void; onRemoveMountFile: (mountIndex: number, path: string) => void;
onPreview: (id: string) => void; onPreview: (id: string) => void;
onRefreshGitMounts: (id: string) => void;
onClosePreview: () => void; onClosePreview: () => void;
} }
@@ -79,6 +81,7 @@ export const ConfigProfilesMobileView = ({
availableProfiles, availableProfiles,
previewData, previewData,
previewingId, previewingId,
isRefreshingGitMounts,
saveStatus, saveStatus,
error, error,
onViewChange, onViewChange,
@@ -104,6 +107,7 @@ export const ConfigProfilesMobileView = ({
onUpdateMountFile, onUpdateMountFile,
onRemoveMountFile, onRemoveMountFile,
onPreview, onPreview,
onRefreshGitMounts,
onClosePreview, onClosePreview,
}: Props) => { }: Props) => {
const [isSaving, setIsSaving] = useState(false); const [isSaving, setIsSaving] = useState(false);
@@ -363,6 +367,23 @@ 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={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"
+74 -14
View File
@@ -5,6 +5,7 @@ import {
deleteConfigProfile, deleteConfigProfile,
listConfigProfiles, listConfigProfiles,
previewConfigProfile, previewConfigProfile,
refreshConfigProfileGitMounts,
updateConfigProfile, updateConfigProfile,
updateProfileIncludes, updateProfileIncludes,
type ConfigProfile, type ConfigProfile,
@@ -34,17 +35,22 @@ export const useConfigProfiles = () => {
const [profiles, setProfiles] = useState<ConfigProfile[]>([]); const [profiles, setProfiles] = useState<ConfigProfile[]>([]);
const [projects, setProjects] = useState<ProjectWithRepos[]>([]); const [projects, setProjects] = useState<ProjectWithRepos[]>([]);
const [toolTypes, setToolTypes] = useState<ToolType[]>([]); const [toolTypes, setToolTypes] = useState<ToolType[]>([]);
const [selectedProfileId, setSelectedProfileId] = useState<string | null>(null); const [selectedProfileId, setSelectedProfileId] = useState<string | null>(
null,
);
const [isCreating, setIsCreating] = useState(false); const [isCreating, setIsCreating] = useState(false);
const [saveStatus, setSaveStatus] = useState<SaveStatus>("idle"); const [saveStatus, setSaveStatus] = useState<SaveStatus>("idle");
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 [formData, setFormData] = useState<CreateConfigProfileRequest>(defaultForm); const [isRefreshingGitMounts, setIsRefreshingGitMounts] = useState(false);
const [formData, setFormData] =
useState<CreateConfigProfileRequest>(defaultForm);
const [includedProfileIds, setIncludedProfileIds] = useState<string[]>([]); const [includedProfileIds, setIncludedProfileIds] = useState<string[]>([]);
const [dragOverIndex, setDragOverIndex] = useState<number | null>(null); const [dragOverIndex, setDragOverIndex] = useState<number | null>(null);
const selectedProfile = profiles.find((p) => p.id === selectedProfileId) || null; const selectedProfile =
profiles.find((p) => p.id === selectedProfileId) || null;
const loadData = useCallback(async () => { const loadData = useCallback(async () => {
setStatus("loading"); setStatus("loading");
@@ -89,7 +95,9 @@ export const useConfigProfiles = () => {
is_default: profile.is_default, is_default: profile.is_default,
}); });
setIncludedProfileIds( setIncludedProfileIds(
profile.includes.map((inc: { included_profile_id: string }) => inc.included_profile_id), profile.includes.map(
(inc: { included_profile_id: string }) => inc.included_profile_id,
),
); );
setError(null); setError(null);
setSaveStatus("idle"); setSaveStatus("idle");
@@ -208,20 +216,39 @@ export const useConfigProfiles = () => {
if (isCreating) { if (isCreating) {
const newProfile = await createConfigProfile(formData); const newProfile = await createConfigProfile(formData);
if (includedProfileIds.length > 0) { if (includedProfileIds.length > 0) {
await updateProfileIncludes(newProfile.id, { includes: includedProfileIds }); await updateProfileIncludes(newProfile.id, {
includes: includedProfileIds,
});
} }
setIsCreating(false); setIsCreating(false);
setSelectedProfileId(newProfile.id); setSelectedProfileId(newProfile.id);
setSaveStatus("saved"); setSaveStatus("saved");
await loadData(); await loadData();
const refreshed = (await listConfigProfiles()).find((p) => p.id === newProfile.id); const refreshed = (await listConfigProfiles()).find(
(p) => p.id === newProfile.id,
);
if (refreshed) populateForm(refreshed); if (refreshed) populateForm(refreshed);
} else if (selectedProfile) { } else if (selectedProfile) {
await updateConfigProfile(selectedProfile.id, formData); const updatedProfile = await updateConfigProfile(
await updateProfileIncludes(selectedProfile.id, { includes: includedProfileIds }); selectedProfile.id,
formData,
);
await updateProfileIncludes(selectedProfile.id, {
includes: includedProfileIds,
});
const restartOutcomes = updatedProfile.refresh_outcomes?.filter(
(outcome) => outcome.status === "restart_required",
);
if (restartOutcomes?.length) {
setError(
`Profile saved. ${restartOutcomes.length} running instance${restartOutcomes.length === 1 ? "" : "s"} must restart to adopt these changes.`,
);
}
setSaveStatus("saved"); setSaveStatus("saved");
await loadData(); await loadData();
const refreshed = (await listConfigProfiles()).find((p) => p.id === selectedProfile.id); const refreshed = (await listConfigProfiles()).find(
(p) => p.id === selectedProfile.id,
);
if (refreshed) populateForm(refreshed); if (refreshed) populateForm(refreshed);
} }
return true; return true;
@@ -233,7 +260,8 @@ export const useConfigProfiles = () => {
}; };
const handleDelete = async (id: string) => { const handleDelete = async (id: string) => {
if (!window.confirm("Are you sure you want to delete this config profile?")) return; if (!window.confirm("Are you sure you want to delete this config profile?"))
return;
try { try {
await deleteConfigProfile(id); await deleteConfigProfile(id);
if (selectedProfileId === id) { if (selectedProfileId === id) {
@@ -242,8 +270,32 @@ export const useConfigProfiles = () => {
resetForm(); resetForm();
} }
await loadData(); await loadData();
} catch { } catch (err) {
alert("Failed to delete config profile"); setError(extractErrorMessage(err));
}
};
const handleRefreshGitMounts = async (id: string): Promise<boolean> => {
if (isRefreshingGitMounts) 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);
} }
}; };
@@ -268,7 +320,10 @@ export const useConfigProfiles = () => {
}; };
const addEnvVar = () => { const addEnvVar = () => {
setFormData((prev) => ({ ...prev, env_vars: { ...prev.env_vars, "": "" } })); setFormData((prev) => ({
...prev,
env_vars: { ...prev.env_vars, "": "" },
}));
setSaveStatus("idle"); setSaveStatus("idle");
}; };
@@ -323,7 +378,10 @@ export const useConfigProfiles = () => {
setSaveStatus("idle"); setSaveStatus("idle");
}; };
const updateMount = (index: number, updates: Partial<ConfigProfile["mounts"][0]>) => { const updateMount = (
index: number,
updates: Partial<ConfigProfile["mounts"][0]>,
) => {
setFormData((prev) => { setFormData((prev) => {
const mounts = [...(prev.mounts || [])]; const mounts = [...(prev.mounts || [])];
mounts[index] = { ...mounts[index], ...updates }; mounts[index] = { ...mounts[index], ...updates };
@@ -393,6 +451,7 @@ export const useConfigProfiles = () => {
error, error,
previewData, previewData,
previewingId, previewingId,
isRefreshingGitMounts,
formData, formData,
includedProfileIds, includedProfileIds,
dragOverIndex, dragOverIndex,
@@ -402,6 +461,7 @@ export const useConfigProfiles = () => {
handleSubmit, handleSubmit,
handleDelete, handleDelete,
handlePreview, handlePreview,
handleRefreshGitMounts,
updateFormField, updateFormField,
addEnvVar, addEnvVar,
updateEnvVar, updateEnvVar,
@@ -23,6 +23,7 @@ export const ConfigProfilesPage = () => {
error, error,
previewData, previewData,
previewingId, previewingId,
isRefreshingGitMounts,
formData, formData,
includedProfileIds, includedProfileIds,
dragOverIndex, dragOverIndex,
@@ -32,6 +33,7 @@ export const ConfigProfilesPage = () => {
handleSubmit, handleSubmit,
handleDelete, handleDelete,
handlePreview, handlePreview,
handleRefreshGitMounts,
updateFormField, updateFormField,
addEnvVar, addEnvVar,
updateEnvVar, updateEnvVar,
@@ -110,6 +112,7 @@ export const ConfigProfilesPage = () => {
availableProfiles={availableProfilesForInclude()} availableProfiles={availableProfilesForInclude()}
previewData={previewData} previewData={previewData}
previewingId={previewingId} previewingId={previewingId}
isRefreshingGitMounts={isRefreshingGitMounts}
saveStatus={saveStatus} saveStatus={saveStatus}
error={error} error={error}
onViewChange={setMobileView} onViewChange={setMobileView}
@@ -135,6 +138,7 @@ export const ConfigProfilesPage = () => {
onUpdateMountFile={updateMountFile} onUpdateMountFile={updateMountFile}
onRemoveMountFile={removeMountFile} onRemoveMountFile={removeMountFile}
onPreview={handlePreview} onPreview={handlePreview}
onRefreshGitMounts={(id) => void handleRefreshGitMounts(id)}
onClosePreview={() => setPreviewData(null)} onClosePreview={() => setPreviewData(null)}
/> />
); );
@@ -167,6 +171,7 @@ export const ConfigProfilesPage = () => {
saveStatus={saveStatus} saveStatus={saveStatus}
previewData={previewData} previewData={previewData}
previewingId={previewingId} previewingId={previewingId}
isRefreshingGitMounts={isRefreshingGitMounts}
projects={projects} projects={projects}
toolTypes={toolTypes} toolTypes={toolTypes}
availableProfiles={availableProfilesForInclude()} availableProfiles={availableProfilesForInclude()}
@@ -176,6 +181,9 @@ export const ConfigProfilesPage = () => {
onSubmit={handleSubmit} onSubmit={handleSubmit}
onReset={handleReset} onReset={handleReset}
onPreview={() => selectedProfile && handlePreview(selectedProfile.id)} onPreview={() => selectedProfile && handlePreview(selectedProfile.id)}
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
@@ -0,0 +1,33 @@
# Live Config Profile Refresh
## Summary
Refresh profile-managed configuration for running tool instances when a Config Profile is saved, without recreating the container or terminal session.
## Scope
- Resolve the saved profile and refresh every running instance that selected it, including profiles that include it.
- Store non-Git profile configuration as a canonical, host-side working copy shared by every instance using the profile.
- Bind-mount canonical profile directories directly into their configured container targets and bind-mount canonical profile files individually under the container working directory, preserving the workspace mount.
- Allow UI and container-side edits to the same canonical files; last writer wins, with an overwrite warning when detectable.
- Defer Git mount refresh and Git-clone mutation to a separate commit-aware feature.
- Standardize all supported tool containers on one shared non-root user/group so canonical writable profile mounts remain accessible across instances.
- Return per-instance refresh results to the profile-save UI.
## Constraints
- Preserve running containers and terminal sessions.
- Preserve the workspace/repository mount.
- Docker bind-mount topology is immutable at runtime. Added, removed, retargeted, or mode-changed mounts MUST be reported as requiring restart, not partially applied.
- Desktop and mobile profile editors use the same save/refresh behavior.
- The standardized container user/group MUST be applied to built-in tool definitions, generated manifests, and image templates; legacy/incompatible tool images must report incompatible permissions rather than silently changing profile mount ownership.
## Acceptance Criteria
- [ ] Saving a profile refreshes every eligible running instance with a direct or transitive dependency on that profile.
- [ ] Canonical non-Git profile files and mount directories are shared writable working copies across compatible running instances.
- [ ] Container-side and UI-side changes become visible to all instances using the profile; last writer wins and detectable overwrites generate a warning.
- [ ] Git mount refresh and Git clone mutation are not performed by this feature.
- [ ] Built-in supported tool containers use a shared non-root user/group compatible with writable canonical profile mounts.
- [ ] Topology changes return a restart-required result without recreating the instance.
- [ ] Terminal WebSocket sessions remain connected throughout a successful refresh.
@@ -0,0 +1,17 @@
# Design: Live Config Profile Refresh
## Canonical working copies
Each non-Git Config Profile owns canonical host-side storage. Directory mounts use canonical profile directories; each top-level profile file uses a canonical host file bind-mounted under the container working directory. All compatible instances selected for the profile mount the same sources, so UI and container edits are immediately shared.
## Container compatibility
Supported built-in tools standardize on one non-root user/group. Existing instances retain their current image/user and report `restart_required`. Custom tools are not rewritten; tools that do not opt into the shared user/group return `incompatible_permissions`.
## Save behavior
A profile save writes canonical profile files atomically per file. The save response reports affected compatible instances, `restart_required` topology/runtime changes, `incompatible_permissions`, and detectable overwrite warnings. Last writer wins; no merge or lock protocol is imposed.
## Scope boundaries
Git mount mutation is explicitly deferred. Workspace/repository mounts are never changed. Profile deletion is rejected while running instances still use the profile.
@@ -0,0 +1,19 @@
# Implementation Plan: Live Config Profile Refresh
1. Standardize built-in tool user/group definitions and remove ownership-changing behavior for shared profile sources.
2. Add canonical profile storage and bind-mount compilation for profile directories and individual working-directory files.
3. Add compatibility/topology analysis, running-instance discovery, save-result schema, and deletion guard.
4. Wire desktop/mobile profile save results into immediate status/warning feedback.
5. Add unit, API, manifest/image, and frontend coverage; run quality gates and manual two-instance QA.
## Delivery order
1. Container-user compatibility
2. Canonical non-Git profile mounts
3. API and deletion semantics
4. UI feedback
5. Verification and commit
## Deferred
Git mount refresh and Git clone mutation require a commit-aware follow-up change.
@@ -0,0 +1,32 @@
# Live Config Profile Refresh — Tasks
## Review Workload Forecast
| Field | Value |
| --- | --- |
| Estimated changed lines | 500750 |
| 400-line budget risk | High |
| Chained PRs recommended | Yes |
| Suggested split | Container-user standardization → canonical profile mounts → API/UI feedback → verification |
| Delivery strategy | feature-branch-chain |
| Chain strategy | feature-branch-chain |
Decision needed before apply: No
Chained PRs recommended: Yes
Chain strategy: feature-branch-chain
400-line budget risk: High
## Tasks
- [ ] **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.
- [ ] **RED/GREEN — canonical profile storage:** create canonical host-side directories/files per profile and mount them directly into compatible instances, without masking workspace mounts.
- [ ] **TRIANGULATE — writable sharing:** prove UI and container edits are shared across instances, with last-writer-wins overwrite warnings.
- [ ] **RED/GREEN — topology/API contract:** return restart-required or incompatible-permissions outcomes for paths that cannot mount live; defer Git mount mutation.
- [ ] **RED/GREEN — UI feedback:** show shared-working-copy, warning, restart-required, and incompatible-permissions results in desktop and mobile profile editors.
- [ ] **Verify:** run targeted backend/frontend tests, typecheck, lint, image/manifest checks, and manual multi-instance permission tests.
## Verification Notes
- Passed: frontend production build (`npm run build`), Python compilation for changed backend modules, and targeted LSP diagnostics.
- Skipped: backend pytest and Ruff; this environment has no project-managed Python runner, system Python lacks those packages, and the user declined system-package installation.
- Skipped: Docker/Compose and manual multi-instance checks; explicit Docker approval was not granted.
@@ -0,0 +1,28 @@
# Test Plan: Live Config Profile Refresh
## Backend
- Canonical file and directory paths are profile-scoped and reject traversal.
- Two compatible instances receive the same host mount source.
- A container-side file edit is visible through the profile read API and another instance mount.
- A profile save updates canonical content and returns overwrite warnings when applicable.
- Topology, environment, runtime, and legacy-instance changes return `restart_required`.
- Incompatible users return `incompatible_permissions`.
- Deleting a profile with running dependents is rejected with their instance identifiers.
- Git mount content is unchanged by this feature.
## Container/image compatibility
- Each built-in supported image uses the common non-root UID/GID.
- Generated manifest Dockerfiles and entrypoints retain that user and writable mount access.
- Existing instances are not mutated until restart/recreation.
## Frontend
- Desktop and mobile save flows display refreshed/shared-working-copy, overwrite-warning, restart-required, and incompatible-permissions results.
## Manual QA
- Open two compatible instances using one profile; edit a mounted file in one terminal and verify it in the other.
- Save a profile edit and verify both running instances see it without terminal disconnection.
- Verify profile deletion is blocked while either instance is running.
@@ -0,0 +1,25 @@
# 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 canonical host clones. Bind the already-mounted directory sources read-only into compatible instances and refresh a stable branch/ref checkout in place under a per-clone lock.
## 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.
- Read-only container Git config mounts.
- An in-progress indicator that prevents duplicate refresh requests in desktop and mobile Config Profile views.
## Out of scope
- Writable shared Git configuration mounts.
- 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 canonical Git clone directories 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; clones are never shared across users.
## 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 update the existing working tree in place.
5. Bind directory mappings read-only. Existing containers see changed directory contents without recreation.
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.
- Containers must not write to shared Git mount sources.
- 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 read-only and prevent profile-content copy into Git sources.
- [x] Add lock-protected clone/fetch/ref checkout refresh that preserves a known-good checkout on failure.
- [x] Add save/refresh outcomes for live refresh, restart-required topology, and failures.
- [x] Add an in-progress desktop/mobile indicator that disables duplicate Git-mount refresh requests.
- [ ] Synchronize affected instance-local composite mounts in place so live refresh reaches running containers.
- [ ] Add focused resolver/service/API/frontend tests.
- [x] Run available verification and document skipped checks.
## Verification Notes
- Passed: frontend production build and Python compilation for changed backend modules.
- Skipped: backend pytest and Ruff are unavailable in this environment; Docker/manual live-session checks were not approved.
- Known tooling limitation: project-map patching fails before execution because its runtime sends an unsupported `temperature` parameter.
+4 -3
View File
@@ -20,9 +20,10 @@ RUN apt-get update && apt-get install -y \
sudo \ sudo \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
# Create a non-root user and allow passwordless sudo so the startup # Use the shared built-in UID/GID so writable Config Profile mounts can be
# permission fixer can adjust ownership of bind-mounted directories. # shared by compatible instances without ownership changes.
RUN useradd -m -s /bin/bash user \ RUN groupadd -g 1000 user \
&& useradd -m -u 1000 -g 1000 -s /bin/bash user \
&& echo "user ALL=(ALL) NOPASSWD:ALL" > /etc/sudoers.d/user \ && echo "user ALL=(ALL) NOPASSWD:ALL" > /etc/sudoers.d/user \
&& chmod 0440 /etc/sudoers.d/user && chmod 0440 /etc/sudoers.d/user
WORKDIR /home/user WORKDIR /home/user
+5 -1
View File
@@ -22,7 +22,11 @@ RUN curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \
# Set up git # Set up git
RUN git config --global init.defaultBranch main RUN git config --global init.defaultBranch main
# Code-server runs as abc user by default # The LinuxServer entrypoint maps abc to this shared UID/GID before starting
# code-server, so writable Config Profile mounts are compatible with other
# built-in tool containers.
ENV PUID=1000 \
PGID=1000
USER abc USER abc
EXPOSE 8443 EXPOSE 8443
+5 -2
View File
@@ -17,7 +17,10 @@ RUN apt-get update && apt-get install -y \
# Set up git # Set up git
RUN git config --global init.defaultBranch main RUN git config --global init.defaultBranch main
# Switch back to jovyan user (default for scipy-notebook) # start-notebook.py maps jovyan to this shared UID/GID and drops privileges.
USER ${NB_UID} # Keep the image root at entrypoint time so that mapping can occur.
ENV NB_UID=1000 \
NB_GID=1000
USER root
EXPOSE 8888 EXPOSE 8888
+3 -2
View File
@@ -27,8 +27,9 @@ RUN curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \
# Install OpenCode # Install OpenCode
RUN npm install -g opencode RUN npm install -g opencode
# Create non-root user # Use the shared built-in UID/GID for writable Config Profile mounts.
RUN useradd -m -s /bin/bash user RUN groupadd -g 1000 user \
&& useradd -m -u 1000 -g 1000 -s /bin/bash user
WORKDIR /home/user WORKDIR /home/user
# Set up git # Set up git
+8 -11
View File
@@ -28,8 +28,9 @@ RUN curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \
# Install Pi Coding Agent globally # Install Pi Coding Agent globally
RUN npm install -g --ignore-scripts @earendil-works/pi-coding-agent RUN npm install -g --ignore-scripts @earendil-works/pi-coding-agent
# Create non-root user # Use the shared built-in UID/GID for writable Config Profile mounts.
RUN useradd -m -s /bin/bash user RUN groupadd -g 1000 user \
&& useradd -m -u 1000 -g 1000 -s /bin/bash user
WORKDIR /home/user WORKDIR /home/user
# Set up git # Set up git
@@ -37,15 +38,11 @@ RUN git config --global init.defaultBranch main \
&& git config --global user.email "dev@headquarter.local" \ && git config --global user.email "dev@headquarter.local" \
&& git config --global user.name "Developer" && git config --global user.name "Developer"
# Create default tmux config # Create user-owned default configuration files.
RUN printf '%s\n' 'set -g mouse on' 'set -g default-terminal "screen-256color"' > /home/user/.tmux.conf RUN printf '%s\n' 'set -g mouse on' 'set -g default-terminal "screen-256color"' > /home/user/.tmux.conf \
&& mkdir -p /home/user/.config/ranger /home/user/.pi/agent \
# Create default ranger config && printf '%s\n' 'set preview_files true' 'set use_preview_script true' > /home/user/.config/ranger/rc.conf \
RUN mkdir -p /home/user/.config/ranger \ && chown -R user:user /home/user
&& printf '%s\n' 'set preview_files true' 'set use_preview_script true' > /home/user/.config/ranger/rc.conf
# Set up Pi config directory
RUN mkdir -p /home/user/.pi/agent
USER user USER user