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:
@@ -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}")
|
||||
|
||||
|
||||
Reference in New Issue
Block a user