Merge feat/home-path-expansion into dev

This commit is contained in:
Alex Blank
2026-05-29 00:01:13 +02:00
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): 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") target_path: str = Field(description="Absolute path inside container")
@field_validator("source_path") @field_validator("source_path")
@@ -80,16 +82,25 @@ class GitMountMapping(BaseModel):
class GitMountItem(BaseModel): class GitMountItem(BaseModel):
remote_url: str = Field(description="Git remote URL (HTTPS or SSH)") 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)") source_path: str | None = Field(
target_path: str | None = Field(default=None, description="Absolute path inside container (legacy single mapping)") 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") 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") @field_validator("remote_url")
@classmethod @classmethod
def validate_remote_url(cls, v: str) -> str: def validate_remote_url(cls, v: str) -> str:
if not v.startswith(("http://", "https://", "git@", "ssh://")): 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 return v
@field_validator("source_path") @field_validator("source_path")
@@ -126,7 +137,9 @@ class GitMountItem(BaseModel):
class MountItem(BaseModel): class MountItem(BaseModel):
target: str = Field(description="Absolute mount target path") target: str = Field(description="Absolute mount target path")
mode: str = Field(default="rw", description="Mount mode: ro or rw") 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") @field_validator("target")
@classmethod @classmethod
@@ -163,10 +176,18 @@ class ConfigProfileCreate(BaseModel):
tool_type_id: str | None = Field(default=None, description="Optional tool type ID") tool_type_id: str | None = Field(default=None, description="Optional tool type ID")
env_vars: dict = Field(default_factory=dict, description="Environment variables") env_vars: dict = Field(default_factory=dict, description="Environment variables")
runtime_hints: dict = Field(default_factory=dict, description="Runtime hints") runtime_hints: dict = Field(default_factory=dict, description="Runtime hints")
mounts: list[MountItem] = Field(default_factory=list, description="Mount definitions") mounts: list[MountItem] = Field(
files: dict = Field(default_factory=dict, description="Files as {relative_path: content}") default_factory=list, description="Mount definitions"
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") 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") @field_validator("project_id", "tool_type_id")
@classmethod @classmethod
@@ -216,10 +237,18 @@ class ConfigProfileUpdate(BaseModel):
tool_type_id: str | None = Field(default=None, description="Optional tool type ID") tool_type_id: str | None = Field(default=None, description="Optional tool type ID")
env_vars: dict | None = Field(default=None, description="Environment variables") env_vars: dict | None = Field(default=None, description="Environment variables")
runtime_hints: dict | None = Field(default=None, description="Runtime hints") runtime_hints: dict | None = Field(default=None, description="Runtime hints")
mounts: list[MountItem] | None = Field(default=None, description="Mount definitions") mounts: list[MountItem] | None = Field(
files: dict | None = Field(default=None, description="Files as {relative_path: content}") default=None, description="Mount definitions"
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") 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") @field_validator("project_id", "tool_type_id")
@classmethod @classmethod
@@ -269,7 +298,9 @@ class ConfigProfileResponse(BaseModel):
updated_at: str 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.""" """Fetch a profile with includes eagerly loaded."""
result = await session.execute( result = await session.execute(
select(ConfigProfile) select(ConfigProfile)
@@ -289,12 +320,16 @@ async def _check_access(
if project_id is not None: if project_id is not None:
project = await session.get(Project, project_id) project = await session.get(Project, project_id)
if project is None: 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 # Add ownership check if needed; for now just verify existence
if tool_type_id is not None: if tool_type_id is not None:
tool_type = await session.get(ToolType, tool_type_id) tool_type = await session.get(ToolType, tool_type_id)
if tool_type is None: 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( async def _validate_git_mounts(
@@ -304,7 +339,7 @@ async def _validate_git_mounts(
project_id: uuid.UUID | None = None, project_id: uuid.UUID | None = None,
) -> None: ) -> None:
"""Validate git mount URLs. """Validate git mount URLs.
Simply checks that remote_url looks like a valid git URL. Simply checks that remote_url looks like a valid git URL.
Actual clone validation happens at instance startup time. Actual clone validation happens at instance startup time.
""" """
@@ -315,7 +350,7 @@ async def _validate_git_mounts(
status_code=status.HTTP_400_BAD_REQUEST, status_code=status.HTTP_400_BAD_REQUEST,
detail="Git mount missing remote_url", detail="Git mount missing remote_url",
) )
if not remote_url.startswith(("http://", "https://", "git@", "ssh://")): if not remote_url.startswith(("http://", "https://", "git@", "ssh://")):
raise HTTPException( raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, 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 { return {
"id": str(profile.id), "id": str(profile.id),
"user_id": str(profile.user_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]) @router.get("", response_model=list[ConfigProfileResponse])
async def list_config_profiles( async def list_config_profiles(
project_id: str | None = Query(None, description="Filter by project compatibility"), project_id: str | None = Query(None, description="Filter by project compatibility"),
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), current_user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session), session: AsyncSession = Depends(get_db_session),
): ):
"""List config profiles, optionally filtered by compatibility.""" """List config profiles, optionally filtered by compatibility."""
user_uuid = current_user_id 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: if project_id or tool_type_id:
# Compatibility filter: include portable profiles and matching scoped profiles # Compatibility filter: include portable profiles and matching scoped profiles
@@ -371,7 +414,8 @@ async def list_config_profiles(
conditions: list = [] conditions: list = []
# Portable profiles (no project, no tool) # Portable profiles (no project, no tool)
conditions.append( 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: if project_uuid:
# Profiles matching this project (with or without tool) # Profiles matching this project (with or without tool)
@@ -382,7 +426,8 @@ async def list_config_profiles(
if project_uuid and tool_uuid: if project_uuid and tool_uuid:
# Exact match # Exact match
conditions.append( 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)) query = query.where(or_(*conditions))
@@ -392,7 +437,9 @@ async def list_config_profiles(
return [_profile_to_response(p) for p in 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( async def create_config_profile(
data: ConfigProfileCreate, data: ConfigProfileCreate,
current_user_id: uuid.UUID = Depends(get_current_user_id), current_user_id: uuid.UUID = Depends(get_current_user_id),
@@ -403,10 +450,12 @@ async def create_config_profile(
# Check for duplicate name # Check for duplicate name
existing = await session.execute( existing = await session.execute(
select(ConfigProfile).where( select(ConfigProfile)
.where(
ConfigProfile.user_id == user_uuid, ConfigProfile.user_id == user_uuid,
ConfigProfile.name == data.name, ConfigProfile.name == data.name,
).options(selectinload(ConfigProfile.includes)) )
.options(selectinload(ConfigProfile.includes))
) )
if existing.scalar_one_or_none() is not None: if existing.scalar_one_or_none() is not None:
raise HTTPException( raise HTTPException(
@@ -418,10 +467,12 @@ async def create_config_profile(
project_uuid = uuid.UUID(data.project_id) if data.project_id else None 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 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) await _check_access(session, user_uuid, project_uuid, tool_uuid)
# Validate git mounts reference existing repositories # Validate git mounts reference existing repositories
if data.git_mounts: 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) await _validate_git_mounts(session, user_uuid, git_mounts_data, project_uuid)
# Check size # Check size
@@ -469,9 +520,13 @@ async def get_config_profile(
"""Get a config profile by ID.""" """Get a config profile by ID."""
profile = await _get_profile_with_includes(session, uuid.UUID(profile_id)) profile = await _get_profile_with_includes(session, uuid.UUID(profile_id))
if profile is None: 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: 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) return _profile_to_response(profile)
@@ -485,9 +540,13 @@ async def update_config_profile(
"""Update a config profile.""" """Update a config profile."""
profile = await _get_profile_with_includes(session, uuid.UUID(profile_id)) profile = await _get_profile_with_includes(session, uuid.UUID(profile_id))
if profile is None: 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: 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) 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) 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) await _check_access(session, profile.user_id, project_uuid, tool_uuid)
# Validate git mounts reference existing repositories # Validate git mounts reference existing repositories
if "git_mounts" in update_data and update_data["git_mounts"] is not None: if "git_mounts" in update_data and update_data["git_mounts"] is not None:
git_mounts_data = [ 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"] 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 # Check size
current_data = _profile_to_response(profile) current_data = _profile_to_response(profile)
@@ -570,9 +631,13 @@ async def delete_config_profile(
"""Delete a config profile.""" """Delete a config profile."""
profile = await _get_profile_with_includes(session, uuid.UUID(profile_id)) profile = await _get_profile_with_includes(session, uuid.UUID(profile_id))
if profile is None: 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: 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.delete(profile)
await session.commit() await session.commit()
@@ -591,9 +656,13 @@ async def update_profile_includes(
"""Update the ordered includes for a config profile.""" """Update the ordered includes for a config profile."""
profile = await _get_profile_with_includes(session, uuid.UUID(profile_id)) profile = await _get_profile_with_includes(session, uuid.UUID(profile_id))
if profile is None: 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: 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 # Validate all included profiles exist and belong to the user
included_uuids = [uuid.UUID(inc_id) for inc_id in data.includes] included_uuids = [uuid.UUID(inc_id) for inc_id in data.includes]
@@ -633,7 +702,9 @@ async def update_profile_includes(
# Remove existing includes # Remove existing includes
result = await session.execute( 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(): for existing in result.scalars().all():
await session.delete(existing) await session.delete(existing)
@@ -658,7 +729,9 @@ async def update_profile_includes(
profile = result.scalar_one() profile = result.scalar_one()
inc_result = await session.execute( 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() direct_includes = inc_result.scalars().all()
@@ -675,9 +748,13 @@ async def preview_config_profile(
"""Preview the resolved output of a config profile.""" """Preview the resolved output of a config profile."""
profile = await _get_profile_with_includes(session, uuid.UUID(profile_id)) profile = await _get_profile_with_includes(session, uuid.UUID(profile_id))
if profile is None: 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: 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: try:
resolved = await resolve_profile(session, profile.id) resolved = await resolve_profile(session, profile.id)
+35 -10
View File
@@ -39,6 +39,7 @@ from src.services.config_profile_resolver import (
ConfigProfileCycleError, ConfigProfileCycleError,
ResolvedProfile, ResolvedProfile,
apply_resolved_profile, apply_resolved_profile,
expand_container_path,
resolve_profile, resolve_profile,
) )
from src.services.docker import ( from src.services.docker import (
@@ -67,6 +68,7 @@ from src.services.manifest_compiler import (
compile_entrypoint, compile_entrypoint,
compute_image_tag, compute_image_tag,
deep_merge, deep_merge,
get_manifest_home_dir,
merge_with_config, merge_with_config,
resolve_base, resolve_base,
) )
@@ -82,6 +84,7 @@ async def _resolve_git_mounts(
resolved: ResolvedProfile, resolved: ResolvedProfile,
instance_dir: str | None = None, instance_dir: str | None = None,
working_directory: str | None = None, working_directory: str | None = None,
home_dir: str = "/root",
) -> list[dict]: ) -> list[dict]:
"""Convert git mounts from resolved profile to Docker volume mounts. """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: for git_mount in resolved.git_mounts:
tasks.append( tasks.append(
_resolve_single_git_mount( _resolve_single_git_mount(
session, git_mount, instance_dir, working_directory session, git_mount, instance_dir, working_directory, home_dir
) )
) )
@@ -184,6 +187,7 @@ def _resolve_git_mount_mappings(
repo_path: str, repo_path: str,
mappings: list[dict], mappings: list[dict],
working_directory: str | None, working_directory: str | None,
home_dir: str = "/root",
) -> list[dict]: ) -> list[dict]:
"""Resolve mappings from an already-cloned repo to volume mount entries. """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") logger.warning("Invalid mapping skipped: missing target_path")
continue continue
# Expand ~ and $HOME in target path
target_path = expand_container_path(target_path, home_dir)
# Resolve relative target paths against working directory # Resolve relative target paths against working directory
final_target = target_path final_target = target_path
if not target_path.startswith("/"): if not target_path.startswith("/"):
@@ -261,6 +268,7 @@ async def _resolve_single_git_mount(
git_mount: dict, git_mount: dict,
instance_dir: str | None = None, instance_dir: str | None = None,
working_directory: str | None = None, working_directory: str | None = None,
home_dir: str = "/root",
) -> list[dict]: ) -> list[dict]:
"""Resolve a single git mount to volume mount entries. """Resolve a single git mount to volume mount entries.
@@ -293,7 +301,7 @@ async def _resolve_single_git_mount(
return [] return []
# Resolve all mappings from the cloned repo # Resolve all mappings from the cloned repo
return _resolve_git_mount_mappings(repo_path, mappings, working_directory) return _resolve_git_mount_mappings(repo_path, mappings, working_directory, home_dir)
def _checkout_branch(repo_path: str, branch: str) -> bool: def _checkout_branch(repo_path: str, branch: str) -> bool:
@@ -545,6 +553,7 @@ def _modify_compose_file(
start_command: str | None = None, start_command: str | None = None,
working_directory: str | None = None, working_directory: str | None = None,
extra_volumes: list[dict] | None = None, extra_volumes: list[dict] | None = None,
home_dir: str = "/root",
) -> None: ) -> None:
"""Modify compose file with runtime overrides.""" """Modify compose file with runtime overrides."""
import yaml import yaml
@@ -571,14 +580,16 @@ def _modify_compose_file(
service_config["command"] = start_command service_config["command"] = start_command
if working_directory: if working_directory:
service_config["working_dir"] = working_directory service_config["working_dir"] = expand_container_path(
working_directory, home_dir
)
if extra_volumes: if extra_volumes:
if "volumes" not in service_config: if "volumes" not in service_config:
service_config["volumes"] = [] service_config["volumes"] = []
for vol in extra_volumes: for vol in extra_volumes:
source = vol.get("source", "") source = vol.get("source", "")
target = vol.get("target", "") target = expand_container_path(vol.get("target", ""), home_dir)
vol_type = vol.get("type", "bind") vol_type = vol.get("type", "bind")
if vol_type == "bind": if vol_type == "bind":
service_config["volumes"].append(f"{source}:{target}") service_config["volumes"].append(f"{source}:{target}")
@@ -826,6 +837,9 @@ services:
deep_merge(dict(base_def.manifest), manifest) 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) image_tag = compute_image_tag(tool_type.name, manifest)
# Build image during creation so start is fast # Build image during creation so start is fast
@@ -1105,11 +1119,11 @@ async def _prepare_manifest_instance(
env_vars: dict, env_vars: dict,
extra_volumes: list, extra_volumes: list,
working_directory: str | None, working_directory: str | None,
) -> tuple[str, str, dict]: ) -> tuple[str, str, dict, str]:
"""Build image and generate compose from a manifest-based tool type. """Build image and generate compose from a manifest-based tool type.
Returns: 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 from src.models.tool_definition_manifest import ToolDefinitionManifest
@@ -1220,7 +1234,8 @@ async def _prepare_manifest_instance(
instance.image_tag = image_tag instance.image_tag = image_tag
instance.manifest_compiled_at = datetime.now() 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( @router.post(
@@ -1283,6 +1298,15 @@ async def start_instance(
working_directory = None working_directory = None
extra_volumes = [] 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 # Apply selected config profile if any
instance_dir = os.path.dirname(instance.compose_path) instance_dir = os.path.dirname(instance.compose_path)
if instance.selected_config_profile_id is not None: if instance.selected_config_profile_id is not None:
@@ -1291,7 +1315,7 @@ async def start_instance(
session, instance.selected_config_profile_id session, instance.selected_config_profile_id
) )
profile_env, profile_files, profile_mounts, profile_hints = ( 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 # Profile env vars override tool config env vars
env_vars.update(profile_env) env_vars.update(profile_env)
@@ -1301,7 +1325,7 @@ async def start_instance(
extra_volumes.extend(profile_mounts) extra_volumes.extend(profile_mounts)
# Git repository mounts are resolved and added # Git repository mounts are resolved and added
git_mount_volumes = await _resolve_git_mounts( 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) extra_volumes.extend(git_mount_volumes)
# Profile runtime hints override tool config values # Profile runtime hints override tool config values
@@ -1345,7 +1369,6 @@ async def start_instance(
) )
# ── MANIFEST-BASED FLOW ────────────────────────────────────── # ── MANIFEST-BASED FLOW ──────────────────────────────────────
tool_type = await session.get(ToolType, instance.tool_type_id)
resolved_manifest = None resolved_manifest = None
if tool_type and tool_type.definition_type == "manifest" and tool_type.manifest_id: if tool_type and tool_type.definition_type == "manifest" and tool_type.manifest_id:
@@ -1362,6 +1385,7 @@ async def start_instance(
image_tag, image_tag,
compose_content, compose_content,
resolved_manifest, resolved_manifest,
_home_dir,
) = await _prepare_manifest_instance( ) = await _prepare_manifest_instance(
session=session, session=session,
instance=instance, instance=instance,
@@ -1420,6 +1444,7 @@ async def start_instance(
start_command, start_command,
working_directory, working_directory,
extra_volumes, extra_volumes,
home_dir,
) )
logger.debug("Modified compose file for instance %s", instance.id) logger.debug("Modified compose file for instance %s", instance.id)
@@ -5,6 +5,7 @@ and cycle protection.
""" """
import logging import logging
import os
import uuid import uuid
from dataclasses import dataclass, field from dataclasses import dataclass, field
from typing import Any from typing import Any
@@ -57,7 +58,9 @@ class ResolvedProfile:
included_profiles: list[dict[str, Any]] = field(default_factory=list) 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. """Detect if adding profile_id to path would create a cycle.
Args: Args:
@@ -252,7 +255,9 @@ async def _resolve_profile_recursive(
""" """
if _detect_cycle(profile_id, visited, path): if _detect_cycle(profile_id, visited, path):
cycle_path = " -> ".join(str(p) for p in path + [profile_id]) 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) profile = await session.get(ConfigProfile, profile_id)
if profile is None: if profile is None:
@@ -279,13 +284,18 @@ async def _resolve_profile_recursive(
included = await _resolve_profile_recursive( included = await _resolve_profile_recursive(
session, include.included_profile_id, new_visited, new_path session, include.included_profile_id, new_visited, new_path
) )
result.included_profiles.append({ result.included_profiles.append(
"id": str(included.profile_id), {
"name": included.profile_name, "id": str(included.profile_id),
}) "name": included.profile_name,
}
)
result.env_vars = _merge_env_vars( 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 = _merge_runtime_hints(
result.runtime_hints, result.runtime_hints,
@@ -429,6 +439,7 @@ async def check_include_cycle(
def apply_resolved_profile( def apply_resolved_profile(
instance_dir: str, instance_dir: str,
resolved: ResolvedProfile, resolved: ResolvedProfile,
home_dir: str = "/root",
) -> tuple[dict[str, str], dict[str, str], list[dict], dict[str, Any]]: ) -> tuple[dict[str, str], dict[str, str], list[dict], dict[str, Any]]:
"""Apply a resolved profile to an instance directory. """Apply a resolved profile to an instance directory.
@@ -458,14 +469,19 @@ def apply_resolved_profile(
try: try:
full_path.resolve().relative_to(instance_path.resolve()) full_path.resolve().relative_to(instance_path.resolve())
except ValueError: 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 continue
full_path.parent.mkdir(parents=True, exist_ok=True) full_path.parent.mkdir(parents=True, exist_ok=True)
full_path.write_text(content) full_path.write_text(content)
# Stage mount files and prepare volume mounts # Stage mount files and prepare volume mounts
for mount in resolved.mounts.values(): 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) mount_dir.mkdir(parents=True, exist_ok=True)
for file_path, content in mount.files.items(): 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.parent.mkdir(parents=True, exist_ok=True)
full_path.write_text(content) full_path.write_text(content)
volume_mounts.append({ volume_mounts.append(
"source": str(mount_dir), {
"target": mount.target, "source": str(mount_dir),
"type": "bind", "target": expanded_target,
}) "type": "bind",
}
)
return env_vars, files, volume_mounts, resolved.runtime_hints 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]: def resolved_profile_to_dict(resolved: ResolvedProfile) -> dict[str, Any]:
"""Convert a ResolvedProfile to a plain dict for serialization. """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"RUN groupadd -g {gid} {name} && \\")
lines.append(f" useradd -u {uid} -g {gid} {create_home}-s {shell} {name}") lines.append(f" useradd -u {uid} -g {gid} {create_home}-s {shell} {name}")
lines.append("") 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
build_scripts = manifest.get("scripts", {}).get("build", []) build_scripts = manifest.get("scripts", {}).get("build", [])
@@ -333,6 +338,21 @@ def resolve_mount_source(mount: dict, variables: dict[str, Any]) -> str:
return "" 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: def compute_image_tag(tool_name: str, manifest: dict) -> str:
"""Compute a deterministic image tag from manifest content. """Compute a deterministic image tag from manifest content.
@@ -75,6 +75,7 @@ class TestMergeFunctions:
def test_merge_mounts_file_override(self) -> None: def test_merge_mounts_file_override(self) -> None:
"""Test mount file map merging with overrides.""" """Test mount file map merging with overrides."""
from src.services.config_profile_resolver import ResolvedMount from src.services.config_profile_resolver import ResolvedMount
result = _merge_mounts( result = _merge_mounts(
{"/app": ResolvedMount(target="/app", mode="rw", files={"a.txt": "old"})}, {"/app": ResolvedMount(target="/app", mode="rw", files={"a.txt": "old"})},
[{"target": "/app", "mode": "rw", "files": {"a.txt": "new"}}], [{"target": "/app", "mode": "rw", "files": {"a.txt": "new"}}],
@@ -86,6 +87,7 @@ class TestMergeFunctions:
def test_merge_mounts_mode_conflict(self) -> None: def test_merge_mounts_mode_conflict(self) -> None:
"""Test that mount mode conflicts are resolved (later wins).""" """Test that mount mode conflicts are resolved (later wins)."""
from src.services.config_profile_resolver import ResolvedMount from src.services.config_profile_resolver import ResolvedMount
overrides = {} overrides = {}
result = _merge_mounts( result = _merge_mounts(
{"/app": ResolvedMount(target="/app", mode="rw", files={})}, {"/app": ResolvedMount(target="/app", mode="rw", files={})},
@@ -100,7 +102,13 @@ class TestMergeFunctions:
"""Test basic git mount merging normalizes to mappings format.""" """Test basic git mount merging normalizes to mappings format."""
result = _merge_git_mounts( 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", "source",
) )
assert len(result) == 1 assert len(result) == 1
@@ -111,8 +119,22 @@ class TestMergeFunctions:
def test_merge_git_mounts_concatenate_same_repo_branch(self) -> None: def test_merge_git_mounts_concatenate_same_repo_branch(self) -> None:
"""Test that git mounts with same repo+branch concatenate mappings.""" """Test that git mounts with same repo+branch concatenate mappings."""
result = _merge_git_mounts( 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", "source",
) )
assert len(result) == 1 assert len(result) == 1
@@ -125,8 +147,22 @@ class TestMergeFunctions:
def test_merge_git_mounts_dedup_same_mapping(self) -> None: def test_merge_git_mounts_dedup_same_mapping(self) -> None:
"""Test that duplicate mappings are deduplicated.""" """Test that duplicate mappings are deduplicated."""
result = _merge_git_mounts( 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", "source",
) )
assert len(result) == 1 assert len(result) == 1
@@ -135,19 +171,48 @@ class TestMergeFunctions:
def test_merge_git_mounts_different_repos(self) -> None: def test_merge_git_mounts_different_repos(self) -> None:
"""Test that git mounts with different repos are preserved.""" """Test that git mounts with different repos are preserved."""
result = _merge_git_mounts( 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", "source",
) )
assert len(result) == 2 assert len(result) == 2
urls = {m["remote_url"] for m in result} 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: def test_merge_git_mounts_different_branches(self) -> None:
"""Test that same repo with different branches are kept separate.""" """Test that same repo with different branches are kept separate."""
result = _merge_git_mounts( 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", "source",
) )
assert len(result) == 2 assert len(result) == 2
@@ -181,7 +246,9 @@ class TestResolveProfile:
assert result.files == {"test.txt": "content"} assert result.files == {"test.txt": "content"}
@pytest.mark.asyncio @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.""" """Test resolving a profile that includes another."""
user_id = uuid.uuid4() user_id = uuid.uuid4()
@@ -225,7 +292,9 @@ class TestResolveProfile:
assert result.included_profiles[0]["name"] == "base" assert result.included_profiles[0]["name"] == "base"
@pytest.mark.asyncio @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.""" """Test that child profile values override parent values."""
user_id = uuid.uuid4() user_id = uuid.uuid4()
@@ -262,7 +331,9 @@ class TestResolveProfile:
assert result.env_overrides == {"VAR": "child"} assert result.env_overrides == {"VAR": "child"}
@pytest.mark.asyncio @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.""" """Test that cycles are detected during resolution."""
user_id = uuid.uuid4() user_id = uuid.uuid4()
@@ -308,10 +379,12 @@ class TestResolveProfile:
await resolve_profile(db_session, profile_a.id) await resolve_profile(db_session, profile_a.id)
@pytest.mark.asyncio @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.""" """Test resolving a profile with git mounts normalizes to mappings."""
user_id = uuid.uuid4() user_id = uuid.uuid4()
profile = ConfigProfile( profile = ConfigProfile(
id=uuid.uuid4(), id=uuid.uuid4(),
user_id=user_id, user_id=user_id,
@@ -319,23 +392,31 @@ class TestResolveProfile:
env_vars={}, env_vars={},
files={}, files={},
git_mounts=[ 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) db_session.add(profile)
await db_session.commit() await db_session.commit()
result = await resolve_profile(db_session, profile.id) result = await resolve_profile(db_session, profile.id)
assert len(result.git_mounts) == 1 assert len(result.git_mounts) == 1
assert result.git_mounts[0]["remote_url"] == "https://github.com/user/repo1.git" assert result.git_mounts[0]["remote_url"] == "https://github.com/user/repo1.git"
assert "mappings" in result.git_mounts[0] 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 @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.""" """Test resolving a profile that includes another with git mounts."""
user_id = uuid.uuid4() user_id = uuid.uuid4()
# Create base profile with git mount # Create base profile with git mount
base = ConfigProfile( base = ConfigProfile(
id=uuid.uuid4(), id=uuid.uuid4(),
@@ -344,11 +425,15 @@ class TestResolveProfile:
env_vars={}, env_vars={},
files={}, files={},
git_mounts=[ 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) db_session.add(base)
# Create child profile with its own git mount # Create child profile with its own git mount
child = ConfigProfile( child = ConfigProfile(
id=uuid.uuid4(), id=uuid.uuid4(),
@@ -357,12 +442,16 @@ class TestResolveProfile:
env_vars={}, env_vars={},
files={}, files={},
git_mounts=[ 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) db_session.add(child)
await db_session.commit() await db_session.commit()
# Create include relationship # Create include relationship
include = ConfigProfileInclude( include = ConfigProfileInclude(
id=uuid.uuid4(), id=uuid.uuid4(),
@@ -372,11 +461,14 @@ class TestResolveProfile:
) )
db_session.add(include) db_session.add(include)
await db_session.commit() await db_session.commit()
result = await resolve_profile(db_session, child.id) result = await resolve_profile(db_session, child.id)
assert len(result.git_mounts) == 2 assert len(result.git_mounts) == 2
urls = {m["remote_url"] for m in result.git_mounts} 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: for m in result.git_mounts:
assert "mappings" in m assert "mappings" in m
+8 -2
View File
@@ -148,7 +148,10 @@ class TestResolveSingleGitMount:
async def test_missing_remote_url(self) -> None: async def test_missing_remote_url(self) -> None:
"""Git mount without remote_url returns empty list.""" """Git mount without remote_url returns empty list."""
result = await _resolve_single_git_mount( 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 == [] assert result == []
@@ -157,7 +160,10 @@ class TestResolveSingleGitMount:
"""Git mount without instance_dir returns empty list.""" """Git mount without instance_dir returns empty list."""
result = await _resolve_single_git_mount( result = await _resolve_single_git_mount(
MagicMock(), 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,
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, compile_entrypoint,
compute_image_tag, compute_image_tag,
deep_merge, deep_merge,
get_manifest_home_dir,
merge_with_config, merge_with_config,
resolve_base, resolve_base,
) )
@@ -161,6 +162,38 @@ class TestCompileDockerfile:
df = compile_dockerfile(manifest) df = compile_dockerfile(manifest)
assert 'CMD ["/bin/bash"]' in df 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: class TestCompileEntrypoint:
"""Tests for compile_entrypoint.""" """Tests for compile_entrypoint."""
@@ -735,6 +735,7 @@ class TestStartInstanceManifestBranch:
"headquarter/test:latest", "headquarter/test:latest",
"services:\n app:\n image: test", "services:\n app:\n image: test",
{"name": "test-manifest"}, {"name": "test-manifest"},
"/root",
) )
instance = ToolInstance( instance = ToolInstance(
+108 -102
View File
@@ -1,163 +1,169 @@
import { apiClient } from "./client"; import { apiClient } from "./client";
export interface ConfigProfile { export interface ConfigProfile {
id: string; id: string;
user_id: string; user_id: string;
name: string; name: string;
description: string | null; description: string | null;
project_id: string | null; project_id: string | null;
tool_type_id: string | null; tool_type_id: string | null;
env_vars: Record<string, string>; env_vars: Record<string, string>;
runtime_hints: Record<string, unknown>; runtime_hints: Record<string, unknown>;
mounts: ConfigProfileMount[]; mounts: ConfigProfileMount[];
git_mounts: GitMount[]; git_mounts: GitMount[];
files: Record<string, string>; files: Record<string, string>;
is_default: boolean; is_default: boolean;
includes: ConfigProfileInclude[]; includes: ConfigProfileInclude[];
created_at: string; created_at: string;
updated_at: string; updated_at: string;
} }
export interface ConfigProfileMount { export interface ConfigProfileMount {
target: string; target: string;
mode: "ro" | "rw"; mode: "ro" | "rw";
files: Record<string, string>; files: Record<string, string>;
} }
export interface GitMountMapping { export interface GitMountMapping {
source_path: string; source_path: string;
target_path: string; target_path: string;
} }
export interface GitMount { export interface GitMount {
remote_url: string; remote_url: string;
branch?: string; branch?: string;
mappings: GitMountMapping[]; mappings: GitMountMapping[];
// Legacy fields (for backward compatibility when reading old data) // Legacy fields (for backward compatibility when reading old data)
source_path?: string; source_path?: string;
target_path?: string; target_path?: string;
} }
export interface ConfigProfileInclude { export interface ConfigProfileInclude {
id: string; id: string;
included_profile_id: string; included_profile_id: string;
order_index: number; order_index: number;
} }
export interface ResolvedProfile { export interface ResolvedProfile {
profile_id: string; profile_id: string;
profile_name: string; profile_name: string;
env_vars: Record<string, string>; env_vars: Record<string, string>;
runtime_hints: Record<string, unknown>; runtime_hints: Record<string, unknown>;
mounts: ResolvedMount[]; mounts: ResolvedMount[];
git_mounts: GitMount[]; git_mounts: GitMount[];
files: Record<string, string>; files: Record<string, string>;
overrides: { overrides: {
env_vars: Record<string, string>; env_vars: Record<string, string>;
runtime_hints: Record<string, string>; runtime_hints: Record<string, string>;
files: Record<string, string>; files: Record<string, string>;
mounts: Record<string, string>; mounts: Record<string, string>;
}; };
included_profiles: Array<{ id: string; name: string }>; included_profiles: Array<{ id: string; name: string }>;
} }
export interface ResolvedMount { export interface ResolvedMount {
target: string; target: string;
mode: "ro" | "rw"; mode: "ro" | "rw";
files: Record<string, string>; files: Record<string, string>;
overridden_files: Record<string, string>; overridden_files: Record<string, string>;
} }
export interface CreateConfigProfileRequest { export interface CreateConfigProfileRequest {
name: string; name: string;
description?: string; description?: string;
project_id?: string; project_id?: string;
tool_type_id?: string; tool_type_id?: string;
env_vars?: Record<string, string>; env_vars?: Record<string, string>;
runtime_hints?: Record<string, unknown>; runtime_hints?: Record<string, unknown>;
mounts?: ConfigProfileMount[]; mounts?: ConfigProfileMount[];
git_mounts?: GitMount[]; git_mounts?: GitMount[];
files?: Record<string, string>; files?: Record<string, string>;
is_default?: boolean; is_default?: boolean;
} }
export interface UpdateConfigProfileRequest { export interface UpdateConfigProfileRequest {
name?: string; name?: string;
description?: string; description?: string;
project_id?: string; project_id?: string;
tool_type_id?: string; tool_type_id?: string;
env_vars?: Record<string, string>; env_vars?: Record<string, string>;
runtime_hints?: Record<string, unknown>; runtime_hints?: Record<string, unknown>;
mounts?: ConfigProfileMount[]; mounts?: ConfigProfileMount[];
git_mounts?: GitMount[]; git_mounts?: GitMount[];
files?: Record<string, string>; files?: Record<string, string>;
is_default?: boolean; is_default?: boolean;
} }
export interface UpdateIncludesRequest { export interface UpdateIncludesRequest {
includes: string[]; includes: string[];
} }
export const listConfigProfiles = async ( export const listConfigProfiles = async (
projectId?: string, projectId?: string,
toolTypeId?: string toolTypeId?: string,
): Promise<ConfigProfile[]> => { ): Promise<ConfigProfile[]> => {
const response = await apiClient.get<ConfigProfile[]>("/config-profiles", { const response = await apiClient.get<ConfigProfile[]>("/config-profiles", {
params: { project_id: projectId, tool_type_id: toolTypeId }, params: { project_id: projectId, tool_type_id: toolTypeId },
}); });
return response.data; return response.data;
}; };
export const getConfigProfile = async (id: string): Promise<ConfigProfile> => { export const getConfigProfile = async (id: string): Promise<ConfigProfile> => {
const response = await apiClient.get<ConfigProfile>(`/config-profiles/${id}`); const response = await apiClient.get<ConfigProfile>(`/config-profiles/${id}`);
return response.data; return response.data;
}; };
export const createConfigProfile = async ( export const createConfigProfile = async (
data: CreateConfigProfileRequest data: CreateConfigProfileRequest,
): Promise<ConfigProfile> => { ): Promise<ConfigProfile> => {
const response = await apiClient.post<ConfigProfile>("/config-profiles", data); const response = await apiClient.post<ConfigProfile>(
return response.data; "/config-profiles",
data,
);
return response.data;
}; };
export const updateConfigProfile = async ( export const updateConfigProfile = async (
id: string, id: string,
data: UpdateConfigProfileRequest data: UpdateConfigProfileRequest,
): Promise<ConfigProfile> => { ): Promise<ConfigProfile> => {
const response = await apiClient.put<ConfigProfile>(`/config-profiles/${id}`, data); const response = await apiClient.put<ConfigProfile>(
return response.data; `/config-profiles/${id}`,
data,
);
return response.data;
}; };
export const deleteConfigProfile = async (id: string): Promise<void> => { export const deleteConfigProfile = async (id: string): Promise<void> => {
await apiClient.delete(`/config-profiles/${id}`); await apiClient.delete(`/config-profiles/${id}`);
}; };
export const updateProfileIncludes = async ( export const updateProfileIncludes = async (
id: string, id: string,
data: UpdateIncludesRequest data: UpdateIncludesRequest,
): Promise<ConfigProfile> => { ): Promise<ConfigProfile> => {
const response = await apiClient.put<ConfigProfile>( const response = await apiClient.put<ConfigProfile>(
`/config-profiles/${id}/includes`, `/config-profiles/${id}/includes`,
data data,
); );
return response.data; return response.data;
}; };
export const previewConfigProfile = async ( export const previewConfigProfile = async (
id: string id: string,
): Promise<ResolvedProfile> => { ): Promise<ResolvedProfile> => {
const response = await apiClient.get<ResolvedProfile>( const response = await apiClient.get<ResolvedProfile>(
`/config-profiles/${id}/preview` `/config-profiles/${id}/preview`,
); );
return response.data; return response.data;
}; };
export const resolveDefaultProfile = async ( export const resolveDefaultProfile = async (
projectId: string, projectId: string,
toolTypeId: string toolTypeId: string,
): Promise<{ profile_id: string | null; profile_name: string | null }> => { ): Promise<{ profile_id: string | null; profile_name: string | null }> => {
const response = await apiClient.get("/config-profiles/defaults/resolve", { const response = await apiClient.get("/config-profiles/defaults/resolve", {
params: { project_id: projectId, tool_type_id: toolTypeId }, params: { project_id: projectId, tool_type_id: toolTypeId },
}); });
return response.data; return response.data;
}; };
+384 -272
View File
@@ -3,309 +3,421 @@ import { Icon } from "./icon";
import type { GitMount, GitMountMapping } from "../api/config_profiles"; import type { GitMount, GitMountMapping } from "../api/config_profiles";
interface GitMountEditorProps { interface GitMountEditorProps {
mounts: GitMount[]; mounts: GitMount[];
onChange: (mounts: GitMount[]) => void; onChange: (mounts: GitMount[]) => void;
} }
function normalizeMount(mount: GitMount): GitMount { function normalizeMount(mount: GitMount): GitMount {
// Auto-convert legacy source_path + target_path to mappings // Auto-convert legacy source_path + target_path to mappings
if ((!mount.mappings || mount.mappings.length === 0) && mount.source_path !== undefined && mount.target_path !== undefined) { if (
return { (!mount.mappings || mount.mappings.length === 0) &&
remote_url: mount.remote_url, mount.source_path !== undefined &&
branch: mount.branch, mount.target_path !== undefined
mappings: [{ source_path: mount.source_path || ".", target_path: mount.target_path }], ) {
}; return {
} remote_url: mount.remote_url,
return mount; branch: mount.branch,
mappings: [
{
source_path: mount.source_path || ".",
target_path: mount.target_path,
},
],
};
}
return mount;
} }
function normalizeMounts(mounts: GitMount[]): GitMount[] { function normalizeMounts(mounts: GitMount[]): GitMount[] {
return mounts.map(normalizeMount); return mounts.map(normalizeMount);
} }
export const GitMountEditor = ({ mounts, onChange }: GitMountEditorProps) => { export const GitMountEditor = ({ mounts, onChange }: GitMountEditorProps) => {
const [normalizedMounts, setNormalizedMounts] = useState<GitMount[]>(() => normalizeMounts(mounts)); const [normalizedMounts, setNormalizedMounts] = useState<GitMount[]>(() =>
const [editingIndex, setEditingIndex] = useState<number | null>(null); normalizeMounts(mounts),
const [isAdding, setIsAdding] = useState(false); );
const [editingIndex, setEditingIndex] = useState<number | null>(null);
const [isAdding, setIsAdding] = useState(false);
useEffect(() => { useEffect(() => {
setNormalizedMounts(normalizeMounts(mounts)); setNormalizedMounts(normalizeMounts(mounts));
}, [mounts]); }, [mounts]);
const handleAdd = (mount: GitMount) => { const handleAdd = (mount: GitMount) => {
const updated = [...normalizedMounts, normalizeMount(mount)]; const updated = [...normalizedMounts, normalizeMount(mount)];
setNormalizedMounts(updated); setNormalizedMounts(updated);
onChange(updated); onChange(updated);
setIsAdding(false); setIsAdding(false);
}; };
const handleUpdate = (index: number, updated: GitMount) => { const handleUpdate = (index: number, updated: GitMount) => {
const updatedMounts = [...normalizedMounts]; const updatedMounts = [...normalizedMounts];
updatedMounts[index] = normalizeMount(updated); updatedMounts[index] = normalizeMount(updated);
setNormalizedMounts(updatedMounts); setNormalizedMounts(updatedMounts);
onChange(updatedMounts); onChange(updatedMounts);
setEditingIndex(null); setEditingIndex(null);
}; };
const handleRemove = (index: number) => { const handleRemove = (index: number) => {
const updated = normalizedMounts.filter((_, i) => i !== index); const updated = normalizedMounts.filter((_, i) => i !== index);
setNormalizedMounts(updated); setNormalizedMounts(updated);
onChange(updated); onChange(updated);
}; };
return ( return (
<div className="git-mount-editor"> <div className="git-mount-editor">
<h4 style={{ margin: "0 0 0.75rem 0" }}>Git Mounts</h4> <h4 style={{ margin: "0 0 0.75rem 0" }}>Git Mounts</h4>
<p className="muted" style={{ margin: "0 0 0.75rem 0", fontSize: "0.875rem" }}> <p
Clone a repository once and mount multiple directories from it. className="muted"
</p> style={{ margin: "0 0 0.75rem 0", fontSize: "0.875rem" }}
>
Clone a repository once and mount multiple directories from it.
</p>
{normalizedMounts.length > 0 && ( {normalizedMounts.length > 0 && (
<div className="git-mount-list" style={{ display: "flex", flexDirection: "column", gap: "0.75rem", marginBottom: "1rem" }}> <div
{normalizedMounts.map((mount, index) => ( className="git-mount-list"
<div key={index} className="card" style={{ padding: "1rem" }}> style={{
{editingIndex === index ? ( display: "flex",
<GitMountForm flexDirection: "column",
mount={mount} gap: "0.75rem",
onSave={(updated) => handleUpdate(index, updated)} marginBottom: "1rem",
onCancel={() => setEditingIndex(null)} }}
/> >
) : ( {normalizedMounts.map((mount, index) => (
<div> <div key={index} className="card" style={{ padding: "1rem" }}>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "flex-start", marginBottom: "0.5rem" }}> {editingIndex === index ? (
<div style={{ flex: 1, minWidth: 0 }}> <GitMountForm
<div style={{ fontWeight: 600, fontSize: "0.9375rem", marginBottom: "0.25rem" }}> mount={mount}
{mount.remote_url} onSave={(updated) => handleUpdate(index, updated)}
{mount.branch && ( onCancel={() => setEditingIndex(null)}
<span style={{ color: "var(--muted)", fontWeight: 400, marginLeft: "0.5rem" }}> />
@{mount.branch} ) : (
</span> <div>
)} <div
</div> style={{
<div style={{ display: "flex", flexDirection: "column", gap: "0.25rem" }}> display: "flex",
{mount.mappings?.map((m, mi) => ( justifyContent: "space-between",
<div key={mi} style={{ fontSize: "0.875rem", color: "var(--muted)", fontFamily: "monospace" }}> alignItems: "flex-start",
{m.source_path || "."} {m.target_path} marginBottom: "0.5rem",
</div> }}
))} >
</div> <div style={{ flex: 1, minWidth: 0 }}>
</div> <div
<div style={{ display: "flex", gap: "0.25rem", flexShrink: 0 }}> style={{
<button fontWeight: 600,
type="button" fontSize: "0.9375rem",
className="ghost-button small" marginBottom: "0.25rem",
onClick={() => setEditingIndex(index)} }}
title="Edit" >
> {mount.remote_url}
<Icon name="edit" size="sm" /> {mount.branch && (
</button> <span
<button style={{
type="button" color: "var(--muted)",
className="ghost-button small" fontWeight: 400,
onClick={() => handleRemove(index)} marginLeft: "0.5rem",
title="Remove" }}
> >
<Icon name="delete" size="sm" /> @{mount.branch}
</button> </span>
</div> )}
</div> </div>
</div> <div
)} style={{
</div> display: "flex",
))} flexDirection: "column",
</div> 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 ? ( {isAdding ? (
<div className="card" style={{ padding: "1rem" }}> <div className="card" style={{ padding: "1rem" }}>
<GitMountForm <GitMountForm
mount={{ remote_url: "", branch: "", mappings: [{ source_path: ".", target_path: "" }] }} mount={{
onSave={handleAdd} remote_url: "",
onCancel={() => setIsAdding(false)} branch: "",
/> mappings: [{ source_path: ".", target_path: "" }],
</div> }}
) : ( onSave={handleAdd}
<button type="button" className="secondary-button" onClick={() => setIsAdding(true)}> onCancel={() => setIsAdding(false)}
<Icon name="add" size="sm" /> />
Add Git Mount </div>
</button> ) : (
)} <button
</div> type="button"
); className="secondary-button"
onClick={() => setIsAdding(true)}
>
<Icon name="add" size="sm" />
Add Git Mount
</button>
)}
</div>
);
}; };
interface GitMountFormProps { interface GitMountFormProps {
mount: GitMount; mount: GitMount;
onSave: (mount: GitMount) => void; onSave: (mount: GitMount) => void;
onCancel: () => void; onCancel: () => void;
} }
const GitMountForm = ({ mount, onSave, onCancel }: GitMountFormProps) => { const GitMountForm = ({ mount, onSave, onCancel }: GitMountFormProps) => {
const [remoteUrl, setRemoteUrl] = useState(mount.remote_url); const [remoteUrl, setRemoteUrl] = useState(mount.remote_url);
const [branch, setBranch] = useState(mount.branch || ""); const [branch, setBranch] = useState(mount.branch || "");
const [mappings, setMappings] = useState<GitMountMapping[]>( const [mappings, setMappings] = useState<GitMountMapping[]>(
mount.mappings?.length ? mount.mappings : [{ source_path: ".", target_path: "" }] mount.mappings?.length
); ? mount.mappings
const [errors, setErrors] = useState<Record<string, string>>({}); : [{ source_path: ".", target_path: "" }],
);
const [errors, setErrors] = useState<Record<string, string>>({});
const validate = (): boolean => { const validate = (): boolean => {
const newErrors: Record<string, string> = {}; const newErrors: Record<string, string> = {};
if (!remoteUrl.trim()) { if (!remoteUrl.trim()) {
newErrors.remote_url = "Git URL is required"; newErrors.remote_url = "Git URL is required";
} else if ( } else if (
!remoteUrl.startsWith("http://") && !remoteUrl.startsWith("http://") &&
!remoteUrl.startsWith("https://") && !remoteUrl.startsWith("https://") &&
!remoteUrl.startsWith("git@") && !remoteUrl.startsWith("git@") &&
!remoteUrl.startsWith("ssh://") !remoteUrl.startsWith("ssh://")
) { ) {
newErrors.remote_url = "Must be a valid git URL (https://, git@, or ssh://)"; newErrors.remote_url =
} "Must be a valid git URL (https://, git@, or ssh://)";
}
mappings.forEach((m, i) => { mappings.forEach((m, i) => {
if (!m.target_path.trim()) { if (!m.target_path.trim()) {
newErrors[`mapping_${i}_target`] = "Target path is required"; newErrors[`mapping_${i}_target`] = "Target path is required";
} }
if (m.source_path.includes("..")) { if (m.source_path.includes("..")) {
newErrors[`mapping_${i}_source`] = "Source path cannot contain .."; newErrors[`mapping_${i}_source`] = "Source path cannot contain ..";
} }
if (m.target_path.includes("..")) { if (m.target_path.includes("..")) {
newErrors[`mapping_${i}_target`] = "Target path cannot contain .."; newErrors[`mapping_${i}_target`] = "Target path cannot contain ..";
} }
}); });
setErrors(newErrors); setErrors(newErrors);
return Object.keys(newErrors).length === 0; return Object.keys(newErrors).length === 0;
}; };
const handleSubmit = () => { const handleSubmit = () => {
if (!validate()) return; if (!validate()) return;
onSave({ onSave({
remote_url: remoteUrl.trim(), remote_url: remoteUrl.trim(),
branch: branch.trim() || undefined, branch: branch.trim() || undefined,
mappings: mappings.map((m) => ({ mappings: mappings.map((m) => ({
source_path: m.source_path.trim() || ".", source_path: m.source_path.trim() || ".",
target_path: m.target_path.trim(), target_path: m.target_path.trim(),
})), })),
}); });
}; };
const addMapping = () => { const addMapping = () => {
setMappings((prev) => [...prev, { source_path: ".", target_path: "" }]); setMappings((prev) => [...prev, { source_path: ".", target_path: "" }]);
}; };
const updateMapping = (index: number, field: keyof GitMountMapping, value: string) => { const updateMapping = (
setMappings((prev) => { index: number,
const next = [...prev]; field: keyof GitMountMapping,
next[index] = { ...next[index], [field]: value }; value: string,
return next; ) => {
}); setMappings((prev) => {
if (errors[`mapping_${index}_${field}`]) { const next = [...prev];
setErrors((prev) => { next[index] = { ...next[index], [field]: value };
const next = { ...prev }; return next;
delete next[`mapping_${index}_${field}`]; });
return next; if (errors[`mapping_${index}_${field}`]) {
}); setErrors((prev) => {
} const next = { ...prev };
}; delete next[`mapping_${index}_${field}`];
return next;
});
}
};
const removeMapping = (index: number) => { const removeMapping = (index: number) => {
setMappings((prev) => prev.filter((_, i) => i !== index)); setMappings((prev) => prev.filter((_, i) => i !== index));
}; };
return ( return (
<div style={{ display: "flex", flexDirection: "column", gap: "0.75rem" }}> <div style={{ display: "flex", flexDirection: "column", gap: "0.75rem" }}>
<div className="form-row" style={{ gap: "0.5rem" }}> <div className="form-row" style={{ gap: "0.5rem" }}>
<div style={{ flex: 2 }}> <div style={{ flex: 2 }}>
<label style={{ fontSize: "0.875rem", fontWeight: 500 }}>Repository URL</label> <label style={{ fontSize: "0.875rem", fontWeight: 500 }}>
<input Repository URL
type="text" </label>
value={remoteUrl} <input
onChange={(e) => { type="text"
setRemoteUrl(e.target.value); value={remoteUrl}
if (errors.remote_url) { onChange={(e) => {
setErrors((prev) => { setRemoteUrl(e.target.value);
const next = { ...prev }; if (errors.remote_url) {
delete next.remote_url; setErrors((prev) => {
return next; const next = { ...prev };
}); delete next.remote_url;
} return next;
}} });
placeholder="https://github.com/user/repo.git" }
className={`form-input ${errors.remote_url ? "error" : ""}`} }}
/> placeholder="https://github.com/user/repo.git"
{errors.remote_url && <span className="error-text">{errors.remote_url}</span>} className={`form-input ${errors.remote_url ? "error" : ""}`}
</div> />
<div style={{ flex: 1 }}> {errors.remote_url && (
<label style={{ fontSize: "0.875rem", fontWeight: 500 }}>Branch (optional)</label> <span className="error-text">{errors.remote_url}</span>
<input )}
type="text" </div>
value={branch} <div style={{ flex: 1 }}>
onChange={(e) => setBranch(e.target.value)} <label style={{ fontSize: "0.875rem", fontWeight: 500 }}>
placeholder="main" Branch (optional)
className="form-input" </label>
/> <input
</div> type="text"
</div> value={branch}
onChange={(e) => setBranch(e.target.value)}
placeholder="main"
className="form-input"
/>
</div>
</div>
<div> <div>
<label style={{ fontSize: "0.875rem", fontWeight: 500 }}>Mappings</label> <label style={{ fontSize: "0.875rem", fontWeight: 500 }}>
<p className="muted" style={{ margin: "0 0 0.5rem 0", fontSize: "0.8125rem" }}> Mappings
Source paths within the repo and where to mount them in the container. </label>
</p> <p
<div style={{ display: "flex", flexDirection: "column", gap: "0.5rem" }}> className="muted"
{mappings.map((mapping, index) => ( style={{ margin: "0 0 0.5rem 0", fontSize: "0.8125rem" }}
<div key={index} className="form-row" style={{ gap: "0.5rem", alignItems: "flex-start" }}> >
<input Source paths within the repo and where to mount them in the container.
type="text" </p>
value={mapping.source_path} <div
onChange={(e) => updateMapping(index, "source_path", e.target.value)} style={{ display: "flex", flexDirection: "column", gap: "0.5rem" }}
placeholder="packages/api" >
className={`form-input ${errors[`mapping_${index}_source`] ? "error" : ""}`} {mappings.map((mapping, index) => (
style={{ flex: 1 }} <div
/> key={index}
<span style={{ padding: "0.5rem 0", color: "var(--muted)", fontSize: "0.875rem" }}></span> className="form-row"
<input style={{ gap: "0.5rem", alignItems: "flex-start" }}
type="text" >
value={mapping.target_path} <input
onChange={(e) => updateMapping(index, "target_path", e.target.value)} type="text"
placeholder="/app/api" value={mapping.source_path}
className={`form-input ${errors[`mapping_${index}_target`] ? "error" : ""}`} onChange={(e) =>
style={{ flex: 1 }} updateMapping(index, "source_path", e.target.value)
/> }
{mappings.length > 1 && ( placeholder="packages/api"
<button className={`form-input ${errors[`mapping_${index}_source`] ? "error" : ""}`}
type="button" style={{ flex: 1 }}
className="ghost-button small" />
onClick={() => removeMapping(index)} <span
title="Remove mapping" style={{
> padding: "0.5rem 0",
<Icon name="delete" size="sm" /> color: "var(--muted)",
</button> fontSize: "0.875rem",
)} }}
{errors[`mapping_${index}_source`] && ( >
<span className="error-text">{errors[`mapping_${index}_source`]}</span>
)} </span>
{errors[`mapping_${index}_target`] && ( <input
<span className="error-text">{errors[`mapping_${index}_target`]}</span> type="text"
)} value={mapping.target_path}
</div> onChange={(e) =>
))} updateMapping(index, "target_path", e.target.value)
</div> }
<button type="button" className="secondary-button small" onClick={addMapping} style={{ marginTop: "0.5rem" }}> placeholder="/app/api"
<Icon name="add" size="sm" /> className={`form-input ${errors[`mapping_${index}_target`] ? "error" : ""}`}
Add Mapping style={{ flex: 1 }}
</button> />
</div> {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" }}> <div
<button type="button" className="primary-button" onClick={handleSubmit}> className="form-actions"
Save style={{ display: "flex", gap: "0.5rem", marginTop: "0.5rem" }}
</button> >
<button type="button" className="secondary-button" onClick={onCancel}> <button type="button" className="primary-button" onClick={handleSubmit}>
Cancel Save
</button> </button>
</div> <button type="button" className="secondary-button" onClick={onCancel}>
</div> 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** |