diff --git a/apps/api/src/api/config_profiles.py b/apps/api/src/api/config_profiles.py index 81e58b1..4fe2c5e 100644 --- a/apps/api/src/api/config_profiles.py +++ b/apps/api/src/api/config_profiles.py @@ -58,7 +58,9 @@ def _calculate_profile_size(data: dict) -> int: class GitMountMapping(BaseModel): - source_path: str = Field(description="Path within repository (supports glob patterns)") + source_path: str = Field( + description="Path within repository (supports glob patterns)" + ) target_path: str = Field(description="Absolute path inside container") @field_validator("source_path") @@ -80,16 +82,25 @@ class GitMountMapping(BaseModel): class GitMountItem(BaseModel): remote_url: str = Field(description="Git remote URL (HTTPS or SSH)") - source_path: str | None = Field(default=None, description="Path within repository (legacy single mapping)") - target_path: str | None = Field(default=None, description="Absolute path inside container (legacy single mapping)") + source_path: str | None = Field( + default=None, description="Path within repository (legacy single mapping)" + ) + target_path: str | None = Field( + default=None, + description="Absolute path inside container (legacy single mapping)", + ) branch: str | None = Field(default=None, description="Optional branch or tag name") - mappings: list[GitMountMapping] | None = Field(default=None, description="Multiple source/target mappings from the same repo") + mappings: list[GitMountMapping] | None = Field( + default=None, description="Multiple source/target mappings from the same repo" + ) @field_validator("remote_url") @classmethod def validate_remote_url(cls, v: str) -> str: if not v.startswith(("http://", "https://", "git@", "ssh://")): - raise ValueError("remote_url must be a valid git URL (https://, git@, or ssh://)") + raise ValueError( + "remote_url must be a valid git URL (https://, git@, or ssh://)" + ) return v @field_validator("source_path") @@ -126,7 +137,9 @@ class GitMountItem(BaseModel): class MountItem(BaseModel): target: str = Field(description="Absolute mount target path") mode: str = Field(default="rw", description="Mount mode: ro or rw") - files: dict = Field(default_factory=dict, description="Files as {relative_path: content}") + files: dict = Field( + default_factory=dict, description="Files as {relative_path: content}" + ) @field_validator("target") @classmethod @@ -163,10 +176,18 @@ class ConfigProfileCreate(BaseModel): tool_type_id: str | None = Field(default=None, description="Optional tool type ID") env_vars: dict = Field(default_factory=dict, description="Environment variables") runtime_hints: dict = Field(default_factory=dict, description="Runtime hints") - mounts: list[MountItem] = Field(default_factory=list, description="Mount definitions") - files: dict = Field(default_factory=dict, description="Files as {relative_path: content}") - git_mounts: list[GitMountItem] = Field(default_factory=list, description="Git repository mounts") - is_default: bool = Field(default=False, description="Whether this is the default profile for its scope") + mounts: list[MountItem] = Field( + default_factory=list, description="Mount definitions" + ) + files: dict = Field( + default_factory=dict, description="Files as {relative_path: content}" + ) + git_mounts: list[GitMountItem] = Field( + default_factory=list, description="Git repository mounts" + ) + is_default: bool = Field( + default=False, description="Whether this is the default profile for its scope" + ) @field_validator("project_id", "tool_type_id") @classmethod @@ -216,10 +237,18 @@ class ConfigProfileUpdate(BaseModel): tool_type_id: str | None = Field(default=None, description="Optional tool type ID") env_vars: dict | None = Field(default=None, description="Environment variables") runtime_hints: dict | None = Field(default=None, description="Runtime hints") - mounts: list[MountItem] | None = Field(default=None, description="Mount definitions") - files: dict | None = Field(default=None, description="Files as {relative_path: content}") - git_mounts: list[GitMountItem] | None = Field(default=None, description="Git repository mounts") - is_default: bool | None = Field(default=None, description="Whether this is the default profile") + mounts: list[MountItem] | None = Field( + default=None, description="Mount definitions" + ) + files: dict | None = Field( + default=None, description="Files as {relative_path: content}" + ) + git_mounts: list[GitMountItem] | None = Field( + default=None, description="Git repository mounts" + ) + is_default: bool | None = Field( + default=None, description="Whether this is the default profile" + ) @field_validator("project_id", "tool_type_id") @classmethod @@ -269,7 +298,9 @@ class ConfigProfileResponse(BaseModel): updated_at: str -async def _get_profile_with_includes(session: AsyncSession, profile_id: uuid.UUID) -> ConfigProfile | None: +async def _get_profile_with_includes( + session: AsyncSession, profile_id: uuid.UUID +) -> ConfigProfile | None: """Fetch a profile with includes eagerly loaded.""" result = await session.execute( select(ConfigProfile) @@ -289,12 +320,16 @@ async def _check_access( if project_id is not None: project = await session.get(Project, project_id) if project is None: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Project not found") + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="Project not found" + ) # Add ownership check if needed; for now just verify existence if tool_type_id is not None: tool_type = await session.get(ToolType, tool_type_id) if tool_type is None: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Tool type not found") + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="Tool type not found" + ) async def _validate_git_mounts( @@ -304,7 +339,7 @@ async def _validate_git_mounts( project_id: uuid.UUID | None = None, ) -> None: """Validate git mount URLs. - + Simply checks that remote_url looks like a valid git URL. Actual clone validation happens at instance startup time. """ @@ -315,7 +350,7 @@ async def _validate_git_mounts( status_code=status.HTTP_400_BAD_REQUEST, detail="Git mount missing remote_url", ) - + if not remote_url.startswith(("http://", "https://", "git@", "ssh://")): raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, @@ -323,7 +358,9 @@ async def _validate_git_mounts( ) -def _profile_to_response(profile: ConfigProfile, includes: list[ConfigProfileInclude] | None = None) -> dict: +def _profile_to_response( + profile: ConfigProfile, includes: list[ConfigProfileInclude] | None = None +) -> dict: return { "id": str(profile.id), "user_id": str(profile.user_id), @@ -353,13 +390,19 @@ def _profile_to_response(profile: ConfigProfile, includes: list[ConfigProfileInc @router.get("", response_model=list[ConfigProfileResponse]) async def list_config_profiles( project_id: str | None = Query(None, description="Filter by project compatibility"), - tool_type_id: str | None = Query(None, description="Filter by tool type compatibility"), + tool_type_id: str | None = Query( + None, description="Filter by tool type compatibility" + ), current_user_id: uuid.UUID = Depends(get_current_user_id), session: AsyncSession = Depends(get_db_session), ): """List config profiles, optionally filtered by compatibility.""" user_uuid = current_user_id - query = select(ConfigProfile).where(ConfigProfile.user_id == user_uuid).options(selectinload(ConfigProfile.includes)) + query = ( + select(ConfigProfile) + .where(ConfigProfile.user_id == user_uuid) + .options(selectinload(ConfigProfile.includes)) + ) if project_id or tool_type_id: # Compatibility filter: include portable profiles and matching scoped profiles @@ -371,7 +414,8 @@ async def list_config_profiles( conditions: list = [] # Portable profiles (no project, no tool) conditions.append( - (ConfigProfile.project_id.is_(None)) & (ConfigProfile.tool_type_id.is_(None)) + (ConfigProfile.project_id.is_(None)) + & (ConfigProfile.tool_type_id.is_(None)) ) if project_uuid: # Profiles matching this project (with or without tool) @@ -382,7 +426,8 @@ async def list_config_profiles( if project_uuid and tool_uuid: # Exact match conditions.append( - (ConfigProfile.project_id == project_uuid) & (ConfigProfile.tool_type_id == tool_uuid) + (ConfigProfile.project_id == project_uuid) + & (ConfigProfile.tool_type_id == tool_uuid) ) query = query.where(or_(*conditions)) @@ -392,7 +437,9 @@ async def list_config_profiles( return [_profile_to_response(p) for p in profiles] -@router.post("", response_model=ConfigProfileResponse, status_code=status.HTTP_201_CREATED) +@router.post( + "", response_model=ConfigProfileResponse, status_code=status.HTTP_201_CREATED +) async def create_config_profile( data: ConfigProfileCreate, current_user_id: uuid.UUID = Depends(get_current_user_id), @@ -403,10 +450,12 @@ async def create_config_profile( # Check for duplicate name existing = await session.execute( - select(ConfigProfile).where( + select(ConfigProfile) + .where( ConfigProfile.user_id == user_uuid, ConfigProfile.name == data.name, - ).options(selectinload(ConfigProfile.includes)) + ) + .options(selectinload(ConfigProfile.includes)) ) if existing.scalar_one_or_none() is not None: raise HTTPException( @@ -418,10 +467,12 @@ async def create_config_profile( project_uuid = uuid.UUID(data.project_id) if data.project_id else None tool_uuid = uuid.UUID(data.tool_type_id) if data.tool_type_id else None await _check_access(session, user_uuid, project_uuid, tool_uuid) - + # Validate git mounts reference existing repositories if data.git_mounts: - git_mounts_data = [m.model_dump() if hasattr(m, "model_dump") else m for m in data.git_mounts] + git_mounts_data = [ + m.model_dump() if hasattr(m, "model_dump") else m for m in data.git_mounts + ] await _validate_git_mounts(session, user_uuid, git_mounts_data, project_uuid) # Check size @@ -469,9 +520,13 @@ async def get_config_profile( """Get a config profile by ID.""" profile = await _get_profile_with_includes(session, uuid.UUID(profile_id)) if profile is None: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Profile not found") + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="Profile not found" + ) if profile.user_id != current_user_id: - raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Not authorized") + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, detail="Not authorized" + ) return _profile_to_response(profile) @@ -485,9 +540,13 @@ async def update_config_profile( """Update a config profile.""" profile = await _get_profile_with_includes(session, uuid.UUID(profile_id)) if profile is None: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Profile not found") + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="Profile not found" + ) if profile.user_id != current_user_id: - raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Not authorized") + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, detail="Not authorized" + ) update_data = data.model_dump(exclude_unset=True) @@ -518,14 +577,16 @@ async def update_config_profile( else (profile.tool_type_id if "tool_type_id" not in update_data else None) ) await _check_access(session, profile.user_id, project_uuid, tool_uuid) - + # Validate git mounts reference existing repositories if "git_mounts" in update_data and update_data["git_mounts"] is not None: git_mounts_data = [ - m.model_dump() if hasattr(m, "model_dump") else m + m.model_dump() if hasattr(m, "model_dump") else m for m in update_data["git_mounts"] ] - await _validate_git_mounts(session, profile.user_id, git_mounts_data, project_uuid) + await _validate_git_mounts( + session, profile.user_id, git_mounts_data, project_uuid + ) # Check size current_data = _profile_to_response(profile) @@ -570,9 +631,13 @@ async def delete_config_profile( """Delete a config profile.""" profile = await _get_profile_with_includes(session, uuid.UUID(profile_id)) if profile is None: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Profile not found") + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="Profile not found" + ) if profile.user_id != current_user_id: - raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Not authorized") + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, detail="Not authorized" + ) await session.delete(profile) await session.commit() @@ -591,9 +656,13 @@ async def update_profile_includes( """Update the ordered includes for a config profile.""" profile = await _get_profile_with_includes(session, uuid.UUID(profile_id)) if profile is None: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Profile not found") + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="Profile not found" + ) if profile.user_id != current_user_id: - raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Not authorized") + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, detail="Not authorized" + ) # Validate all included profiles exist and belong to the user included_uuids = [uuid.UUID(inc_id) for inc_id in data.includes] @@ -633,7 +702,9 @@ async def update_profile_includes( # Remove existing includes result = await session.execute( - select(ConfigProfileInclude).where(ConfigProfileInclude.profile_id == profile.id) + select(ConfigProfileInclude).where( + ConfigProfileInclude.profile_id == profile.id + ) ) for existing in result.scalars().all(): await session.delete(existing) @@ -658,7 +729,9 @@ async def update_profile_includes( profile = result.scalar_one() inc_result = await session.execute( - select(ConfigProfileInclude).where(ConfigProfileInclude.profile_id == profile.id) + select(ConfigProfileInclude).where( + ConfigProfileInclude.profile_id == profile.id + ) ) direct_includes = inc_result.scalars().all() @@ -675,9 +748,13 @@ async def preview_config_profile( """Preview the resolved output of a config profile.""" profile = await _get_profile_with_includes(session, uuid.UUID(profile_id)) if profile is None: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Profile not found") + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="Profile not found" + ) if profile.user_id != current_user_id: - raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Not authorized") + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, detail="Not authorized" + ) try: resolved = await resolve_profile(session, profile.id) diff --git a/apps/api/src/api/tool_instances.py b/apps/api/src/api/tool_instances.py index c0b699e..4be64c6 100644 --- a/apps/api/src/api/tool_instances.py +++ b/apps/api/src/api/tool_instances.py @@ -39,6 +39,7 @@ from src.services.config_profile_resolver import ( ConfigProfileCycleError, ResolvedProfile, apply_resolved_profile, + expand_container_path, resolve_profile, ) from src.services.docker import ( @@ -67,6 +68,7 @@ from src.services.manifest_compiler import ( compile_entrypoint, compute_image_tag, deep_merge, + get_manifest_home_dir, merge_with_config, resolve_base, ) @@ -82,6 +84,7 @@ async def _resolve_git_mounts( resolved: ResolvedProfile, instance_dir: str | None = None, working_directory: str | None = None, + home_dir: str = "/root", ) -> list[dict]: """Convert git mounts from resolved profile to Docker volume mounts. @@ -97,7 +100,7 @@ async def _resolve_git_mounts( for git_mount in resolved.git_mounts: tasks.append( _resolve_single_git_mount( - session, git_mount, instance_dir, working_directory + session, git_mount, instance_dir, working_directory, home_dir ) ) @@ -184,6 +187,7 @@ def _resolve_git_mount_mappings( repo_path: str, mappings: list[dict], working_directory: str | None, + home_dir: str = "/root", ) -> list[dict]: """Resolve mappings from an already-cloned repo to volume mount entries. @@ -199,6 +203,9 @@ def _resolve_git_mount_mappings( logger.warning("Invalid mapping skipped: missing target_path") continue + # Expand ~ and $HOME in target path + target_path = expand_container_path(target_path, home_dir) + # Resolve relative target paths against working directory final_target = target_path if not target_path.startswith("/"): @@ -261,6 +268,7 @@ async def _resolve_single_git_mount( git_mount: dict, instance_dir: str | None = None, working_directory: str | None = None, + home_dir: str = "/root", ) -> list[dict]: """Resolve a single git mount to volume mount entries. @@ -293,7 +301,7 @@ async def _resolve_single_git_mount( return [] # Resolve all mappings from the cloned repo - return _resolve_git_mount_mappings(repo_path, mappings, working_directory) + return _resolve_git_mount_mappings(repo_path, mappings, working_directory, home_dir) def _checkout_branch(repo_path: str, branch: str) -> bool: @@ -545,6 +553,7 @@ def _modify_compose_file( start_command: str | None = None, working_directory: str | None = None, extra_volumes: list[dict] | None = None, + home_dir: str = "/root", ) -> None: """Modify compose file with runtime overrides.""" import yaml @@ -571,14 +580,16 @@ def _modify_compose_file( service_config["command"] = start_command if working_directory: - service_config["working_dir"] = working_directory + service_config["working_dir"] = expand_container_path( + working_directory, home_dir + ) if extra_volumes: if "volumes" not in service_config: service_config["volumes"] = [] for vol in extra_volumes: source = vol.get("source", "") - target = vol.get("target", "") + target = expand_container_path(vol.get("target", ""), home_dir) vol_type = vol.get("type", "bind") if vol_type == "bind": service_config["volumes"].append(f"{source}:{target}") @@ -826,6 +837,9 @@ services: deep_merge(dict(base_def.manifest), manifest) ) + # Determine home directory for path expansion + home_dir = get_manifest_home_dir(manifest) + image_tag = compute_image_tag(tool_type.name, manifest) # Build image during creation so start is fast @@ -1105,11 +1119,11 @@ async def _prepare_manifest_instance( env_vars: dict, extra_volumes: list, working_directory: str | None, -) -> tuple[str, str, dict]: +) -> tuple[str, str, dict, str]: """Build image and generate compose from a manifest-based tool type. Returns: - Tuple of (image_tag, compose_content, resolved_manifest) + Tuple of (image_tag, compose_content, resolved_manifest, home_dir) """ from src.models.tool_definition_manifest import ToolDefinitionManifest @@ -1220,7 +1234,8 @@ async def _prepare_manifest_instance( instance.image_tag = image_tag instance.manifest_compiled_at = datetime.now() - return image_tag, compose_content, manifest + home_dir = get_manifest_home_dir(manifest) + return image_tag, compose_content, manifest, home_dir @router.post( @@ -1283,6 +1298,15 @@ async def start_instance( working_directory = None extra_volumes = [] + # Fetch tool type early to determine home directory + tool_type = await session.get(ToolType, instance.tool_type_id) + home_dir = "/root" + if tool_type and tool_type.definition_type == "manifest" and tool_type.manifest_id: + from src.models.tool_definition_manifest import ToolDefinitionManifest + manifest_def = await session.get(ToolDefinitionManifest, tool_type.manifest_id) + if manifest_def: + home_dir = get_manifest_home_dir(dict(manifest_def.manifest)) + # Apply selected config profile if any instance_dir = os.path.dirname(instance.compose_path) if instance.selected_config_profile_id is not None: @@ -1291,7 +1315,7 @@ async def start_instance( session, instance.selected_config_profile_id ) profile_env, profile_files, profile_mounts, profile_hints = ( - apply_resolved_profile(instance_dir, resolved) + apply_resolved_profile(instance_dir, resolved, home_dir) ) # Profile env vars override tool config env vars env_vars.update(profile_env) @@ -1301,7 +1325,7 @@ async def start_instance( extra_volumes.extend(profile_mounts) # Git repository mounts are resolved and added git_mount_volumes = await _resolve_git_mounts( - session, resolved, instance_dir, working_directory + session, resolved, instance_dir, working_directory, home_dir ) extra_volumes.extend(git_mount_volumes) # Profile runtime hints override tool config values @@ -1345,7 +1369,6 @@ async def start_instance( ) # ── MANIFEST-BASED FLOW ────────────────────────────────────── - tool_type = await session.get(ToolType, instance.tool_type_id) resolved_manifest = None if tool_type and tool_type.definition_type == "manifest" and tool_type.manifest_id: @@ -1362,6 +1385,7 @@ async def start_instance( image_tag, compose_content, resolved_manifest, + _home_dir, ) = await _prepare_manifest_instance( session=session, instance=instance, @@ -1420,6 +1444,7 @@ async def start_instance( start_command, working_directory, extra_volumes, + home_dir, ) logger.debug("Modified compose file for instance %s", instance.id) diff --git a/apps/api/src/services/config_profile_resolver.py b/apps/api/src/services/config_profile_resolver.py index d6ad2b0..b839214 100644 --- a/apps/api/src/services/config_profile_resolver.py +++ b/apps/api/src/services/config_profile_resolver.py @@ -5,6 +5,7 @@ and cycle protection. """ import logging +import os import uuid from dataclasses import dataclass, field from typing import Any @@ -57,7 +58,9 @@ class ResolvedProfile: included_profiles: list[dict[str, Any]] = field(default_factory=list) -def _detect_cycle(profile_id: uuid.UUID, visited: set[uuid.UUID], path: list[uuid.UUID]) -> bool: +def _detect_cycle( + profile_id: uuid.UUID, visited: set[uuid.UUID], path: list[uuid.UUID] +) -> bool: """Detect if adding profile_id to path would create a cycle. Args: @@ -252,7 +255,9 @@ async def _resolve_profile_recursive( """ if _detect_cycle(profile_id, visited, path): cycle_path = " -> ".join(str(p) for p in path + [profile_id]) - raise ConfigProfileCycleError(f"Cycle detected in profile includes: {cycle_path}") + raise ConfigProfileCycleError( + f"Cycle detected in profile includes: {cycle_path}" + ) profile = await session.get(ConfigProfile, profile_id) if profile is None: @@ -279,13 +284,18 @@ async def _resolve_profile_recursive( included = await _resolve_profile_recursive( session, include.included_profile_id, new_visited, new_path ) - result.included_profiles.append({ - "id": str(included.profile_id), - "name": included.profile_name, - }) + result.included_profiles.append( + { + "id": str(included.profile_id), + "name": included.profile_name, + } + ) result.env_vars = _merge_env_vars( - result.env_vars, included.env_vars, result.env_overrides, included.profile_name + result.env_vars, + included.env_vars, + result.env_overrides, + included.profile_name, ) result.runtime_hints = _merge_runtime_hints( result.runtime_hints, @@ -429,6 +439,7 @@ async def check_include_cycle( def apply_resolved_profile( instance_dir: str, resolved: ResolvedProfile, + home_dir: str = "/root", ) -> tuple[dict[str, str], dict[str, str], list[dict], dict[str, Any]]: """Apply a resolved profile to an instance directory. @@ -458,14 +469,19 @@ def apply_resolved_profile( try: full_path.resolve().relative_to(instance_path.resolve()) except ValueError: - logger.warning("Profile file path escapes instance directory: %s", file_path) + logger.warning( + "Profile file path escapes instance directory: %s", file_path + ) continue full_path.parent.mkdir(parents=True, exist_ok=True) full_path.write_text(content) # Stage mount files and prepare volume mounts for mount in resolved.mounts.values(): - mount_dir = instance_path / "mounts" / mount.target.lstrip("/").replace("/", "_") + expanded_target = expand_container_path(mount.target, home_dir) + mount_dir = ( + instance_path / "mounts" / expanded_target.lstrip("/").replace("/", "_") + ) mount_dir.mkdir(parents=True, exist_ok=True) for file_path, content in mount.files.items(): @@ -478,15 +494,41 @@ def apply_resolved_profile( full_path.parent.mkdir(parents=True, exist_ok=True) full_path.write_text(content) - volume_mounts.append({ - "source": str(mount_dir), - "target": mount.target, - "type": "bind", - }) + volume_mounts.append( + { + "source": str(mount_dir), + "target": expanded_target, + "type": "bind", + } + ) return env_vars, files, volume_mounts, resolved.runtime_hints +def expand_container_path(path: str, home_dir: str) -> str: + """Expand ~ and $HOME in a container path to the actual home directory. + + Only expands at the start of the path (e.g., ~/foo, $HOME/foo, $HOME). + Leaves mid-string occurrences unchanged. + + Args: + path: Container path that may contain ~ or $HOME. + home_dir: The container's home directory (e.g., /home/user or /root). + + Returns: + Path with ~ and $HOME expanded. + """ + if path.startswith("~/"): + return os.path.join(home_dir, path[2:]) + if path == "~": + return home_dir + if path.startswith("$HOME/"): + return home_dir + "/" + path[6:] + if path == "$HOME": + return home_dir + return path + + def resolved_profile_to_dict(resolved: ResolvedProfile) -> dict[str, Any]: """Convert a ResolvedProfile to a plain dict for serialization. diff --git a/apps/api/src/services/manifest_compiler.py b/apps/api/src/services/manifest_compiler.py index e155c64..75166b8 100644 --- a/apps/api/src/services/manifest_compiler.py +++ b/apps/api/src/services/manifest_compiler.py @@ -160,6 +160,11 @@ def compile_dockerfile(manifest: dict) -> str: lines.append(f"RUN groupadd -g {gid} {name} && \\") lines.append(f" useradd -u {uid} -g {gid} {create_home}-s {shell} {name}") lines.append("") + # Set HOME and USER for runtime compatibility + home = f"/home/{name}" + lines.append(f"ENV HOME={home}") + lines.append(f"ENV USER={name}") + lines.append("") # Build scripts build_scripts = manifest.get("scripts", {}).get("build", []) @@ -333,6 +338,21 @@ def resolve_mount_source(mount: dict, variables: dict[str, Any]) -> str: return "" +def get_manifest_home_dir(manifest: dict) -> str: + """Get the home directory for a container based on manifest user config. + + Args: + manifest: Fully resolved manifest JSON. + + Returns: + Home directory path (e.g., /home/user or /root). + """ + user = manifest.get("user") + if user and user.get("name"): + return f"/home/{user['name']}" + return "/root" + + def compute_image_tag(tool_name: str, manifest: dict) -> str: """Compute a deterministic image tag from manifest content. diff --git a/apps/api/tests/unit/test_config_profile_resolver.py b/apps/api/tests/unit/test_config_profile_resolver.py index c9544d4..ead1377 100644 --- a/apps/api/tests/unit/test_config_profile_resolver.py +++ b/apps/api/tests/unit/test_config_profile_resolver.py @@ -75,6 +75,7 @@ class TestMergeFunctions: def test_merge_mounts_file_override(self) -> None: """Test mount file map merging with overrides.""" from src.services.config_profile_resolver import ResolvedMount + result = _merge_mounts( {"/app": ResolvedMount(target="/app", mode="rw", files={"a.txt": "old"})}, [{"target": "/app", "mode": "rw", "files": {"a.txt": "new"}}], @@ -86,6 +87,7 @@ class TestMergeFunctions: def test_merge_mounts_mode_conflict(self) -> None: """Test that mount mode conflicts are resolved (later wins).""" from src.services.config_profile_resolver import ResolvedMount + overrides = {} result = _merge_mounts( {"/app": ResolvedMount(target="/app", mode="rw", files={})}, @@ -100,7 +102,13 @@ class TestMergeFunctions: """Test basic git mount merging normalizes to mappings format.""" result = _merge_git_mounts( [], - [{"remote_url": "https://github.com/user/repo1.git", "source_path": ".", "target_path": "/app"}], + [ + { + "remote_url": "https://github.com/user/repo1.git", + "source_path": ".", + "target_path": "/app", + } + ], "source", ) assert len(result) == 1 @@ -111,8 +119,22 @@ class TestMergeFunctions: def test_merge_git_mounts_concatenate_same_repo_branch(self) -> None: """Test that git mounts with same repo+branch concatenate mappings.""" result = _merge_git_mounts( - [{"remote_url": "https://github.com/user/repo1.git", "source_path": ".", "target_path": "/app", "branch": "main"}], - [{"remote_url": "https://github.com/user/repo1.git", "source_path": "src", "target_path": "/src", "branch": "main"}], + [ + { + "remote_url": "https://github.com/user/repo1.git", + "source_path": ".", + "target_path": "/app", + "branch": "main", + } + ], + [ + { + "remote_url": "https://github.com/user/repo1.git", + "source_path": "src", + "target_path": "/src", + "branch": "main", + } + ], "source", ) assert len(result) == 1 @@ -125,8 +147,22 @@ class TestMergeFunctions: def test_merge_git_mounts_dedup_same_mapping(self) -> None: """Test that duplicate mappings are deduplicated.""" result = _merge_git_mounts( - [{"remote_url": "https://github.com/user/repo1.git", "source_path": ".", "target_path": "/app", "branch": "main"}], - [{"remote_url": "https://github.com/user/repo1.git", "source_path": ".", "target_path": "/app", "branch": "main"}], + [ + { + "remote_url": "https://github.com/user/repo1.git", + "source_path": ".", + "target_path": "/app", + "branch": "main", + } + ], + [ + { + "remote_url": "https://github.com/user/repo1.git", + "source_path": ".", + "target_path": "/app", + "branch": "main", + } + ], "source", ) assert len(result) == 1 @@ -135,19 +171,48 @@ class TestMergeFunctions: def test_merge_git_mounts_different_repos(self) -> None: """Test that git mounts with different repos are preserved.""" result = _merge_git_mounts( - [{"remote_url": "https://github.com/user/repo1.git", "source_path": ".", "target_path": "/app"}], - [{"remote_url": "https://github.com/user/repo2.git", "source_path": ".", "target_path": "/config"}], + [ + { + "remote_url": "https://github.com/user/repo1.git", + "source_path": ".", + "target_path": "/app", + } + ], + [ + { + "remote_url": "https://github.com/user/repo2.git", + "source_path": ".", + "target_path": "/config", + } + ], "source", ) assert len(result) == 2 urls = {m["remote_url"] for m in result} - assert urls == {"https://github.com/user/repo1.git", "https://github.com/user/repo2.git"} + assert urls == { + "https://github.com/user/repo1.git", + "https://github.com/user/repo2.git", + } def test_merge_git_mounts_different_branches(self) -> None: """Test that same repo with different branches are kept separate.""" result = _merge_git_mounts( - [{"remote_url": "https://github.com/user/repo1.git", "source_path": ".", "target_path": "/app", "branch": "main"}], - [{"remote_url": "https://github.com/user/repo1.git", "source_path": ".", "target_path": "/app", "branch": "dev"}], + [ + { + "remote_url": "https://github.com/user/repo1.git", + "source_path": ".", + "target_path": "/app", + "branch": "main", + } + ], + [ + { + "remote_url": "https://github.com/user/repo1.git", + "source_path": ".", + "target_path": "/app", + "branch": "dev", + } + ], "source", ) assert len(result) == 2 @@ -181,7 +246,9 @@ class TestResolveProfile: assert result.files == {"test.txt": "content"} @pytest.mark.asyncio - async def test_resolve_profile_with_includes(self, db_session: AsyncSession) -> None: + async def test_resolve_profile_with_includes( + self, db_session: AsyncSession + ) -> None: """Test resolving a profile that includes another.""" user_id = uuid.uuid4() @@ -225,7 +292,9 @@ class TestResolveProfile: assert result.included_profiles[0]["name"] == "base" @pytest.mark.asyncio - async def test_resolve_profile_child_overrides_parent(self, db_session: AsyncSession) -> None: + async def test_resolve_profile_child_overrides_parent( + self, db_session: AsyncSession + ) -> None: """Test that child profile values override parent values.""" user_id = uuid.uuid4() @@ -262,7 +331,9 @@ class TestResolveProfile: assert result.env_overrides == {"VAR": "child"} @pytest.mark.asyncio - async def test_resolve_profile_cycle_detection(self, db_session: AsyncSession) -> None: + async def test_resolve_profile_cycle_detection( + self, db_session: AsyncSession + ) -> None: """Test that cycles are detected during resolution.""" user_id = uuid.uuid4() @@ -308,10 +379,12 @@ class TestResolveProfile: await resolve_profile(db_session, profile_a.id) @pytest.mark.asyncio - async def test_resolve_profile_with_git_mounts(self, db_session: AsyncSession) -> None: + async def test_resolve_profile_with_git_mounts( + self, db_session: AsyncSession + ) -> None: """Test resolving a profile with git mounts normalizes to mappings.""" user_id = uuid.uuid4() - + profile = ConfigProfile( id=uuid.uuid4(), user_id=user_id, @@ -319,23 +392,31 @@ class TestResolveProfile: env_vars={}, files={}, git_mounts=[ - {"remote_url": "https://github.com/user/repo1.git", "source_path": ".", "target_path": "/app"}, + { + "remote_url": "https://github.com/user/repo1.git", + "source_path": ".", + "target_path": "/app", + }, ], ) db_session.add(profile) await db_session.commit() - + result = await resolve_profile(db_session, profile.id) assert len(result.git_mounts) == 1 assert result.git_mounts[0]["remote_url"] == "https://github.com/user/repo1.git" assert "mappings" in result.git_mounts[0] - assert result.git_mounts[0]["mappings"] == [{"source_path": ".", "target_path": "/app"}] - + assert result.git_mounts[0]["mappings"] == [ + {"source_path": ".", "target_path": "/app"} + ] + @pytest.mark.asyncio - async def test_resolve_profile_with_git_mount_includes(self, db_session: AsyncSession) -> None: + async def test_resolve_profile_with_git_mount_includes( + self, db_session: AsyncSession + ) -> None: """Test resolving a profile that includes another with git mounts.""" user_id = uuid.uuid4() - + # Create base profile with git mount base = ConfigProfile( id=uuid.uuid4(), @@ -344,11 +425,15 @@ class TestResolveProfile: env_vars={}, files={}, git_mounts=[ - {"remote_url": "https://github.com/user/repo1.git", "source_path": ".", "target_path": "/app"}, + { + "remote_url": "https://github.com/user/repo1.git", + "source_path": ".", + "target_path": "/app", + }, ], ) db_session.add(base) - + # Create child profile with its own git mount child = ConfigProfile( id=uuid.uuid4(), @@ -357,12 +442,16 @@ class TestResolveProfile: env_vars={}, files={}, git_mounts=[ - {"remote_url": "https://github.com/user/repo2.git", "source_path": "config", "target_path": "/config"}, + { + "remote_url": "https://github.com/user/repo2.git", + "source_path": "config", + "target_path": "/config", + }, ], ) db_session.add(child) await db_session.commit() - + # Create include relationship include = ConfigProfileInclude( id=uuid.uuid4(), @@ -372,11 +461,14 @@ class TestResolveProfile: ) db_session.add(include) await db_session.commit() - + result = await resolve_profile(db_session, child.id) assert len(result.git_mounts) == 2 urls = {m["remote_url"] for m in result.git_mounts} - assert urls == {"https://github.com/user/repo1.git", "https://github.com/user/repo2.git"} + assert urls == { + "https://github.com/user/repo1.git", + "https://github.com/user/repo2.git", + } for m in result.git_mounts: assert "mappings" in m diff --git a/apps/api/tests/unit/test_git_mounts.py b/apps/api/tests/unit/test_git_mounts.py index f1354dd..9f0c950 100644 --- a/apps/api/tests/unit/test_git_mounts.py +++ b/apps/api/tests/unit/test_git_mounts.py @@ -148,7 +148,10 @@ class TestResolveSingleGitMount: async def test_missing_remote_url(self) -> None: """Git mount without remote_url returns empty list.""" result = await _resolve_single_git_mount( - MagicMock(), {"mappings": [{"source_path": ".", "target_path": "/app"}]}, "/tmp", None + MagicMock(), + {"mappings": [{"source_path": ".", "target_path": "/app"}]}, + "/tmp", + None, ) assert result == [] @@ -157,7 +160,10 @@ class TestResolveSingleGitMount: """Git mount without instance_dir returns empty list.""" result = await _resolve_single_git_mount( MagicMock(), - {"remote_url": "https://github.com/user/repo.git", "mappings": [{"source_path": ".", "target_path": "/app"}]}, + { + "remote_url": "https://github.com/user/repo.git", + "mappings": [{"source_path": ".", "target_path": "/app"}], + }, None, None, ) diff --git a/apps/api/tests/unit/test_home_path_expansion.py b/apps/api/tests/unit/test_home_path_expansion.py new file mode 100644 index 0000000..a8ac4b6 --- /dev/null +++ b/apps/api/tests/unit/test_home_path_expansion.py @@ -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" diff --git a/apps/api/tests/unit/test_manifest_compiler.py b/apps/api/tests/unit/test_manifest_compiler.py index f95df02..2702a8b 100644 --- a/apps/api/tests/unit/test_manifest_compiler.py +++ b/apps/api/tests/unit/test_manifest_compiler.py @@ -8,6 +8,7 @@ from src.services.manifest_compiler import ( compile_entrypoint, compute_image_tag, deep_merge, + get_manifest_home_dir, merge_with_config, resolve_base, ) @@ -161,6 +162,38 @@ class TestCompileDockerfile: df = compile_dockerfile(manifest) assert 'CMD ["/bin/bash"]' in df + def test_sets_home_env_for_user(self) -> None: + manifest = { + "base_image": "ubuntu:24.04", + "name": "test", + "user": {"name": "dev", "uid": 1001, "gid": 1001}, + } + df = compile_dockerfile(manifest) + assert "ENV HOME=/home/dev" in df + assert "ENV USER=dev" in df + + def test_no_home_env_without_user(self) -> None: + manifest = {"base_image": "ubuntu:24.04", "name": "test"} + df = compile_dockerfile(manifest) + assert "ENV HOME=" not in df + assert "ENV USER=" not in df + + +class TestGetManifestHomeDir: + """Tests for get_manifest_home_dir.""" + + def test_with_user_name(self) -> None: + manifest = {"user": {"name": "dev", "uid": 1001, "gid": 1001}} + assert get_manifest_home_dir(manifest) == "/home/dev" + + def test_without_user(self) -> None: + manifest = {"base_image": "ubuntu:24.04"} + assert get_manifest_home_dir(manifest) == "/root" + + def test_with_empty_user_name(self) -> None: + manifest = {"user": {"name": "", "uid": 1001, "gid": 1001}} + assert get_manifest_home_dir(manifest) == "/root" + class TestCompileEntrypoint: """Tests for compile_entrypoint.""" diff --git a/apps/api/tests/unit/test_tool_instances_legacy.py b/apps/api/tests/unit/test_tool_instances_legacy.py index d322800..c662193 100644 --- a/apps/api/tests/unit/test_tool_instances_legacy.py +++ b/apps/api/tests/unit/test_tool_instances_legacy.py @@ -735,6 +735,7 @@ class TestStartInstanceManifestBranch: "headquarter/test:latest", "services:\n app:\n image: test", {"name": "test-manifest"}, + "/root", ) instance = ToolInstance( diff --git a/apps/web/src/api/config_profiles.ts b/apps/web/src/api/config_profiles.ts index 3f08ad0..048d70c 100644 --- a/apps/web/src/api/config_profiles.ts +++ b/apps/web/src/api/config_profiles.ts @@ -1,163 +1,169 @@ import { apiClient } from "./client"; export interface ConfigProfile { - id: string; - user_id: string; - name: string; - description: string | null; - project_id: string | null; - tool_type_id: string | null; - env_vars: Record; - runtime_hints: Record; - mounts: ConfigProfileMount[]; - git_mounts: GitMount[]; - files: Record; - is_default: boolean; - includes: ConfigProfileInclude[]; - created_at: string; - updated_at: string; + id: string; + user_id: string; + name: string; + description: string | null; + project_id: string | null; + tool_type_id: string | null; + env_vars: Record; + runtime_hints: Record; + mounts: ConfigProfileMount[]; + git_mounts: GitMount[]; + files: Record; + is_default: boolean; + includes: ConfigProfileInclude[]; + created_at: string; + updated_at: string; } export interface ConfigProfileMount { - target: string; - mode: "ro" | "rw"; - files: Record; + target: string; + mode: "ro" | "rw"; + files: Record; } export interface GitMountMapping { - source_path: string; - target_path: string; + source_path: string; + target_path: string; } export interface GitMount { - remote_url: string; - branch?: string; - mappings: GitMountMapping[]; - // Legacy fields (for backward compatibility when reading old data) - source_path?: string; - target_path?: string; + remote_url: string; + branch?: string; + mappings: GitMountMapping[]; + // Legacy fields (for backward compatibility when reading old data) + source_path?: string; + target_path?: string; } export interface ConfigProfileInclude { - id: string; - included_profile_id: string; - order_index: number; + id: string; + included_profile_id: string; + order_index: number; } export interface ResolvedProfile { - profile_id: string; - profile_name: string; - env_vars: Record; - runtime_hints: Record; - mounts: ResolvedMount[]; - git_mounts: GitMount[]; - files: Record; - overrides: { - env_vars: Record; - runtime_hints: Record; - files: Record; - mounts: Record; - }; - included_profiles: Array<{ id: string; name: string }>; + profile_id: string; + profile_name: string; + env_vars: Record; + runtime_hints: Record; + mounts: ResolvedMount[]; + git_mounts: GitMount[]; + files: Record; + overrides: { + env_vars: Record; + runtime_hints: Record; + files: Record; + mounts: Record; + }; + included_profiles: Array<{ id: string; name: string }>; } export interface ResolvedMount { - target: string; - mode: "ro" | "rw"; - files: Record; - overridden_files: Record; + target: string; + mode: "ro" | "rw"; + files: Record; + overridden_files: Record; } export interface CreateConfigProfileRequest { - name: string; - description?: string; - project_id?: string; - tool_type_id?: string; - env_vars?: Record; - runtime_hints?: Record; - mounts?: ConfigProfileMount[]; - git_mounts?: GitMount[]; - files?: Record; - is_default?: boolean; + name: string; + description?: string; + project_id?: string; + tool_type_id?: string; + env_vars?: Record; + runtime_hints?: Record; + mounts?: ConfigProfileMount[]; + git_mounts?: GitMount[]; + files?: Record; + is_default?: boolean; } export interface UpdateConfigProfileRequest { - name?: string; - description?: string; - project_id?: string; - tool_type_id?: string; - env_vars?: Record; - runtime_hints?: Record; - mounts?: ConfigProfileMount[]; - git_mounts?: GitMount[]; - files?: Record; - is_default?: boolean; + name?: string; + description?: string; + project_id?: string; + tool_type_id?: string; + env_vars?: Record; + runtime_hints?: Record; + mounts?: ConfigProfileMount[]; + git_mounts?: GitMount[]; + files?: Record; + is_default?: boolean; } export interface UpdateIncludesRequest { - includes: string[]; + includes: string[]; } export const listConfigProfiles = async ( - projectId?: string, - toolTypeId?: string + projectId?: string, + toolTypeId?: string, ): Promise => { - const response = await apiClient.get("/config-profiles", { - params: { project_id: projectId, tool_type_id: toolTypeId }, - }); - return response.data; + const response = await apiClient.get("/config-profiles", { + params: { project_id: projectId, tool_type_id: toolTypeId }, + }); + return response.data; }; export const getConfigProfile = async (id: string): Promise => { - const response = await apiClient.get(`/config-profiles/${id}`); - return response.data; + const response = await apiClient.get(`/config-profiles/${id}`); + return response.data; }; export const createConfigProfile = async ( - data: CreateConfigProfileRequest + data: CreateConfigProfileRequest, ): Promise => { - const response = await apiClient.post("/config-profiles", data); - return response.data; + const response = await apiClient.post( + "/config-profiles", + data, + ); + return response.data; }; export const updateConfigProfile = async ( - id: string, - data: UpdateConfigProfileRequest + id: string, + data: UpdateConfigProfileRequest, ): Promise => { - const response = await apiClient.put(`/config-profiles/${id}`, data); - return response.data; + const response = await apiClient.put( + `/config-profiles/${id}`, + data, + ); + return response.data; }; export const deleteConfigProfile = async (id: string): Promise => { - await apiClient.delete(`/config-profiles/${id}`); + await apiClient.delete(`/config-profiles/${id}`); }; export const updateProfileIncludes = async ( - id: string, - data: UpdateIncludesRequest + id: string, + data: UpdateIncludesRequest, ): Promise => { - const response = await apiClient.put( - `/config-profiles/${id}/includes`, - data - ); - return response.data; + const response = await apiClient.put( + `/config-profiles/${id}/includes`, + data, + ); + return response.data; }; export const previewConfigProfile = async ( - id: string + id: string, ): Promise => { - const response = await apiClient.get( - `/config-profiles/${id}/preview` - ); - return response.data; + const response = await apiClient.get( + `/config-profiles/${id}/preview`, + ); + return response.data; }; export const resolveDefaultProfile = async ( - projectId: string, - toolTypeId: string + projectId: string, + toolTypeId: string, ): Promise<{ profile_id: string | null; profile_name: string | null }> => { - const response = await apiClient.get("/config-profiles/defaults/resolve", { - params: { project_id: projectId, tool_type_id: toolTypeId }, - }); - return response.data; + const response = await apiClient.get("/config-profiles/defaults/resolve", { + params: { project_id: projectId, tool_type_id: toolTypeId }, + }); + return response.data; }; diff --git a/apps/web/src/components/git-mount-editor.tsx b/apps/web/src/components/git-mount-editor.tsx index 0408b71..573b5db 100644 --- a/apps/web/src/components/git-mount-editor.tsx +++ b/apps/web/src/components/git-mount-editor.tsx @@ -3,309 +3,421 @@ import { Icon } from "./icon"; import type { GitMount, GitMountMapping } from "../api/config_profiles"; interface GitMountEditorProps { - mounts: GitMount[]; - onChange: (mounts: GitMount[]) => void; + mounts: GitMount[]; + onChange: (mounts: GitMount[]) => void; } function normalizeMount(mount: GitMount): GitMount { - // Auto-convert legacy source_path + target_path to mappings - if ((!mount.mappings || mount.mappings.length === 0) && mount.source_path !== undefined && mount.target_path !== undefined) { - return { - remote_url: mount.remote_url, - branch: mount.branch, - mappings: [{ source_path: mount.source_path || ".", target_path: mount.target_path }], - }; - } - return mount; + // Auto-convert legacy source_path + target_path to mappings + if ( + (!mount.mappings || mount.mappings.length === 0) && + mount.source_path !== undefined && + mount.target_path !== undefined + ) { + return { + remote_url: mount.remote_url, + branch: mount.branch, + mappings: [ + { + source_path: mount.source_path || ".", + target_path: mount.target_path, + }, + ], + }; + } + return mount; } function normalizeMounts(mounts: GitMount[]): GitMount[] { - return mounts.map(normalizeMount); + return mounts.map(normalizeMount); } export const GitMountEditor = ({ mounts, onChange }: GitMountEditorProps) => { - const [normalizedMounts, setNormalizedMounts] = useState(() => normalizeMounts(mounts)); - const [editingIndex, setEditingIndex] = useState(null); - const [isAdding, setIsAdding] = useState(false); + const [normalizedMounts, setNormalizedMounts] = useState(() => + normalizeMounts(mounts), + ); + const [editingIndex, setEditingIndex] = useState(null); + const [isAdding, setIsAdding] = useState(false); - useEffect(() => { - setNormalizedMounts(normalizeMounts(mounts)); - }, [mounts]); + useEffect(() => { + setNormalizedMounts(normalizeMounts(mounts)); + }, [mounts]); - const handleAdd = (mount: GitMount) => { - const updated = [...normalizedMounts, normalizeMount(mount)]; - setNormalizedMounts(updated); - onChange(updated); - setIsAdding(false); - }; + const handleAdd = (mount: GitMount) => { + const updated = [...normalizedMounts, normalizeMount(mount)]; + setNormalizedMounts(updated); + onChange(updated); + setIsAdding(false); + }; - const handleUpdate = (index: number, updated: GitMount) => { - const updatedMounts = [...normalizedMounts]; - updatedMounts[index] = normalizeMount(updated); - setNormalizedMounts(updatedMounts); - onChange(updatedMounts); - setEditingIndex(null); - }; + const handleUpdate = (index: number, updated: GitMount) => { + const updatedMounts = [...normalizedMounts]; + updatedMounts[index] = normalizeMount(updated); + setNormalizedMounts(updatedMounts); + onChange(updatedMounts); + setEditingIndex(null); + }; - const handleRemove = (index: number) => { - const updated = normalizedMounts.filter((_, i) => i !== index); - setNormalizedMounts(updated); - onChange(updated); - }; + const handleRemove = (index: number) => { + const updated = normalizedMounts.filter((_, i) => i !== index); + setNormalizedMounts(updated); + onChange(updated); + }; - return ( -
-

Git Mounts

-

- Clone a repository once and mount multiple directories from it. -

+ return ( +
+

Git Mounts

+

+ Clone a repository once and mount multiple directories from it. +

- {normalizedMounts.length > 0 && ( -
- {normalizedMounts.map((mount, index) => ( -
- {editingIndex === index ? ( - handleUpdate(index, updated)} - onCancel={() => setEditingIndex(null)} - /> - ) : ( -
-
-
-
- {mount.remote_url} - {mount.branch && ( - - @{mount.branch} - - )} -
-
- {mount.mappings?.map((m, mi) => ( -
- {m.source_path || "."} → {m.target_path} -
- ))} -
-
-
- - -
-
-
- )} -
- ))} -
- )} + {normalizedMounts.length > 0 && ( +
+ {normalizedMounts.map((mount, index) => ( +
+ {editingIndex === index ? ( + handleUpdate(index, updated)} + onCancel={() => setEditingIndex(null)} + /> + ) : ( +
+
+
+
+ {mount.remote_url} + {mount.branch && ( + + @{mount.branch} + + )} +
+
+ {mount.mappings?.map((m, mi) => ( +
+ {m.source_path || "."} → {m.target_path} +
+ ))} +
+
+
+ + +
+
+
+ )} +
+ ))} +
+ )} - {isAdding ? ( -
- setIsAdding(false)} - /> -
- ) : ( - - )} -
- ); + {isAdding ? ( +
+ setIsAdding(false)} + /> +
+ ) : ( + + )} +
+ ); }; interface GitMountFormProps { - mount: GitMount; - onSave: (mount: GitMount) => void; - onCancel: () => void; + mount: GitMount; + onSave: (mount: GitMount) => void; + onCancel: () => void; } const GitMountForm = ({ mount, onSave, onCancel }: GitMountFormProps) => { - const [remoteUrl, setRemoteUrl] = useState(mount.remote_url); - const [branch, setBranch] = useState(mount.branch || ""); - const [mappings, setMappings] = useState( - mount.mappings?.length ? mount.mappings : [{ source_path: ".", target_path: "" }] - ); - const [errors, setErrors] = useState>({}); + const [remoteUrl, setRemoteUrl] = useState(mount.remote_url); + const [branch, setBranch] = useState(mount.branch || ""); + const [mappings, setMappings] = useState( + mount.mappings?.length + ? mount.mappings + : [{ source_path: ".", target_path: "" }], + ); + const [errors, setErrors] = useState>({}); - const validate = (): boolean => { - const newErrors: Record = {}; + const validate = (): boolean => { + const newErrors: Record = {}; - if (!remoteUrl.trim()) { - newErrors.remote_url = "Git URL is required"; - } else if ( - !remoteUrl.startsWith("http://") && - !remoteUrl.startsWith("https://") && - !remoteUrl.startsWith("git@") && - !remoteUrl.startsWith("ssh://") - ) { - newErrors.remote_url = "Must be a valid git URL (https://, git@, or ssh://)"; - } + if (!remoteUrl.trim()) { + newErrors.remote_url = "Git URL is required"; + } else if ( + !remoteUrl.startsWith("http://") && + !remoteUrl.startsWith("https://") && + !remoteUrl.startsWith("git@") && + !remoteUrl.startsWith("ssh://") + ) { + newErrors.remote_url = + "Must be a valid git URL (https://, git@, or ssh://)"; + } - mappings.forEach((m, i) => { - if (!m.target_path.trim()) { - newErrors[`mapping_${i}_target`] = "Target path is required"; - } - if (m.source_path.includes("..")) { - newErrors[`mapping_${i}_source`] = "Source path cannot contain .."; - } - if (m.target_path.includes("..")) { - newErrors[`mapping_${i}_target`] = "Target path cannot contain .."; - } - }); + mappings.forEach((m, i) => { + if (!m.target_path.trim()) { + newErrors[`mapping_${i}_target`] = "Target path is required"; + } + if (m.source_path.includes("..")) { + newErrors[`mapping_${i}_source`] = "Source path cannot contain .."; + } + if (m.target_path.includes("..")) { + newErrors[`mapping_${i}_target`] = "Target path cannot contain .."; + } + }); - setErrors(newErrors); - return Object.keys(newErrors).length === 0; - }; + setErrors(newErrors); + return Object.keys(newErrors).length === 0; + }; - const handleSubmit = () => { - if (!validate()) return; - onSave({ - remote_url: remoteUrl.trim(), - branch: branch.trim() || undefined, - mappings: mappings.map((m) => ({ - source_path: m.source_path.trim() || ".", - target_path: m.target_path.trim(), - })), - }); - }; + const handleSubmit = () => { + if (!validate()) return; + onSave({ + remote_url: remoteUrl.trim(), + branch: branch.trim() || undefined, + mappings: mappings.map((m) => ({ + source_path: m.source_path.trim() || ".", + target_path: m.target_path.trim(), + })), + }); + }; - const addMapping = () => { - setMappings((prev) => [...prev, { source_path: ".", target_path: "" }]); - }; + const addMapping = () => { + setMappings((prev) => [...prev, { source_path: ".", target_path: "" }]); + }; - const updateMapping = (index: number, field: keyof GitMountMapping, value: string) => { - setMappings((prev) => { - const next = [...prev]; - next[index] = { ...next[index], [field]: value }; - return next; - }); - if (errors[`mapping_${index}_${field}`]) { - setErrors((prev) => { - const next = { ...prev }; - delete next[`mapping_${index}_${field}`]; - return next; - }); - } - }; + const updateMapping = ( + index: number, + field: keyof GitMountMapping, + value: string, + ) => { + setMappings((prev) => { + const next = [...prev]; + next[index] = { ...next[index], [field]: value }; + return next; + }); + if (errors[`mapping_${index}_${field}`]) { + setErrors((prev) => { + const next = { ...prev }; + delete next[`mapping_${index}_${field}`]; + return next; + }); + } + }; - const removeMapping = (index: number) => { - setMappings((prev) => prev.filter((_, i) => i !== index)); - }; + const removeMapping = (index: number) => { + setMappings((prev) => prev.filter((_, i) => i !== index)); + }; - return ( -
-
-
- - { - setRemoteUrl(e.target.value); - if (errors.remote_url) { - setErrors((prev) => { - const next = { ...prev }; - delete next.remote_url; - return next; - }); - } - }} - placeholder="https://github.com/user/repo.git" - className={`form-input ${errors.remote_url ? "error" : ""}`} - /> - {errors.remote_url && {errors.remote_url}} -
-
- - setBranch(e.target.value)} - placeholder="main" - className="form-input" - /> -
-
+ return ( +
+
+
+ + { + setRemoteUrl(e.target.value); + if (errors.remote_url) { + setErrors((prev) => { + const next = { ...prev }; + delete next.remote_url; + return next; + }); + } + }} + placeholder="https://github.com/user/repo.git" + className={`form-input ${errors.remote_url ? "error" : ""}`} + /> + {errors.remote_url && ( + {errors.remote_url} + )} +
+
+ + setBranch(e.target.value)} + placeholder="main" + className="form-input" + /> +
+
-
- -

- Source paths within the repo and where to mount them in the container. -

-
- {mappings.map((mapping, index) => ( -
- updateMapping(index, "source_path", e.target.value)} - placeholder="packages/api" - className={`form-input ${errors[`mapping_${index}_source`] ? "error" : ""}`} - style={{ flex: 1 }} - /> - - updateMapping(index, "target_path", e.target.value)} - placeholder="/app/api" - className={`form-input ${errors[`mapping_${index}_target`] ? "error" : ""}`} - style={{ flex: 1 }} - /> - {mappings.length > 1 && ( - - )} - {errors[`mapping_${index}_source`] && ( - {errors[`mapping_${index}_source`]} - )} - {errors[`mapping_${index}_target`] && ( - {errors[`mapping_${index}_target`]} - )} -
- ))} -
- -
+
+ +

+ Source paths within the repo and where to mount them in the container. +

+
+ {mappings.map((mapping, index) => ( +
+ + updateMapping(index, "source_path", e.target.value) + } + placeholder="packages/api" + className={`form-input ${errors[`mapping_${index}_source`] ? "error" : ""}`} + style={{ flex: 1 }} + /> + + → + + + updateMapping(index, "target_path", e.target.value) + } + placeholder="/app/api" + className={`form-input ${errors[`mapping_${index}_target`] ? "error" : ""}`} + style={{ flex: 1 }} + /> + {mappings.length > 1 && ( + + )} + {errors[`mapping_${index}_source`] && ( + + {errors[`mapping_${index}_source`]} + + )} + {errors[`mapping_${index}_target`] && ( + + {errors[`mapping_${index}_target`]} + + )} +
+ ))} +
+ +
-
- - -
-
- ); +
+ + +
+
+ ); }; diff --git a/openspec/changes/home-path-expansion/.openspec.yaml b/openspec/changes/home-path-expansion/.openspec.yaml new file mode 100644 index 0000000..45c6469 --- /dev/null +++ b/openspec/changes/home-path-expansion/.openspec.yaml @@ -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 diff --git a/openspec/designs/home-path-expansion.md b/openspec/designs/home-path-expansion.md new file mode 100644 index 0000000..1bbc118 --- /dev/null +++ b/openspec/designs/home-path-expansion.md @@ -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 | diff --git a/openspec/explorations/home-path-expansion.md b/openspec/explorations/home-path-expansion.md new file mode 100644 index 0000000..93eb323 --- /dev/null +++ b/openspec/explorations/home-path-expansion.md @@ -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 | diff --git a/openspec/proposals/home-path-expansion.md b/openspec/proposals/home-path-expansion.md new file mode 100644 index 0000000..29ecebe --- /dev/null +++ b/openspec/proposals/home-path-expansion.md @@ -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` diff --git a/openspec/specs/home-path-expansion.md b/openspec/specs/home-path-expansion.md new file mode 100644 index 0000000..69e4262 --- /dev/null +++ b/openspec/specs/home-path-expansion.md @@ -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 diff --git a/openspec/tasks/home-path-expansion.md b/openspec/tasks/home-path-expansion.md new file mode 100644 index 0000000..00c64e3 --- /dev/null +++ b/openspec/tasks/home-path-expansion.md @@ -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** |