feat: expand ~ and $HOME in mount target paths

- Add expand_container_path() helper that resolves ~/ and $HOME/ prefixes
- Add get_manifest_home_dir() to compute /home/{user.name} or /root from manifest
- Set ENV HOME=... and ENV USER=... in generated Dockerfile for runtime compatibility
- Pass home_dir through instance creation and startup pipeline
- Expand mount targets in apply_resolved_profile() for regular profile mounts
- Expand mapping targets in _resolve_git_mount_mappings() for git mounts
- Expand working_directory and volume targets in _modify_compose_file()
- Update _prepare_manifest_instance to return home_dir alongside image tag
- Fetch tool_type early in start_instance to determine home_dir before profile application

Quality gates: pytest 188 passed, frontend typecheck clean

Addresses: home-path-expansion
This commit is contained in:
Alex Blank
2026-05-29 00:01:04 +02:00
parent 270764ff0f
commit 29a12bb102
17 changed files with 1364 additions and 471 deletions
+121 -44
View File
@@ -58,7 +58,9 @@ def _calculate_profile_size(data: dict) -> int:
class GitMountMapping(BaseModel):
source_path: str = Field(description="Path within repository (supports glob patterns)")
source_path: str = Field(
description="Path within repository (supports glob patterns)"
)
target_path: str = Field(description="Absolute path inside container")
@field_validator("source_path")
@@ -80,16 +82,25 @@ class GitMountMapping(BaseModel):
class GitMountItem(BaseModel):
remote_url: str = Field(description="Git remote URL (HTTPS or SSH)")
source_path: str | None = Field(default=None, description="Path within repository (legacy single mapping)")
target_path: str | None = Field(default=None, description="Absolute path inside container (legacy single mapping)")
source_path: str | None = Field(
default=None, description="Path within repository (legacy single mapping)"
)
target_path: str | None = Field(
default=None,
description="Absolute path inside container (legacy single mapping)",
)
branch: str | None = Field(default=None, description="Optional branch or tag name")
mappings: list[GitMountMapping] | None = Field(default=None, description="Multiple source/target mappings from the same repo")
mappings: list[GitMountMapping] | None = Field(
default=None, description="Multiple source/target mappings from the same repo"
)
@field_validator("remote_url")
@classmethod
def validate_remote_url(cls, v: str) -> str:
if not v.startswith(("http://", "https://", "git@", "ssh://")):
raise ValueError("remote_url must be a valid git URL (https://, git@, or ssh://)")
raise ValueError(
"remote_url must be a valid git URL (https://, git@, or ssh://)"
)
return v
@field_validator("source_path")
@@ -126,7 +137,9 @@ class GitMountItem(BaseModel):
class MountItem(BaseModel):
target: str = Field(description="Absolute mount target path")
mode: str = Field(default="rw", description="Mount mode: ro or rw")
files: dict = Field(default_factory=dict, description="Files as {relative_path: content}")
files: dict = Field(
default_factory=dict, description="Files as {relative_path: content}"
)
@field_validator("target")
@classmethod
@@ -163,10 +176,18 @@ class ConfigProfileCreate(BaseModel):
tool_type_id: str | None = Field(default=None, description="Optional tool type ID")
env_vars: dict = Field(default_factory=dict, description="Environment variables")
runtime_hints: dict = Field(default_factory=dict, description="Runtime hints")
mounts: list[MountItem] = Field(default_factory=list, description="Mount definitions")
files: dict = Field(default_factory=dict, description="Files as {relative_path: content}")
git_mounts: list[GitMountItem] = Field(default_factory=list, description="Git repository mounts")
is_default: bool = Field(default=False, description="Whether this is the default profile for its scope")
mounts: list[MountItem] = Field(
default_factory=list, description="Mount definitions"
)
files: dict = Field(
default_factory=dict, description="Files as {relative_path: content}"
)
git_mounts: list[GitMountItem] = Field(
default_factory=list, description="Git repository mounts"
)
is_default: bool = Field(
default=False, description="Whether this is the default profile for its scope"
)
@field_validator("project_id", "tool_type_id")
@classmethod
@@ -216,10 +237,18 @@ class ConfigProfileUpdate(BaseModel):
tool_type_id: str | None = Field(default=None, description="Optional tool type ID")
env_vars: dict | None = Field(default=None, description="Environment variables")
runtime_hints: dict | None = Field(default=None, description="Runtime hints")
mounts: list[MountItem] | None = Field(default=None, description="Mount definitions")
files: dict | None = Field(default=None, description="Files as {relative_path: content}")
git_mounts: list[GitMountItem] | None = Field(default=None, description="Git repository mounts")
is_default: bool | None = Field(default=None, description="Whether this is the default profile")
mounts: list[MountItem] | None = Field(
default=None, description="Mount definitions"
)
files: dict | None = Field(
default=None, description="Files as {relative_path: content}"
)
git_mounts: list[GitMountItem] | None = Field(
default=None, description="Git repository mounts"
)
is_default: bool | None = Field(
default=None, description="Whether this is the default profile"
)
@field_validator("project_id", "tool_type_id")
@classmethod
@@ -269,7 +298,9 @@ class ConfigProfileResponse(BaseModel):
updated_at: str
async def _get_profile_with_includes(session: AsyncSession, profile_id: uuid.UUID) -> ConfigProfile | None:
async def _get_profile_with_includes(
session: AsyncSession, profile_id: uuid.UUID
) -> ConfigProfile | None:
"""Fetch a profile with includes eagerly loaded."""
result = await session.execute(
select(ConfigProfile)
@@ -289,12 +320,16 @@ async def _check_access(
if project_id is not None:
project = await session.get(Project, project_id)
if project is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Project not found")
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Project not found"
)
# Add ownership check if needed; for now just verify existence
if tool_type_id is not None:
tool_type = await session.get(ToolType, tool_type_id)
if tool_type is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Tool type not found")
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Tool type not found"
)
async def _validate_git_mounts(
@@ -304,7 +339,7 @@ async def _validate_git_mounts(
project_id: uuid.UUID | None = None,
) -> None:
"""Validate git mount URLs.
Simply checks that remote_url looks like a valid git URL.
Actual clone validation happens at instance startup time.
"""
@@ -315,7 +350,7 @@ async def _validate_git_mounts(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Git mount missing remote_url",
)
if not remote_url.startswith(("http://", "https://", "git@", "ssh://")):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
@@ -323,7 +358,9 @@ async def _validate_git_mounts(
)
def _profile_to_response(profile: ConfigProfile, includes: list[ConfigProfileInclude] | None = None) -> dict:
def _profile_to_response(
profile: ConfigProfile, includes: list[ConfigProfileInclude] | None = None
) -> dict:
return {
"id": str(profile.id),
"user_id": str(profile.user_id),
@@ -353,13 +390,19 @@ def _profile_to_response(profile: ConfigProfile, includes: list[ConfigProfileInc
@router.get("", response_model=list[ConfigProfileResponse])
async def list_config_profiles(
project_id: str | None = Query(None, description="Filter by project compatibility"),
tool_type_id: str | None = Query(None, description="Filter by tool type compatibility"),
tool_type_id: str | None = Query(
None, description="Filter by tool type compatibility"
),
current_user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
):
"""List config profiles, optionally filtered by compatibility."""
user_uuid = current_user_id
query = select(ConfigProfile).where(ConfigProfile.user_id == user_uuid).options(selectinload(ConfigProfile.includes))
query = (
select(ConfigProfile)
.where(ConfigProfile.user_id == user_uuid)
.options(selectinload(ConfigProfile.includes))
)
if project_id or tool_type_id:
# Compatibility filter: include portable profiles and matching scoped profiles
@@ -371,7 +414,8 @@ async def list_config_profiles(
conditions: list = []
# Portable profiles (no project, no tool)
conditions.append(
(ConfigProfile.project_id.is_(None)) & (ConfigProfile.tool_type_id.is_(None))
(ConfigProfile.project_id.is_(None))
& (ConfigProfile.tool_type_id.is_(None))
)
if project_uuid:
# Profiles matching this project (with or without tool)
@@ -382,7 +426,8 @@ async def list_config_profiles(
if project_uuid and tool_uuid:
# Exact match
conditions.append(
(ConfigProfile.project_id == project_uuid) & (ConfigProfile.tool_type_id == tool_uuid)
(ConfigProfile.project_id == project_uuid)
& (ConfigProfile.tool_type_id == tool_uuid)
)
query = query.where(or_(*conditions))
@@ -392,7 +437,9 @@ async def list_config_profiles(
return [_profile_to_response(p) for p in profiles]
@router.post("", response_model=ConfigProfileResponse, status_code=status.HTTP_201_CREATED)
@router.post(
"", response_model=ConfigProfileResponse, status_code=status.HTTP_201_CREATED
)
async def create_config_profile(
data: ConfigProfileCreate,
current_user_id: uuid.UUID = Depends(get_current_user_id),
@@ -403,10 +450,12 @@ async def create_config_profile(
# Check for duplicate name
existing = await session.execute(
select(ConfigProfile).where(
select(ConfigProfile)
.where(
ConfigProfile.user_id == user_uuid,
ConfigProfile.name == data.name,
).options(selectinload(ConfigProfile.includes))
)
.options(selectinload(ConfigProfile.includes))
)
if existing.scalar_one_or_none() is not None:
raise HTTPException(
@@ -418,10 +467,12 @@ async def create_config_profile(
project_uuid = uuid.UUID(data.project_id) if data.project_id else None
tool_uuid = uuid.UUID(data.tool_type_id) if data.tool_type_id else None
await _check_access(session, user_uuid, project_uuid, tool_uuid)
# Validate git mounts reference existing repositories
if data.git_mounts:
git_mounts_data = [m.model_dump() if hasattr(m, "model_dump") else m for m in data.git_mounts]
git_mounts_data = [
m.model_dump() if hasattr(m, "model_dump") else m for m in data.git_mounts
]
await _validate_git_mounts(session, user_uuid, git_mounts_data, project_uuid)
# Check size
@@ -469,9 +520,13 @@ async def get_config_profile(
"""Get a config profile by ID."""
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")
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")
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, detail="Not authorized"
)
return _profile_to_response(profile)
@@ -485,9 +540,13 @@ async def update_config_profile(
"""Update a config profile."""
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")
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")
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, detail="Not authorized"
)
update_data = data.model_dump(exclude_unset=True)
@@ -518,14 +577,16 @@ async def update_config_profile(
else (profile.tool_type_id if "tool_type_id" not in update_data else None)
)
await _check_access(session, profile.user_id, project_uuid, tool_uuid)
# Validate git mounts reference existing repositories
if "git_mounts" in update_data and update_data["git_mounts"] is not None:
git_mounts_data = [
m.model_dump() if hasattr(m, "model_dump") else m
m.model_dump() if hasattr(m, "model_dump") else m
for m in update_data["git_mounts"]
]
await _validate_git_mounts(session, profile.user_id, git_mounts_data, project_uuid)
await _validate_git_mounts(
session, profile.user_id, git_mounts_data, project_uuid
)
# Check size
current_data = _profile_to_response(profile)
@@ -570,9 +631,13 @@ async def delete_config_profile(
"""Delete a config profile."""
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")
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")
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, detail="Not authorized"
)
await session.delete(profile)
await session.commit()
@@ -591,9 +656,13 @@ async def update_profile_includes(
"""Update the ordered includes for a config profile."""
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")
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")
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, detail="Not authorized"
)
# Validate all included profiles exist and belong to the user
included_uuids = [uuid.UUID(inc_id) for inc_id in data.includes]
@@ -633,7 +702,9 @@ async def update_profile_includes(
# Remove existing includes
result = await session.execute(
select(ConfigProfileInclude).where(ConfigProfileInclude.profile_id == profile.id)
select(ConfigProfileInclude).where(
ConfigProfileInclude.profile_id == profile.id
)
)
for existing in result.scalars().all():
await session.delete(existing)
@@ -658,7 +729,9 @@ async def update_profile_includes(
profile = result.scalar_one()
inc_result = await session.execute(
select(ConfigProfileInclude).where(ConfigProfileInclude.profile_id == profile.id)
select(ConfigProfileInclude).where(
ConfigProfileInclude.profile_id == profile.id
)
)
direct_includes = inc_result.scalars().all()
@@ -675,9 +748,13 @@ async def preview_config_profile(
"""Preview the resolved output of a config profile."""
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")
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")
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, detail="Not authorized"
)
try:
resolved = await resolve_profile(session, profile.id)
+35 -10
View File
@@ -39,6 +39,7 @@ from src.services.config_profile_resolver import (
ConfigProfileCycleError,
ResolvedProfile,
apply_resolved_profile,
expand_container_path,
resolve_profile,
)
from src.services.docker import (
@@ -67,6 +68,7 @@ from src.services.manifest_compiler import (
compile_entrypoint,
compute_image_tag,
deep_merge,
get_manifest_home_dir,
merge_with_config,
resolve_base,
)
@@ -82,6 +84,7 @@ async def _resolve_git_mounts(
resolved: ResolvedProfile,
instance_dir: str | None = None,
working_directory: str | None = None,
home_dir: str = "/root",
) -> list[dict]:
"""Convert git mounts from resolved profile to Docker volume mounts.
@@ -97,7 +100,7 @@ async def _resolve_git_mounts(
for git_mount in resolved.git_mounts:
tasks.append(
_resolve_single_git_mount(
session, git_mount, instance_dir, working_directory
session, git_mount, instance_dir, working_directory, home_dir
)
)
@@ -184,6 +187,7 @@ def _resolve_git_mount_mappings(
repo_path: str,
mappings: list[dict],
working_directory: str | None,
home_dir: str = "/root",
) -> list[dict]:
"""Resolve mappings from an already-cloned repo to volume mount entries.
@@ -199,6 +203,9 @@ def _resolve_git_mount_mappings(
logger.warning("Invalid mapping skipped: missing target_path")
continue
# Expand ~ and $HOME in target path
target_path = expand_container_path(target_path, home_dir)
# Resolve relative target paths against working directory
final_target = target_path
if not target_path.startswith("/"):
@@ -261,6 +268,7 @@ async def _resolve_single_git_mount(
git_mount: dict,
instance_dir: str | None = None,
working_directory: str | None = None,
home_dir: str = "/root",
) -> list[dict]:
"""Resolve a single git mount to volume mount entries.
@@ -293,7 +301,7 @@ async def _resolve_single_git_mount(
return []
# Resolve all mappings from the cloned repo
return _resolve_git_mount_mappings(repo_path, mappings, working_directory)
return _resolve_git_mount_mappings(repo_path, mappings, working_directory, home_dir)
def _checkout_branch(repo_path: str, branch: str) -> bool:
@@ -545,6 +553,7 @@ def _modify_compose_file(
start_command: str | None = None,
working_directory: str | None = None,
extra_volumes: list[dict] | None = None,
home_dir: str = "/root",
) -> None:
"""Modify compose file with runtime overrides."""
import yaml
@@ -571,14 +580,16 @@ def _modify_compose_file(
service_config["command"] = start_command
if working_directory:
service_config["working_dir"] = working_directory
service_config["working_dir"] = expand_container_path(
working_directory, home_dir
)
if extra_volumes:
if "volumes" not in service_config:
service_config["volumes"] = []
for vol in extra_volumes:
source = vol.get("source", "")
target = vol.get("target", "")
target = expand_container_path(vol.get("target", ""), home_dir)
vol_type = vol.get("type", "bind")
if vol_type == "bind":
service_config["volumes"].append(f"{source}:{target}")
@@ -826,6 +837,9 @@ services:
deep_merge(dict(base_def.manifest), manifest)
)
# Determine home directory for path expansion
home_dir = get_manifest_home_dir(manifest)
image_tag = compute_image_tag(tool_type.name, manifest)
# Build image during creation so start is fast
@@ -1105,11 +1119,11 @@ async def _prepare_manifest_instance(
env_vars: dict,
extra_volumes: list,
working_directory: str | None,
) -> tuple[str, str, dict]:
) -> tuple[str, str, dict, str]:
"""Build image and generate compose from a manifest-based tool type.
Returns:
Tuple of (image_tag, compose_content, resolved_manifest)
Tuple of (image_tag, compose_content, resolved_manifest, home_dir)
"""
from src.models.tool_definition_manifest import ToolDefinitionManifest
@@ -1220,7 +1234,8 @@ async def _prepare_manifest_instance(
instance.image_tag = image_tag
instance.manifest_compiled_at = datetime.now()
return image_tag, compose_content, manifest
home_dir = get_manifest_home_dir(manifest)
return image_tag, compose_content, manifest, home_dir
@router.post(
@@ -1283,6 +1298,15 @@ async def start_instance(
working_directory = None
extra_volumes = []
# Fetch tool type early to determine home directory
tool_type = await session.get(ToolType, instance.tool_type_id)
home_dir = "/root"
if tool_type and tool_type.definition_type == "manifest" and tool_type.manifest_id:
from src.models.tool_definition_manifest import ToolDefinitionManifest
manifest_def = await session.get(ToolDefinitionManifest, tool_type.manifest_id)
if manifest_def:
home_dir = get_manifest_home_dir(dict(manifest_def.manifest))
# Apply selected config profile if any
instance_dir = os.path.dirname(instance.compose_path)
if instance.selected_config_profile_id is not None:
@@ -1291,7 +1315,7 @@ async def start_instance(
session, instance.selected_config_profile_id
)
profile_env, profile_files, profile_mounts, profile_hints = (
apply_resolved_profile(instance_dir, resolved)
apply_resolved_profile(instance_dir, resolved, home_dir)
)
# Profile env vars override tool config env vars
env_vars.update(profile_env)
@@ -1301,7 +1325,7 @@ async def start_instance(
extra_volumes.extend(profile_mounts)
# Git repository mounts are resolved and added
git_mount_volumes = await _resolve_git_mounts(
session, resolved, instance_dir, working_directory
session, resolved, instance_dir, working_directory, home_dir
)
extra_volumes.extend(git_mount_volumes)
# Profile runtime hints override tool config values
@@ -1345,7 +1369,6 @@ async def start_instance(
)
# ── MANIFEST-BASED FLOW ──────────────────────────────────────
tool_type = await session.get(ToolType, instance.tool_type_id)
resolved_manifest = None
if tool_type and tool_type.definition_type == "manifest" and tool_type.manifest_id:
@@ -1362,6 +1385,7 @@ async def start_instance(
image_tag,
compose_content,
resolved_manifest,
_home_dir,
) = await _prepare_manifest_instance(
session=session,
instance=instance,
@@ -1420,6 +1444,7 @@ async def start_instance(
start_command,
working_directory,
extra_volumes,
home_dir,
)
logger.debug("Modified compose file for instance %s", instance.id)
@@ -5,6 +5,7 @@ and cycle protection.
"""
import logging
import os
import uuid
from dataclasses import dataclass, field
from typing import Any
@@ -57,7 +58,9 @@ class ResolvedProfile:
included_profiles: list[dict[str, Any]] = field(default_factory=list)
def _detect_cycle(profile_id: uuid.UUID, visited: set[uuid.UUID], path: list[uuid.UUID]) -> bool:
def _detect_cycle(
profile_id: uuid.UUID, visited: set[uuid.UUID], path: list[uuid.UUID]
) -> bool:
"""Detect if adding profile_id to path would create a cycle.
Args:
@@ -252,7 +255,9 @@ async def _resolve_profile_recursive(
"""
if _detect_cycle(profile_id, visited, path):
cycle_path = " -> ".join(str(p) for p in path + [profile_id])
raise ConfigProfileCycleError(f"Cycle detected in profile includes: {cycle_path}")
raise ConfigProfileCycleError(
f"Cycle detected in profile includes: {cycle_path}"
)
profile = await session.get(ConfigProfile, profile_id)
if profile is None:
@@ -279,13 +284,18 @@ async def _resolve_profile_recursive(
included = await _resolve_profile_recursive(
session, include.included_profile_id, new_visited, new_path
)
result.included_profiles.append({
"id": str(included.profile_id),
"name": included.profile_name,
})
result.included_profiles.append(
{
"id": str(included.profile_id),
"name": included.profile_name,
}
)
result.env_vars = _merge_env_vars(
result.env_vars, included.env_vars, result.env_overrides, included.profile_name
result.env_vars,
included.env_vars,
result.env_overrides,
included.profile_name,
)
result.runtime_hints = _merge_runtime_hints(
result.runtime_hints,
@@ -429,6 +439,7 @@ async def check_include_cycle(
def apply_resolved_profile(
instance_dir: str,
resolved: ResolvedProfile,
home_dir: str = "/root",
) -> tuple[dict[str, str], dict[str, str], list[dict], dict[str, Any]]:
"""Apply a resolved profile to an instance directory.
@@ -458,14 +469,19 @@ def apply_resolved_profile(
try:
full_path.resolve().relative_to(instance_path.resolve())
except ValueError:
logger.warning("Profile file path escapes instance directory: %s", file_path)
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 files and prepare volume mounts
for mount in resolved.mounts.values():
mount_dir = instance_path / "mounts" / mount.target.lstrip("/").replace("/", "_")
expanded_target = expand_container_path(mount.target, home_dir)
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():
@@ -478,15 +494,41 @@ def apply_resolved_profile(
full_path.parent.mkdir(parents=True, exist_ok=True)
full_path.write_text(content)
volume_mounts.append({
"source": str(mount_dir),
"target": mount.target,
"type": "bind",
})
volume_mounts.append(
{
"source": str(mount_dir),
"target": expanded_target,
"type": "bind",
}
)
return env_vars, files, volume_mounts, resolved.runtime_hints
def expand_container_path(path: str, home_dir: str) -> str:
"""Expand ~ and $HOME in a container path to the actual home directory.
Only expands at the start of the path (e.g., ~/foo, $HOME/foo, $HOME).
Leaves mid-string occurrences unchanged.
Args:
path: Container path that may contain ~ or $HOME.
home_dir: The container's home directory (e.g., /home/user or /root).
Returns:
Path with ~ and $HOME expanded.
"""
if path.startswith("~/"):
return os.path.join(home_dir, path[2:])
if path == "~":
return home_dir
if path.startswith("$HOME/"):
return home_dir + "/" + path[6:]
if path == "$HOME":
return home_dir
return path
def resolved_profile_to_dict(resolved: ResolvedProfile) -> dict[str, Any]:
"""Convert a ResolvedProfile to a plain dict for serialization.
@@ -160,6 +160,11 @@ def compile_dockerfile(manifest: dict) -> str:
lines.append(f"RUN groupadd -g {gid} {name} && \\")
lines.append(f" useradd -u {uid} -g {gid} {create_home}-s {shell} {name}")
lines.append("")
# Set HOME and USER for runtime compatibility
home = f"/home/{name}"
lines.append(f"ENV HOME={home}")
lines.append(f"ENV USER={name}")
lines.append("")
# Build scripts
build_scripts = manifest.get("scripts", {}).get("build", [])
@@ -333,6 +338,21 @@ def resolve_mount_source(mount: dict, variables: dict[str, Any]) -> str:
return ""
def get_manifest_home_dir(manifest: dict) -> str:
"""Get the home directory for a container based on manifest user config.
Args:
manifest: Fully resolved manifest JSON.
Returns:
Home directory path (e.g., /home/user or /root).
"""
user = manifest.get("user")
if user and user.get("name"):
return f"/home/{user['name']}"
return "/root"
def compute_image_tag(tool_name: str, manifest: dict) -> str:
"""Compute a deterministic image tag from manifest content.
@@ -75,6 +75,7 @@ class TestMergeFunctions:
def test_merge_mounts_file_override(self) -> None:
"""Test mount file map merging with overrides."""
from src.services.config_profile_resolver import ResolvedMount
result = _merge_mounts(
{"/app": ResolvedMount(target="/app", mode="rw", files={"a.txt": "old"})},
[{"target": "/app", "mode": "rw", "files": {"a.txt": "new"}}],
@@ -86,6 +87,7 @@ class TestMergeFunctions:
def test_merge_mounts_mode_conflict(self) -> None:
"""Test that mount mode conflicts are resolved (later wins)."""
from src.services.config_profile_resolver import ResolvedMount
overrides = {}
result = _merge_mounts(
{"/app": ResolvedMount(target="/app", mode="rw", files={})},
@@ -100,7 +102,13 @@ class TestMergeFunctions:
"""Test basic git mount merging normalizes to mappings format."""
result = _merge_git_mounts(
[],
[{"remote_url": "https://github.com/user/repo1.git", "source_path": ".", "target_path": "/app"}],
[
{
"remote_url": "https://github.com/user/repo1.git",
"source_path": ".",
"target_path": "/app",
}
],
"source",
)
assert len(result) == 1
@@ -111,8 +119,22 @@ class TestMergeFunctions:
def test_merge_git_mounts_concatenate_same_repo_branch(self) -> None:
"""Test that git mounts with same repo+branch concatenate mappings."""
result = _merge_git_mounts(
[{"remote_url": "https://github.com/user/repo1.git", "source_path": ".", "target_path": "/app", "branch": "main"}],
[{"remote_url": "https://github.com/user/repo1.git", "source_path": "src", "target_path": "/src", "branch": "main"}],
[
{
"remote_url": "https://github.com/user/repo1.git",
"source_path": ".",
"target_path": "/app",
"branch": "main",
}
],
[
{
"remote_url": "https://github.com/user/repo1.git",
"source_path": "src",
"target_path": "/src",
"branch": "main",
}
],
"source",
)
assert len(result) == 1
@@ -125,8 +147,22 @@ class TestMergeFunctions:
def test_merge_git_mounts_dedup_same_mapping(self) -> None:
"""Test that duplicate mappings are deduplicated."""
result = _merge_git_mounts(
[{"remote_url": "https://github.com/user/repo1.git", "source_path": ".", "target_path": "/app", "branch": "main"}],
[{"remote_url": "https://github.com/user/repo1.git", "source_path": ".", "target_path": "/app", "branch": "main"}],
[
{
"remote_url": "https://github.com/user/repo1.git",
"source_path": ".",
"target_path": "/app",
"branch": "main",
}
],
[
{
"remote_url": "https://github.com/user/repo1.git",
"source_path": ".",
"target_path": "/app",
"branch": "main",
}
],
"source",
)
assert len(result) == 1
@@ -135,19 +171,48 @@ class TestMergeFunctions:
def test_merge_git_mounts_different_repos(self) -> None:
"""Test that git mounts with different repos are preserved."""
result = _merge_git_mounts(
[{"remote_url": "https://github.com/user/repo1.git", "source_path": ".", "target_path": "/app"}],
[{"remote_url": "https://github.com/user/repo2.git", "source_path": ".", "target_path": "/config"}],
[
{
"remote_url": "https://github.com/user/repo1.git",
"source_path": ".",
"target_path": "/app",
}
],
[
{
"remote_url": "https://github.com/user/repo2.git",
"source_path": ".",
"target_path": "/config",
}
],
"source",
)
assert len(result) == 2
urls = {m["remote_url"] for m in result}
assert urls == {"https://github.com/user/repo1.git", "https://github.com/user/repo2.git"}
assert urls == {
"https://github.com/user/repo1.git",
"https://github.com/user/repo2.git",
}
def test_merge_git_mounts_different_branches(self) -> None:
"""Test that same repo with different branches are kept separate."""
result = _merge_git_mounts(
[{"remote_url": "https://github.com/user/repo1.git", "source_path": ".", "target_path": "/app", "branch": "main"}],
[{"remote_url": "https://github.com/user/repo1.git", "source_path": ".", "target_path": "/app", "branch": "dev"}],
[
{
"remote_url": "https://github.com/user/repo1.git",
"source_path": ".",
"target_path": "/app",
"branch": "main",
}
],
[
{
"remote_url": "https://github.com/user/repo1.git",
"source_path": ".",
"target_path": "/app",
"branch": "dev",
}
],
"source",
)
assert len(result) == 2
@@ -181,7 +246,9 @@ class TestResolveProfile:
assert result.files == {"test.txt": "content"}
@pytest.mark.asyncio
async def test_resolve_profile_with_includes(self, db_session: AsyncSession) -> None:
async def test_resolve_profile_with_includes(
self, db_session: AsyncSession
) -> None:
"""Test resolving a profile that includes another."""
user_id = uuid.uuid4()
@@ -225,7 +292,9 @@ class TestResolveProfile:
assert result.included_profiles[0]["name"] == "base"
@pytest.mark.asyncio
async def test_resolve_profile_child_overrides_parent(self, db_session: AsyncSession) -> None:
async def test_resolve_profile_child_overrides_parent(
self, db_session: AsyncSession
) -> None:
"""Test that child profile values override parent values."""
user_id = uuid.uuid4()
@@ -262,7 +331,9 @@ class TestResolveProfile:
assert result.env_overrides == {"VAR": "child"}
@pytest.mark.asyncio
async def test_resolve_profile_cycle_detection(self, db_session: AsyncSession) -> None:
async def test_resolve_profile_cycle_detection(
self, db_session: AsyncSession
) -> None:
"""Test that cycles are detected during resolution."""
user_id = uuid.uuid4()
@@ -308,10 +379,12 @@ class TestResolveProfile:
await resolve_profile(db_session, profile_a.id)
@pytest.mark.asyncio
async def test_resolve_profile_with_git_mounts(self, db_session: AsyncSession) -> None:
async def test_resolve_profile_with_git_mounts(
self, db_session: AsyncSession
) -> None:
"""Test resolving a profile with git mounts normalizes to mappings."""
user_id = uuid.uuid4()
profile = ConfigProfile(
id=uuid.uuid4(),
user_id=user_id,
@@ -319,23 +392,31 @@ class TestResolveProfile:
env_vars={},
files={},
git_mounts=[
{"remote_url": "https://github.com/user/repo1.git", "source_path": ".", "target_path": "/app"},
{
"remote_url": "https://github.com/user/repo1.git",
"source_path": ".",
"target_path": "/app",
},
],
)
db_session.add(profile)
await db_session.commit()
result = await resolve_profile(db_session, profile.id)
assert len(result.git_mounts) == 1
assert result.git_mounts[0]["remote_url"] == "https://github.com/user/repo1.git"
assert "mappings" in result.git_mounts[0]
assert result.git_mounts[0]["mappings"] == [{"source_path": ".", "target_path": "/app"}]
assert result.git_mounts[0]["mappings"] == [
{"source_path": ".", "target_path": "/app"}
]
@pytest.mark.asyncio
async def test_resolve_profile_with_git_mount_includes(self, db_session: AsyncSession) -> None:
async def test_resolve_profile_with_git_mount_includes(
self, db_session: AsyncSession
) -> None:
"""Test resolving a profile that includes another with git mounts."""
user_id = uuid.uuid4()
# Create base profile with git mount
base = ConfigProfile(
id=uuid.uuid4(),
@@ -344,11 +425,15 @@ class TestResolveProfile:
env_vars={},
files={},
git_mounts=[
{"remote_url": "https://github.com/user/repo1.git", "source_path": ".", "target_path": "/app"},
{
"remote_url": "https://github.com/user/repo1.git",
"source_path": ".",
"target_path": "/app",
},
],
)
db_session.add(base)
# Create child profile with its own git mount
child = ConfigProfile(
id=uuid.uuid4(),
@@ -357,12 +442,16 @@ class TestResolveProfile:
env_vars={},
files={},
git_mounts=[
{"remote_url": "https://github.com/user/repo2.git", "source_path": "config", "target_path": "/config"},
{
"remote_url": "https://github.com/user/repo2.git",
"source_path": "config",
"target_path": "/config",
},
],
)
db_session.add(child)
await db_session.commit()
# Create include relationship
include = ConfigProfileInclude(
id=uuid.uuid4(),
@@ -372,11 +461,14 @@ class TestResolveProfile:
)
db_session.add(include)
await db_session.commit()
result = await resolve_profile(db_session, child.id)
assert len(result.git_mounts) == 2
urls = {m["remote_url"] for m in result.git_mounts}
assert urls == {"https://github.com/user/repo1.git", "https://github.com/user/repo2.git"}
assert urls == {
"https://github.com/user/repo1.git",
"https://github.com/user/repo2.git",
}
for m in result.git_mounts:
assert "mappings" in m
+8 -2
View File
@@ -148,7 +148,10 @@ class TestResolveSingleGitMount:
async def test_missing_remote_url(self) -> None:
"""Git mount without remote_url returns empty list."""
result = await _resolve_single_git_mount(
MagicMock(), {"mappings": [{"source_path": ".", "target_path": "/app"}]}, "/tmp", None
MagicMock(),
{"mappings": [{"source_path": ".", "target_path": "/app"}]},
"/tmp",
None,
)
assert result == []
@@ -157,7 +160,10 @@ class TestResolveSingleGitMount:
"""Git mount without instance_dir returns empty list."""
result = await _resolve_single_git_mount(
MagicMock(),
{"remote_url": "https://github.com/user/repo.git", "mappings": [{"source_path": ".", "target_path": "/app"}]},
{
"remote_url": "https://github.com/user/repo.git",
"mappings": [{"source_path": ".", "target_path": "/app"}],
},
None,
None,
)
@@ -0,0 +1,99 @@
"""Unit tests for ~ / $HOME expansion in container paths."""
import pytest
from src.api.tool_instances import _resolve_git_mount_mappings
from src.services.config_profile_resolver import expand_container_path
from src.services.manifest_compiler import get_manifest_home_dir
class TestExpandContainerPath:
"""Tests for expand_container_path helper."""
def test_tilde_slash_expands(self) -> None:
"""~/foo should expand to home_dir/foo."""
assert expand_container_path("~/workspace", "/home/user") == "/home/user/workspace"
def test_tilde_alone_expands(self) -> None:
"""~ should expand to home_dir."""
assert expand_container_path("~", "/home/user") == "/home/user"
def test_dollar_home_slash_expands(self) -> None:
"""$HOME/foo should expand to home_dir/foo."""
assert expand_container_path("$HOME/workspace", "/home/user") == "/home/user/workspace"
def test_dollar_home_alone_expands(self) -> None:
"""$HOME should expand to home_dir."""
assert expand_container_path("$HOME", "/home/user") == "/home/user"
def test_absolute_path_unchanged(self) -> None:
"""Absolute paths should not be modified."""
assert expand_container_path("/app/workspace", "/home/user") == "/app/workspace"
def test_relative_path_unchanged(self) -> None:
"""Relative paths should not be modified."""
assert expand_container_path("workspace", "/home/user") == "workspace"
def test_tilde_in_middle_unchanged(self) -> None:
"""~ in the middle of a path should not expand."""
assert expand_container_path("/app/~user", "/home/user") == "/app/~user"
def test_dollar_home_in_middle_unchanged(self) -> None:
"""$HOME in the middle of a path should not expand."""
assert expand_container_path("/app/$HOMEuser", "/home/user") == "/app/$HOMEuser"
def test_root_home(self) -> None:
"""Expansion works with /root as home."""
assert expand_container_path("~/config", "/root") == "/root/config"
class TestGetManifestHomeDir:
"""Tests for get_manifest_home_dir helper."""
def test_with_user_block(self) -> None:
"""Manifest with user block returns /home/{name}."""
manifest = {"user": {"name": "developer", "uid": 1000, "gid": 1000}}
assert get_manifest_home_dir(manifest) == "/home/developer"
def test_without_user_block(self) -> None:
"""Manifest without user block returns /root."""
manifest = {"base_image": "ubuntu:24.04"}
assert get_manifest_home_dir(manifest) == "/root"
def test_with_empty_user_name(self) -> None:
"""Manifest with empty user name returns /root."""
manifest = {"user": {"name": "", "uid": 1000, "gid": 1000}}
assert get_manifest_home_dir(manifest) == "/root"
def test_with_none_user_name(self) -> None:
"""Manifest with None user name returns /root."""
manifest = {"user": {"name": None, "uid": 1000, "gid": 1000}}
assert get_manifest_home_dir(manifest) == "/root"
class TestResolveGitMountMappingsExpansion:
"""Tests that git mount mapping targets expand ~ and $HOME."""
def test_tilde_target_expansion(self, tmp_path) -> None:
"""Mapping with ~/repo target expands to home dir."""
(tmp_path / "src").mkdir()
mappings = [{"source_path": "src", "target_path": "~/repo"}]
result = _resolve_git_mount_mappings(str(tmp_path), mappings, None, "/home/user")
assert len(result) == 1
assert result[0]["target"] == "/home/user/repo"
def test_dollar_home_target_expansion(self, tmp_path) -> None:
"""Mapping with $HOME/repo target expands to home dir."""
(tmp_path / "src").mkdir()
mappings = [{"source_path": "src", "target_path": "$HOME/repo"}]
result = _resolve_git_mount_mappings(str(tmp_path), mappings, None, "/home/user")
assert len(result) == 1
assert result[0]["target"] == "/home/user/repo"
def test_absolute_target_unchanged(self, tmp_path) -> None:
"""Absolute target paths are not modified."""
(tmp_path / "src").mkdir()
mappings = [{"source_path": "src", "target_path": "/app/src"}]
result = _resolve_git_mount_mappings(str(tmp_path), mappings, None, "/home/user")
assert len(result) == 1
assert result[0]["target"] == "/app/src"
@@ -8,6 +8,7 @@ from src.services.manifest_compiler import (
compile_entrypoint,
compute_image_tag,
deep_merge,
get_manifest_home_dir,
merge_with_config,
resolve_base,
)
@@ -161,6 +162,38 @@ class TestCompileDockerfile:
df = compile_dockerfile(manifest)
assert 'CMD ["/bin/bash"]' in df
def test_sets_home_env_for_user(self) -> None:
manifest = {
"base_image": "ubuntu:24.04",
"name": "test",
"user": {"name": "dev", "uid": 1001, "gid": 1001},
}
df = compile_dockerfile(manifest)
assert "ENV HOME=/home/dev" in df
assert "ENV USER=dev" in df
def test_no_home_env_without_user(self) -> None:
manifest = {"base_image": "ubuntu:24.04", "name": "test"}
df = compile_dockerfile(manifest)
assert "ENV HOME=" not in df
assert "ENV USER=" not in df
class TestGetManifestHomeDir:
"""Tests for get_manifest_home_dir."""
def test_with_user_name(self) -> None:
manifest = {"user": {"name": "dev", "uid": 1001, "gid": 1001}}
assert get_manifest_home_dir(manifest) == "/home/dev"
def test_without_user(self) -> None:
manifest = {"base_image": "ubuntu:24.04"}
assert get_manifest_home_dir(manifest) == "/root"
def test_with_empty_user_name(self) -> None:
manifest = {"user": {"name": "", "uid": 1001, "gid": 1001}}
assert get_manifest_home_dir(manifest) == "/root"
class TestCompileEntrypoint:
"""Tests for compile_entrypoint."""
@@ -735,6 +735,7 @@ class TestStartInstanceManifestBranch:
"headquarter/test:latest",
"services:\n app:\n image: test",
{"name": "test-manifest"},
"/root",
)
instance = ToolInstance(
+108 -102
View File
@@ -1,163 +1,169 @@
import { apiClient } from "./client";
export interface ConfigProfile {
id: string;
user_id: string;
name: string;
description: string | null;
project_id: string | null;
tool_type_id: string | null;
env_vars: Record<string, string>;
runtime_hints: Record<string, unknown>;
mounts: ConfigProfileMount[];
git_mounts: GitMount[];
files: Record<string, string>;
is_default: boolean;
includes: ConfigProfileInclude[];
created_at: string;
updated_at: string;
id: string;
user_id: string;
name: string;
description: string | null;
project_id: string | null;
tool_type_id: string | null;
env_vars: Record<string, string>;
runtime_hints: Record<string, unknown>;
mounts: ConfigProfileMount[];
git_mounts: GitMount[];
files: Record<string, string>;
is_default: boolean;
includes: ConfigProfileInclude[];
created_at: string;
updated_at: string;
}
export interface ConfigProfileMount {
target: string;
mode: "ro" | "rw";
files: Record<string, string>;
target: string;
mode: "ro" | "rw";
files: Record<string, string>;
}
export interface GitMountMapping {
source_path: string;
target_path: string;
source_path: string;
target_path: string;
}
export interface GitMount {
remote_url: string;
branch?: string;
mappings: GitMountMapping[];
// Legacy fields (for backward compatibility when reading old data)
source_path?: string;
target_path?: string;
remote_url: string;
branch?: string;
mappings: GitMountMapping[];
// Legacy fields (for backward compatibility when reading old data)
source_path?: string;
target_path?: string;
}
export interface ConfigProfileInclude {
id: string;
included_profile_id: string;
order_index: number;
id: string;
included_profile_id: string;
order_index: number;
}
export interface ResolvedProfile {
profile_id: string;
profile_name: string;
env_vars: Record<string, string>;
runtime_hints: Record<string, unknown>;
mounts: ResolvedMount[];
git_mounts: GitMount[];
files: Record<string, string>;
overrides: {
env_vars: Record<string, string>;
runtime_hints: Record<string, string>;
files: Record<string, string>;
mounts: Record<string, string>;
};
included_profiles: Array<{ id: string; name: string }>;
profile_id: string;
profile_name: string;
env_vars: Record<string, string>;
runtime_hints: Record<string, unknown>;
mounts: ResolvedMount[];
git_mounts: GitMount[];
files: Record<string, string>;
overrides: {
env_vars: Record<string, string>;
runtime_hints: Record<string, string>;
files: Record<string, string>;
mounts: Record<string, string>;
};
included_profiles: Array<{ id: string; name: string }>;
}
export interface ResolvedMount {
target: string;
mode: "ro" | "rw";
files: Record<string, string>;
overridden_files: Record<string, string>;
target: string;
mode: "ro" | "rw";
files: Record<string, string>;
overridden_files: Record<string, string>;
}
export interface CreateConfigProfileRequest {
name: string;
description?: string;
project_id?: string;
tool_type_id?: string;
env_vars?: Record<string, string>;
runtime_hints?: Record<string, unknown>;
mounts?: ConfigProfileMount[];
git_mounts?: GitMount[];
files?: Record<string, string>;
is_default?: boolean;
name: string;
description?: string;
project_id?: string;
tool_type_id?: string;
env_vars?: Record<string, string>;
runtime_hints?: Record<string, unknown>;
mounts?: ConfigProfileMount[];
git_mounts?: GitMount[];
files?: Record<string, string>;
is_default?: boolean;
}
export interface UpdateConfigProfileRequest {
name?: string;
description?: string;
project_id?: string;
tool_type_id?: string;
env_vars?: Record<string, string>;
runtime_hints?: Record<string, unknown>;
mounts?: ConfigProfileMount[];
git_mounts?: GitMount[];
files?: Record<string, string>;
is_default?: boolean;
name?: string;
description?: string;
project_id?: string;
tool_type_id?: string;
env_vars?: Record<string, string>;
runtime_hints?: Record<string, unknown>;
mounts?: ConfigProfileMount[];
git_mounts?: GitMount[];
files?: Record<string, string>;
is_default?: boolean;
}
export interface UpdateIncludesRequest {
includes: string[];
includes: string[];
}
export const listConfigProfiles = async (
projectId?: string,
toolTypeId?: string
projectId?: string,
toolTypeId?: string,
): Promise<ConfigProfile[]> => {
const response = await apiClient.get<ConfigProfile[]>("/config-profiles", {
params: { project_id: projectId, tool_type_id: toolTypeId },
});
return response.data;
const response = await apiClient.get<ConfigProfile[]>("/config-profiles", {
params: { project_id: projectId, tool_type_id: toolTypeId },
});
return response.data;
};
export const getConfigProfile = async (id: string): Promise<ConfigProfile> => {
const response = await apiClient.get<ConfigProfile>(`/config-profiles/${id}`);
return response.data;
const response = await apiClient.get<ConfigProfile>(`/config-profiles/${id}`);
return response.data;
};
export const createConfigProfile = async (
data: CreateConfigProfileRequest
data: CreateConfigProfileRequest,
): Promise<ConfigProfile> => {
const response = await apiClient.post<ConfigProfile>("/config-profiles", data);
return response.data;
const response = await apiClient.post<ConfigProfile>(
"/config-profiles",
data,
);
return response.data;
};
export const updateConfigProfile = async (
id: string,
data: UpdateConfigProfileRequest
id: string,
data: UpdateConfigProfileRequest,
): Promise<ConfigProfile> => {
const response = await apiClient.put<ConfigProfile>(`/config-profiles/${id}`, data);
return response.data;
const response = await apiClient.put<ConfigProfile>(
`/config-profiles/${id}`,
data,
);
return response.data;
};
export const deleteConfigProfile = async (id: string): Promise<void> => {
await apiClient.delete(`/config-profiles/${id}`);
await apiClient.delete(`/config-profiles/${id}`);
};
export const updateProfileIncludes = async (
id: string,
data: UpdateIncludesRequest
id: string,
data: UpdateIncludesRequest,
): Promise<ConfigProfile> => {
const response = await apiClient.put<ConfigProfile>(
`/config-profiles/${id}/includes`,
data
);
return response.data;
const response = await apiClient.put<ConfigProfile>(
`/config-profiles/${id}/includes`,
data,
);
return response.data;
};
export const previewConfigProfile = async (
id: string
id: string,
): Promise<ResolvedProfile> => {
const response = await apiClient.get<ResolvedProfile>(
`/config-profiles/${id}/preview`
);
return response.data;
const response = await apiClient.get<ResolvedProfile>(
`/config-profiles/${id}/preview`,
);
return response.data;
};
export const resolveDefaultProfile = async (
projectId: string,
toolTypeId: string
projectId: string,
toolTypeId: string,
): Promise<{ profile_id: string | null; profile_name: string | null }> => {
const response = await apiClient.get("/config-profiles/defaults/resolve", {
params: { project_id: projectId, tool_type_id: toolTypeId },
});
return response.data;
const response = await apiClient.get("/config-profiles/defaults/resolve", {
params: { project_id: projectId, tool_type_id: toolTypeId },
});
return response.data;
};
+384 -272
View File
@@ -3,309 +3,421 @@ import { Icon } from "./icon";
import type { GitMount, GitMountMapping } from "../api/config_profiles";
interface GitMountEditorProps {
mounts: GitMount[];
onChange: (mounts: GitMount[]) => void;
mounts: GitMount[];
onChange: (mounts: GitMount[]) => void;
}
function normalizeMount(mount: GitMount): GitMount {
// Auto-convert legacy source_path + target_path to mappings
if ((!mount.mappings || mount.mappings.length === 0) && mount.source_path !== undefined && mount.target_path !== undefined) {
return {
remote_url: mount.remote_url,
branch: mount.branch,
mappings: [{ source_path: mount.source_path || ".", target_path: mount.target_path }],
};
}
return mount;
// Auto-convert legacy source_path + target_path to mappings
if (
(!mount.mappings || mount.mappings.length === 0) &&
mount.source_path !== undefined &&
mount.target_path !== undefined
) {
return {
remote_url: mount.remote_url,
branch: mount.branch,
mappings: [
{
source_path: mount.source_path || ".",
target_path: mount.target_path,
},
],
};
}
return mount;
}
function normalizeMounts(mounts: GitMount[]): GitMount[] {
return mounts.map(normalizeMount);
return mounts.map(normalizeMount);
}
export const GitMountEditor = ({ mounts, onChange }: GitMountEditorProps) => {
const [normalizedMounts, setNormalizedMounts] = useState<GitMount[]>(() => normalizeMounts(mounts));
const [editingIndex, setEditingIndex] = useState<number | null>(null);
const [isAdding, setIsAdding] = useState(false);
const [normalizedMounts, setNormalizedMounts] = useState<GitMount[]>(() =>
normalizeMounts(mounts),
);
const [editingIndex, setEditingIndex] = useState<number | null>(null);
const [isAdding, setIsAdding] = useState(false);
useEffect(() => {
setNormalizedMounts(normalizeMounts(mounts));
}, [mounts]);
useEffect(() => {
setNormalizedMounts(normalizeMounts(mounts));
}, [mounts]);
const handleAdd = (mount: GitMount) => {
const updated = [...normalizedMounts, normalizeMount(mount)];
setNormalizedMounts(updated);
onChange(updated);
setIsAdding(false);
};
const handleAdd = (mount: GitMount) => {
const updated = [...normalizedMounts, normalizeMount(mount)];
setNormalizedMounts(updated);
onChange(updated);
setIsAdding(false);
};
const handleUpdate = (index: number, updated: GitMount) => {
const updatedMounts = [...normalizedMounts];
updatedMounts[index] = normalizeMount(updated);
setNormalizedMounts(updatedMounts);
onChange(updatedMounts);
setEditingIndex(null);
};
const handleUpdate = (index: number, updated: GitMount) => {
const updatedMounts = [...normalizedMounts];
updatedMounts[index] = normalizeMount(updated);
setNormalizedMounts(updatedMounts);
onChange(updatedMounts);
setEditingIndex(null);
};
const handleRemove = (index: number) => {
const updated = normalizedMounts.filter((_, i) => i !== index);
setNormalizedMounts(updated);
onChange(updated);
};
const handleRemove = (index: number) => {
const updated = normalizedMounts.filter((_, i) => i !== index);
setNormalizedMounts(updated);
onChange(updated);
};
return (
<div className="git-mount-editor">
<h4 style={{ margin: "0 0 0.75rem 0" }}>Git Mounts</h4>
<p className="muted" style={{ margin: "0 0 0.75rem 0", fontSize: "0.875rem" }}>
Clone a repository once and mount multiple directories from it.
</p>
return (
<div className="git-mount-editor">
<h4 style={{ margin: "0 0 0.75rem 0" }}>Git Mounts</h4>
<p
className="muted"
style={{ margin: "0 0 0.75rem 0", fontSize: "0.875rem" }}
>
Clone a repository once and mount multiple directories from it.
</p>
{normalizedMounts.length > 0 && (
<div className="git-mount-list" style={{ display: "flex", flexDirection: "column", gap: "0.75rem", marginBottom: "1rem" }}>
{normalizedMounts.map((mount, index) => (
<div key={index} className="card" style={{ padding: "1rem" }}>
{editingIndex === index ? (
<GitMountForm
mount={mount}
onSave={(updated) => handleUpdate(index, updated)}
onCancel={() => setEditingIndex(null)}
/>
) : (
<div>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "flex-start", marginBottom: "0.5rem" }}>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontWeight: 600, fontSize: "0.9375rem", marginBottom: "0.25rem" }}>
{mount.remote_url}
{mount.branch && (
<span style={{ color: "var(--muted)", fontWeight: 400, marginLeft: "0.5rem" }}>
@{mount.branch}
</span>
)}
</div>
<div style={{ display: "flex", flexDirection: "column", gap: "0.25rem" }}>
{mount.mappings?.map((m, mi) => (
<div key={mi} style={{ fontSize: "0.875rem", color: "var(--muted)", fontFamily: "monospace" }}>
{m.source_path || "."} {m.target_path}
</div>
))}
</div>
</div>
<div style={{ display: "flex", gap: "0.25rem", flexShrink: 0 }}>
<button
type="button"
className="ghost-button small"
onClick={() => setEditingIndex(index)}
title="Edit"
>
<Icon name="edit" size="sm" />
</button>
<button
type="button"
className="ghost-button small"
onClick={() => handleRemove(index)}
title="Remove"
>
<Icon name="delete" size="sm" />
</button>
</div>
</div>
</div>
)}
</div>
))}
</div>
)}
{normalizedMounts.length > 0 && (
<div
className="git-mount-list"
style={{
display: "flex",
flexDirection: "column",
gap: "0.75rem",
marginBottom: "1rem",
}}
>
{normalizedMounts.map((mount, index) => (
<div key={index} className="card" style={{ padding: "1rem" }}>
{editingIndex === index ? (
<GitMountForm
mount={mount}
onSave={(updated) => handleUpdate(index, updated)}
onCancel={() => setEditingIndex(null)}
/>
) : (
<div>
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "flex-start",
marginBottom: "0.5rem",
}}
>
<div style={{ flex: 1, minWidth: 0 }}>
<div
style={{
fontWeight: 600,
fontSize: "0.9375rem",
marginBottom: "0.25rem",
}}
>
{mount.remote_url}
{mount.branch && (
<span
style={{
color: "var(--muted)",
fontWeight: 400,
marginLeft: "0.5rem",
}}
>
@{mount.branch}
</span>
)}
</div>
<div
style={{
display: "flex",
flexDirection: "column",
gap: "0.25rem",
}}
>
{mount.mappings?.map((m, mi) => (
<div
key={mi}
style={{
fontSize: "0.875rem",
color: "var(--muted)",
fontFamily: "monospace",
}}
>
{m.source_path || "."} {m.target_path}
</div>
))}
</div>
</div>
<div
style={{ display: "flex", gap: "0.25rem", flexShrink: 0 }}
>
<button
type="button"
className="ghost-button small"
onClick={() => setEditingIndex(index)}
title="Edit"
>
<Icon name="edit" size="sm" />
</button>
<button
type="button"
className="ghost-button small"
onClick={() => handleRemove(index)}
title="Remove"
>
<Icon name="delete" size="sm" />
</button>
</div>
</div>
</div>
)}
</div>
))}
</div>
)}
{isAdding ? (
<div className="card" style={{ padding: "1rem" }}>
<GitMountForm
mount={{ remote_url: "", branch: "", mappings: [{ source_path: ".", target_path: "" }] }}
onSave={handleAdd}
onCancel={() => setIsAdding(false)}
/>
</div>
) : (
<button type="button" className="secondary-button" onClick={() => setIsAdding(true)}>
<Icon name="add" size="sm" />
Add Git Mount
</button>
)}
</div>
);
{isAdding ? (
<div className="card" style={{ padding: "1rem" }}>
<GitMountForm
mount={{
remote_url: "",
branch: "",
mappings: [{ source_path: ".", target_path: "" }],
}}
onSave={handleAdd}
onCancel={() => setIsAdding(false)}
/>
</div>
) : (
<button
type="button"
className="secondary-button"
onClick={() => setIsAdding(true)}
>
<Icon name="add" size="sm" />
Add Git Mount
</button>
)}
</div>
);
};
interface GitMountFormProps {
mount: GitMount;
onSave: (mount: GitMount) => void;
onCancel: () => void;
mount: GitMount;
onSave: (mount: GitMount) => void;
onCancel: () => void;
}
const GitMountForm = ({ mount, onSave, onCancel }: GitMountFormProps) => {
const [remoteUrl, setRemoteUrl] = useState(mount.remote_url);
const [branch, setBranch] = useState(mount.branch || "");
const [mappings, setMappings] = useState<GitMountMapping[]>(
mount.mappings?.length ? mount.mappings : [{ source_path: ".", target_path: "" }]
);
const [errors, setErrors] = useState<Record<string, string>>({});
const [remoteUrl, setRemoteUrl] = useState(mount.remote_url);
const [branch, setBranch] = useState(mount.branch || "");
const [mappings, setMappings] = useState<GitMountMapping[]>(
mount.mappings?.length
? mount.mappings
: [{ source_path: ".", target_path: "" }],
);
const [errors, setErrors] = useState<Record<string, string>>({});
const validate = (): boolean => {
const newErrors: Record<string, string> = {};
const validate = (): boolean => {
const newErrors: Record<string, string> = {};
if (!remoteUrl.trim()) {
newErrors.remote_url = "Git URL is required";
} else if (
!remoteUrl.startsWith("http://") &&
!remoteUrl.startsWith("https://") &&
!remoteUrl.startsWith("git@") &&
!remoteUrl.startsWith("ssh://")
) {
newErrors.remote_url = "Must be a valid git URL (https://, git@, or ssh://)";
}
if (!remoteUrl.trim()) {
newErrors.remote_url = "Git URL is required";
} else if (
!remoteUrl.startsWith("http://") &&
!remoteUrl.startsWith("https://") &&
!remoteUrl.startsWith("git@") &&
!remoteUrl.startsWith("ssh://")
) {
newErrors.remote_url =
"Must be a valid git URL (https://, git@, or ssh://)";
}
mappings.forEach((m, i) => {
if (!m.target_path.trim()) {
newErrors[`mapping_${i}_target`] = "Target path is required";
}
if (m.source_path.includes("..")) {
newErrors[`mapping_${i}_source`] = "Source path cannot contain ..";
}
if (m.target_path.includes("..")) {
newErrors[`mapping_${i}_target`] = "Target path cannot contain ..";
}
});
mappings.forEach((m, i) => {
if (!m.target_path.trim()) {
newErrors[`mapping_${i}_target`] = "Target path is required";
}
if (m.source_path.includes("..")) {
newErrors[`mapping_${i}_source`] = "Source path cannot contain ..";
}
if (m.target_path.includes("..")) {
newErrors[`mapping_${i}_target`] = "Target path cannot contain ..";
}
});
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
const handleSubmit = () => {
if (!validate()) return;
onSave({
remote_url: remoteUrl.trim(),
branch: branch.trim() || undefined,
mappings: mappings.map((m) => ({
source_path: m.source_path.trim() || ".",
target_path: m.target_path.trim(),
})),
});
};
const handleSubmit = () => {
if (!validate()) return;
onSave({
remote_url: remoteUrl.trim(),
branch: branch.trim() || undefined,
mappings: mappings.map((m) => ({
source_path: m.source_path.trim() || ".",
target_path: m.target_path.trim(),
})),
});
};
const addMapping = () => {
setMappings((prev) => [...prev, { source_path: ".", target_path: "" }]);
};
const addMapping = () => {
setMappings((prev) => [...prev, { source_path: ".", target_path: "" }]);
};
const updateMapping = (index: number, field: keyof GitMountMapping, value: string) => {
setMappings((prev) => {
const next = [...prev];
next[index] = { ...next[index], [field]: value };
return next;
});
if (errors[`mapping_${index}_${field}`]) {
setErrors((prev) => {
const next = { ...prev };
delete next[`mapping_${index}_${field}`];
return next;
});
}
};
const updateMapping = (
index: number,
field: keyof GitMountMapping,
value: string,
) => {
setMappings((prev) => {
const next = [...prev];
next[index] = { ...next[index], [field]: value };
return next;
});
if (errors[`mapping_${index}_${field}`]) {
setErrors((prev) => {
const next = { ...prev };
delete next[`mapping_${index}_${field}`];
return next;
});
}
};
const removeMapping = (index: number) => {
setMappings((prev) => prev.filter((_, i) => i !== index));
};
const removeMapping = (index: number) => {
setMappings((prev) => prev.filter((_, i) => i !== index));
};
return (
<div style={{ display: "flex", flexDirection: "column", gap: "0.75rem" }}>
<div className="form-row" style={{ gap: "0.5rem" }}>
<div style={{ flex: 2 }}>
<label style={{ fontSize: "0.875rem", fontWeight: 500 }}>Repository URL</label>
<input
type="text"
value={remoteUrl}
onChange={(e) => {
setRemoteUrl(e.target.value);
if (errors.remote_url) {
setErrors((prev) => {
const next = { ...prev };
delete next.remote_url;
return next;
});
}
}}
placeholder="https://github.com/user/repo.git"
className={`form-input ${errors.remote_url ? "error" : ""}`}
/>
{errors.remote_url && <span className="error-text">{errors.remote_url}</span>}
</div>
<div style={{ flex: 1 }}>
<label style={{ fontSize: "0.875rem", fontWeight: 500 }}>Branch (optional)</label>
<input
type="text"
value={branch}
onChange={(e) => setBranch(e.target.value)}
placeholder="main"
className="form-input"
/>
</div>
</div>
return (
<div style={{ display: "flex", flexDirection: "column", gap: "0.75rem" }}>
<div className="form-row" style={{ gap: "0.5rem" }}>
<div style={{ flex: 2 }}>
<label style={{ fontSize: "0.875rem", fontWeight: 500 }}>
Repository URL
</label>
<input
type="text"
value={remoteUrl}
onChange={(e) => {
setRemoteUrl(e.target.value);
if (errors.remote_url) {
setErrors((prev) => {
const next = { ...prev };
delete next.remote_url;
return next;
});
}
}}
placeholder="https://github.com/user/repo.git"
className={`form-input ${errors.remote_url ? "error" : ""}`}
/>
{errors.remote_url && (
<span className="error-text">{errors.remote_url}</span>
)}
</div>
<div style={{ flex: 1 }}>
<label style={{ fontSize: "0.875rem", fontWeight: 500 }}>
Branch (optional)
</label>
<input
type="text"
value={branch}
onChange={(e) => setBranch(e.target.value)}
placeholder="main"
className="form-input"
/>
</div>
</div>
<div>
<label style={{ fontSize: "0.875rem", fontWeight: 500 }}>Mappings</label>
<p className="muted" style={{ margin: "0 0 0.5rem 0", fontSize: "0.8125rem" }}>
Source paths within the repo and where to mount them in the container.
</p>
<div style={{ display: "flex", flexDirection: "column", gap: "0.5rem" }}>
{mappings.map((mapping, index) => (
<div key={index} className="form-row" style={{ gap: "0.5rem", alignItems: "flex-start" }}>
<input
type="text"
value={mapping.source_path}
onChange={(e) => updateMapping(index, "source_path", e.target.value)}
placeholder="packages/api"
className={`form-input ${errors[`mapping_${index}_source`] ? "error" : ""}`}
style={{ flex: 1 }}
/>
<span style={{ padding: "0.5rem 0", color: "var(--muted)", fontSize: "0.875rem" }}></span>
<input
type="text"
value={mapping.target_path}
onChange={(e) => updateMapping(index, "target_path", e.target.value)}
placeholder="/app/api"
className={`form-input ${errors[`mapping_${index}_target`] ? "error" : ""}`}
style={{ flex: 1 }}
/>
{mappings.length > 1 && (
<button
type="button"
className="ghost-button small"
onClick={() => removeMapping(index)}
title="Remove mapping"
>
<Icon name="delete" size="sm" />
</button>
)}
{errors[`mapping_${index}_source`] && (
<span className="error-text">{errors[`mapping_${index}_source`]}</span>
)}
{errors[`mapping_${index}_target`] && (
<span className="error-text">{errors[`mapping_${index}_target`]}</span>
)}
</div>
))}
</div>
<button type="button" className="secondary-button small" onClick={addMapping} style={{ marginTop: "0.5rem" }}>
<Icon name="add" size="sm" />
Add Mapping
</button>
</div>
<div>
<label style={{ fontSize: "0.875rem", fontWeight: 500 }}>
Mappings
</label>
<p
className="muted"
style={{ margin: "0 0 0.5rem 0", fontSize: "0.8125rem" }}
>
Source paths within the repo and where to mount them in the container.
</p>
<div
style={{ display: "flex", flexDirection: "column", gap: "0.5rem" }}
>
{mappings.map((mapping, index) => (
<div
key={index}
className="form-row"
style={{ gap: "0.5rem", alignItems: "flex-start" }}
>
<input
type="text"
value={mapping.source_path}
onChange={(e) =>
updateMapping(index, "source_path", e.target.value)
}
placeholder="packages/api"
className={`form-input ${errors[`mapping_${index}_source`] ? "error" : ""}`}
style={{ flex: 1 }}
/>
<span
style={{
padding: "0.5rem 0",
color: "var(--muted)",
fontSize: "0.875rem",
}}
>
</span>
<input
type="text"
value={mapping.target_path}
onChange={(e) =>
updateMapping(index, "target_path", e.target.value)
}
placeholder="/app/api"
className={`form-input ${errors[`mapping_${index}_target`] ? "error" : ""}`}
style={{ flex: 1 }}
/>
{mappings.length > 1 && (
<button
type="button"
className="ghost-button small"
onClick={() => removeMapping(index)}
title="Remove mapping"
>
<Icon name="delete" size="sm" />
</button>
)}
{errors[`mapping_${index}_source`] && (
<span className="error-text">
{errors[`mapping_${index}_source`]}
</span>
)}
{errors[`mapping_${index}_target`] && (
<span className="error-text">
{errors[`mapping_${index}_target`]}
</span>
)}
</div>
))}
</div>
<button
type="button"
className="secondary-button small"
onClick={addMapping}
style={{ marginTop: "0.5rem" }}
>
<Icon name="add" size="sm" />
Add Mapping
</button>
</div>
<div className="form-actions" style={{ display: "flex", gap: "0.5rem", marginTop: "0.5rem" }}>
<button type="button" className="primary-button" onClick={handleSubmit}>
Save
</button>
<button type="button" className="secondary-button" onClick={onCancel}>
Cancel
</button>
</div>
</div>
);
<div
className="form-actions"
style={{ display: "flex", gap: "0.5rem", marginTop: "0.5rem" }}
>
<button type="button" className="primary-button" onClick={handleSubmit}>
Save
</button>
<button type="button" className="secondary-button" onClick={onCancel}>
Cancel
</button>
</div>
</div>
);
};
@@ -0,0 +1,7 @@
name: home-path-expansion
status: completed
phase: verify
type: feature
description: Resolve ~ and $HOME in mount target paths to the container's correct home directory based on manifest user configuration.
created_at: 2026-05-28
updated_at: 2026-05-28
+102
View File
@@ -0,0 +1,102 @@
# Design: ~ / $HOME Expansion in Mount Paths
## Architecture
### New Helpers
#### `expand_container_path(path: str, home_dir: str) -> str`
Located in `config_profile_resolver.py` (or new shared module).
```python
def expand_container_path(path: str, home_dir: str) -> str:
if path.startswith("~/"):
return os.path.join(home_dir, path[2:])
if path == "~":
return home_dir
path = path.replace("$HOME/", home_dir + "/")
path = path.replace("$HOME", home_dir)
return path
```
#### `get_manifest_home_dir(manifest: dict) -> str`
Located in `manifest_compiler.py`.
```python
def get_manifest_home_dir(manifest: dict) -> str:
user = manifest.get("user")
if user and user.get("name"):
return f"/home/{user['name']}"
return "/root"
```
#### `get_tool_home_dir(tool_type: ToolType, manifest: dict | None) -> str`
Located in `tool_instances.py` or `manifest_compiler.py`.
```python
def get_tool_home_dir(tool_type: ToolType, manifest: dict | None = None) -> str:
if tool_type.definition_type == "manifest" and manifest:
return get_manifest_home_dir(manifest)
return "/root"
```
### Pipeline Changes
#### `create_instance` flow
1. Determine `home_dir` from tool type + manifest (if manifest-based)
2. Pass `home_dir` to `_modify_compose_file()` — expand mount targets in compose
#### `start_instance` flow
1. Determine `home_dir` from tool type + resolved manifest
2. Pass `home_dir` to `apply_resolved_profile()` — expand profile mount targets
3. Pass `home_dir` to `_resolve_git_mounts()` — expand git mount mapping targets
#### `apply_resolved_profile()`
```python
def apply_resolved_profile(
instance_dir: str,
resolved: ResolvedProfile,
home_dir: str = "/root",
) -> tuple[...]:
...
for mount in resolved.mounts.values():
target = expand_container_path(mount.target, home_dir)
...
```
#### `_resolve_git_mount_mappings()`
```python
def _resolve_git_mount_mappings(
repo_path: str,
mappings: list[dict],
working_directory: str | None,
home_dir: str = "/root",
) -> list[dict]:
...
final_target = expand_container_path(target_path, home_dir)
...
```
### Dockerfile Change
In `compile_dockerfile()`, after user creation, set `HOME`:
```python
if user:
home = f"/home/{user['name']}"
lines.append(f"ENV HOME={home}")
lines.append(f"ENV USER={user['name']}")
```
### File Changes
| File | Change |
|------|--------|
| `apps/api/src/services/config_profile_resolver.py` | Add `expand_container_path()`, apply in `apply_resolved_profile()` |
| `apps/api/src/services/manifest_compiler.py` | Add `get_manifest_home_dir()`, set `HOME`/`USER` env in Dockerfile |
| `apps/api/src/api/tool_instances.py` | Determine `home_dir`, pass through all mount resolution functions |
| `apps/api/tests/unit/test_home_path_expansion.py` | New unit tests |
| `apps/api/tests/unit/test_manifest_compiler.py` | Add tests for `get_manifest_home_dir` and Dockerfile `HOME` env |
@@ -0,0 +1,76 @@
# Exploration: ~ / $HOME Expansion in Mount Paths
## User Request
Allow `~` and `$HOME` in mount paths for both regular mounts and git mounts.
## Where This Applies
### Container target paths (where it makes sense)
- **Regular mounts** (`mount.target`): The absolute path inside the container where files are bind-mounted
- **Git mount mappings** (`mapping.target_path`): The absolute path inside the container where repo subdirectories are mounted
### Where it does NOT apply
- **Regular mount file paths** (`mount.files` keys): These are relative to the mount target
- **Git mount source paths** (`mapping.source_path`): These are relative to the cloned repo
- **Host-side source paths**: The API runs in a container; `~` on the host would mean the Docker host's home, which the API container cannot access
## Complexity Assessment
### The Core Problem
`~` means "user's home directory". But whose home?
| Context | Home Directory | Knowable at Mount Time? |
|---------|---------------|------------------------|
| API container | `/root` or `/app` | Yes |
| Target container (manifest, user=root) | `/root` | Yes (from manifest) |
| Target container (manifest, user=user) | `/home/user` | Yes (from manifest) |
| Target container (legacy tool type) | Unknown | No (assume `/root`) |
| Docker host | `/home/alex` or similar | No (API is containerized) |
### Docker Compose Reality Check
Docker Compose **does not expand** `~` or `$HOME` in volume targets. These are passed literally to the Docker daemon. So `~/workspace` becomes a directory literally named `~` in the container root.
This means **we must resolve the path ourselves** before writing the compose file.
## Design Options
### Option A: Simple `/root` default (minimal change)
- Replace `~` and `$HOME` with `/root` in all container target paths
- Apply during compose modification and git mount resolution
- **Effort**: ~30 min, ~20 lines
- **Pros**: Dead simple, works for root-running containers (most legacy setups)
- **Cons**: Wrong for pi-agent (runs as `user`, home `/home/user`)
### Option B: Manifest-aware home directory (recommended)
- For manifest-based tools: read `user.name` from manifest, compute home as `/home/{name}` or `/root`
- For legacy tools: default to `/root`
- Pass `home_dir` through the mount resolution pipeline
- Apply expansion in `apply_resolved_profile()` and `_resolve_git_mount_mappings()`
- **Effort**: ~2 hours, touches 3-4 files
- **Pros**: Correct for all container types
- **Cons**: Slightly more plumbing
### Option C: Configurable home per profile
- Add `home_directory` field to ConfigProfile
- User can override the container home directory
- **Effort**: ~3 hours, schema change
- **Cons**: Overkill, clutters UI
## Recommendation
**Option B** — manifest-aware expansion. The pi-agent manifest already declares `user.name`, so we can compute the correct home directory. For backward compatibility, legacy tool types default to `/root`.
## Files to Touch
1. `apps/api/src/services/config_profile_resolver.py` — add `expand_container_path()` helper
2. `apps/api/src/api/tool_instances.py` — pass `home_dir` to `apply_resolved_profile()` and git mount functions; determine home from manifest/tool type
3. `apps/api/src/services/manifest_compiler.py` — expose helper to extract user from manifest
4. Tests for expansion logic
## Risks
| Risk | Mitigation |
|------|------------|
| Wrong home for custom containers | Document that manifest should declare `user.name` |
| `$HOME` env var not set in container | We resolve it at compose generation time, so no runtime dependency |
| Breaking existing profiles with literal `~` in path | Very unlikely; we can add an escape hatch if needed |
+49
View File
@@ -0,0 +1,49 @@
# Proposal: ~ / $HOME Expansion in Mount Paths
## Context
Users want to write mount target paths like `~/workspace` or `$HOME/workspace` instead of absolute paths like `/home/user/workspace` or `/root/workspace`. Docker Compose does not expand these — they must be resolved before writing the compose file.
## Goal
Resolve `~` and `$HOME` in container target paths to the correct home directory for the target container.
## Direction
**Manifest-aware home directory (Option B)**
- Extract `user.name` from the tool manifest to compute `/home/{name}`
- For root-based manifests (no user block), use `/root`
- For legacy tool types (non-manifest), default to `/root`
- Apply expansion at compose generation time for both regular mounts and git mount targets
- Also set `HOME` env var in the Dockerfile for runtime compatibility
## Acceptance Criteria
1. `~` in a mount target path is expanded to the container's home directory
2. `$HOME` in a mount target path is expanded to the container's home directory
3. For manifest-based tools with `user.name`, home is `/home/{user.name}`
4. For manifest-based tools without user block, home is `/root`
5. For legacy tool types, home is `/root`
6. Git mount `mapping.target_path` also supports `~` and `$HOME`
7. `HOME` env var is set in generated Dockerfile
8. No frontend changes needed (users type `~`, backend resolves it)
## Out of Scope
- `~` expansion in host-side source paths
- `~` expansion in mount file relative paths
- `~user` syntax (e.g., `~alice`)
## Risks
| Risk | Mitigation |
|------|------------|
| Wrong home for custom containers | Document that manifest should declare `user.name` |
| `$HOME` env var not set in container | Set it in Dockerfile via `ENV HOME=...` |
## Related Artifacts
- Exploration: `openspec/explorations/home-path-expansion.md`
- Spec: `openspec/specs/home-path-expansion.md`
- Design: `openspec/designs/home-path-expansion.md`
- Tasks: `openspec/tasks/home-path-expansion.md`
+71
View File
@@ -0,0 +1,71 @@
# Spec: ~ / $HOME Expansion in Mount Paths
## Requirements
### Functional
1. **FR-1**: `~` in mount target paths MUST be expanded to the container's home directory.
2. **FR-2**: `$HOME` in mount target paths MUST be expanded to the container's home directory.
3. **FR-3**: For manifest-based tools with `user.name`, home MUST be `/home/{user.name}`.
4. **FR-4**: For manifest-based tools without user block, home MUST be `/root`.
5. **FR-5**: For legacy tool types, home MUST be `/root`.
6. **FR-6**: Git mount `mapping.target_path` MUST support `~` and `$HOME`.
7. **FR-7**: The generated Dockerfile MUST set `HOME` env var.
### Non-Functional
1. **NFR-1**: No database schema changes.
2. **NFR-2**: No frontend changes.
3. **NFR-3**: Existing profiles without `~` MUST continue working.
## API Contracts
No API changes. Resolution happens server-side during compose generation.
## Scenarios
### Scenario 1: pi-agent with ~ mount
**Given** a Config Profile with:
```json
{"mounts": [{"target": "~/workspace", "mode": "rw", "files": {}}]}
```
**And** a pi-agent manifest with `user.name = "user"`
**When** the profile is applied
**Then** the compose file contains `/home/user/workspace` as the mount target.
### Scenario 2: Legacy tool with $HOME mount
**Given** a Config Profile with:
```json
{"mounts": [{"target": "$HOME/config", "mode": "ro", "files": {}}]}
```
**And** a legacy tool type (no manifest)
**When** the profile is applied
**Then** the compose file contains `/root/config` as the mount target.
### Scenario 3: Git mount with ~ target
**Given** a Config Profile with:
```json
{"git_mounts": [{"remote_url": "...", "mappings": [{"source_path": ".", "target_path": "~/repo"}]}]}
```
**And** a manifest with `user.name = "user"`
**When** the profile is applied
**Then** the compose file contains `/home/user/repo` as the mount target.
## Test Strategy
1. Unit test `expand_container_path` with `~`, `$HOME`, absolute paths, relative paths
2. Unit test `get_manifest_home_dir` with user block, without user block
3. Integration test: profile with `~` mount applied to pi-agent instance
4. Integration test: profile with `$HOME` mount applied to legacy instance
+75
View File
@@ -0,0 +1,75 @@
# Tasks: ~ / $HOME Expansion in Mount Paths
## T1: Backend — Core helpers and pipeline
### T1.1: Add `expand_container_path` helper
**File**: `apps/api/src/services/config_profile_resolver.py`
- Add `expand_container_path(path: str, home_dir: str) -> str`
- Handle `~/`, `~`, `$HOME/`, `$HOME` patterns
- Must not expand if path doesn't start with these patterns
### T1.2: Add `get_manifest_home_dir` helper
**File**: `apps/api/src/services/manifest_compiler.py`
- Add `get_manifest_home_dir(manifest: dict) -> str`
- Returns `/home/{user.name}` if user block exists, else `/root`
### T1.3: Set `HOME` and `USER` env vars in Dockerfile
**File**: `apps/api/src/services/manifest_compiler.py`
- In `compile_dockerfile()`, after user creation block, add `ENV HOME=...` and `ENV USER=...`
- Update existing manifest compiler tests
### T1.4: Update `apply_resolved_profile` to expand paths
**File**: `apps/api/src/services/config_profile_resolver.py`
- Add `home_dir: str = "/root"` parameter
- Expand mount targets before creating mount directories and volume entries
### T1.5: Update git mount resolution to expand paths
**File**: `apps/api/src/api/tool_instances.py`
- Add `home_dir: str = "/root"` parameter to `_resolve_git_mount_mappings()`
- Expand mapping target paths before resolving
- Add `home_dir` parameter to `_resolve_git_mounts()` and `_resolve_single_git_mount()`
### T1.6: Determine home_dir in instance lifecycle
**File**: `apps/api/src/api/tool_instances.py`
- In `create_instance`: determine `home_dir` from tool type + manifest (if manifest), pass to `_modify_compose_file`
- In `start_instance`: determine `home_dir` from tool type + resolved manifest, pass to `apply_resolved_profile` and `_resolve_git_mounts`
- In `_prepare_manifest_instance`: return `home_dir` alongside image_tag and compose_content
### T1.7: Update `_modify_compose_file` to expand paths
**File**: `apps/api/src/api/tool_instances.py`
- Add `home_dir: str = "/root"` parameter
- Expand `working_directory` and any mount targets in extra_volumes
### T1.8: Unit tests
**File**: `apps/api/tests/unit/test_home_path_expansion.py` (new)
- Test `expand_container_path` with `~`, `~/foo`, `$HOME`, `$HOME/foo`, `/abs/path`, `rel/path`
- Test `get_manifest_home_dir` with user, without user
**File**: `apps/api/tests/unit/test_manifest_compiler.py`
- Test Dockerfile contains `ENV HOME=...` for user-based manifests
- Test Dockerfile contains `ENV HOME=/root` for root manifests
---
## T2: Verification
### T2.1: Run all affected tests
```bash
cd apps/api && pytest tests/unit/test_home_path_expansion.py tests/unit/test_manifest_compiler.py tests/unit/test_config_profile_resolver.py tests/unit/test_git_mounts.py -xvs
```
### T2.2: Frontend typecheck
```bash
cd apps/web && npm run typecheck
```
---
## Estimation
| Task | Effort | Files |
|------|--------|-------|
| T1.1-T1.7 | 2h | 3 |
| T1.8 | 1h | 2 |
| T2.1-T2.2 | 0.5h | — |
| **Total** | **3.5h** | **5** |