docs: tool container home directory design, plan, and test plan

- Add design doc / ADR for configurable /home/user home directory
   - Add implementation plan with phased rollout
   - Add test plan / QA checklist
   - Update OpenSpec task for home-path-expansion
This commit is contained in:
Developer
2026-06-14 10:23:59 +00:00
parent e6114ed18c
commit 5fc8e035e6
4 changed files with 540 additions and 51 deletions
@@ -0,0 +1,171 @@
# Implementation Plan: Tool Container Home Directory
## Overview
Make `/home/user` the configurable, default workspace/home directory for all tool containers, preserve the repository/workspace directory name in the mount target, migrate legacy tool types, and keep `/workspace` as a compatibility symlink.
## Goals
- Add a `home_directory` field to `ToolType` and the manifest schema.
- Use that field to control workspace mount target, `HOME`, `WORKDIR`, and `~`/`$HOME` expansion.
- Preserve `{repo_name}` / `{workspace_name}` in the mount target.
- Migrate legacy Dockerfile/Compose templates via Alembic.
- Keep `/workspace` symlink for backward compatibility.
- Add an entrypoint permission fixer for runtime-mounted paths.
- Validate with unit and integration tests.
## Phases
### Phase 1: Database and model changes
**Files:**
- `apps/api/src/models/tool/tool_type.py`
- `apps/api/alembic/versions/<new>_add_tool_type_home_directory.py`
**Tasks:**
1. Add `home_directory: Mapped[str]` column to `ToolType`, non-nullable with server default `"/home/user"`.
2. Create Alembic migration that:
- Adds the column.
- Updates existing rows to `/home/user`.
- Rewrites `compose_template` and `dockerfile_template` to replace `/workspace` with `/home/user/{{WORKSPACE_NAME}}` (or a compatible template variable).
3. Provide downgrade that reverses template rewrites and drops the column.
**Quality gate:**
- `cd apps/api && alembic upgrade head` succeeds.
- `alembic downgrade -1` succeeds and restores `/workspace` strings.
- Existing tests still pass.
### Phase 2: Manifest schema and compiler
**Files:**
- `apps/api/src/services/build/manifest_compiler.py`
- `apps/api/src/services/tool/instance_service.py`
- `apps/api/src/api/tool/tool_types_validation.py` (if schema validation is added)
**Tasks:**
1. Accept `home_directory` in the manifest schema (optional; fallback to ToolType value).
2. In `compile_dockerfile()`:
- Set `ENV HOME={home_directory}` and `ENV USER={user.name}`.
- Set `WORKDIR {home_directory}` (unless `runtime.working_dir` is present).
- Create the home directory and pre-create `.config`, `.local/share`, `.cache` under it.
- Add a step to create `/workspace` as a symlink to `{home_directory}/{repo_name}` (placeholder or startup-time).
3. In `compile_compose()`:
- Use `{home_directory}/{repo_name}` as the default repo mount target when the manifest has no explicit repo mount.
- Keep `~`/`$HOME` expansion base equal to `home_directory`.
4. Update `get_manifest_home_dir()` to honor `manifest.home_directory` before deriving from `user.name`.
5. Pass `home_directory` through the manifest-based instance lifecycle.
**Quality gate:**
- `compile_dockerfile()` output contains `ENV HOME=/home/user` and `WORKDIR /home/user` for default manifests.
- `compile_compose()` output mounts repo at `/home/user/{repo_name}` when no explicit repo mount exists.
### Phase 3: Legacy instance generation
**Files:**
- `apps/api/src/services/tool/instance_service.py`
- `apps/api/src/services/docker/compose.py`
**Tasks:**
1. In `create_tool_instance()` for `definition_type == "dockerfile"`:
- Read `tool_type.home_directory` (default `/home/user`).
- Mount `{repo_path}:{home_directory}/{repo_name}` instead of `{repo_path}:/workspace`.
- Generate or adjust Dockerfile/Compose to create `/workspace` symlink.
2. For `definition_type == "compose"`:
- Render `{home_directory}` and `{WORKSPACE_NAME}` into the template.
- Validate that `{WORKSPACE_NAME}` is available as a template variable.
3. Add `WORKSPACE_NAME` to the render variables in `render_compose_template()`.
**Quality gate:**
- Legacy `dockerfile` instance compose mounts repo at `/home/user/{repo_name}`.
- Legacy `compose` template with `/home/user/{{WORKSPACE_NAME}}` renders correctly.
### Phase 4: Config-profile and git mount expansion
**Files:**
- `apps/api/src/services/config/config_profile_resolver.py`
- `apps/api/src/services/tool/instance_service.py`
**Tasks:**
1. Ensure `expand_container_path()` uses the resolved `home_directory` (already present; verify it is threaded through).
2. In `start_tool_instance()`, compute `home_dir` from ToolType/manifest and pass it to:
- `apply_resolved_profile()`
- `resolve_git_mounts()` / `resolve_git_mount_mappings()`
3. Confirm workspace/repo name is used as the mount target, not a generic `workspace` string.
**Quality gate:**
- Config profile mount target `~/config` expands to `/home/user/config`.
- Git mount target `~/repo` expands to `/home/user/repo`.
### Phase 5: Entrypoint permission fixer
**Files:**
- `apps/api/src/services/build/manifest_compiler.py`
- `apps/api/src/services/shared/permission_fixer.py`
- `tool-images/base.dockerfile` or generated entrypoint
**Tasks:**
1. Generate an entrypoint script that, before switching to the runtime user:
- Detects the container user name/uid.
- Runs `chown` on `{home_directory}` and key mount points.
- Creates `/workspace` symlink if it does not yet exist.
- Avoids recursive chown of large subtrees; target top-level dirs and runtime-created files.
2. Ensure manifest-generated Dockerfiles install `sudo` and configure passwordless sudo for the runtime user (already partially done).
3. Consider updating `tool-images/base.dockerfile` to include `sudo` and an entrypoint hook, or keep the fixer entirely in generated images.
**Quality gate:**
- Container starts successfully.
- Container user can write to `{home_directory}` and mounted config/git directories.
- `/workspace` symlink resolves to the repo/workspace directory.
### Phase 6: Tests
**Files:**
- `apps/api/tests/unit/test_home_path_expansion.py`
- `apps/api/tests/unit/test_manifest_compiler.py`
- `apps/api/tests/unit/test_instance_service.py` (new or expanded)
- `apps/api/tests/integration/test_tool_instance_lifecycle.py` (new or expanded)
**Tasks:**
1. Unit tests:
- `expand_container_path` with `~`, `$HOME`, absolute, and relative paths.
- `get_manifest_home_dir` with and without `home_directory`, with and without `user.name`.
- `compile_dockerfile` includes correct `ENV HOME`, `WORKDIR`, and `/workspace` symlink step.
- `compile_compose` defaults repo mount to `/home/user/{repo_name}` when no explicit repo mount exists.
- Explicit manifest repo mount target is preserved.
2. Integration tests:
- Create a manifest tool instance and verify the running container has `HOME=/home/user`, repo at `/home/user/{repo_name}`, and `/workspace` symlink.
- Verify a config-profile mount under `~` is writable by the container user.
- Verify git mount under `~` is writable.
- Verify legacy dockerfile tool uses `/home/user/{repo_name}` after migration.
**Quality gate:**
- `cd apps/api && pytest tests/unit/... tests/integration/... -xvs` passes.
- `cd apps/web && npm run typecheck` passes.
## Dependencies
- The existing `home-path-expansion` helpers (`expand_container_path`) must already be in place.
- Alembic migration infrastructure must be working.
- Manifest compiler must support generated entrypoints (already partially implemented).
## Risks and Mitigations
| Risk | Mitigation |
|------|-----------|
| Alembic migration rewrites commands/env vars that contain `/workspace` | Scope the replacement to volume mount lines only; add tests for edge cases. |
| `/workspace` symlink target missing at build time | Create placeholder directory in Dockerfile; finalize symlink in entrypoint at startup. |
| Entrypoint chown slow on large repos | Chown top-level directories only; rely on runtime user for new files. |
| Base image `USER user` cannot run sudo | Either install `sudo` in base image or keep entrypoint running as root before `su`/`gosu` to runtime user. |
| Legacy templates without `{{WORKSPACE_NAME}}` break | Migration injects the variable; validation rejects missing required variables. |
## OpenSpec Task Update
After this plan is accepted, update or replace `openspec/tasks/home-path-expansion.md` to reflect the expanded scope (configurable `home_directory`, repo-name mount target, migration, compatibility symlink, permission fixer, tests).
## Rollout
1. Merge Phase 1 (schema + migration) first so the column exists.
2. Merge Phase 2 and 3 (compiler + legacy generation) next.
3. Merge Phase 4 and 5 (profile/git mounts + permission fixer).
4. Merge Phase 6 (tests) with the previous phases as appropriate.
5. Run migration in production during a maintenance window; validate a few existing tool types before full rollout.
@@ -0,0 +1,114 @@
# Test Plan / QA Checklist: Tool Container Home Directory
## Scope
This plan covers validation of the configurable `home_directory` feature for tool containers, including:
- `home_directory` field on `ToolType` and manifest schema.
- Repository/workspace mount target using `{home_directory}/{repo_name}` or `{home_directory}/{workspace_name}`.
- Manifest compiler behavior (`HOME`, `WORKDIR`, `/workspace` symlink).
- Legacy tool type migration via Alembic.
- Config-profile and git-mount `~`/`$HOME` expansion.
- Entrypoint permission fixer.
## Unit Tests
### `apps/api/tests/unit/test_home_path_expansion.py`
| ID | Test | Expected Result |
|----|------|-----------------|
| U1 | `expand_container_path("~/config", "/home/user")` | returns `/home/user/config` |
| U2 | `expand_container_path("~", "/home/user")` | returns `/home/user` |
| U3 | `expand_container_path("$HOME/config", "/home/user")` | returns `/home/user/config` |
| U4 | `expand_container_path("$HOME", "/home/user")` | returns `/home/user` |
| U5 | `expand_container_path("/opt/data", "/home/user")` | returns `/opt/data` unchanged |
| U6 | `expand_container_path("relative/path", "/home/user")` | returns `relative/path` unchanged |
| U7 | `expand_container_path("path/$HOME/other", "/home/user")` | returns `path/$HOME/other` unchanged (mid-string not expanded) |
### `apps/api/tests/unit/test_manifest_compiler.py`
| ID | Test | Expected Result |
|----|------|-----------------|
| U10 | `get_manifest_home_dir({"home_directory": "/home/dev"})` | returns `/home/dev` |
| U11 | `get_manifest_home_dir({"user": {"name": "user"}})` | returns `/home/user` |
| U12 | `get_manifest_home_dir({"user": {"name": "dev"}})` | returns `/home/dev` |
| U13 | `get_manifest_home_dir({})` | returns `/root` |
| U14 | `compile_dockerfile()` for manifest with `home_directory: /home/user` and `user.name: user` | Dockerfile contains `ENV HOME=/home/user`, `ENV USER=user`, `WORKDIR /home/user`, and creates `/workspace` symlink |
| U15 | `compile_dockerfile()` for manifest without user block | Dockerfile does not create user and defaults `HOME=/root` |
| U16 | `compile_compose()` with no explicit repo mount and `home_directory: /home/user` | Compose mounts `{repo_path}:/home/user/{repo_name}` |
| U17 | `compile_compose()` with explicit repo mount target `/custom/path` | Compose preserves `/custom/path`; `/home/user` default is not injected |
### `apps/api/tests/unit/test_instance_service.py` (new or expanded)
| ID | Test | Expected Result |
|----|------|-----------------|
| U20 | `create_tool_instance()` for `definition_type == "dockerfile"` | Compose mounts `{repo_path}:/home/user/{repo_name}` |
| U21 | `create_tool_instance()` for `definition_type == "compose"` with migrated template | Template renders `/home/user/{{WORKSPACE_NAME}}` correctly |
| U22 | `start_tool_instance()` passes `home_dir` to `apply_resolved_profile()` | Config-profile `~/config` expands to `/home/user/config` |
| U23 | `start_tool_instance()` passes `home_dir` to `resolve_git_mounts()` | Git mount `~/repo` expands to `/home/user/repo` |
### `apps/api/tests/unit/test_alembic_migrations.py` (new or expanded)
| ID | Test | Expected Result |
|----|------|-----------------|
| U30 | Upgrade adds `home_directory` column with default `/home/user` | Column exists and legacy rows have value `/home/user` |
| U31 | Upgrade rewrites `compose_template` containing `/workspace` | Template now uses `/home/user/{{WORKSPACE_NAME}}` |
| U32 | Downgrade restores `/workspace` strings | Original templates are restored |
| U33 | Downgrade drops `home_directory` column | Column no longer exists |
## Integration Tests
### `apps/api/tests/integration/test_tool_instance_lifecycle.py` (new or expanded)
| ID | Test | Steps | Expected Result |
|----|------|-------|-----------------|
| I1 | Manifest tool starts with `/home/user` home | Create manifest tool type with default `home_directory`; create and start instance | Container has `HOME=/home/user`, repo at `/home/user/{repo_name}`, `/workspace` symlink resolves correctly |
| I2 | Manifest tool with overridden `home_directory` | Set `home_directory: /home/dev` in manifest; create and start instance | Container has `HOME=/home/dev`, repo at `/home/dev/{repo_name}` |
| I3 | Container user can write under `/home/user` | Start instance; exec `touch /home/user/test-file` as container user | Command succeeds and file is owned by container user |
| I4 | Config-profile mount under `~` is writable | Create profile with mount `{"target": "~/config", "files": {"settings.json": "{}"}}`; start instance with profile | File appears at `/home/user/config/settings.json` and is writable by container user |
| I5 | Git mount under `~` is writable | Create profile with git mount mapping `{"target_path": "~/dotfiles"}`; start instance | Repo files appear at `/home/user/dotfiles` and are writable by container user |
| I6 | Legacy dockerfile tool uses new mount target after migration | Run Alembic migration; create legacy dockerfile tool instance | Repo mounts at `/home/user/{repo_name}` and `/workspace` symlink works |
| I7 | Explicit manifest repo mount target is preserved | Create manifest with explicit repo mount target `/opt/repo`; start instance | Repo mounts at `/opt/repo`; no `/home/user/{repo_name}` mount injected |
## Manual QA Checklist
- [ ] Create a new manifest tool type without specifying `home_directory`; verify default `/home/user` is used.
- [ ] Create a manifest tool type with `home_directory: /home/custom`; verify container reflects the override.
- [ ] Verify repository root directory name appears in the mount target (e.g., `/home/user/my-app`).
- [ ] Verify workspace-based instance uses workspace name in the mount target.
- [ ] Verify `/workspace` symlink points to the actual repo/workspace directory and `cd /workspace && pwd` works.
- [ ] Verify legacy dockerfile tool, after migration, mounts repo under `/home/user/{repo_name}`.
- [ ] Verify config-profile file mount under `~/config` lands at `/home/user/config`.
- [ ] Verify git mount under `~/dotfiles` lands at `/home/user/dotfiles`.
- [ ] Verify container user can create files under `/home/user` and mounted config directories.
- [ ] Verify `alembic downgrade -1` restores `/workspace` strings and removes the column.
## Regression Coverage
- Existing manifest tools without `home_directory` continue to work (default `/home/user`).
- Existing config profiles without `~`/`$HOME` continue to work.
- Existing legacy tools that did not use `/workspace` are unaffected by the migration rewrite.
- Existing `home-path-expansion` tests still pass.
## Test Commands
```bash
cd apps/api
pytest tests/unit/test_home_path_expansion.py tests/unit/test_manifest_compiler.py tests/unit/test_instance_service.py tests/unit/test_alembic_migrations.py -xvs
pytest tests/integration/test_tool_instance_lifecycle.py -xvs
# Alembic round-trip
alembic upgrade head
alembic downgrade -1
alembic upgrade head
cd apps/web
npm run typecheck
```
## Exit Criteria
- All listed unit and integration tests pass.
- Manual QA checklist is completed or equivalent automated coverage is added.
- Alembic migration round-trip passes without errors.
- No regressions in existing `home-path-expansion` or tool instance tests.