Compare commits

..

6 Commits

Author SHA1 Message Date
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 1110 additions and 352 deletions
+84 -2
View File
@@ -1,6 +1,7 @@
"""Config profile API endpoints."""
import logging
import os
import uuid
from fastapi import APIRouter, Depends, HTTPException, Query, status
@@ -9,7 +10,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
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 (
ConfigProfileCreate,
ConfigProfileIncludeUpdate,
@@ -43,6 +44,34 @@ logger = logging.getLogger(__name__)
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])
async def list_config_profiles(
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)
response = profile_to_response(profile)
response["refresh_outcomes"] = await _running_profile_outcomes(session, 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)
@@ -161,6 +233,16 @@ async def delete_config_profile(
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.commit()
@@ -240,6 +240,12 @@ class ConfigProfileIncludeUpdate(BaseModel):
return v
class ConfigProfileRefreshOutcome(BaseModel):
instance_id: str
status: str
reason: str | None = None
class ConfigProfileResponse(BaseModel):
id: str
user_id: str
@@ -256,6 +262,7 @@ class ConfigProfileResponse(BaseModel):
includes: list[dict]
created_at: str
updated_at: str
refresh_outcomes: list[ConfigProfileRefreshOutcome] = Field(default_factory=list)
class DefaultProfilesUpdate(BaseModel):
+118 -82
View File
@@ -5,10 +5,125 @@ import logging
from sqlalchemy import select, text
from src.database import SessionLocal
from src.models import ToolType
from src.models import ToolDefinitionManifest, ToolType
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
BUILTIN_TOOL_TYPES = [
{
"name": "code-server",
"display_name": "VS Code Server",
"description": "VS Code running in the browser via code-server",
"category": "editor",
"interface_type": "web",
"compose_template": """version: "3.8"
services:
code-server:
image: lscr.io/linuxserver/code-server:latest
container_name: {{TOOL_NAME}}
environment:
- PUID=1000
- PGID=1000
- TZ=Europe/London
volumes:
- {{REPO_PATH}}:/config/workspace
ports:
- "8443:8443"
restart: 'no'""",
"default_port": 8443,
"required_variables": ["REPO_PATH", "TOOL_NAME"],
},
{
"name": "jupyter-notebook",
"display_name": "Jupyter Notebook",
"description": "Jupyter Lab for interactive development",
"category": "notebook",
"interface_type": "web",
"default_port": 8888,
"compose_template": """version: "3.8"
services:
jupyter:
image: jupyter/scipy-notebook:latest
container_name: {{TOOL_NAME}}
environment:
- JUPYTER_ENABLE_LAB=yes
- NB_UID=1000
- NB_GID=1000
volumes:
- {{REPO_PATH}}:/home/jovyan/work
ports:
- "8888:8888"
restart: 'no'""",
"required_variables": ["REPO_PATH", "TOOL_NAME"],
},
{
"name": "opencode",
"display_name": "OpenCode",
"description": "AI coding assistant - run opencode in terminal",
"category": "ai-assistant",
"interface_type": "terminal",
"default_port": 3000,
"compose_template": """version: "3.8"
services:
opencode:
image: node:20-slim
container_name: {{TOOL_NAME}}
working_dir: /home/node/{{WORKSPACE_NAME}}
volumes:
- {{REPO_PATH}}:/home/node/{{WORKSPACE_NAME}}
ports:
- "3000:3000"
command: >
sh -ec "apt-get update && apt-get install -y git ca-certificates &&
npm install -g opencode-ai &&
exec setpriv --reuid=node --regid=node --init-groups opencode server"
stdin_open: true
tty: true
restart: 'no'""",
"required_variables": ["REPO_PATH", "TOOL_NAME"],
},
]
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."""
@@ -45,88 +160,9 @@ async def seed_builtin_tool_types():
)
return
builtin_types = [
{
"name": "code-server",
"display_name": "VS Code Server",
"description": "VS Code running in the browser via code-server",
"category": "editor",
"interface_type": "web",
"compose_template": """version: "3.8"
services:
code-server:
image: lscr.io/linuxserver/code-server:latest
container_name: {{TOOL_NAME}}
environment:
- PUID=1000
- PGID=1000
- TZ=Europe/London
volumes:
- {{REPO_PATH}}:/config/workspace
ports:
- "8443:8443"
restart: 'no'""",
"default_port": 8443,
"required_variables": ["REPO_PATH", "TOOL_NAME"],
},
{
"name": "jupyter-notebook",
"display_name": "Jupyter Notebook",
"description": "Jupyter Lab for interactive development",
"category": "notebook",
"interface_type": "web",
"default_port": 8888,
"compose_template": """version: "3.8"
services:
jupyter:
image: jupyter/scipy-notebook:latest
container_name: {{TOOL_NAME}}
environment:
- JUPYTER_ENABLE_LAB=yes
volumes:
- {{REPO_PATH}}:/home/jovyan/work
ports:
- "8888:8888"
restart: 'no'""",
"required_variables": ["REPO_PATH", "TOOL_NAME"],
},
{
"name": "opencode",
"display_name": "OpenCode",
"description": "AI coding assistant - run opencode in terminal",
"category": "ai-assistant",
"interface_type": "terminal",
"default_port": 3000,
"compose_template": """version: "3.8"
services:
opencode:
image: node:20-slim
container_name: {{TOOL_NAME}}
working_dir: /home/user/{{WORKSPACE_NAME}}
volumes:
- {{REPO_PATH}}:/home/user/{{WORKSPACE_NAME}}
ports:
- "3000:3000"
command: >
sh -c "set -x &&
apt-get update && apt-get install -y git ca-certificates &&
echo 'Installing opencode...' &&
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
tty: true
restart: 'no'""",
"required_variables": ["REPO_PATH", "TOOL_NAME"],
},
]
await _standardize_pi_agent_manifest(session)
for tool_data in builtin_types:
for tool_data in BUILTIN_TOOL_TYPES:
existing = await session.scalar(
select(ToolType).where(ToolType.name == tool_data["name"])
)
@@ -6,6 +6,7 @@ and cycle protection.
import logging
import os
import tempfile
import uuid
from dataclasses import dataclass, field
from typing import Any
@@ -481,6 +482,7 @@ def apply_resolved_profile(
instance_dir: str,
resolved: ResolvedProfile,
home_dir: str = "/root",
working_dir: str | None = None,
) -> tuple[dict[str, str], dict[str, str], list[dict], dict[str, Any]]:
"""Apply a resolved profile to an instance directory.
@@ -489,6 +491,8 @@ def apply_resolved_profile(
Args:
instance_dir: Path to the instance directory.
resolved: The resolved profile.
home_dir: Container home directory used for path expansion.
working_dir: Container working directory for top-level profile files.
Returns:
Tuple of (env_vars, files, volume_mounts, runtime_hints).
@@ -501,49 +505,57 @@ def apply_resolved_profile(
instance_path = Path(instance_dir)
env_vars = dict(resolved.env_vars)
files = dict(resolved.files)
working_dir = working_dir or home_dir
volume_mounts = []
# Write profile files to instance directory
for file_path, content in files.items():
full_path = instance_path / file_path
try:
full_path.resolve().relative_to(instance_path.resolve())
except ValueError:
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)
# Profile content belongs to the profile, not an individual tool instance.
# Keeping it beside the instance root gives every compatible instance the
# same host source while retaining the existing instance storage setting.
profile_dir = instance_path.parent / "config-profiles" / str(resolved.profile_id)
files_dir = profile_dir / "files"
mounts_dir = profile_dir / "mounts"
# Stage mount directories and prepare directory-level volume mounts.
# Each ResolvedMount targets a container directory; we stage all of its
# files under a single host directory and bind-mount that directory. This
# keeps the target directory writable by the container user, instead of
# having Docker create a root-owned parent directory when only individual
# files are mounted.
def write_canonical_file(root: Path, relative_path: str, content: str) -> Path | None:
path = root / relative_path
try:
path.resolve().relative_to(root.resolve())
except ValueError:
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():
if not mount.files:
continue
expanded_target = os.path.normpath(
expand_container_path(mount.target, home_dir)
)
mount_dir = (
instance_path / "mounts" / expanded_target.lstrip("/").replace("/", "_")
)
mount_dir.mkdir(parents=True, exist_ok=True)
expanded_target = os.path.normpath(expand_container_path(mount.target, home_dir))
mount_dir = mounts_dir / expanded_target.lstrip("/").replace("/", "_")
for file_path, content in mount.files.items():
full_path = mount_dir / file_path
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)
write_canonical_file(mount_dir, file_path, content)
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:
+251 -69
View File
@@ -2,7 +2,9 @@
import asyncio
import contextlib
import fcntl
import glob as glob_module
import hashlib
import logging
import os
import re
@@ -125,11 +127,11 @@ def _chown_staged_mounts(
uid: int,
gid: int,
) -> None:
"""Recursively chown staged mount sources to the container user.
"""Recursively chown instance-local mount sources to the container user.
Config-profile mounts, git mounts, and SSH key mounts are staged under
instance_dir by the API process (root). Without this, the container
user cannot write into bind-mounted directories such as ~/.config.
Profile copies, profile/Git composites, and SSH key mounts are created by
the API process. Their sources must be owned by the target container user
before Docker bind-mounts them into writable paths.
"""
for vol in extra_volumes:
source = vol.get("source", "")
@@ -154,61 +156,180 @@ def _relative_under(parent: str, child: str) -> str | None:
return None
def _stage_profile_mounts(profile_mounts: list[dict], instance_dir: str) -> list[dict]:
"""Copy profile bind sources into an instance-local, writable staging area."""
staged_mounts: list[dict] = []
staging_root = os.path.join(instance_dir, "mounts", "profiles")
for mount in profile_mounts:
source = mount.get("source", "")
target = mount.get("target", "")
if not source or not target:
continue
if not os.path.exists(source):
logger.warning("Skipping missing config profile mount source: %s", source)
continue
digest = hashlib.sha256(f"{source}\0{target}".encode()).hexdigest()[:16]
staged_source = os.path.join(staging_root, digest)
try:
if os.path.lexists(staged_source):
if os.path.isdir(staged_source):
shutil.rmtree(staged_source)
else:
os.unlink(staged_source)
os.makedirs(os.path.dirname(staged_source), exist_ok=True)
if os.path.isdir(source):
shutil.copytree(source, staged_source, symlinks=True)
else:
shutil.copy2(source, staged_source, follow_symlinks=False)
except OSError as exc:
logger.error("Failed to stage config profile mount %s: %s", source, exc)
continue
staged_mount = dict(mount)
staged_mount["source"] = staged_source
staged_mounts.append(staged_mount)
return staged_mounts
def _mounts_overlap(first_target: str, second_target: str) -> bool:
"""Return whether two normalized container mount targets intersect."""
return (
_relative_under(first_target, second_target) is not None
or _relative_under(second_target, first_target) is not None
)
def _copy_mount_source(source: str, destination: str) -> None:
"""Copy a bind-mount source into its destination in a composite tree."""
try:
if os.path.isdir(source):
os.makedirs(destination, exist_ok=True)
for entry in os.listdir(source):
source_entry = os.path.join(source, entry)
destination_entry = os.path.join(destination, entry)
if os.path.isdir(source_entry):
shutil.copytree(
source_entry,
destination_entry,
dirs_exist_ok=True,
symlinks=True,
)
else:
os.makedirs(os.path.dirname(destination_entry), exist_ok=True)
shutil.copy2(source_entry, destination_entry, follow_symlinks=False)
return
os.makedirs(os.path.dirname(destination), exist_ok=True)
shutil.copy2(source, destination, follow_symlinks=False)
except OSError as exc:
raise RuntimeError(f"Unable to compose mount source {source}: {exc}") from exc
def _stack_profile_mounts_with_git_mounts(
profile_mounts: list[dict],
git_mount_volumes: list[dict],
instance_dir: str,
) -> list[dict]:
"""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
git-mount (e.g. ``~/.pi``), a directory-level bind mount for the profile
would mask the cloned repository. Instead, copy the profile files into
the git-mount source directory so the container sees both sets of files
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.
Docker applies one bind mount per target; it never merges their contents.
A composite therefore copies shared Git content first and profile content
second, so profile files extend (and intentionally override) the Git tree
without dirtying the shared clone.
"""
remaining: list[dict] = []
for pvol in profile_mounts:
p_source = pvol.get("source", "")
p_target = pvol.get("target", "")
if not p_source or not os.path.exists(p_source):
remaining.append(pvol)
records: list[tuple[str, dict]] = [
("profile", mount) for mount in profile_mounts
] + [("git", mount) for mount in git_mount_volumes]
components: list[list[int]] = []
remaining: set[int] = set(range(len(records)))
while remaining:
component_indices: set[int] = {min(remaining)}
remaining.difference_update(component_indices)
pending = list(component_indices)
while pending:
current_index = pending.pop()
current_target = records[current_index][1].get("target", "")
for candidate_index in list(remaining):
candidate_target = records[candidate_index][1].get("target", "")
if _mounts_overlap(current_target, candidate_target):
remaining.remove(candidate_index)
component_indices.add(candidate_index)
pending.append(candidate_index)
components.append(sorted(component_indices))
result: list[dict] = []
for component_indexes in components:
component_records = [records[index] for index in component_indexes]
kinds = {kind for kind, _mount in component_records}
if kinds != {"profile", "git"}:
result.extend(mount for _kind, mount in component_records)
continue
merged = False
for gvol in git_mount_volumes:
g_source = gvol.get("source", "")
g_target = gvol.get("target", "")
if not g_source or not os.path.isdir(g_source):
continue
targets = [
os.path.normpath(mount["target"]) for _kind, mount in component_records
]
composite_target = os.path.commonpath(targets)
if any(
not os.path.isdir(mount["source"])
and os.path.normpath(mount["target"]) == composite_target
for _kind, mount in component_records
):
composite_target = os.path.dirname(composite_target)
rel = _relative_under(g_target, p_target)
if rel is None:
continue
digest_input = "\0".join(
f"{kind}:{mount['source']}:{mount['target']}"
for kind, mount in component_records
)
composite_source = os.path.join(
instance_dir,
"mounts",
"composites",
hashlib.sha256(digest_input.encode()).hexdigest()[:16],
)
try:
if os.path.lexists(composite_source):
shutil.rmtree(composite_source)
os.makedirs(composite_source, exist_ok=True)
except OSError as exc:
raise RuntimeError(
f"Unable to prepare composite mount directory {composite_source}: {exc}"
) from exc
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)
for kind in ("git", "profile"):
for record_kind, mount in component_records:
if record_kind != kind:
continue
relative_target = _relative_under(composite_target, mount["target"])
destination = (
composite_source
if relative_target == ""
else os.path.join(composite_source, relative_target or "")
)
_copy_mount_source(mount["source"], destination)
logger.debug(
"Stacked profile mount %s into git mount %s at %s",
p_target,
g_target,
dst,
)
merged = True
break
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,
)
if not merged:
remaining.append(pvol)
return remaining
return result
async def resolve_git_mounts(
@@ -227,12 +348,18 @@ async def resolve_git_mounts(
if not resolved.git_mounts:
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
tasks = []
for git_mount in resolved.git_mounts:
tasks.append(
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
@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(
remote_url: str,
branch: str | None,
@@ -425,7 +583,7 @@ def resolve_git_mount_mappings(
async def resolve_single_git_mount(
session: AsyncSession,
git_mount: dict,
instance_dir: str | None = None,
clone_parent: str | None = None,
working_directory: str | None = None,
home_dir: str = "/root",
) -> list[dict]:
@@ -447,15 +605,15 @@ async def resolve_single_git_mount(
logger.warning("Invalid git mount skipped: no mappings")
return []
if not instance_dir:
logger.warning("Git mount skipped: no instance_dir provided for cloning")
if not clone_parent:
logger.warning("Git mount skipped: no canonical profile directory provided")
return []
# Clone or pull the repository. Git mounts are auxiliary, so they keep
# using the repository URL basename rather than the project name.
try:
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:
logger.warning(
@@ -468,7 +626,12 @@ async def resolve_single_git_mount(
return []
# 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:
@@ -1402,17 +1565,22 @@ async def start_tool_instance(
resolved = await resolve_profile(
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 = (
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
# need the final working directory to resolve relative target paths.
if profile_hints.get("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"):
port_override = profile_hints["port_override"]
env_vars.update(profile_env)
@@ -1420,22 +1588,22 @@ async def start_tool_instance(
git_mount_volumes = await resolve_git_mounts(
session, resolved, instance_dir, working_directory, home_dir
)
# Stack static file mounts on top of git repo mounts so they do
# not mask each other when they target the same directory.
stacked_profile_mounts = _stack_profile_mounts_with_git_mounts(
profile_mounts, git_mount_volumes
staged_profile_mounts = _stage_profile_mounts(profile_mounts, instance_dir)
composed_mounts = _stack_profile_mounts_with_git_mounts(
staged_profile_mounts,
git_mount_volumes,
instance_dir,
)
extra_volumes.extend(stacked_profile_mounts)
extra_volumes.extend(git_mount_volumes)
extra_volumes.extend(composed_mounts)
logger.debug(
"Applied config profile %s to instance %s (env=%d, files=%d, mounts=%d, git_mounts=%d, stacked=%d)",
"Applied config profile %s to instance %s (env=%d, files=%d, profile_mounts=%d, git_mounts=%d, composed_mounts=%d)",
resolved.profile_name,
instance.id,
len(profile_env),
len(profile_files),
len(profile_mounts),
len(staged_profile_mounts),
len(git_mount_volumes),
len(profile_mounts) - len(stacked_profile_mounts),
len(composed_mounts),
)
except ConfigProfileCycleError as exc:
logger.error(
@@ -1476,7 +1644,15 @@ async def start_tool_instance(
if ssh_keys_to_mount:
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 = []
for ssh_key in ssh_keys_to_mount:
@@ -2189,7 +2365,13 @@ async def delete_tool_instance(
if os.path.exists(instance_dir):
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(
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"]) / "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:
"""A mount with no files should not produce any volume entries."""
resolved = ResolvedProfile(
@@ -579,7 +627,7 @@ class TestApplyResolvedProfile:
assert len(volumes) == 1
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:
"""A mount with mode 'rw' should not set readonly on the volume entry."""
@@ -598,7 +646,7 @@ class TestApplyResolvedProfile:
assert len(volumes) == 1
assert volumes[0]["target"] == "/app"
assert volumes[0].get("readonly") is False
assert not volumes[0].get("readonly")
class TestCheckIncludeCycle:
+159 -127
View File
@@ -2,13 +2,16 @@
import hashlib
import uuid
from pathlib import Path
from unittest.mock import MagicMock, AsyncMock
import pytest
from src.services.tool.instance_service import (
_chown_staged_mounts,
_get_repository_mount_name,
_stack_profile_mounts_with_git_mounts,
_stage_profile_mounts,
clone_git_repo,
modify_compose_file,
prepare_manifest_instance,
@@ -80,12 +83,7 @@ class TestCloneGitRepo:
branch = None
clone_parent = str(tmp_path)
url_hash = hashlib.md5(f"{remote_url}:default".encode()).hexdigest()[:12]
repo_path = (
tmp_path
/ "git-mounts"
/ f"dotfiles-{url_hash}"
/ "repo-clone"
)
repo_path = tmp_path / "git-mounts" / f"dotfiles-{url_hash}" / "repo-clone"
(repo_path / ".git").mkdir(parents=True)
clone = MagicMock(side_effect=AssertionError("existing clone must be reused"))
@@ -99,7 +97,9 @@ class TestCloneGitRepo:
clone.assert_not_called()
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
remote_url = "https://gitlab.com/example/dotfiles"
@@ -126,153 +126,185 @@ class TestCloneGitRepo:
@pytest.mark.unit
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:
"""When a profile mount targets the same directory as a git mount,
the profile files should be copied into the git-mount source so the
container sees both sets of files through one bind mount."""
def test_stages_profile_source_under_instance_directory(self, tmp_path) -> None:
"""Profile sources must be instance-local before ownership is fixed."""
instance_dir = tmp_path / "instance"
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.mkdir(parents=True)
(git_source / "existing.txt").write_text("from git")
profile_source = tmp_path / "profile" / "home_user_.pi"
profile_source.mkdir(parents=True)
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",
"readonly": False,
}
]
git_mount_volumes = [
{"source": str(git_source), "target": "/home/user/.pi", "type": "bind"}
]
profile_mounts = _stage_profile_mounts(
[
{
"source": str(profile_source),
"target": "/home/user/.pi",
"type": "bind",
}
],
str(instance_dir),
)
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 (git_source / "existing.txt").read_text() == "from git"
assert (git_source / "settings.json").read_text() == "{}"
assert len(result) == 1
composite = result[0]
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:
"""Profile mounts targeting a child directory are copied into the
corresponding subdirectory of the git-mount source."""
def test_descendant_profile_mount_extends_git_root(self, tmp_path) -> None:
"""Nested targets are composed at the Git root, preserving siblings."""
instance_dir = tmp_path / "instance"
git_source = tmp_path / "git"
git_source.mkdir()
(git_source / "README").write_text("repo")
profile_source = tmp_path / "profile" / "agent"
profile_source.mkdir(parents=True)
profile_source = tmp_path / "profile"
profile_source.mkdir()
(profile_source / "settings.json").write_text("x")
profile_mounts = [
{
"source": str(profile_source),
"target": "/home/user/.pi/agent",
"type": "bind",
}
]
git_mount_volumes = [
{"source": str(git_source), "target": "/home/user/.pi", "type": "bind"}
]
profile_mounts = _stage_profile_mounts(
[{"source": str(profile_source), "target": "/home/user/.pi/agent"}],
str(instance_dir),
)
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 (git_source / "agent" / "settings.json").read_text() == "x"
assert (git_source / "README").read_text() == "repo"
assert len(result) == 1
composite = Path(result[0]["source"])
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:
"""Profile mounts that do not overlap a git mount are returned as-is."""
def test_file_profile_mount_extends_git_root(self, tmp_path) -> None:
"""A file bind mount is composed into the Git directory, not masked."""
instance_dir = tmp_path / "instance"
git_source = tmp_path / "git"
git_source.mkdir()
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()
(git_source / "README").write_text("repo")
profile_source = tmp_path / "settings.json"
profile_source.write_text("{}")
profile_mounts = [
{
"source": str(profile_source),
"target": "/home/user/.pi/settings.json",
"type": "bind",
}
]
git_mount_volumes = [
{"source": str(git_source), "target": "/home/user/.pi", "type": "bind"}
]
profile_mounts = _stage_profile_mounts(
[
{
"source": str(profile_source),
"target": "/home/user/.pi/settings.json",
}
],
str(instance_dir),
)
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 (git_source / "settings.json").read_text() == "{}"
assert len(result) == 1
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
+16
View File
@@ -1,5 +1,11 @@
import { apiClient } from "./client";
export interface ConfigProfileRefreshOutcome {
instance_id: string;
status: "compatible" | "refreshed" | "restart_required" | "incompatible_permissions";
reason?: string;
}
export interface ConfigProfile {
id: string;
user_id: string;
@@ -16,6 +22,7 @@ export interface ConfigProfile {
includes: ConfigProfileInclude[];
created_at: string;
updated_at: string;
refresh_outcomes?: ConfigProfileRefreshOutcome[];
}
export interface ConfigProfileMount {
@@ -138,6 +145,15 @@ export const deleteConfigProfile = async (id: string): Promise<void> => {
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 (
id: string,
data: UpdateIncludesRequest,
@@ -23,6 +23,7 @@ interface Props {
onSubmit: (e?: React.FormEvent) => void;
onReset: () => void;
onPreview: () => void;
onRefreshGitMounts: () => void;
onAddInclude: (id: string) => void;
onRemoveInclude: (index: number) => void;
onDragStart: (e: React.DragEvent, index: number) => void;
@@ -63,6 +64,7 @@ export const ConfigProfileEditorPanel = ({
onSubmit,
onReset,
onPreview,
onRefreshGitMounts,
onAddInclude,
onRemoveInclude,
onDragStart,
@@ -114,6 +116,9 @@ export const ConfigProfileEditorPanel = ({
</div>
{!isCreating && selectedProfile && (
<div className="row row-sm">
<button className="btn btn-secondary" onClick={onRefreshGitMounts}>
<Icon name="refresh" size="sm" /> Refresh Git mounts
</button>
<button className="btn btn-secondary" onClick={onPreview} disabled={previewingId === selectedProfile.id}>
{previewingId === selectedProfile.id ? (
<><Icon name="loading" size="sm" /> Previewing...</>
@@ -64,6 +64,7 @@ interface Props {
) => void;
onRemoveMountFile: (mountIndex: number, path: string) => void;
onPreview: (id: string) => void;
onRefreshGitMounts: (id: string) => void;
onClosePreview: () => void;
}
@@ -104,6 +105,7 @@ export const ConfigProfilesMobileView = ({
onUpdateMountFile,
onRemoveMountFile,
onPreview,
onRefreshGitMounts,
onClosePreview,
}: Props) => {
const [isSaving, setIsSaving] = useState(false);
@@ -363,6 +365,13 @@ export const ConfigProfilesMobileView = ({
onDelete={handleDeleteClick}
>
<div className="mobile-detail-actions-extra">
<button
type="button"
className="secondary-button"
onClick={() => onRefreshGitMounts(selectedProfile.id)}
>
<Icon name="refresh" size="sm" /> Refresh Git mounts
</button>
<button
type="button"
className="secondary-button"
+67 -14
View File
@@ -5,6 +5,7 @@ import {
deleteConfigProfile,
listConfigProfiles,
previewConfigProfile,
refreshConfigProfileGitMounts,
updateConfigProfile,
updateProfileIncludes,
type ConfigProfile,
@@ -34,17 +35,21 @@ export const useConfigProfiles = () => {
const [profiles, setProfiles] = useState<ConfigProfile[]>([]);
const [projects, setProjects] = useState<ProjectWithRepos[]>([]);
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 [saveStatus, setSaveStatus] = useState<SaveStatus>("idle");
const [error, setError] = useState<string | null>(null);
const [previewData, setPreviewData] = useState<ResolvedProfile | null>(null);
const [previewingId, setPreviewingId] = useState<string | null>(null);
const [formData, setFormData] = useState<CreateConfigProfileRequest>(defaultForm);
const [formData, setFormData] =
useState<CreateConfigProfileRequest>(defaultForm);
const [includedProfileIds, setIncludedProfileIds] = useState<string[]>([]);
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 () => {
setStatus("loading");
@@ -89,7 +94,9 @@ export const useConfigProfiles = () => {
is_default: profile.is_default,
});
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);
setSaveStatus("idle");
@@ -208,20 +215,39 @@ export const useConfigProfiles = () => {
if (isCreating) {
const newProfile = await createConfigProfile(formData);
if (includedProfileIds.length > 0) {
await updateProfileIncludes(newProfile.id, { includes: includedProfileIds });
await updateProfileIncludes(newProfile.id, {
includes: includedProfileIds,
});
}
setIsCreating(false);
setSelectedProfileId(newProfile.id);
setSaveStatus("saved");
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);
} else if (selectedProfile) {
await updateConfigProfile(selectedProfile.id, formData);
await updateProfileIncludes(selectedProfile.id, { includes: includedProfileIds });
const updatedProfile = await updateConfigProfile(
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");
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);
}
return true;
@@ -233,7 +259,8 @@ export const useConfigProfiles = () => {
};
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 {
await deleteConfigProfile(id);
if (selectedProfileId === id) {
@@ -242,8 +269,27 @@ export const useConfigProfiles = () => {
resetForm();
}
await loadData();
} catch {
alert("Failed to delete config profile");
} catch (err) {
setError(extractErrorMessage(err));
}
};
const handleRefreshGitMounts = async (id: string): Promise<boolean> => {
setError(null);
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;
}
};
@@ -268,7 +314,10 @@ export const useConfigProfiles = () => {
};
const addEnvVar = () => {
setFormData((prev) => ({ ...prev, env_vars: { ...prev.env_vars, "": "" } }));
setFormData((prev) => ({
...prev,
env_vars: { ...prev.env_vars, "": "" },
}));
setSaveStatus("idle");
};
@@ -323,7 +372,10 @@ export const useConfigProfiles = () => {
setSaveStatus("idle");
};
const updateMount = (index: number, updates: Partial<ConfigProfile["mounts"][0]>) => {
const updateMount = (
index: number,
updates: Partial<ConfigProfile["mounts"][0]>,
) => {
setFormData((prev) => {
const mounts = [...(prev.mounts || [])];
mounts[index] = { ...mounts[index], ...updates };
@@ -402,6 +454,7 @@ export const useConfigProfiles = () => {
handleSubmit,
handleDelete,
handlePreview,
handleRefreshGitMounts,
updateFormField,
addEnvVar,
updateEnvVar,
@@ -32,6 +32,7 @@ export const ConfigProfilesPage = () => {
handleSubmit,
handleDelete,
handlePreview,
handleRefreshGitMounts,
updateFormField,
addEnvVar,
updateEnvVar,
@@ -135,6 +136,7 @@ export const ConfigProfilesPage = () => {
onUpdateMountFile={updateMountFile}
onRemoveMountFile={removeMountFile}
onPreview={handlePreview}
onRefreshGitMounts={(id) => void handleRefreshGitMounts(id)}
onClosePreview={() => setPreviewData(null)}
/>
);
@@ -176,6 +178,7 @@ export const ConfigProfilesPage = () => {
onSubmit={handleSubmit}
onReset={handleReset}
onPreview={() => selectedProfile && handlePreview(selectedProfile.id)}
onRefreshGitMounts={() => selectedProfile && handleRefreshGitMounts(selectedProfile.id)}
onAddInclude={addInclude}
onRemoveInclude={removeInclude}
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.
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.
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
@@ -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.
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.
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
@@ -12,5 +12,8 @@
- [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] 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
- [ ] 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,23 @@
# 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.
## 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.
@@ -0,0 +1,30 @@
# 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.
## 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.
## 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,15 @@
# 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 desktop/mobile feedback for refresh outcomes.
- [ ] 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 \
&& rm -rf /var/lib/apt/lists/*
# Create a non-root user and allow passwordless sudo so the startup
# permission fixer can adjust ownership of bind-mounted directories.
RUN useradd -m -s /bin/bash user \
# Use the shared built-in UID/GID so writable Config Profile mounts can be
# shared by compatible instances without ownership changes.
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 \
&& chmod 0440 /etc/sudoers.d/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
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
EXPOSE 8443
+5 -2
View File
@@ -17,7 +17,10 @@ RUN apt-get update && apt-get install -y \
# Set up git
RUN git config --global init.defaultBranch main
# Switch back to jovyan user (default for scipy-notebook)
USER ${NB_UID}
# start-notebook.py maps jovyan to this shared UID/GID and drops privileges.
# Keep the image root at entrypoint time so that mapping can occur.
ENV NB_UID=1000 \
NB_GID=1000
USER root
EXPOSE 8888
+3 -2
View File
@@ -27,8 +27,9 @@ RUN curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \
# Install OpenCode
RUN npm install -g opencode
# Create non-root user
RUN useradd -m -s /bin/bash user
# Use the shared built-in UID/GID for writable Config Profile mounts.
RUN groupadd -g 1000 user \
&& useradd -m -u 1000 -g 1000 -s /bin/bash user
WORKDIR /home/user
# 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
RUN npm install -g --ignore-scripts @earendil-works/pi-coding-agent
# Create non-root user
RUN useradd -m -s /bin/bash user
# Use the shared built-in UID/GID for writable Config Profile mounts.
RUN groupadd -g 1000 user \
&& useradd -m -u 1000 -g 1000 -s /bin/bash user
WORKDIR /home/user
# 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.name "Developer"
# Create default tmux config
RUN printf '%s\n' 'set -g mouse on' 'set -g default-terminal "screen-256color"' > /home/user/.tmux.conf
# Create default ranger config
RUN mkdir -p /home/user/.config/ranger \
&& 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
# Create user-owned default configuration files.
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 \
&& printf '%s\n' 'set preview_files true' 'set use_preview_script true' > /home/user/.config/ranger/rc.conf \
&& chown -R user:user /home/user
USER user