Files
headquarter/openspec/changes/fix-pi-container-mount-permissions/change.md
T
alex 16984b7cf6 fix(containers): compose profile and Git mounts safely
Stage profile sources per instance and compose overlapping bind mounts so Docker cannot mask Git content or leave writable files root-owned.\n\n- preserve shared Git clones while applying profile overlays\n- add mount composition and ownership regression coverage\n- update OpenSpec tracking
2026-07-21 20:49:03 +02:00

69 lines
6.1 KiB
Markdown

# Fix pi container repo mount and npm update permissions
## Problem
After implementing configurable tool container home directories, new `pi-agent` containers still bind-mount the git repository at `/workspace` instead of under `/home/user/{repo_name}`. In addition, users cannot run `npm update -g @earendil-works/pi-coding-agent` inside the container because the global npm prefix (`/usr/lib/node_modules`) is owned by root.
## Root cause
1. The built-in `pi-agent` manifest in `tool_definition_manifests` still declares an explicit repo mount with `"target": "/workspace"` and `"working_dir": "/workspace"`. This masks the generated `/workspace → /home/user/{repo}` compatibility symlink.
2. `manifest_compiler.py` does not substitute the instance-specific `{{WORKSPACE_NAME}}` placeholder in explicit mount targets, and `instance_service.py` does not pass `WORKSPACE_NAME`/`REPO_NAME` to `compile_compose` for manifest-based tools.
3. The generated entrypoint hardcodes the literal string `{{WORKSPACE_NAME}}` as the symlink target.
4. `npm_global` packages are installed with `RUN npm install -g ...` as root into the system npm prefix, so the non-root container user cannot update them.
5. Once the repo mount moves out of `/workspace`, the generated `/workspace` compatibility symlink is created in the image as root. The non-root entrypoint cannot replace it (write permission is required on `/`), so container startup fails.
6. Older images baked a literal `{{WORKSPACE_NAME}}` directory into `/home/user`, which survives alongside the real repo-named mount directory.
7. Config-profile file mounts use canonical profile storage owned by the API process. A non-root container user therefore cannot write to writable bind mounts. When a directory-level profile mount and a Git mount share or nest under the same target, Docker bind mounting masks the earlier source rather than merging their files.
## Fix
1. Remove the compose-level `user: 0:0` override from `manifest_compiler.py`. The
Dockerfile intentionally omits `USER` so the entrypoint can start as root,
fix mount ownership, and drop privileges to the container user internally.
Pinning `user: 0:0` in the compose service forces `docker exec` sessions to
run as root even after the entrypoint drops privileges.
2. Pass the manifest-declared container user into terminal sessions so
`docker exec` is invoked with `--user <user>`. This makes WebSocket terminal
sessions run as the same non-root user as the main container process.
3. Add an Alembic data migration that updates the built-in `pi-agent` manifest:
- Change the repo mount target to `~/{{WORKSPACE_NAME}}`.
- Keep `runtime.working_dir` as `/workspace` (the compatibility symlink).
- Update the startup script to chown the real mount path (`$HOME/$WORKSPACE_NAME`).
4. Update `manifest_compiler.py`:
- Substitute `{{WORKSPACE_NAME}}` in mount targets in `compile_compose`.
- Pass `WORKSPACE_NAME` as a container environment variable.
- Generate the entrypoint symlink from the runtime `WORKSPACE_NAME` environment variable.
- Install `npm_global` packages into a user-writable prefix (`{home_dir}/.npm-global`) and add it to `PATH`.
- Use `sudo` or root to create the `/workspace` compatibility symlink, because `/` is owned by root and the non-root entrypoint cannot replace a root-owned symlink.
- Start the container as root and drop privileges to the container user inside the entrypoint via `su`.
- Do not create mount target directories or the `/workspace` symlink in the image when they depend on the runtime `{{WORKSPACE_NAME}}` placeholder.
- Remove any stale literal `{{WORKSPACE_NAME}}` directory left over from older images at container startup.
5. Update `instance_service.py` to pass `REPO_NAME` and `WORKSPACE_NAME` into manifest compilation.
6. Remove the explicit repo mount from the built-in `pi-agent` manifest so the repo mount is synthesized by `compile_compose` rather than depending on tool config. Add a follow-up Alembic data migration that strips the `source_type: repo` mount from the manifest.
7. Add `_get_repository_mount_name()` helper. When the instance is bound to a workspace, the helper returns the basename of `workspace.path`. For legacy repo-only instances it falls back to parsing the remote URL like `git clone` would, then to the user-provided repository name.
8. Switch workspace storage layout to `/data/working-copies/{workspace_id}/{repo_name}/` so `git clone` creates the repo-named directory naturally, making `workspace.path.basename` the correct container mount name. This replaces the previous `/data/working-copies/{repo_id}/{workspace_name}/` layout.
9. Stage every config-profile bind-mount source into the instance directory before compose generation. This makes writable mounts user-owned without changing the shared canonical profile source.
10. Replace overlapping profile and Git bind mounts with a per-instance composite source. The composite copies Git content first and profile content second, preserving Git siblings while allowing profile files to override matching paths; it is then chowned with the other staged mounts. This removes duplicate/nested Docker mounts rather than relying on mount order to merge them.
11. Update unit tests for the new behavior.
## Affected files
- `apps/api/alembic/versions/2026_06_14_182955_fix_pi_agent_home_directory_mount.py`
- `apps/api/alembic/versions/2026_06_15_090500_remove_pi_agent_explicit_repo_mount.py`
- `apps/api/src/services/build/manifest_compiler.py`
- `apps/api/src/services/terminal/terminal_session.py`
- `apps/api/src/services/terminal/terminal_manager.py`
- `apps/api/src/api/system/terminal.py`
- `apps/api/src/services/shared/workspace_manager.py`
- `apps/api/src/services/tool/instance_service.py`
- `apps/api/tests/unit/test_manifest_compiler.py`
- `apps/api/tests/unit/test_terminal_session.py`
- `apps/api/tests/unit/test_instance_service.py`
- `apps/api/tests/unit/test_alembic_migrations.py`
## Verification
- `pytest apps/api/tests/unit/test_manifest_compiler.py`
- `pytest apps/api/tests/unit/test_terminal_session.py`
- `pytest apps/api/tests/unit/test_alembic_migrations.py`
- `ruff`, `mypy`, `npm run typecheck`, `npm run lint`