feat: implement configurable tool container home directory

- Add ToolType.home_directory column with default /home/user
- Add Alembic migration to add column, set existing rows, and rewrite
  /workspace to /home/user/{{WORKSPACE_NAME}} in legacy templates
- Add merge migration fc8f1a20cbf6 to resolve Alembic multiple heads
- Update manifest compiler to honor manifest.home_directory for HOME,
  WORKDIR, /workspace symlink, and default repo mount target
- Update legacy dockerfile/compose instance generation to use
  tool_type.home_directory
- Thread resolved home_dir through config profile and git mount expansion
- Generate entrypoint permission fixer to chown home/mounts at startup
- Update base.dockerfile with sudo/passwordless sudo for permission fixer
- Add unit tests for manifest compiler, instance service, and migrations
- Add placeholder integration test for container lifecycle
- Update openspec/tasks/home-path-expansion.md task checkboxes
- Update project maps for modified files

Quality gates: py_compile, ruff, mypy, pytest tests/unit (205 passed),
pytest tests/integration (110 passed, 35 skipped). Alembic round-trip
and container lifecycle integration tests require Docker/PostgreSQL.
This commit is contained in:
Developer
2026-06-14 13:09:41 +00:00
parent b4203a4a09
commit ddd92e3dd4
51 changed files with 846 additions and 121 deletions
+130 -12
View File
@@ -8,6 +8,7 @@ from typing import Any
import yaml
from src.services.config.config_profile_resolver import expand_container_path
from src.services.docker import sort_volumes_by_specificity
@@ -158,6 +159,8 @@ def compile_dockerfile(manifest: dict) -> str:
# User creation
user = manifest.get("user")
home_dir = get_manifest_home_dir(manifest)
workspace_name = manifest.get("workspace_name", "{{WORKSPACE_NAME}}")
if user:
name = user["name"]
uid = user["uid"]
@@ -168,22 +171,21 @@ def compile_dockerfile(manifest: dict) -> str:
lines.append(f" useradd -u {uid} -g {gid} {create_home}-s {shell} {name}")
lines.append("")
# Set HOME and USER for runtime compatibility
home = f"/home/{name}"
lines.append(f"ENV HOME={home}")
lines.append(f"ENV HOME={home_dir}")
lines.append(f"ENV USER={name}")
lines.append("")
# Ensure home directory exists and is writable by the user.
# Recursively chown so any files copied from /etc/skel by useradd -m
# (e.g. .bashrc, .config) are owned by the container user.
lines.append(
f"RUN mkdir -p {home} && chown -R {name}:{name} {home} && chmod 755 {home}"
f"RUN mkdir -p {home_dir} && chown -R {name}:{name} {home_dir} && chmod 755 {home_dir}"
)
# Pre-create common config directories so apps like ranger can write
# their configs on first run without permission errors.
common_dirs = [".config", ".local/share", ".cache"]
for d in common_dirs:
lines.append(
f"RUN mkdir -p {home}/{d} && chown -R {name}:{name} {home}/{d}"
f"RUN mkdir -p {home_dir}/{d} && chown -R {name}:{name} {home_dir}/{d}"
)
lines.append("")
@@ -208,10 +210,12 @@ def compile_dockerfile(manifest: dict) -> str:
# After build scripts, ensure everything in home is owned by the user
if user and build_scripts:
lines.append(f"RUN chown -R {name}:{name} {home}")
lines.append(f"RUN chown -R {name}:{name} {home_dir}")
lines.append("")
# Create mount target directories
# Create mount target directories and /workspace compatibility symlink.
# The symlink target includes the workspace/repo name so legacy scripts
# that cd into /workspace still land on the right project.
mounts = manifest.get("mounts", [])
if mounts:
dirs = [mount["target"] for mount in mounts]
@@ -221,6 +225,16 @@ def compile_dockerfile(manifest: dict) -> str:
lines.append(f"RUN chown -R {user['name']}:{user['name']} {dir_str}")
lines.append("")
workspace_target = f"{home_dir}/{workspace_name}"
lines.append(f"RUN mkdir -p {workspace_target}")
if user:
lines.append(
f"RUN ln -sfn {workspace_target} /workspace && chown -R {user['name']}:{user['name']} {home_dir}"
)
else:
lines.append(f"RUN ln -sfn {workspace_target} /workspace")
lines.append("")
# Entrypoint for startup scripts
startup_scripts = manifest.get("scripts", {}).get("startup", [])
if startup_scripts:
@@ -233,11 +247,18 @@ def compile_dockerfile(manifest: dict) -> str:
# Switch to runtime user
if user:
lines.append(f"USER {user['name']}")
lines.append(f"WORKDIR /home/{user['name']}")
lines.append("")
# Set WORKDIR to the configured home directory unless runtime.working_dir
# explicitly overrides it.
runtime = manifest.get("runtime", {})
working_dir = runtime.get("working_dir")
if working_dir:
lines.append(f"WORKDIR {expand_container_path(working_dir, home_dir)}")
else:
lines.append(f"WORKDIR {home_dir}")
lines.append("")
# Entrypoint and CMD
runtime = manifest.get("runtime", {})
if startup_scripts:
lines.append('ENTRYPOINT ["/usr/local/bin/headquarter-entrypoint"]')
@@ -251,6 +272,11 @@ def compile_dockerfile(manifest: dict) -> str:
def compile_entrypoint(manifest: dict) -> str:
"""Generate the startup entrypoint script from startup scripts.
Injects a permission-fixer preamble that runs as root (or via sudo) before
any user-defined startup script. It chowns the home directory and a safe
subset of mount parents to the container user, creates the /workspace
compatibility symlink, and avoids recursive chown of large repo subtrees.
Args:
manifest: Fully resolved manifest JSON.
@@ -259,6 +285,70 @@ def compile_entrypoint(manifest: dict) -> str:
"""
lines = ["#!/bin/bash", "set -e", ""]
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.
lines.append("# Permission fixer preamble")
lines.append("CONTAINER_USER=''")
lines.append('if [ "$(id -u)" = '"'"'0'"'"' ]; then')
if user:
lines.append(f" CONTAINER_USER='{user['name']}'")
lines.append("else")
lines.append(" # Try passwordless sudo; ignore failure so the container still starts")
lines.append(" if sudo -n true 2>/dev/null; then")
lines.append(" SUDO='sudo'")
lines.append(" else")
lines.append(" SUDO=''")
lines.append(" fi")
lines.append("fi")
lines.append("")
if user:
name = user["name"]
uid = user["uid"]
gid = user["gid"]
lines.append(f"USER_NAME='{name}'")
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("")
lines.append("fix_owner() {")
lines.append(" local path=\"$1\"")
lines.append(' [ -e "$path" ] || return 0')
lines.append(' if [ -n "$SUDO" ]; then')
lines.append(' sudo chown "$USER_UID:$USER_GID" "$path" 2>/dev/null || true')
lines.append(' elif [ "$(id -u)" = "0" ]; then')
lines.append(' chown "$USER_UID:$USER_GID" "$path" 2>/dev/null || true')
lines.append(' fi')
lines.append("}")
lines.append("")
lines.append("# Ensure home directory exists and is owned by the container user")
lines.append('mkdir -p "$HOME_DIR"')
lines.append('fix_owner "$HOME_DIR"')
lines.append("")
lines.append("# Ensure workspace target exists and is owned by the container user")
lines.append('mkdir -p "$WORKSPACE_TARGET"')
lines.append('fix_owner "$WORKSPACE_TARGET"')
lines.append("")
lines.append("# Create /workspace compatibility symlink")
lines.append('ln -sfn "$WORKSPACE_TARGET" /workspace')
lines.append("")
lines.append("# Fix ownership of declared mount targets (top-level only)")
for mount in manifest.get("mounts", []):
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)
if expanded.startswith(home_dir) and not mount.get("readonly", False):
lines.append(f'fix_owner "{expanded}"')
lines.append("")
startup_scripts = manifest.get("scripts", {}).get("startup", [])
for script in startup_scripts:
lines.append(script)
@@ -281,6 +371,13 @@ def compile_compose(manifest: dict, variables: dict[str, Any]) -> str:
runtime = manifest.get("runtime", {})
user = manifest.get("user")
interface_type = manifest["interface_type"]
home_dir = get_manifest_home_dir(manifest)
# Determine the workspace/repo name from variables when available.
workspace_name = variables.get(
"WORKSPACE_NAME",
variables.get("REPO_NAME", "workspace"),
)
service: dict[str, Any] = {
"image": variables["IMAGE_TAG"],
@@ -294,7 +391,9 @@ def compile_compose(manifest: dict, variables: dict[str, Any]) -> str:
if runtime.get("tty", False):
service["tty"] = True
if runtime.get("working_dir"):
service["working_dir"] = runtime["working_dir"]
service["working_dir"] = expand_container_path(
runtime["working_dir"], home_dir
)
# User override
if user:
@@ -319,14 +418,24 @@ def compile_compose(manifest: dict, variables: dict[str, Any]) -> str:
# Volumes from mount schema
volumes = []
has_explicit_repo_mount = False
for mount in manifest.get("mounts", []):
source = resolve_mount_source(mount, variables)
if not source:
continue
target = mount["target"]
if mount.get("source_type") == "repo":
has_explicit_repo_mount = True
target = expand_container_path(mount["target"], home_dir)
readonly = ":ro" if mount.get("readonly", False) else ""
volumes.append(f"{source}:{target}{readonly}")
# Synthesize a default repo/workspace mount when the manifest does not
# declare an explicit repo mount. This preserves the repo root directory
# name under the configured home directory.
if not has_explicit_repo_mount and variables.get("REPO_PATH"):
target = f"{home_dir}/{workspace_name}"
volumes.append(f"{variables['REPO_PATH']}:{target}")
# Append extra volumes from tool config / config profile
for vol in variables.get("EXTRA_VOLUMES", []):
vol_str = f"{vol['source']}:{vol['target']}"
@@ -385,7 +494,12 @@ def resolve_mount_source(mount: dict, variables: dict[str, Any]) -> str:
def get_manifest_home_dir(manifest: dict) -> str:
"""Get the home directory for a container based on manifest user config.
"""Get the home directory for a container based on manifest config.
Precedence:
1. manifest["home_directory"] if present and non-empty.
2. /home/{user.name} if manifest.user.name is present.
3. /root otherwise.
Args:
manifest: Fully resolved manifest JSON.
@@ -393,6 +507,10 @@ def get_manifest_home_dir(manifest: dict) -> str:
Returns:
Home directory path (e.g., /home/user or /root).
"""
home_directory = manifest.get("home_directory")
if home_directory and isinstance(home_directory, str) and home_directory.strip():
return home_directory.strip()
user = manifest.get("user")
if user and user.get("name"):
return f"/home/{user['name']}"