fix: run tool terminal sessions as container user instead of root

- Remove compose-level user: 0:0 override from manifest_compiler.py so the
  entrypoint can start as root, fix mount ownership, and drop privileges to
  the container user internally.
- Add get_manifest_container_user() helper to resolve the manifest-declared
  container user (with uid:gid fallback).
- Pass container user through TerminalSession, TerminalManager, and the
  terminal WebSocket handler so docker exec is invoked with --user <user>.
- Update and add unit tests for the manifest compiler and terminal session.
- Record the additional root-user fix in the fix-pi-container-mount-permissions
  OpenSpec change/tasks.

Quality gates: pytest tests/unit/ (226 passed), pytest tests/services/test_terminal_manager_multi.py (7 passed), ruff check on changed files (clean), mypy on changed files (clean)
This commit is contained in:
Developer
2026-06-17 20:51:46 +00:00
parent 3e59a257dc
commit 9f720930ea
28 changed files with 358 additions and 131 deletions
@@ -408,7 +408,6 @@ def compile_compose(manifest: dict, variables: dict[str, Any]) -> str:
Docker Compose YAML content.
"""
runtime = manifest.get("runtime", {})
user = manifest.get("user")
interface_type = manifest["interface_type"]
home_dir = get_manifest_home_dir(manifest)
@@ -436,12 +435,11 @@ def compile_compose(manifest: dict, variables: dict[str, Any]) -> str:
else:
service["working_dir"] = f"{home_dir}/{workspace_name}"
# The entrypoint starts as root (Dockerfile does not set USER) so it can
# fix mount ownership. It drops privileges to the container user internally
# before exec-ing the real command, so do not set compose-level user
# override here.
if user:
service["user"] = "0:0"
# The Dockerfile does not set USER so the entrypoint starts as root,
# fixes mount ownership, and drops privileges to the container user
# internally. Do not set a compose-level user override: that would pin
# the container metadata to root and make docker exec sessions run as
# root even after the entrypoint drops privileges.
# Ports for web tools
default_port = manifest.get("default_port")
@@ -568,6 +566,35 @@ def get_manifest_home_dir(manifest: dict) -> str:
return "/root"
def get_manifest_container_user(manifest: dict) -> str | None:
"""Resolve the container user identifier from a manifest.
Returns the user name when available so that docker exec sessions can
attach as the container user instead of defaulting to root. Falls back
to ``uid:gid`` when a name is absent but numeric ids are present.
Args:
manifest: Fully resolved manifest JSON.
Returns:
User name (e.g. ``user``), ``uid:gid`` string, or None when the
manifest does not declare a user.
"""
user = manifest.get("user")
if not user:
return None
name = user.get("name")
if name:
return name
uid = user.get("uid")
gid = user.get("gid")
if uid is not None and gid is not None:
return f"{uid}:{gid}"
return None
def compute_image_tag(tool_name: str, manifest: dict) -> str:
"""Compute a deterministic image tag from manifest content.