668592da0d
Validate / docs-check (push) Successful in 9s
Validate / base-change-warning (push) Successful in 11s
Validate / validate-with-pi (push) Failing after 4m6s
Validate / validate-omos (push) Failing after 4m31s
Validate / validate-omos-with-pi (push) Failing after 4m52s
Validate / validate-base (push) Failing after 13m20s
Devboxes typically mount ~/.ssh from the host read-only (security: keys
readable, but agents can't tamper with config / known_hosts /
authorized_keys / plant a malicious ProxyCommand). OpenSSH's default
ControlPath is ~/.ssh/cm/... which is unwritable on such mounts, so
any attempt to multiplex fails with:
unix_listener: cannot bind to path .../cm/...: Read-only file system
kex_exchange_identification: Connection closed by remote host
The second line is downstream — when ControlMaster fails, ssh falls
back to fresh TCP connections, and on residential CGNAT (most European
ISPs) the per-(src,dst) concurrent-flow cap (~4) silently drops further
SYNs once exceeded, manifesting as banner-exchange timeouts that look
like a remote problem.
Fix: bake /etc/ssh/ssh_config.d/00-devbox-controlmaster.conf in the
base image with Host * defaults — ControlPath rooted at /tmp/sshcm/
(per-container, always writable), ControlMaster auto, ControlPersist
10m, ServerAlive{Interval=30,CountMax=6}. Companion entrypoint-user.sh
creates /tmp/sshcm mode 700 on each container start (/tmp is
per-container so the dir can't be baked into a layer; mode 700 is
required by OpenSSH for ControlPath dirs). Debian's stock ssh_config
sources ssh_config.d/*.conf before its own Host * block, so user
~/.ssh/config overrides still win.
Two smoke assertions catch regressions: (a) the conf file exists, (b)
ssh -G reports a controlpath rooted at /tmp/sshcm/ — second one catches
the silent case where something later in the config chain shadows the
bake-in.
Discovered while running a recon shell from inside pi-devbox to a
Proxmox node — fresh ssh hit banner-exchange timeout, debug output
pointed at the read-only socket dir as the actual root cause.
Cascades to all variants and to pi-devbox automatically on next build
against base-latest. No size/threshold impact (~250-byte conf file).
177 lines
7.7 KiB
Bash
177 lines
7.7 KiB
Bash
#!/usr/bin/env bash
|
|
set -euo pipefail
|
|
|
|
# ── SSH ControlMaster socket dir ────────────────────────────────
|
|
# Companion to /etc/ssh/ssh_config.d/00-devbox-controlmaster.conf in the
|
|
# base image — that file declares ControlPath=/tmp/sshcm/%r@%h:%p; this
|
|
# creates the directory with the right permissions on every container
|
|
# start. /tmp is per-container so the dir doesn't survive recreation;
|
|
# baking it into a Dockerfile layer would be wrong.
|
|
# Mode 700 is required — OpenSSH refuses to use a ControlPath dir that
|
|
# others can write to.
|
|
mkdir -p /tmp/sshcm
|
|
chmod 700 /tmp/sshcm
|
|
|
|
# ── Shell defaults: copy baked files from /etc/skel-devbox/ if absent
|
|
# Respects host bind-mounts and user customizations — existing files
|
|
# are never overwritten. To restore defaults: rm ~/.bash_aliases (or
|
|
# .inputrc) and recreate the container, or cp from /etc/skel-devbox/
|
|
# directly.
|
|
SKEL_DIR="/etc/skel-devbox"
|
|
if [ -d "$SKEL_DIR" ]; then
|
|
for f in .bash_aliases .inputrc; do
|
|
if [ -f "$SKEL_DIR/$f" ] && [ ! -e "$HOME/$f" ]; then
|
|
cp "$SKEL_DIR/$f" "$HOME/$f"
|
|
fi
|
|
done
|
|
fi
|
|
|
|
# ── MemPalace: initialize palace for the workspace if mempalace is installed
|
|
# Creates the palace directory structure on first run. Idempotent — skips
|
|
# if palace already exists, so upgrades from older versions preserve
|
|
# existing data. `--yes` auto-accepts detected entities so the init is
|
|
# non-interactive — the container entrypoint has no usable stdin for
|
|
# prompts anyway.
|
|
if command -v mempalace &>/dev/null && [ -d /workspace ]; then
|
|
PALACE_DIR="${HOME}/.mempalace"
|
|
if [ ! -d "$PALACE_DIR/palace" ]; then
|
|
echo "Initializing MemPalace for workspace (non-interactive)..."
|
|
# </dev/null: mempalace init has an interactive "Mine this directory
|
|
# now? [Y/n]" prompt that --yes does not auto-answer in all paths.
|
|
# Without redirected stdin, the process blocks here forever when run
|
|
# from `docker run -it` (the TTY keeps stdin open). EOF on stdin
|
|
# makes the prompt fall through to its default (skip).
|
|
mempalace init --yes /workspace </dev/null >/dev/null 2>&1 || true
|
|
fi
|
|
fi
|
|
|
|
# ── Git config defaults ──────────────────────────────────────────────
|
|
if [ -n "${GIT_USER_NAME:-}" ] && ! git config --global user.name &>/dev/null; then
|
|
git config --global user.name "$GIT_USER_NAME"
|
|
fi
|
|
if [ -n "${GIT_USER_EMAIL:-}" ] && ! git config --global user.email &>/dev/null; then
|
|
git config --global user.email "$GIT_USER_EMAIL"
|
|
fi
|
|
|
|
# ── Generate opencode config from env vars if no config mounted ──────
|
|
# Delegated to a standalone Python script for clarity and testability.
|
|
# The script is idempotent: it never overwrites an existing opencode.json
|
|
# (bind-mounted from host, persisted in named volume, or previously
|
|
# generated) and no-ops if OPENCODE_PROVIDER is unset.
|
|
python3 /usr/local/lib/opencode-devbox/generate-config.py
|
|
|
|
# ── pi: deploy toolkit + extensions + mempalace bridge ─────────────
|
|
# Runs only when pi was baked into the image (INSTALL_PI=true at build).
|
|
# Each install.sh is idempotent and backs up real files before linking,
|
|
# so re-running across container restarts is safe.
|
|
#
|
|
# Order: pi-toolkit first (creates ~/.pi/agent/keybindings.json symlink
|
|
# and writes the AWS env loader), then pi-extensions (symlinks our 6
|
|
# extensions), then settings.json bootstrap from the toolkit template,
|
|
# then the mempalace bridge symlink (one-liner; mempalace-toolkit's
|
|
# install_skill is intentionally skipped to avoid racing with skillset
|
|
# auto-deploy below).
|
|
if command -v pi &>/dev/null; then
|
|
if [ -d /opt/pi-toolkit ]; then
|
|
(cd /opt/pi-toolkit && ./install.sh --yes) || \
|
|
echo "WARN: pi-toolkit install.sh failed (continuing)"
|
|
fi
|
|
|
|
if [ -d /opt/pi-extensions ]; then
|
|
(cd /opt/pi-extensions && ./install.sh --yes) || \
|
|
echo "WARN: pi-extensions install.sh failed (continuing)"
|
|
fi
|
|
|
|
# Bootstrap settings.json from template if absent (pi rewrites this
|
|
# file at runtime — lastChangelogVersion, etc — so we can't symlink it).
|
|
if [ ! -f "$HOME/.pi/agent/settings.json" ] && \
|
|
[ -f /opt/pi-toolkit/settings.example.json ]; then
|
|
cp /opt/pi-toolkit/settings.example.json "$HOME/.pi/agent/settings.json"
|
|
fi
|
|
|
|
# pi↔mempalace MCP bridge — single extension symlink.
|
|
if [ -f /opt/mempalace-toolkit/extensions/pi/mempalace.ts ] && \
|
|
command -v mempalace &>/dev/null && \
|
|
[ ! -L "$HOME/.pi/agent/extensions/mempalace.ts" ]; then
|
|
ln -sf /opt/mempalace-toolkit/extensions/pi/mempalace.ts \
|
|
"$HOME/.pi/agent/extensions/mempalace.ts"
|
|
fi
|
|
fi
|
|
|
|
# ── Skillset: deploy skills/instructions from mounted skillset repo ──
|
|
# When the skillset repo is mounted (at $HOME/skillset or /workspace/skillset),
|
|
# run the deploy script to create relative symlinks for skills and instructions.
|
|
# This ensures skills resolve correctly inside the container regardless of
|
|
# where the repo lives on the host. Idempotent — second run is a no-op.
|
|
#
|
|
# Detection order:
|
|
# 1. SKILLSET_CONTAINER_PATH env var (explicit, for non-standard layouts)
|
|
# 2. $HOME/skillset (dedicated volume mount via SKILLSET_PATH in compose)
|
|
# 3. /workspace/skillset (skillset is directly inside workspace root)
|
|
SKILLSET_DEPLOY=""
|
|
if [ -n "${SKILLSET_CONTAINER_PATH:-}" ] && [ -x "${SKILLSET_CONTAINER_PATH}/deploy-skills.sh" ]; then
|
|
SKILLSET_DEPLOY="${SKILLSET_CONTAINER_PATH}/deploy-skills.sh"
|
|
elif [ -x "$HOME/skillset/deploy-skills.sh" ]; then
|
|
SKILLSET_DEPLOY="$HOME/skillset/deploy-skills.sh"
|
|
elif [ -x /workspace/skillset/deploy-skills.sh ]; then
|
|
SKILLSET_DEPLOY="/workspace/skillset/deploy-skills.sh"
|
|
fi
|
|
if [ -n "$SKILLSET_DEPLOY" ]; then
|
|
"$SKILLSET_DEPLOY" --bootstrap --prune-stale >/dev/null 2>&1 || true
|
|
fi
|
|
|
|
CONFIG_DIR="$HOME/.config/opencode"
|
|
OMOS_CONFIG="$CONFIG_DIR/oh-my-opencode-slim.json"
|
|
|
|
# ── oh-my-opencode-slim setup (multi-agent orchestration) ────────────
|
|
# Activated by ENABLE_OMOS=true. Requires the image to be built with
|
|
# INSTALL_OMOS=true (which installs bun + the oh-my-opencode-slim package).
|
|
OMOS_CONFIG="$CONFIG_DIR/oh-my-opencode-slim.json"
|
|
|
|
if [ "${ENABLE_OMOS:-false}" = "true" ]; then
|
|
if ! command -v bun &>/dev/null; then
|
|
echo "WARNING: ENABLE_OMOS=true but bun is not installed."
|
|
echo "Rebuild with: docker compose build --build-arg INSTALL_OMOS=true"
|
|
elif [ ! -f "$OMOS_CONFIG" ]; then
|
|
echo "Setting up oh-my-opencode-slim agents..."
|
|
|
|
# Determine installer flags
|
|
OMOS_TMUX_FLAG="no"
|
|
if [ "${OMOS_TMUX:-false}" = "true" ]; then
|
|
OMOS_TMUX_FLAG="yes"
|
|
fi
|
|
|
|
OMOS_SKILLS_FLAG="yes"
|
|
if [ "${OMOS_SKILLS:-true}" = "false" ]; then
|
|
OMOS_SKILLS_FLAG="no"
|
|
fi
|
|
|
|
bun x oh-my-opencode-slim@latest install \
|
|
--no-tui \
|
|
--tmux="${OMOS_TMUX_FLAG}" \
|
|
--skills="${OMOS_SKILLS_FLAG}"
|
|
|
|
echo "oh-my-opencode-slim configured successfully."
|
|
else
|
|
echo "oh-my-opencode-slim config found at $OMOS_CONFIG (use OMOS_RESET=true to overwrite)."
|
|
|
|
# Allow reset via env var (creates backup automatically)
|
|
if [ "${OMOS_RESET:-false}" = "true" ]; then
|
|
echo "OMOS_RESET=true — regenerating oh-my-opencode-slim config..."
|
|
OMOS_TMUX_FLAG="no"
|
|
[ "${OMOS_TMUX:-false}" = "true" ] && OMOS_TMUX_FLAG="yes"
|
|
OMOS_SKILLS_FLAG="yes"
|
|
[ "${OMOS_SKILLS:-true}" = "false" ] && OMOS_SKILLS_FLAG="no"
|
|
|
|
bun x oh-my-opencode-slim@latest install \
|
|
--no-tui \
|
|
--tmux="${OMOS_TMUX_FLAG}" \
|
|
--skills="${OMOS_SKILLS_FLAG}" \
|
|
--reset
|
|
fi
|
|
fi
|
|
fi
|
|
|
|
# ── Execute command ──────────────────────────────────────────────────
|
|
exec "$@"
|