f51e9f52a1
Optional integration of pi-coding-agent alongside opencode in the same
container. Both harnesses share the mempalace install and palace path —
wing/diary entries are mutually visible.
Build:
--build-arg INSTALL_PI=true # opt-in
--build-arg PI_VERSION=0.73.1 # pin a version (default: latest)
--build-arg INSTALL_OPENCODE=false # build pi-only image
Dockerfile:
• New INSTALL_PI block: npm install -g @mariozechner/pi-coding-agent
+ git-clones pi-toolkit and pi-extensions to /opt/.
• Existing opencode install gated behind new INSTALL_OPENCODE arg
(default true; existing builds unaffected).
• mkdir adds ~/.pi/agent/extensions for the named volume mount root.
• CMD changed from ['opencode'] to ['bash', '-l']. compose run --rm
devbox now drops to a login shell so users pick the harness; pass
'opencode' or 'pi' explicitly to launch directly. compose exec
workflows are unaffected (bypass entrypoint+CMD).
entrypoint.sh:
• Adds ~/.pi to volume ownership loop.
entrypoint-user.sh:
• New 'pi: deploy toolkit + extensions + mempalace bridge' block runs
pi-toolkit/install.sh, pi-extensions/install.sh, settings.json
template bootstrap, then symlinks the mempalace.ts bridge directly.
Order: toolkit before extensions before bridge. mempalace-toolkit's
full install.sh is intentionally NOT called (its install_skill
would race with skillset auto-deploy --prune-stale).
docker-compose.yml:
• New devbox-pi-config named volume mounted at /home/developer/.pi.
Persists user toggles (/ext-disabled extensions) and settings.json
edits across container recreate. Mirrors devbox-opencode-config
pattern from v1.14.33.
scripts/smoke-test.sh:
• New --variant with-pi (threshold 2700 MB) and --variant omos-with-pi
(3400 MB).
• Pi assertions gated on `command -v pi`: version, /opt/pi-toolkit
clone HEAD, /opt/pi-extensions clone HEAD, deployed keybindings
symlink, ≥4 extension symlinks, mempalace.ts bridge symlink,
settings.json bootstrap.
• Pi state assertions use docker exec from the host (not 'run'),
since the container has no docker CLI.
• opencode core test now gated on INSTALL_OPENCODE presence.
scripts/generate-dockerhub-md.py:
• SECTION_RULES adds 'pi (alternative/complementary harness)': drop.
Section stays in README; dropped from DOCKER_HUB.md to keep under
the 25 kB Docker Hub limit.
Docs:
• README adds full 'pi (alternative/complementary harness)' section.
• AGENTS.md codifies pi install contract, deploy ordering, named
volume rationale, and CMD change.
• CHANGELOG.md gets an Unreleased entry.
• .env.example documents new build args.
• docker-compose.yml example args block updated.
Verification (local builds on arm64):
• Default (INSTALL_PI=false): 1871 MB, all assertions pass — no
regression.
• INSTALL_PI=true: 2110 MB (within 2700 threshold), 37 assertions
pass including pi version, all 7 extensions deployed (6 from
pi-extensions + mempalace.ts bridge), settings.json bootstrap.
Not yet:
• CI workflow updates to add -with-pi tag variants. Deferred until
local path stabilizes through user testing.
• pi-devbox separate repo for fully stripped pi-only image. Phase 2.
122 lines
5.6 KiB
Bash
122 lines
5.6 KiB
Bash
#!/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"/.pi \
|
|
/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 "$@"
|