#!/usr/bin/env bash set -euo pipefail USER_NAME="developer" CURRENT_UID=$(id -u "$USER_NAME") CURRENT_GID=$(id -g "$USER_NAME") # ── UID/GID adjustment ─────────────────────────────────────────────── # Priority per dimension: env var > auto-detect from /workspace > no-op # UID and GID are detected independently so a GID-only mismatch (e.g. host # user has UID 1000 but primary group at GID 1001) is still corrected. TARGET_UID="${USER_UID:-}" TARGET_GID="${USER_GID:-}" if [ -d /workspace ]; then WORKSPACE_UID=$(stat -c '%u' /workspace 2>/dev/null || stat -f '%u' /workspace 2>/dev/null || echo "") WORKSPACE_GID=$(stat -c '%g' /workspace 2>/dev/null || stat -f '%g' /workspace 2>/dev/null || echo "") # Adopt workspace UID if env var not set and workspace is non-root-owned if [ -z "$TARGET_UID" ] && [ -n "$WORKSPACE_UID" ] && [ "$WORKSPACE_UID" != "0" ] && [ "$WORKSPACE_UID" != "$CURRENT_UID" ]; then TARGET_UID="$WORKSPACE_UID" fi # Adopt workspace GID if env var not set and workspace group differs if [ -z "$TARGET_GID" ] && [ -n "$WORKSPACE_GID" ] && [ "$WORKSPACE_GID" != "0" ] && [ "$WORKSPACE_GID" != "$CURRENT_GID" ]; then TARGET_GID="$WORKSPACE_GID" fi fi # Apply UID/GID changes if needed if [ -n "$TARGET_GID" ] && [ "$TARGET_GID" != "$CURRENT_GID" ]; then groupmod -g "$TARGET_GID" "$USER_NAME" 2>/dev/null || true find /home/"$USER_NAME" -not -path "/home/$USER_NAME/.ssh/*" -group "$CURRENT_GID" -exec chgrp "$TARGET_GID" {} + 2>/dev/null || true echo "Adjusted developer GID to $TARGET_GID" fi if [ -n "$TARGET_UID" ] && [ "$TARGET_UID" != "$CURRENT_UID" ]; then usermod -u "$TARGET_UID" "$USER_NAME" 2>/dev/null || true find /home/"$USER_NAME" -not -path "/home/$USER_NAME/.ssh/*" -user "$CURRENT_UID" -exec chown "$TARGET_UID" {} + 2>/dev/null || true echo "Adjusted developer UID to $TARGET_UID" fi # ── SSH key permissions ────────────────────────────────────────────── # If SSH keys are mounted, fix permissions (skip if read-only mount) if [ -d "/home/$USER_NAME/.ssh" ] && [ "$(ls -A "/home/$USER_NAME/.ssh" 2>/dev/null)" ]; then if touch "/home/$USER_NAME/.ssh/.perm_test" 2>/dev/null; then rm -f "/home/$USER_NAME/.ssh/.perm_test" chmod 700 "/home/$USER_NAME/.ssh" find "/home/$USER_NAME/.ssh" -type f -name "id_*" ! -name "*.pub" -exec chmod 600 {} \; 2>/dev/null || true find "/home/$USER_NAME/.ssh" -type f -name "*.pub" -exec chmod 644 {} \; 2>/dev/null || true [ -f "/home/$USER_NAME/.ssh/known_hosts" ] && chmod 644 "/home/$USER_NAME/.ssh/known_hosts" [ -f "/home/$USER_NAME/.ssh/config" ] && chmod 600 "/home/$USER_NAME/.ssh/config" fi fi # ── Fix ownership of named volume mount points ────────────────────── # Named volumes are created as root on first use. Fix ownership so the # developer user can write to them. FINAL_UID="${TARGET_UID:-$CURRENT_UID}" FINAL_GID="${TARGET_GID:-$CURRENT_GID}" # First, fix parent dirs that Docker auto-creates as root:root when it # materializes nested mount points (e.g. mounting a volume at # .local/state/opencode creates .local/state as root). Non-recursive — # we only need the dir node itself; children are handled below or were # created by the user. for parent in \ /home/"$USER_NAME"/.local \ /home/"$USER_NAME"/.local/share \ /home/"$USER_NAME"/.local/state \ /home/"$USER_NAME"/.cache \ /home/"$USER_NAME"/.config; do if [ -d "$parent" ] && [ "$(stat -c '%u' "$parent" 2>/dev/null)" != "$FINAL_UID" ]; then chown "$FINAL_UID":"$FINAL_GID" "$parent" 2>/dev/null || true fi done for dir in \ /home/"$USER_NAME"/.local/share/opencode \ /home/"$USER_NAME"/.local/state/opencode \ /home/"$USER_NAME"/.local/share/uv \ /home/"$USER_NAME"/.local/share/zoxide \ /home/"$USER_NAME"/.local/share/nvim \ /home/"$USER_NAME"/.mempalace \ /home/"$USER_NAME"/.cache/bash \ /home/"$USER_NAME"/.cache/chroma \ /home/"$USER_NAME"/.rustup \ /home/"$USER_NAME"/.cargo \ /home/"$USER_NAME"/.vscode-server \ /home/"$USER_NAME"/.config/opencode \ /home/"$USER_NAME"/.config/nvim \ /home/"$USER_NAME"/.agents/skills; do [ -d "$dir" ] || continue # Sentinel-file fast path: on volumes with thousands of files (nvim # plugins, palace data) the recursive chown used to cost multiple # seconds on every container start even when ownership was already # correct. Now we write a sentinel after a successful chown and skip # the walk when the sentinel matches the target UID:GID. # # If USER_UID changes between runs (user switches hosts, different # workspace owner), the sentinel won't match and the full chown runs. sentinel="$dir/.devbox-owner" expected="$FINAL_UID:$FINAL_GID" if [ -f "$sentinel" ] && [ "$(cat "$sentinel" 2>/dev/null)" = "$expected" ]; then continue fi # Recursive chown needed. Only do it when the top-level differs too # (covers the common case of fresh root-owned named volumes). if [ "$(stat -c '%u' "$dir" 2>/dev/null)" != "$FINAL_UID" ]; then chown -R "$FINAL_UID":"$FINAL_GID" "$dir" 2>/dev/null || true fi # Write sentinel so subsequent starts skip the recursive walk. # Suppress errors — a read-only mount would fail here, but that would # already have failed above on the chown itself. echo "$expected" > "$sentinel" 2>/dev/null || true done # ── Drop to developer user for remaining setup ────────────────────── exec gosu "$USER_NAME" /usr/local/bin/entrypoint-user.sh "$@"