feat: expand ~ and $HOME in mount target paths
- Add expand_container_path() helper that resolves ~/ and $HOME/ prefixes
- Add get_manifest_home_dir() to compute /home/{user.name} or /root from manifest
- Set ENV HOME=... and ENV USER=... in generated Dockerfile for runtime compatibility
- Pass home_dir through instance creation and startup pipeline
- Expand mount targets in apply_resolved_profile() for regular profile mounts
- Expand mapping targets in _resolve_git_mount_mappings() for git mounts
- Expand working_directory and volume targets in _modify_compose_file()
- Update _prepare_manifest_instance to return home_dir alongside image tag
- Fetch tool_type early in start_instance to determine home_dir before profile application
Quality gates: pytest 188 passed, frontend typecheck clean
Addresses: home-path-expansion
This commit is contained in:
@@ -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
|
||||
@@ -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 |
|
||||
@@ -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`
|
||||
@@ -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
|
||||
@@ -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** |
|
||||
Reference in New Issue
Block a user