fix: pi container repo mount target and npm update permissions

- Add Alembic migration to update built-in pi-agent manifest:
  * repo mount target from /workspace to ~/{{WORKSPACE_NAME}}
  * keep /workspace as compatibility symlink via working_dir
  * update startup chown target to $HOME/$WORKSPACE_NAME
- Pass REPO_NAME and WORKSPACE_NAME to compile_compose from instance_service
- Substitute {{WORKSPACE_NAME}} in manifest mount targets and expose it as
  a container env var so the entrypoint can create the /workspace symlink
- Generate entrypoint workspace symlink from runtime WORKSPACE_NAME env var
- Install npm_global packages into {home_dir}/.npm-global with PATH so the
  non-root container user can update global packages
- Update manifest compiler unit tests for the new behavior

Quality gates:
- pytest tests/unit: 207 passed
- ruff: clean on changed files
- mypy: clean on changed files
- alembic heads: single head
This commit is contained in:
Developer
2026-06-14 18:45:32 +00:00
parent c8db6ce933
commit fe82a248ec
30 changed files with 364 additions and 120 deletions
@@ -143,13 +143,6 @@ def compile_dockerfile(manifest: dict) -> str:
lines.append(" rm -rf /var/lib/apt/lists/*")
lines.append("")
# NPM global packages
npm_packages = manifest.get("packages", {}).get("npm_global", [])
if npm_packages:
pkg_list = " ".join(shlex.quote(p) for p in npm_packages)
lines.append(f"RUN npm install -g {pkg_list}")
lines.append("")
# Pip packages
pip_packages = manifest.get("packages", {}).get("pip", [])
if pip_packages:
@@ -161,6 +154,7 @@ def compile_dockerfile(manifest: dict) -> str:
user = manifest.get("user")
home_dir = get_manifest_home_dir(manifest)
workspace_name = manifest.get("workspace_name", "{{WORKSPACE_NAME}}")
npm_prefix = ""
if user:
name = user["name"]
uid = user["uid"]
@@ -195,6 +189,20 @@ def compile_dockerfile(manifest: dict) -> str:
)
lines.append("")
# NPM global packages: install into a user-writable prefix so the
# container user can update global packages without touching
# /usr/lib/node_modules (which is owned by root).
npm_packages = manifest.get("packages", {}).get("npm_global", [])
if npm_packages:
pkg_list = " ".join(shlex.quote(p) for p in npm_packages)
npm_prefix = f"{home_dir}/.npm-global"
lines.append(
f"RUN mkdir -p {npm_prefix} && "
f"npm install -g --prefix {npm_prefix} {pkg_list}"
)
lines.append(f"ENV PATH={npm_prefix}/bin:$PATH")
lines.append("")
# Build scripts
build_scripts = manifest.get("scripts", {}).get("build", [])
for script in build_scripts:
@@ -287,8 +295,6 @@ def compile_entrypoint(manifest: dict) -> str:
user = manifest.get("user")
home_dir = get_manifest_home_dir(manifest)
workspace_name = manifest.get("workspace_name", "{{WORKSPACE_NAME}}")
workspace_target = f"{home_dir}/{workspace_name}"
# Permission fixer preamble: run as root when possible, else fall back to
# passwordless sudo configured in the Dockerfile.
@@ -315,7 +321,8 @@ def compile_entrypoint(manifest: dict) -> str:
lines.append(f"USER_UID='{uid}'")
lines.append(f"USER_GID='{gid}'")
lines.append(f"HOME_DIR='{home_dir}'")
lines.append(f"WORKSPACE_TARGET='{workspace_target}'")
lines.append('WORKSPACE_NAME="${WORKSPACE_NAME:-workspace}"')
lines.append('WORKSPACE_TARGET="${HOME_DIR}/${WORKSPACE_NAME}"')
lines.append("")
lines.append("fix_owner() {")
lines.append(" local path=\"$1\"")
@@ -343,8 +350,13 @@ def compile_entrypoint(manifest: dict) -> str:
target = mount.get("target")
if not target:
continue
# Expand any ~/$HOME placeholders in the mount target.
expanded = target.replace("~", home_dir).replace("$HOME", home_dir)
# Expand any ~/$HOME placeholders and the runtime workspace name
# in the mount target so ownership is fixed at container startup.
expanded = (
target.replace("~", home_dir)
.replace("$HOME", home_dir)
.replace("{{WORKSPACE_NAME}}", "${WORKSPACE_NAME}")
)
if expanded.startswith(home_dir) and not mount.get("readonly", False):
lines.append(f'fix_owner "{expanded}"')
lines.append("")
@@ -409,6 +421,12 @@ def compile_compose(manifest: dict, variables: dict[str, Any]) -> str:
if env:
service["environment"] = dict(env)
# Expose the workspace/repo name so the entrypoint can finalize the
# /workspace compatibility symlink at container startup.
if "environment" not in service:
service["environment"] = {}
service["environment"]["WORKSPACE_NAME"] = workspace_name
# Merge extra env from config
extra_env = variables.get("EXTRA_ENV", {})
if extra_env:
@@ -426,6 +444,7 @@ def compile_compose(manifest: dict, variables: dict[str, Any]) -> str:
if mount.get("source_type") == "repo":
has_explicit_repo_mount = True
target = expand_container_path(mount["target"], home_dir)
target = target.replace("{{WORKSPACE_NAME}}", workspace_name)
readonly = ":ro" if mount.get("readonly", False) else ""
volumes.append(f"{source}:{target}{readonly}")