16984b7cf6
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
6.1 KiB
6.1 KiB
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
- The built-in
pi-agentmanifest intool_definition_manifestsstill declares an explicit repo mount with"target": "/workspace"and"working_dir": "/workspace". This masks the generated/workspace → /home/user/{repo}compatibility symlink. manifest_compiler.pydoes not substitute the instance-specific{{WORKSPACE_NAME}}placeholder in explicit mount targets, andinstance_service.pydoes not passWORKSPACE_NAME/REPO_NAMEtocompile_composefor manifest-based tools.- The generated entrypoint hardcodes the literal string
{{WORKSPACE_NAME}}as the symlink target. npm_globalpackages are installed withRUN npm install -g ...as root into the system npm prefix, so the non-root container user cannot update them.- Once the repo mount moves out of
/workspace, the generated/workspacecompatibility symlink is created in the image as root. The non-root entrypoint cannot replace it (write permission is required on/), so container startup fails. - Older images baked a literal
{{WORKSPACE_NAME}}directory into/home/user, which survives alongside the real repo-named mount directory. - 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
- Remove the compose-level
user: 0:0override frommanifest_compiler.py. The Dockerfile intentionally omitsUSERso the entrypoint can start as root, fix mount ownership, and drop privileges to the container user internally. Pinninguser: 0:0in the compose service forcesdocker execsessions to run as root even after the entrypoint drops privileges. - Pass the manifest-declared container user into terminal sessions so
docker execis invoked with--user <user>. This makes WebSocket terminal sessions run as the same non-root user as the main container process. - Add an Alembic data migration that updates the built-in
pi-agentmanifest:- Change the repo mount target to
~/{{WORKSPACE_NAME}}. - Keep
runtime.working_diras/workspace(the compatibility symlink). - Update the startup script to chown the real mount path (
$HOME/$WORKSPACE_NAME).
- Change the repo mount target to
- Update
manifest_compiler.py:- Substitute
{{WORKSPACE_NAME}}in mount targets incompile_compose. - Pass
WORKSPACE_NAMEas a container environment variable. - Generate the entrypoint symlink from the runtime
WORKSPACE_NAMEenvironment variable. - Install
npm_globalpackages into a user-writable prefix ({home_dir}/.npm-global) and add it toPATH. - Use
sudoor root to create the/workspacecompatibility 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
/workspacesymlink 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.
- Substitute
- Update
instance_service.pyto passREPO_NAMEandWORKSPACE_NAMEinto manifest compilation. - Remove the explicit repo mount from the built-in
pi-agentmanifest so the repo mount is synthesized bycompile_composerather than depending on tool config. Add a follow-up Alembic data migration that strips thesource_type: repomount from the manifest. - Add
_get_repository_mount_name()helper. When the instance is bound to a workspace, the helper returns the basename ofworkspace.path. For legacy repo-only instances it falls back to parsing the remote URL likegit clonewould, then to the user-provided repository name. - Switch workspace storage layout to
/data/working-copies/{workspace_id}/{repo_name}/sogit clonecreates the repo-named directory naturally, makingworkspace.path.basenamethe correct container mount name. This replaces the previous/data/working-copies/{repo_id}/{workspace_name}/layout. - 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.
- 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.
- Update unit tests for the new behavior.
Affected files
apps/api/alembic/versions/2026_06_14_182955_fix_pi_agent_home_directory_mount.pyapps/api/alembic/versions/2026_06_15_090500_remove_pi_agent_explicit_repo_mount.pyapps/api/src/services/build/manifest_compiler.pyapps/api/src/services/terminal/terminal_session.pyapps/api/src/services/terminal/terminal_manager.pyapps/api/src/api/system/terminal.pyapps/api/src/services/shared/workspace_manager.pyapps/api/src/services/tool/instance_service.pyapps/api/tests/unit/test_manifest_compiler.pyapps/api/tests/unit/test_terminal_session.pyapps/api/tests/unit/test_instance_service.pyapps/api/tests/unit/test_alembic_migrations.py
Verification
pytest apps/api/tests/unit/test_manifest_compiler.pypytest apps/api/tests/unit/test_terminal_session.pypytest apps/api/tests/unit/test_alembic_migrations.pyruff,mypy,npm run typecheck,npm run lint