From 1c4239e9b0283f63636bc491d731745082224dd8 Mon Sep 17 00:00:00 2001 From: pi Date: Fri, 19 Jun 2026 19:45:11 +0200 Subject: [PATCH] =?UTF-8?q?port=20pi-devbox=20v1.1.4=E2=80=93v1.1.6=20hard?= =?UTF-8?q?ening;=20bump=20opencode=201.17.7=E2=86=921.17.8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Functional (not verbatim) port of the build-provenance, CI-hardening, SSH and shell fixes from the sibling pi-devbox repo, adapted to opencode-devbox's companions and two-variant (base/omos) shape. Defaults unchanged → canonical CI build stays byte-identical apart from the opencode bump and the (cache-free) provenance layer. Fixed: - SSH read-only ~/.ssh ControlPath: setup-lan-access.sh now renders the writable ~/.ssh-local/config sidecar (ControlPath redirect + Include) on EVERY host OS instead of exit 0-ing on native Linux; jump-specific blocks gated behind new NEED_JUMP flag. dssh/dscp + ControlMaster now survive a read-only ~/.ssh on native-Linux hosts. (pi-devbox v1.1.5) - bash history loss in nested/tmux shells: DEVBOX_HIST_SET no longer exported so each shell re-installs its own history -a flush. (pi-devbox v1.1.4) Added: - build provenance: OCI labels + /etc/opencode-devbox/build-manifest.json written from ground truth (opencode --version, installed omos version, /opt/mempalace-toolkit HEAD); wired into build-variant-* and smoke-* jobs; smoke-test.sh asserts manifest + label. (pi-devbox v1.1.6) - scripts/check-base-hash.sh CI guard: fails if a Dockerfile.base ARG *_REF is not folded into the base_tag hash. (pi-devbox v1.1.6) - overridable MEMPALACE_TOOLKIT_REPO build-arg in Dockerfile.base. (v1.1.6) Changed: - resolve-versions: fail-loud validation (SHA / semver) that aborts the release instead of silently falling back to floating main; adds shell: bash (set -o pipefail is illegal under the runner default dash). (pi-devbox v1.1.6) Bumped: - opencode-ai 1.17.7 → 1.17.8 (current npm latest stable). Deferred (needs a decision): opencode.json merge-on-recreate — see CHANGELOG. --- .gitea/workflows/docker-publish-split.yml | 47 +++++++- CHANGELOG.md | 96 +++++++++++++++ Dockerfile.base | 16 ++- Dockerfile.variant | 56 ++++++++- entrypoint-user.sh | 16 ++- rootfs/home/developer/.bash_aliases | 9 +- .../lib/opencode-devbox/setup-lan-access.sh | 114 +++++++++++------- scripts/check-base-hash.sh | 43 +++++++ scripts/smoke-test.sh | 28 +++++ 9 files changed, 367 insertions(+), 58 deletions(-) create mode 100755 scripts/check-base-hash.sh diff --git a/.gitea/workflows/docker-publish-split.yml b/.gitea/workflows/docker-publish-split.yml index 8018a47..c567396 100644 --- a/.gitea/workflows/docker-publish-split.yml +++ b/.gitea/workflows/docker-publish-split.yml @@ -59,6 +59,9 @@ jobs: - name: Checkout uses: actions/checkout@v4 + - name: Guard — base *_REF args must be folded into the base hash + run: bash scripts/check-base-hash.sh + - name: Compute base tag from Dockerfile.base + dependencies id: compute run: | @@ -130,14 +133,32 @@ jobs: steps: - name: Resolve omos version from npm registry id: resolve + shell: bash run: | - set -eu + set -euo pipefail + # Fail loud rather than silently shipping a floating ref or a bad + # version. A transient network/API failure must ABORT the release, + # not bake an unpinned ref that defeats both cache-busting AND + # after-the-fact reproducibility. (Previously the gitea lookup fell + # back to `main` via `|| echo`, and the npm lookup had no guard.) + # NOTE: shell: bash is REQUIRED — `set -o pipefail` is illegal in + # the runner's default dash/sh and aborts the step immediately. + require_sha() { # $1=label $2=value + if ! printf '%s' "${2:-}" | grep -qiE '^[0-9a-f]{40}$'; then + echo "::error::Could not resolve $1 to a commit SHA (got '${2:-}'). Refusing to fall back to a floating ref — published images must stay reproducible. Check connectivity and GITEA_BUILD_TOKEN/GITHUB_TOKEN." + exit 1 + fi + } # Query the npm registry directly via curl+jq rather than `npm view`. # catthehacker/ubuntu:act-latest ships Node/npm under /opt/acttoolcache/ # and adds it to PATH only via /etc/environment — which act_runner never # sources (it reads the Docker image's ENV instructions, not /etc/environment). # curl and jq are both guaranteed present in every job in this workflow. - OMOS_VERSION=$(curl -sf "https://registry.npmjs.org/oh-my-opencode-slim/latest" | jq -r '.version') + OMOS_VERSION=$(curl -sf "https://registry.npmjs.org/oh-my-opencode-slim/latest" | jq -r '.version' 2>/dev/null || true) + if ! printf '%s' "${OMOS_VERSION:-}" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+'; then + echo "::error::Could not resolve oh-my-opencode-slim version from npm (got '${OMOS_VERSION:-}'). Refusing to build with an unresolved version." + exit 1 + fi echo "omos_version=${OMOS_VERSION}" >> "$GITHUB_OUTPUT" echo "Resolved OMOS_VERSION=${OMOS_VERSION}" # Resolve mempalace-toolkit main HEAD to a commit SHA. Unlike omos @@ -150,8 +171,8 @@ jobs: # env vars are unset (degrades to anon, still HTTP 200). MEMPALACE_TOOLKIT_REF=$(curl -sf -H "Authorization: token ${GITEA_BUILD_TOKEN:-${GITHUB_TOKEN:-}}" \ "https://gitea.jordbo.se/api/v1/repos/joakimp/mempalace-toolkit/commits?limit=1&sha=main" \ - | jq -r '.[0].sha // "main"' 2>/dev/null || echo "main") - [ -n "$MEMPALACE_TOOLKIT_REF" ] || MEMPALACE_TOOLKIT_REF=main + | jq -r '.[0].sha // empty' 2>/dev/null || true) + require_sha MEMPALACE_TOOLKIT_REF "$MEMPALACE_TOOLKIT_REF" echo "mempalace_toolkit_ref=${MEMPALACE_TOOLKIT_REF}" >> "$GITHUB_OUTPUT" echo "Resolved MEMPALACE_TOOLKIT_REF=${MEMPALACE_TOOLKIT_REF}" @@ -288,6 +309,8 @@ jobs: BASE_IMAGE=${{ env.IMAGE }}:${{ needs.base-decide.outputs.base_tag }} INSTALL_OPENCODE=true INSTALL_OMOS=false + RELEASE_TAG=smoke + SOURCE_REVISION=${{ github.sha }} - name: Smoke test (amd64) run: bash scripts/smoke-test.sh opencode-devbox:smoke-base --variant base @@ -331,6 +354,8 @@ jobs: INSTALL_OPENCODE=true INSTALL_OMOS=true OMOS_VERSION=${{ needs.resolve-versions.outputs.omos_version }} + RELEASE_TAG=smoke + SOURCE_REVISION=${{ github.sha }} - env: EXPECTED_OMOS_VERSION: ${{ needs.resolve-versions.outputs.omos_version }} run: bash scripts/smoke-test.sh opencode-devbox:smoke-omos --variant omos @@ -338,7 +363,7 @@ jobs: # ── Phase 4: multi-arch publish per variant ──────────────────────── build-variant-base: - needs: [base-decide, smoke-base] + needs: [base-decide, smoke-base, resolve-versions] runs-on: ubuntu-latest container: image: catthehacker/ubuntu:act-latest @@ -377,8 +402,10 @@ jobs: env: TAGS: ${{ steps.tags.outputs.tags }} BASE_IMAGE_FULL: ${{ env.IMAGE }}:${{ needs.base-decide.outputs.base_tag }} + MEMPALACE_TOOLKIT_REF: ${{ needs.resolve-versions.outputs.mempalace_toolkit_ref }} run: | set -euo pipefail + BUILD_DATE=$(date -u +%Y-%m-%dT%H:%M:%SZ) TAG_FLAGS=() while IFS= read -r t; do [[ -n "$t" ]] && TAG_FLAGS+=( -t "$t" ); done <<< "${TAGS}" # 3-attempt retry around `docker buildx build --push` (see build-base @@ -392,6 +419,10 @@ jobs: --build-arg "BASE_IMAGE=${BASE_IMAGE_FULL}" \ --build-arg "INSTALL_OPENCODE=true" \ --build-arg "INSTALL_OMOS=false" \ + --build-arg "MEMPALACE_TOOLKIT_REF=${MEMPALACE_TOOLKIT_REF}" \ + --build-arg "RELEASE_TAG=${RELEASE_TAG}" \ + --build-arg "BUILD_DATE=${BUILD_DATE}" \ + --build-arg "SOURCE_REVISION=${GITHUB_SHA:-}" \ "${TAG_FLAGS[@]}" \ .; then echo "==> Attempt ${attempt} succeeded" @@ -447,8 +478,10 @@ jobs: TAGS: ${{ steps.tags.outputs.tags }} BASE_IMAGE_FULL: ${{ env.IMAGE }}:${{ needs.base-decide.outputs.base_tag }} OMOS_VERSION: ${{ needs.resolve-versions.outputs.omos_version }} + MEMPALACE_TOOLKIT_REF: ${{ needs.resolve-versions.outputs.mempalace_toolkit_ref }} run: | set -euo pipefail + BUILD_DATE=$(date -u +%Y-%m-%dT%H:%M:%SZ) TAG_FLAGS=() while IFS= read -r t; do [[ -n "$t" ]] && TAG_FLAGS+=( -t "$t" ); done <<< "${TAGS}" # 3-attempt retry (see build-base step for rationale). Variant: omos. @@ -462,6 +495,10 @@ jobs: --build-arg "INSTALL_OPENCODE=true" \ --build-arg "INSTALL_OMOS=true" \ --build-arg "OMOS_VERSION=${OMOS_VERSION}" \ + --build-arg "MEMPALACE_TOOLKIT_REF=${MEMPALACE_TOOLKIT_REF}" \ + --build-arg "RELEASE_TAG=${RELEASE_TAG}" \ + --build-arg "BUILD_DATE=${BUILD_DATE}" \ + --build-arg "SOURCE_REVISION=${GITHUB_SHA:-}" \ "${TAG_FLAGS[@]}" \ .; then echo "==> Attempt ${attempt} succeeded" diff --git a/CHANGELOG.md b/CHANGELOG.md index 49dff26..ce3f7b2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,102 @@ Tags follow **independent semver** (since `v2.0.0`) — they version *this image --- +## Unreleased + +Ports the build-provenance, CI-hardening, SSH and shell fixes that landed in +the sibling **pi-devbox** repo (v1.1.4–v1.1.6) into opencode-devbox, adapted to +this image's companions and two-variant (`base`/`omos`) shape. Also bumps +opencode. Defaults are unchanged, so the canonical CI build stays byte-identical +apart from the opencode bump and the (cache-free) provenance layer. + +### Fixed: read-only `~/.ssh` ControlPath / LAN sidecar on native Linux + +`rootfs/usr/local/lib/opencode-devbox/setup-lan-access.sh` previously +`exit 0`-ed early on native-Linux hosts (`auto` mode, not VM-backed) **before** +rendering the writable `~/.ssh-local/config` sidecar. On such hosts with a +read-only `~/.ssh` bind-mount, `dssh`/`dscp` got no config and the `Host *` +ControlPath redirect into `~/.ssh-local/cm` never happened, so a user +`~/.ssh/config` carrying the CGNAT idiom `ControlPath ~/.ssh/cm/%r@%h:%p` +broke ControlMaster. The sidecar (ControlPath redirect + `Include +~/.ssh/config`) is now rendered on **every** host OS; only the jump-specific +blocks (host alias, key generation, peer overrides, RFC1918 catch-all) stay +gated behind a new `NEED_JUMP` flag. `Dockerfile.base` and `entrypoint-user.sh` +comments updated to document the always-render behavior and the +plain-`ssh ` caveat. (Mirrors pi-devbox v1.1.5; the pi-only +`ssh-controlmaster` extension layer has no opencode equivalent and is N/A.) + +### Fixed: bash history loss in nested / tmux shells + +`rootfs/home/developer/.bash_aliases` exported the `DEVBOX_HIST_SET` flush +guard, so it leaked into child processes — every nested shell (crucially each +tmux pane, which inherits the tmux server's env) saw the guard already set and +skipped installing `history -a` in `PROMPT_COMMAND`. Those shells only +persisted history on a clean exit, silently losing in-memory history on abrupt +termination (`docker stop`, `tmux kill-server`, SIGKILL). The guard is now +shell-local (dropped `export`). (Mirrors pi-devbox v1.1.4.) + +### Added: build provenance — OCI labels + on-disk manifest + +The variant build now bakes OCI labels +(`org.opencontainers.image.{version,revision,created}` + +`se.jordbo.opencode-devbox.{opencode-version,install-omos,omos-version,mempalace-toolkit-ref}`) +and writes `/etc/opencode-devbox/build-manifest.json` from **ground truth** — +the live `opencode --version`, the installed `oh-my-opencode-slim` version +(JSON `null` in the `base` variant), and the actual checked-out HEAD of +`/opt/mempalace-toolkit` — so a published tag is self-describing and +reconstructable after CI logs rotate. Provenance ARGs (`RELEASE_TAG`, +`BUILD_DATE`, `SOURCE_REVISION`, re-declared `MEMPALACE_TOOLKIT_REF`) are +declared last in `Dockerfile.variant` so they never bust the expensive +npm-install layers. Wired into both `build-variant-*` and `smoke-*` jobs; +`scripts/smoke-test.sh` now asserts the manifest exists, is complete, has no +`unknown` components, and that the `opencode-version` OCI label is present. +(Mirrors pi-devbox v1.1.6.) + +### Added: base-rebuild hash guard (`scripts/check-base-hash.sh`) + +New CI guard (run first in the `base-decide` job) that fails the build if any +floating `ARG *_REF` consumed by `Dockerfile.base` is not folded into the +`base_tag` hash — preventing the v1.1.2-class staleness footgun where a +ref-only dependency change silently fails to rebuild the base. Passes today +(`MEMPALACE_TOOLKIT_REF` is already folded in); this is forward protection. +(Mirrors pi-devbox v1.1.6.) + +### Changed: fail-loud version/ref resolution + +The `resolve-versions` step now validates each resolved value — the +mempalace-toolkit ref must be a 40-hex commit SHA, the omos version must be +semver — and **aborts the release** on failure instead of silently falling +back to a floating `main` ref (which defeats both cache-busting and +reproducibility). The step also gains `shell: bash`, because `set -o pipefail` +is illegal under the runner's default dash/sh and would otherwise abort the +step (this exact latent bug bit pi-devbox's first v1.1.6 run). (Mirrors +pi-devbox v1.1.6.) + +### Added: overridable `MEMPALACE_TOOLKIT_REPO` build-arg + +`Dockerfile.base` no longer hardcodes the mempalace-toolkit clone URL inline; +it is now an `ARG MEMPALACE_TOOLKIT_REPO` defaulting to the canonical gitea +origin, so a relocated/forked build can repoint it via `--build-arg` without +editing the Dockerfile. Default unchanged. (Mirrors pi-devbox v1.1.6.) + +### Bumped: opencode-ai 1.17.7 → 1.17.8 + +`OPENCODE_VERSION` ARG in `Dockerfile.variant`. `1.17.8` is the current npm +`latest` stable. Only the variant layer rebuilds; the base is unaffected. + +### Deferred (needs a decision): opencode.json merge-on-recreate + +pi-devbox v1.1.4 added a non-destructive deep-merge of new template keys into a +preserved-volume `settings.json`. The direct analogue does **not** port cleanly +here: opencode's config is *generated from env vars* and written as **JSONC +with comments** (not a static image-owned template), and `generate-config.py` +deliberately never touches an existing config (host bind-mount or persisted +volume). A `jq`-style merge would strip the JSONC comments and risks clobbering +or re-adding entries a user removed. Left for a separate, deliberate change — +see discussion. + +--- + ## v2.1.2 — 2026-06-16 Image-semver **patch**: bumps opencode to `1.17.7`. No devbox-side changes diff --git a/Dockerfile.base b/Dockerfile.base index 43b8534..024d1f2 100644 --- a/Dockerfile.base +++ b/Dockerfile.base @@ -94,6 +94,15 @@ RUN apt-get update && \ # the last session closes, so consecutive ssh calls in a workflow reuse # the same TCP flow. Companion entrypoint-user.sh creates /tmp/sshcm # (mode 700) on each container start. +# +# CAVEAT (and why dssh/dscp are handled elsewhere): a user per-host override +# that points ControlPath BACK under the read-only ~/.ssh (e.g. the common +# CGNAT idiom `ControlPath ~/.ssh/cm/%r@%h:%p`) re-introduces the +# unwritable-socket failure for a plain `ssh ` — a system drop-in here +# can never override a user's per-host value. For `ssh -F ~/.ssh-local/config` +# (the dssh/dscp aliases), setup-lan-access.sh redirects ControlPath into the +# writable ~/.ssh-local sidecar, so those paths are unaffected. See CHANGELOG +# "Unreleased". RUN mkdir -p /etc/ssh/ssh_config.d && \ printf '%s\n' \ '# Devbox-baked default. See Dockerfile.base "SSH client defaults".' \ @@ -312,6 +321,11 @@ RUN if [ "${INSTALL_MEMPALACE}" = "true" ]; then \ # ── mempalace-toolkit — bash wrappers for session/docs mining ──────── ARG INSTALL_MEMPALACE_TOOLKIT=true ARG MEMPALACE_TOOLKIT_REF=main +# MEMPALACE_TOOLKIT_REPO is overridable so a relocated/forked build can repoint +# the clone without editing this Dockerfile (matches the *_REPO pattern used by +# other companions). Defaults to the canonical gitea origin; the default CI +# build is byte-identical. +ARG MEMPALACE_TOOLKIT_REPO=https://gitea.jordbo.se/joakimp/mempalace-toolkit.git # MEMPALACE_TOOLKIT_REF accepts EITHER a branch name OR a commit SHA. CI # resolves it to a SHA (resolve-versions job) and folds that SHA into the # base-decide hash so the base rebuilds when the toolkit moves. `git clone @@ -320,7 +334,7 @@ ARG MEMPALACE_TOOLKIT_REF=main RUN if [ "${INSTALL_MEMPALACE}" = "true" ] && [ "${INSTALL_MEMPALACE_TOOLKIT}" = "true" ]; then \ rm -rf /opt/mempalace-toolkit && mkdir -p /opt/mempalace-toolkit && \ git -C /opt/mempalace-toolkit init -q && \ - git -C /opt/mempalace-toolkit remote add origin https://gitea.jordbo.se/joakimp/mempalace-toolkit.git && \ + git -C /opt/mempalace-toolkit remote add origin "${MEMPALACE_TOOLKIT_REPO}" && \ ok=0; for i in 1 2 3 4 5; do \ if git -C /opt/mempalace-toolkit fetch --depth 1 origin "${MEMPALACE_TOOLKIT_REF}" && \ git -C /opt/mempalace-toolkit checkout -q FETCH_HEAD; then ok=1; break; fi; \ diff --git a/Dockerfile.variant b/Dockerfile.variant index 9f0bafb..630852d 100644 --- a/Dockerfile.variant +++ b/Dockerfile.variant @@ -39,7 +39,7 @@ ARG USER_NAME=developer # edit, so the cache-hit class of bug that bit pi-devbox v0.74.0.. # v0.75.5 cannot apply here. ARG INSTALL_OPENCODE=true -ARG OPENCODE_VERSION=1.17.7 +ARG OPENCODE_VERSION=1.17.8 RUN if [ "${INSTALL_OPENCODE}" = "true" ]; then \ NPM_CONFIG_PREFIX=/usr npm install -g opencode-ai@${OPENCODE_VERSION} && \ opencode --version ; \ @@ -91,4 +91,58 @@ RUN if [ "${INSTALL_OMOS}" = "true" ]; then \ NPM_CONFIG_PREFIX=/usr npm install -g oh-my-opencode-slim@${OMOS_VERSION}; \ fi +# ── Build provenance: OCI labels + on-disk manifest ────────────────── +# These ARGs are declared LAST, immediately before the layer that uses +# them, so a changing BUILD_DATE / RELEASE_TAG / SOURCE_REVISION never +# invalidates the expensive npm-install layers above. OPENCODE_VERSION, +# OMOS_VERSION and INSTALL_OMOS are already in scope from earlier in this +# stage and need no re-declaration; MEMPALACE_TOOLKIT_REF is consumed in +# Dockerfile.base, so it is re-declared here only to land in the labels. +ARG RELEASE_TAG=dev +ARG BUILD_DATE= +ARG SOURCE_REVISION= +ARG MEMPALACE_TOOLKIT_REF=main + +LABEL org.opencontainers.image.version="${RELEASE_TAG}" \ + org.opencontainers.image.revision="${SOURCE_REVISION}" \ + org.opencontainers.image.created="${BUILD_DATE}" \ + se.jordbo.opencode-devbox.opencode-version="${OPENCODE_VERSION}" \ + se.jordbo.opencode-devbox.install-omos="${INSTALL_OMOS}" \ + se.jordbo.opencode-devbox.omos-version="${OMOS_VERSION}" \ + se.jordbo.opencode-devbox.mempalace-toolkit-ref="${MEMPALACE_TOOLKIT_REF}" + +# The manifest is written from GROUND TRUTH — the live `opencode --version`, +# the omos package's installed version (when present), and the actual +# checked-out HEAD of /opt/mempalace-toolkit (cloned in the base) — not +# merely the intended build-args. That way it also exposes a dependency +# that silently resolved to something other than the requested value. +# oh-my-opencode-slim is present only in the omos variant (JSON null +# otherwise). NOTE: omos is installed under prefix /usr at build time, so +# we resolve its dir via `npm root -g` with that prefix rather than the +# runtime NPM_CONFIG_PREFIX the base sets for the developer volume. +RUN set -e; \ + mkdir -p /etc/opencode-devbox; \ + rev() { git -C "$1" rev-parse HEAD 2>/dev/null || echo "unknown"; }; \ + OPENCODE_V="$(opencode --version 2>/dev/null | head -n1 | tr -d '\r\n')"; \ + OMOS_REV='null'; \ + if [ "${INSTALL_OMOS}" = "true" ]; then \ + OMOS_DIR="$(NPM_CONFIG_PREFIX=/usr npm root -g 2>/dev/null)/oh-my-opencode-slim"; \ + OMOS_V="$(node -e "process.stdout.write(require('${OMOS_DIR}/package.json').version)" 2>/dev/null || echo unknown)"; \ + OMOS_REV="\"${OMOS_V}\""; \ + fi; \ + { \ + echo '{'; \ + echo " \"release_tag\": \"${RELEASE_TAG}\","; \ + echo " \"build_date\": \"${BUILD_DATE}\","; \ + echo " \"source_revision\": \"${SOURCE_REVISION}\","; \ + echo " \"opencode_version\": \"${OPENCODE_V}\","; \ + echo " \"components\": {"; \ + echo " \"opencode\": \"${OPENCODE_V}\","; \ + echo " \"oh-my-opencode-slim\": ${OMOS_REV},"; \ + echo " \"mempalace-toolkit\": \"$(rev /opt/mempalace-toolkit)\""; \ + echo " }"; \ + echo '}'; \ + } > /etc/opencode-devbox/build-manifest.json; \ + echo "── build manifest ──"; cat /etc/opencode-devbox/build-manifest.json + # WORKDIR / ENTRYPOINT / CMD inherited from base. diff --git a/entrypoint-user.sh b/entrypoint-user.sh index 1232de2..d62ba10 100644 --- a/entrypoint-user.sh +++ b/entrypoint-user.sh @@ -12,12 +12,16 @@ set -euo pipefail mkdir -p /tmp/sshcm chmod 700 /tmp/sshcm -# ── LAN access: generic host-OS-agnostic reachability helper ──────── -# On VM-backed hosts (macOS OrbStack / Docker Desktop) the container can't -# reach the host's directly-attached LAN peers by default; this generates a -# writable ~/.ssh-local/config that uses the host as an SSH jump. On native -# Linux (LAN reachable directly) it is a no-op. Controlled by DEVBOX_LAN_ACCESS -# (auto|jump|off) + HOST_SSH_USER. Always non-fatal. See the script header. +# ── LAN access + writable SSH sidecar: host-OS-agnostic helper ────── +# Generates the writable ~/.ssh-local/config on EVERY host OS: a `Host *` +# ControlPath redirect into ~/.ssh-local/cm (so `ssh -F` / dssh / dscp work +# even when ~/.ssh is bind-mounted read-only) plus `Include ~/.ssh/config`. On +# VM-backed hosts (macOS OrbStack / Docker Desktop) it ALSO adds an +# SSH-jump-via-host block so the container can reach the host's +# directly-attached LAN peers; on native Linux (LAN reachable directly) the +# jump block is omitted but the sidecar is still rendered. Controlled by +# DEVBOX_LAN_ACCESS (auto|jump|off) + HOST_SSH_USER. Always non-fatal. See the +# script header. if [ -r /usr/local/lib/opencode-devbox/setup-lan-access.sh ]; then bash /usr/local/lib/opencode-devbox/setup-lan-access.sh || true fi diff --git a/rootfs/home/developer/.bash_aliases b/rootfs/home/developer/.bash_aliases index 65d7148..c11ab13 100644 --- a/rootfs/home/developer/.bash_aliases +++ b/rootfs/home/developer/.bash_aliases @@ -89,9 +89,16 @@ fi # we append with a newline separator to avoid the ';;' parse error # described at the top of this file. Guarded so repeated sourcing # (e.g. `exec bash`) doesn't stack duplicates. +# +# The guard MUST stay shell-local (NOT exported): if it leaks into child +# processes, every nested shell -- crucially each tmux pane, which inherits +# the tmux server's env -- skips installing `history -a` and only persists +# history on a clean exit. Abrupt termination (docker stop, tmux kill-server, +# SIGKILL) then loses that shell's in-memory history. Keeping it unexported +# means each new interactive shell re-installs its own per-prompt flush. if [ -z "${DEVBOX_HIST_SET:-}" ]; then PROMPT_COMMAND="${PROMPT_COMMAND:+$PROMPT_COMMAND$'\n'}history -a" - export DEVBOX_HIST_SET=1 + DEVBOX_HIST_SET=1 fi # ── Prompt: show [opencode-devbox] tag so it's obvious you're in the container diff --git a/rootfs/usr/local/lib/opencode-devbox/setup-lan-access.sh b/rootfs/usr/local/lib/opencode-devbox/setup-lan-access.sh index 5061a9a..03d36db 100755 --- a/rootfs/usr/local/lib/opencode-devbox/setup-lan-access.sh +++ b/rootfs/usr/local/lib/opencode-devbox/setup-lan-access.sh @@ -14,7 +14,9 @@ # The one thing reachable from a container on every OS is the host itself # (host.docker.internal). So on VM-backed hosts we generate a writable SSH # config that reaches the host and lets the user ProxyJump onward to LAN -# peers the host can reach. On native Linux we do nothing. +# peers the host can reach. On native Linux we render the same writable +# config (for the ControlPath redirect + Include ~/.ssh/config) but emit no +# jump block, since LAN peers are reachable directly there. # # We ship the MECHANISM (a generic `host` jump alias + writable config), # never the POLICY: the user's specific target hosts live in their own @@ -30,7 +32,9 @@ # # CONTROLS (env) # DEVBOX_LAN_ACCESS = auto (default) | jump | off -# auto → set up the jump config only on VM-backed hosts; no-op on Linux. +# auto → set up the host jump only on VM-backed hosts. The writable +# sidecar config (ControlPath redirect + Include) is always +# rendered, on every OS. # jump → always set up (e.g. native Linux with extra_hosts host-gateway). # off → do nothing. # HOST_SSH_USER — the username to SSH into the host as. REQUIRED for the @@ -84,42 +88,72 @@ is_vm_backed() { getent hosts "$HOST_ALIAS_HOSTNAME" >/dev/null 2>&1 } -if [ "$MODE" = "auto" ] && ! is_vm_backed; then - # Native Linux host: LAN peers are reachable directly. Nothing to do. - exit 0 -fi - -# From here: MODE=jump, or MODE=auto on a VM-backed host. - -command -v ssh-keygen >/dev/null 2>&1 || exit 0 - +# ── Writable socket dir + sidecar (ALWAYS, every host OS) ───────────── +# The ControlPath redirect in the generated config needs a writable directory +# regardless of host OS or jump mode. ~/.ssh is typically read-only, so the +# master socket lives under the writable ~/.ssh-local. We create it and render +# the config UNCONDITIONALLY so the redirect (and `Include ~/.ssh/config`) works +# even on native Linux — where we set up no host jump but a read-only ~/.ssh +# would otherwise still break ControlMaster sockets. mkdir -p "${SSH_LOCAL}/cm" 2>/dev/null || true chmod 700 "${SSH_LOCAL}" "${SSH_LOCAL}/cm" 2>/dev/null || true -# ── Jump key (generated once; preserved across restarts) ────────────── +# ── Decide whether to set up the host jump ──────────────────────────── +# Jump = reach the container host (host.docker.internal) as an SSH ProxyJump +# onward to the host's LAN peers. Needed on VM-backed hosts (macOS / Docker +# Desktop) or when forced with DEVBOX_LAN_ACCESS=jump. On native Linux LAN +# peers are reachable directly, so NEED_JUMP=0 and we emit no jump block — but +# we still render the config for the ControlPath redirect + Include. +NEED_JUMP=0 +if [ "$MODE" = "jump" ] || { [ "$MODE" = "auto" ] && is_vm_backed; }; then + NEED_JUMP=1 +fi + +# ── Jump key (only when a jump is needed; generated once, preserved) ── # Persisted via a named volume on ~/.ssh-local (see compose), so a fresh key # is generated only on the very first start (or if the volume is wiped). When # we DO generate one it must be (re-)authorized on the host, so we flag it and # print a copy-paste authorize line below. KEY_JUST_GENERATED=0 -if [ ! -f "$KEY" ]; then - ssh-keygen -t ed25519 -N '' -C "devbox-jump@${HOSTNAME:-container}" -f "$KEY" >/dev/null 2>&1 || exit 0 - chmod 600 "$KEY" 2>/dev/null || true - KEY_JUST_GENERATED=1 +if [ "$NEED_JUMP" = "1" ] && command -v ssh-keygen >/dev/null 2>&1 && [ ! -f "$KEY" ]; then + if ssh-keygen -t ed25519 -N '' -C "devbox-jump@${HOSTNAME:-container}" -f "$KEY" >/dev/null 2>&1; then + chmod 600 "$KEY" 2>/dev/null || true + KEY_JUST_GENERATED=1 + fi fi # ── Render the writable config ──────────────────────────────────────── -USER_LINE="" -if [ -n "${HOST_SSH_USER:-}" ]; then - USER_LINE=" User ${HOST_SSH_USER}" -fi - -# Optional host-owned named-peer jump overrides (portable: lives on the host, -# not in the image). Included BEFORE ~/.ssh/config so its ProxyJump wins. -SSH_LAN_CONF="${HOME}/.config/devbox-shell/ssh-lan.conf" +# Jump-specific blocks (the host alias, host-owned peer overrides, and the +# optional RFC1918 catch-all) only make sense when a jump is set up; on native +# Linux they are all empty and only the ControlPath redirect + Include remain. +JUMP_BLOCK="" LAN_CONF_BLOCK="" -if [ -r "$SSH_LAN_CONF" ]; then - LAN_CONF_BLOCK=$(cat <<'EOF' +AUTOJUMP_BLOCK="" +if [ "$NEED_JUMP" = "1" ]; then + USER_LINE="" + if [ -n "${HOST_SSH_USER:-}" ]; then + USER_LINE=" User ${HOST_SSH_USER}" + fi + JUMP_BLOCK=$(cat <`. Public IPs are unmatched @@ -146,6 +179,7 @@ Host 10.* 192.168.* 172.16.* 172.17.* 172.18.* 172.19.* 172.20.* 172.21.* 172.22 ProxyJump host EOF ) + fi fi INCLUDE_BLOCK="" @@ -176,17 +210,7 @@ Host * UserKnownHostsFile ~/.ssh-local/known_hosts StrictHostKeyChecking accept-new ControlPath ~/.ssh-local/cm/%r@%h:%p - -# The container host (OrbStack / Docker Desktop). 'host' and 'mac' are aliases. -Host host mac - HostName ${HOST_ALIAS_HOSTNAME} -${USER_LINE} - IdentityFile ~/.ssh-local/devbox_jump_ed25519 - IdentitiesOnly yes - ControlMaster auto - ControlPath ~/.ssh-local/cm/%r@%h:%p - ControlPersist 4h - ServerAliveInterval 30 +${JUMP_BLOCK} ${LAN_CONF_BLOCK} ${AUTOJUMP_BLOCK} ${INCLUDE_BLOCK} @@ -199,6 +223,7 @@ chmod 600 "$CONFIG" 2>/dev/null || true # host won't recognize. With ~/.ssh-local persisted via a named volume, case # (b) fires only on first-ever start (or after the volume is reset) — so this # is normally a one-time, one-line step per machine, with no file to locate. +if [ "$NEED_JUMP" = "1" ]; then PUBKEY_TEXT="$(cat "${KEY}.pub" 2>/dev/null)" if [ -z "${HOST_SSH_USER:-}" ]; then cat </dev/null || true) +if [ -n "$LBL" ] && [ "$LBL" != "" ]; then + pass "OCI label se.jordbo.opencode-devbox.opencode-version=$LBL" +else + fail "OCI label se.jordbo.opencode-devbox.opencode-version missing or empty" +fi + echo echo "-- Entrypoint behaviour --"