Compare commits

..

13 Commits

Author SHA1 Message Date
pi 992cb6702f release: v2.2.0
Publish Docker Image / update-description (push) Successful in 6s
Publish Docker Image / promote-base-latest (push) Successful in 9s
Publish Docker Image / resolve-versions (push) Successful in 7s
Publish Docker Image / base-decide (push) Successful in 9s
Publish Docker Image / build-base (push) Successful in 30m33s
Publish Docker Image / smoke-base (push) Successful in 3m23s
Publish Docker Image / smoke-omos (push) Successful in 13m49s
Publish Docker Image / build-variant-base (push) Successful in 14m1s
Publish Docker Image / build-variant-omos (push) Successful in 22m56s
2026-06-19 20:18:40 +02:00
pi 9b1e8c0b30 docs: keep v2.1.1 CHANGELOG entry historically accurate (revert over-eager fork edit)
The doc-drift pass rewrote the v2.1.1 release entry to describe v2.2.0
behaviour; at v2.1.1 the sidecar genuinely was a no-op on native Linux, so
the edit falsified release history. The new behaviour is already documented
in the Unreleased section. Other doc-drift fixes retained.
2026-06-19 20:15:45 +02:00
pi 1f0d06444b docs: fix drift against main batch (SSH sidecar, proposed config, provenance, forked build)
Validate / docs-check (push) Successful in 7s
Validate / base-change-warning (push) Successful in 14s
Validate / validate-omos (push) Failing after 4m23s
Validate / validate-base (push) Failing after 5m8s
DRIFT-STALE fixes (stale/misleading text corrected):
- README.md:160 — "on native Linux it does nothing" → accurate always-render
  description (sidecar written on every OS; jump block still omitted on Linux)
- AGENTS.md:22 — setup-lan-access.sh "no-op on native Linux" → corrected to
  always-render + removed now-redundant inline ControlPath/UserKnownHostsFile
  sentence (that's what the sidecar does, not what the script detects)
- CHANGELOG.md:594 (v2.1.1 intro) — "no-op" → "(see v2.2.0)" historical note
- docs/plan-lan-access-and-pi-extensions.md:222 — annotated the completed
  checkbox with the v2.2.0 refinement

GAP additions:
- README.md:212 — custom config section now describes opencode.jsonc.proposed
  sidecar: written-on-diff, removed-on-match, never overwrites, one-line hint
- README.md build-args table — INSTALL_MEMPALACE_TOOLKIT row now mentions
  MEMPALACE_TOOLKIT_REPO alongside MEMPALACE_TOOLKIT_REF
- README.md (after build-args table) — two new sections:
    "Building a fork / relocated build" — MEMPALACE_TOOLKIT_REPO build-arg
    table + two-step docker build example + credentials note
    "Build provenance (labels + manifest)" — docker inspect + run examples,
    OCI label set, ground-truth manifest path

Version string drift: no hard-coded 1.17.7 found outside CHANGELOG (all
occurrences are historical release entries — correct by definition).
CI-internal items (resolve-versions, check-base-hash.sh): no existing doc
described these mechanics, so no update needed.
2026-06-19 20:14:12 +02:00
pi af11c32f4f feat(config): non-destructive opencode.jsonc.proposed sidecar (closes #8 batch item)
Validate / base-change-warning (push) Successful in 6s
Validate / docs-check (push) Successful in 14s
Validate / validate-omos (push) Failing after 4m22s
Validate / validate-base (push) Failing after 5m5s
Completes the pi-devbox v1.1.4 "merge new defaults into preserved config"
idea, adapted to opencode-devbox's env-generated, JSONC-with-comments config
where an in-place merge would be destructive.

generate-config.py keeps its "never touch an existing config" guarantee and
adds a side-channel: when a live config exists, render the config it WOULD
generate for the current env + image defaults and write it to a NON-loaded
opencode.jsonc.proposed — but only when it differs from the live config;
remove it once they match. opencode never loads .proposed files, so it is a
pure manual-merge reference (e.g. surfacing a default MCP server added in a
newer image). An unparseable live config surfaces the proposal rather than
guessing equivalence. A one-line hint is logged on write.

- render_config(): shared renderer so first-gen and proposed paths can't drift
- _loads_jsonc(): string-aware //-comment strip (same approach as smoke-test;
  preserves https:// inside strings), raises on invalid JSON
- write_proposed(): write-on-diff + stale removal + live untouched
- smoke-test.sh: asserts write-on-diff, removal-on-match, live not clobbered
- entrypoint-user.sh + module docstring: document the sidecar
- CHANGELOG: moved from "Deferred" to "Added"

Caveat (documented in the file header): the proposal reflects env + image
defaults, so a diff may include the user's own past edits, not only new
image defaults.
2026-06-19 20:07:54 +02:00
pi 1c4239e9b0 port pi-devbox v1.1.4–v1.1.6 hardening; bump opencode 1.17.7→1.17.8
Validate / base-change-warning (push) Successful in 6s
Validate / docs-check (push) Successful in 9s
Validate / validate-base (push) Successful in 3m9s
Validate / validate-omos (push) Successful in 17m47s
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.
2026-06-19 19:45:11 +02:00
Joakim Persson 717c69ee17 v2.1.2: bump opencode 1.17.6->1.17.7
Validate / docs-check (push) Successful in 7s
Validate / base-change-warning (push) Successful in 10s
Publish Docker Image / resolve-versions (push) Successful in 5s
Publish Docker Image / base-decide (push) Successful in 16s
Publish Docker Image / build-base (push) Has been skipped
Validate / validate-base (push) Successful in 3m16s
Publish Docker Image / smoke-omos (push) Successful in 4m22s
Publish Docker Image / smoke-base (push) Successful in 5m9s
Validate / validate-omos (push) Successful in 13m55s
Publish Docker Image / build-variant-base (push) Successful in 15m11s
Publish Docker Image / build-variant-omos (push) Successful in 19m14s
Publish Docker Image / promote-base-latest (push) Has been skipped
Publish Docker Image / update-description (push) Successful in 7s
Image-semver patch: opencode-only version bump (variant-layer rebuild).
Upstream 1.17.7 is bugfixes + minor improvements, no breaking/runtime/Bun/AVX
changes. Also fixes a stale CHANGELOG preamble that still described the
pre-v2.0.0 'v{opencode_version}[letter]' scheme instead of independent semver.
2026-06-16 08:41:14 +02:00
Joakim Persson 2ac84fa4fb docs(AGENTS): clarify opencode repo moved sst->anomalyco (not a fork)
Old note framed sst/opencode as a separate fork with a divergent release
timeline. It is the same repo, renamed/moved months ago; sst/opencode now
301-redirects to anomalyco/opencode (verified). Reframe as the canonical
source of truth with a quick verification command to stop the recurring
surprise.
2026-06-16 08:41:14 +02:00
pi 66527aeec9 docs(AGENTS): document GITEA_ACCESS_TOKEN env for general Gitea API access
Validate / base-change-warning (push) Successful in 28s
Validate / docs-check (push) Successful in 56s
Validate / validate-base (push) Successful in 3m17s
Validate / validate-omos (push) Successful in 4m23s
GITEA_ACCESS_TOKEN + GITEA_HOST (passed from host .env via compose,
primarily for gitea-mcp) are also usable for any direct Gitea API work —
run inspection, tag checks — not just ci-release-watcher. Prefer over a
PAT file when present; host-managed lifecycle, nothing to revoke. Mirrors
the same note added to pi-devbox AGENTS.md.
2026-06-15 22:30:43 +02:00
Joakim Persson 063cc6b6e6 test: add runtime recreate-sanity-check script
Validate / docs-check (push) Successful in 6s
Validate / base-change-warning (push) Successful in 10s
Validate / validate-omos (push) Successful in 4m11s
Validate / validate-base (push) Successful in 12m45s
Runtime peer to the build-time smoke-test.sh: run inside the container
after `docker compose up -d --force-recreate` to confirm the new image is
live (opencode version matches Dockerfile.variant), persisted named volumes
survived, omos skill symlinks resolve, shell defaults re-seeded, and /opt
toolkits intact. smoke-test.sh runs with --entrypoint="" and cannot see the
running container's volumes/symlinks, hence a separate runtime check.

Not run by CI or the entrypoint (it needs the release-time expected version
and a running container). Maintainer tooling, not baked into the image.
Registered in AGENTS.md File roles. Doc/script-only — no image rebuild.
2026-06-14 22:45:24 +02:00
Joakim Persson 52e8affa86 v2.1.1: bump opencode 1.17.5->1.17.6
Validate / base-change-warning (push) Successful in 6s
Validate / docs-check (push) Successful in 15s
Publish Docker Image / resolve-versions (push) Successful in 8s
Publish Docker Image / base-decide (push) Successful in 12s
Validate / validate-base (push) Successful in 3m16s
Validate / validate-omos (push) Successful in 4m12s
Publish Docker Image / build-base (push) Successful in 37m15s
Publish Docker Image / smoke-base (push) Successful in 5m0s
Publish Docker Image / smoke-omos (push) Successful in 18m20s
Publish Docker Image / build-variant-base (push) Successful in 15m7s
Publish Docker Image / build-variant-omos (push) Successful in 18m29s
Publish Docker Image / update-description (push) Successful in 6s
Publish Docker Image / promote-base-latest (push) Successful in 7s
Patch release. Upstream 1.17.6 is a single MCP-compatibility bugfix (no breaking/runtime/AVX changes). Also lands the unreleased mempalace-toolkit SHA-resolution CI fix and two doc corrections; the toolkit change advances the base hash so this carries a base rebuild + base-latest re-promote.
2026-06-14 18:38:35 +02:00
pi e963f83e70 ci: CI-resolve mempalace-toolkit to a pinned SHA
Validate / docs-check (push) Successful in 7s
Validate / base-change-warning (push) Successful in 58s
Validate / validate-base (push) Successful in 3m19s
Validate / validate-omos (push) Successful in 4m19s
mempalace-toolkit is the only dependency cloned in Dockerfile.base (all
others live in the variant), so it bypassed the resolve-versions ->
build-arg plumbing and its ref stayed a literal `main`. Because the base
only rebuilds on a content hash, a toolkit-only fix would silently fail to
land unless Dockerfile.base itself changed.

Mirrors pi-devbox commit 4744f05, adapted to this repo:
- resolve-versions: new mempalace_toolkit_ref output via the gitea commits
  API (first gitea call in this repo's CI; works unauthenticated, no secret).
- base-decide: needs resolve-versions; fold the SHA into the base-tag hash
  so a moved toolkit forces a base rebuild (they no longer run in parallel).
- build-base: needs resolve-versions; pass --build-arg MEMPALACE_TOOLKIT_REF.
- Dockerfile.base: clone switched to SHA-capable git fetch + checkout
  FETCH_HEAD (git clone --branch <SHA> would fail).
- docs lockstep: .gitea/README.md Step 1 (no longer "in parallel"), AGENTS.md
  Critical conventions, CHANGELOG Unreleased.

base_tag now reflects a live gitea lookup; on API blip it falls back to
`main`, triggering one extra rebuild, never a missed one. No new tag —
lands on the next release or workflow_dispatch.
2026-06-14 15:51:55 +02:00
pi 4409bd0719 docs: correct mempalace anyOf workaround watch-target (PR #1735 is dead)
PR #1735 (the diary_write root-anyOf fix) was closed UNMERGED on 2026-06-11,
so the old "remove once PR #1735 ships" TODO points at a dead PR. Issue #1728
is still open; PR #1717 is the current live fix candidate; mempalace PyPI
latest is still 3.4.0 (== our pin), so the workaround must stay.

- Dockerfile.base: rewrite the upstream-tracking comment + TODO to reflect
  #1735 dead / watch #1717 / removal trigger = a PyPI release > 3.4.0 that
  actually strips the root anyOf.
- AGENTS.md: add a durable "anyOf workaround — upstream watch target" note
  under Critical conventions (persists context across machines/sessions),
  incl. the MEMPALACE_VERSION-vs-MEMPALACE_TOOLKIT_REF non-conflation warning.

Docs-only; no behavior change. Workaround remains live and correct for 3.4.0.
2026-06-14 15:33:46 +02:00
Joakim Persson c0d2516456 docs: fix quick-start — bare 'run devbox' lands in a shell, not opencode
Validate / base-change-warning (push) Successful in 7s
Validate / docs-check (push) Successful in 10s
Validate / validate-base (push) Successful in 3m2s
Validate / validate-omos (push) Successful in 6m38s
The image's default CMD is bash -l, so 'docker compose run --rm devbox'
with no command drops into a login shell; you pass 'opencode' explicitly to
start the harness. The README quick-start claimed the opposite and carried a
garbled 'Use bash instead of (no command)' half-sentence; the same error was
mirrored in the Hub HUB_TEMPLATE. Fix both and regenerate DOCKER_HUB.md.
Doc-only — no image bytes change.
2026-06-13 23:24:49 +02:00
17 changed files with 971 additions and 115 deletions
+10 -1
View File
@@ -75,7 +75,13 @@ The split-base architecture is what the `docker-publish-split.yml` workflow exer
└──────────────────────────┘ └──────────────────────────┘
``` ```
### Step 1: `base-decide` (and `resolve-versions` in parallel) ### Step 1: `resolve-versions`, then `base-decide`
**`resolve-versions`** resolves floating refs to concrete values: `omos_version`
(npm `latest`) and `mempalace_toolkit_ref` (the `mempalace-toolkit` `main` HEAD
resolved to a commit SHA via the gitea commits API). **`base-decide`** now
**depends on `resolve-versions`** (they no longer run in parallel) because it
folds `mempalace_toolkit_ref` into the base hash — see below.
**`base-decide`** computes a SHA-256 hash over the inputs that determine **`base-decide`** computes a SHA-256 hash over the inputs that determine
the base image's content: the base image's content:
@@ -90,6 +96,9 @@ the base image's content:
! -name '._*' \ ! -name '._*' \
-print0 | sort -z | xargs -0 cat -print0 | sort -z | xargs -0 cat
cat entrypoint.sh entrypoint-user.sh cat entrypoint.sh entrypoint-user.sh
echo "$mempalace_toolkit_ref" # CI-resolved SHA; mempalace-toolkit is
# cloned in Dockerfile.base, so a moved
# toolkit must force a base rebuild
} | sha256sum | cut -c1-12 } | sha256sum | cut -c1-12
``` ```
+63 -4
View File
@@ -48,6 +48,7 @@ env:
jobs: jobs:
# ── Phase 1: decide whether base needs rebuilding ────────────────── # ── Phase 1: decide whether base needs rebuilding ──────────────────
base-decide: base-decide:
needs: [resolve-versions]
runs-on: ubuntu-latest runs-on: ubuntu-latest
container: container:
image: catthehacker/ubuntu:act-latest image: catthehacker/ubuntu:act-latest
@@ -58,6 +59,9 @@ jobs:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 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 - name: Compute base tag from Dockerfile.base + dependencies
id: compute id: compute
run: | run: |
@@ -77,6 +81,10 @@ jobs:
! -name '._*' \ ! -name '._*' \
-print0 2>/dev/null | sort -z | xargs -0 cat 2>/dev/null -print0 2>/dev/null | sort -z | xargs -0 cat 2>/dev/null
cat entrypoint.sh entrypoint-user.sh cat entrypoint.sh entrypoint-user.sh
# mempalace-toolkit is cloned in Dockerfile.base at a ref CI
# resolves to a SHA; fold it in so base_tag changes when the
# toolkit moves (otherwise a toolkit-only fix never lands).
echo "${{ needs.resolve-versions.outputs.mempalace_toolkit_ref }}"
} | sha256sum | cut -c1-12 } | sha256sum | cut -c1-12
) )
BASE_TAG="base-${HASH}" BASE_TAG="base-${HASH}"
@@ -121,23 +129,56 @@ jobs:
image: catthehacker/ubuntu:act-latest image: catthehacker/ubuntu:act-latest
outputs: outputs:
omos_version: ${{ steps.resolve.outputs.omos_version }} omos_version: ${{ steps.resolve.outputs.omos_version }}
mempalace_toolkit_ref: ${{ steps.resolve.outputs.mempalace_toolkit_ref }}
steps: steps:
- name: Resolve omos version from npm registry - name: Resolve omos version from npm registry
id: resolve id: resolve
shell: bash
run: | 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:-<empty>}'). 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`. # Query the npm registry directly via curl+jq rather than `npm view`.
# catthehacker/ubuntu:act-latest ships Node/npm under /opt/acttoolcache/ # catthehacker/ubuntu:act-latest ships Node/npm under /opt/acttoolcache/
# and adds it to PATH only via /etc/environment — which act_runner never # 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). # 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. # 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:-<empty>}'). Refusing to build with an unresolved version."
exit 1
fi
echo "omos_version=${OMOS_VERSION}" >> "$GITHUB_OUTPUT" echo "omos_version=${OMOS_VERSION}" >> "$GITHUB_OUTPUT"
echo "Resolved OMOS_VERSION=${OMOS_VERSION}" echo "Resolved OMOS_VERSION=${OMOS_VERSION}"
# Resolve mempalace-toolkit main HEAD to a commit SHA. Unlike omos
# (an npm pkg baked into the VARIANT), mempalace-toolkit is cloned
# in Dockerfile.base, so this SHA is ALSO folded into the
# base-decide hash to force a base rebuild when the toolkit moves
# (without it, a toolkit-only fix silently fails to land unless
# Dockerfile.base itself changes). gitea allows unauthenticated
# public-repo commit listing; the token header is harmless if the
# 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 // 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}"
# ── Phase 2: build & push base (multi-arch), only when needed ────── # ── Phase 2: build & push base (multi-arch), only when needed ──────
build-base: build-base:
needs: [base-decide] needs: [base-decide, resolve-versions]
if: needs.base-decide.outputs.need_build == 'true' if: needs.base-decide.outputs.need_build == 'true'
runs-on: ubuntu-latest runs-on: ubuntu-latest
container: container:
@@ -185,6 +226,7 @@ jobs:
shell: bash shell: bash
env: env:
BASE_TAG_FULL: ${{ env.IMAGE }}:${{ needs.base-decide.outputs.base_tag }} BASE_TAG_FULL: ${{ env.IMAGE }}:${{ needs.base-decide.outputs.base_tag }}
MEMPALACE_TOOLKIT_REF: ${{ needs.resolve-versions.outputs.mempalace_toolkit_ref }}
run: | run: |
set -euo pipefail set -euo pipefail
# 3-attempt retry around `docker buildx build --push` for transient # 3-attempt retry around `docker buildx build --push` for transient
@@ -205,6 +247,7 @@ jobs:
if docker buildx build \ if docker buildx build \
--platform linux/amd64,linux/arm64 \ --platform linux/amd64,linux/arm64 \
--file Dockerfile.base \ --file Dockerfile.base \
--build-arg MEMPALACE_TOOLKIT_REF="${MEMPALACE_TOOLKIT_REF}" \
--push \ --push \
--tag "${BASE_TAG_FULL}" \ --tag "${BASE_TAG_FULL}" \
.; then .; then
@@ -266,6 +309,8 @@ jobs:
BASE_IMAGE=${{ env.IMAGE }}:${{ needs.base-decide.outputs.base_tag }} BASE_IMAGE=${{ env.IMAGE }}:${{ needs.base-decide.outputs.base_tag }}
INSTALL_OPENCODE=true INSTALL_OPENCODE=true
INSTALL_OMOS=false INSTALL_OMOS=false
RELEASE_TAG=smoke
SOURCE_REVISION=${{ github.sha }}
- name: Smoke test (amd64) - name: Smoke test (amd64)
run: bash scripts/smoke-test.sh opencode-devbox:smoke-base --variant base run: bash scripts/smoke-test.sh opencode-devbox:smoke-base --variant base
@@ -309,6 +354,8 @@ jobs:
INSTALL_OPENCODE=true INSTALL_OPENCODE=true
INSTALL_OMOS=true INSTALL_OMOS=true
OMOS_VERSION=${{ needs.resolve-versions.outputs.omos_version }} OMOS_VERSION=${{ needs.resolve-versions.outputs.omos_version }}
RELEASE_TAG=smoke
SOURCE_REVISION=${{ github.sha }}
- env: - env:
EXPECTED_OMOS_VERSION: ${{ needs.resolve-versions.outputs.omos_version }} EXPECTED_OMOS_VERSION: ${{ needs.resolve-versions.outputs.omos_version }}
run: bash scripts/smoke-test.sh opencode-devbox:smoke-omos --variant omos run: bash scripts/smoke-test.sh opencode-devbox:smoke-omos --variant omos
@@ -316,7 +363,7 @@ jobs:
# ── Phase 4: multi-arch publish per variant ──────────────────────── # ── Phase 4: multi-arch publish per variant ────────────────────────
build-variant-base: build-variant-base:
needs: [base-decide, smoke-base] needs: [base-decide, smoke-base, resolve-versions]
runs-on: ubuntu-latest runs-on: ubuntu-latest
container: container:
image: catthehacker/ubuntu:act-latest image: catthehacker/ubuntu:act-latest
@@ -355,8 +402,10 @@ jobs:
env: env:
TAGS: ${{ steps.tags.outputs.tags }} TAGS: ${{ steps.tags.outputs.tags }}
BASE_IMAGE_FULL: ${{ env.IMAGE }}:${{ needs.base-decide.outputs.base_tag }} BASE_IMAGE_FULL: ${{ env.IMAGE }}:${{ needs.base-decide.outputs.base_tag }}
MEMPALACE_TOOLKIT_REF: ${{ needs.resolve-versions.outputs.mempalace_toolkit_ref }}
run: | run: |
set -euo pipefail set -euo pipefail
BUILD_DATE=$(date -u +%Y-%m-%dT%H:%M:%SZ)
TAG_FLAGS=() TAG_FLAGS=()
while IFS= read -r t; do [[ -n "$t" ]] && TAG_FLAGS+=( -t "$t" ); done <<< "${TAGS}" 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 # 3-attempt retry around `docker buildx build --push` (see build-base
@@ -370,6 +419,10 @@ jobs:
--build-arg "BASE_IMAGE=${BASE_IMAGE_FULL}" \ --build-arg "BASE_IMAGE=${BASE_IMAGE_FULL}" \
--build-arg "INSTALL_OPENCODE=true" \ --build-arg "INSTALL_OPENCODE=true" \
--build-arg "INSTALL_OMOS=false" \ --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[@]}" \ "${TAG_FLAGS[@]}" \
.; then .; then
echo "==> Attempt ${attempt} succeeded" echo "==> Attempt ${attempt} succeeded"
@@ -425,8 +478,10 @@ jobs:
TAGS: ${{ steps.tags.outputs.tags }} TAGS: ${{ steps.tags.outputs.tags }}
BASE_IMAGE_FULL: ${{ env.IMAGE }}:${{ needs.base-decide.outputs.base_tag }} BASE_IMAGE_FULL: ${{ env.IMAGE }}:${{ needs.base-decide.outputs.base_tag }}
OMOS_VERSION: ${{ needs.resolve-versions.outputs.omos_version }} OMOS_VERSION: ${{ needs.resolve-versions.outputs.omos_version }}
MEMPALACE_TOOLKIT_REF: ${{ needs.resolve-versions.outputs.mempalace_toolkit_ref }}
run: | run: |
set -euo pipefail set -euo pipefail
BUILD_DATE=$(date -u +%Y-%m-%dT%H:%M:%SZ)
TAG_FLAGS=() TAG_FLAGS=()
while IFS= read -r t; do [[ -n "$t" ]] && TAG_FLAGS+=( -t "$t" ); done <<< "${TAGS}" 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. # 3-attempt retry (see build-base step for rationale). Variant: omos.
@@ -440,6 +495,10 @@ jobs:
--build-arg "INSTALL_OPENCODE=true" \ --build-arg "INSTALL_OPENCODE=true" \
--build-arg "INSTALL_OMOS=true" \ --build-arg "INSTALL_OMOS=true" \
--build-arg "OMOS_VERSION=${OMOS_VERSION}" \ --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[@]}" \ "${TAG_FLAGS[@]}" \
.; then .; then
echo "==> Attempt ${attempt} succeeded" echo "==> Attempt ${attempt} succeeded"
+22 -2
View File
@@ -19,9 +19,10 @@ Docker image packaging [opencode](https://opencode.ai) into a production-ready d
- `Dockerfile.variant``FROM`s the base and adds only opencode/omos installs gated by build args: `INSTALL_OPENCODE` (default true), `INSTALL_OMOS`, and `INSTALL_MEMPALACE`. All GitHub-sourced binaries are pinned with version ARGs. Two variants: `base` (`INSTALL_OPENCODE=true`) and `omos` (`+INSTALL_OMOS=true`). - `Dockerfile.variant``FROM`s the base and adds only opencode/omos installs gated by build args: `INSTALL_OPENCODE` (default true), `INSTALL_OMOS`, and `INSTALL_MEMPALACE`. All GitHub-sourced binaries are pinned with version ARGs. Two variants: `base` (`INSTALL_OPENCODE=true`) and `omos` (`+INSTALL_OMOS=true`).
- `entrypoint.sh` — runs as root: UID/GID adjustment, SSH permissions, volume ownership fixes (skipped via `.devbox-owner` sentinel when ownership is already correct). Then drops to developer via gosu. - `entrypoint.sh` — runs as root: UID/GID adjustment, SSH permissions, volume ownership fixes (skipped via `.devbox-owner` sentinel when ownership is already correct). Then drops to developer via gosu.
- `entrypoint-user.sh` — runs as developer: git config, opencode.jsonc generation (delegated to `generate-config.py`), LAN-access setup (delegated to `setup-lan-access.sh`), a one-time npm-global prefix migration shim (legacy `~/.pi/npm-global``~/.config/opencode/npm-global`), skillset auto-deploy from mounted skillset repo, OMOS bundled-skills reconcile (symlinks the image's bundled skills into `~/.agents/skills/`), OMOS config setup. - `entrypoint-user.sh` — runs as developer: git config, opencode.jsonc generation (delegated to `generate-config.py`), LAN-access setup (delegated to `setup-lan-access.sh`), a one-time npm-global prefix migration shim (legacy `~/.pi/npm-global``~/.config/opencode/npm-global`), skillset auto-deploy from mounted skillset repo, OMOS bundled-skills reconcile (symlinks the image's bundled skills into `~/.agents/skills/`), OMOS config setup.
- `rootfs/usr/local/lib/opencode-devbox/setup-lan-access.sh` — host-OS-agnostic LAN reachability helper. Detects VM-backed hosts (macOS OrbStack / Docker Desktop, via `host.docker.internal` resolution) and generates a writable `~/.ssh-local/config` using the host as an SSH jump; no-op on native Linux. Controlled by `DEVBOX_LAN_ACCESS` / `HOST_SSH_USER` / `DEVBOX_HOST_ALIAS` / `DEVBOX_LAN_AUTOJUMP_PRIVATE`. Ships the mechanism only (generic `host` jump alias); user targets stay host-side — named-peer `ProxyJump host` overrides go in a bind-mounted `~/.config/devbox-shell/ssh-lan.conf` (Included before `~/.ssh/config`), never baked into the image. **Scoping invariant:** every `Include` in the generated config MUST be preceded by a bare `Host *` reset — an `Include` is scoped to the enclosing `Host`/`Match` block, so without the reset the included config only applies when targeting `host`/`mac` and named peers fall back to SSH defaults. The top `Host *` block also overrides `UserKnownHostsFile` and `ControlPath` into the writable `~/.ssh-local` sidecar (first-value-wins), because the bind-mounted `~/.ssh` is read-only — otherwise multiplexed hosts (`ControlPath ~/.ssh/cm/...`) fail to create their master socket. Non-fatal. Counted in the base hash, so editing it advances `base-latest`. - `rootfs/usr/local/lib/opencode-devbox/setup-lan-access.sh` — host-OS-agnostic LAN reachability helper. Always writes the writable `~/.ssh-local/config` sidecar on **every** host OS: a `Host *` block that redirects `ControlPath` into `~/.ssh-local/cm/` (first-value-wins over any read-only `~/.ssh`-bound per-host setting) plus `Include ~/.ssh/config`. On VM-backed hosts (macOS OrbStack / Docker Desktop, detected via `host.docker.internal` resolution) it additionally inserts the host-jump block; on native Linux that block is omitted (LAN is reachable directly) but the sidecar is still rendered. Previously the script exited early on native Linux, leaving `dssh`/`dscp` broken when `~/.ssh` was read-only there. Controlled by `DEVBOX_LAN_ACCESS` / `HOST_SSH_USER` / `DEVBOX_HOST_ALIAS` / `DEVBOX_LAN_AUTOJUMP_PRIVATE`. Ships the mechanism only (generic `host` jump alias); user targets stay host-side — named-peer `ProxyJump host` overrides go in a bind-mounted `~/.config/devbox-shell/ssh-lan.conf` (Included before `~/.ssh/config`), never baked into the image. **Scoping invariant:** every `Include` in the generated config MUST be preceded by a bare `Host *` reset — an `Include` is scoped to the enclosing `Host`/`Match` block, so without the reset the included config only applies when targeting `host`/`mac` and named peers fall back to SSH defaults. Non-fatal. Counted in the base hash, so editing it advances `base-latest`.
- `rootfs/usr/local/lib/opencode-devbox/generate-config.py` — generates `~/.config/opencode/opencode.jsonc` from env vars. Never overwrites an existing config (checks both `.json` and `.jsonc`). Auto-registers MCP servers for detected tools (mempalace via `mempalace-mcp`, gitea-mcp, context7 remote endpoint). - `rootfs/usr/local/lib/opencode-devbox/generate-config.py` — generates `~/.config/opencode/opencode.jsonc` from env vars. Never overwrites an existing config (checks both `.json` and `.jsonc`). Auto-registers MCP servers for detected tools (mempalace via `mempalace-mcp`, gitea-mcp, context7 remote endpoint).
- `scripts/smoke-test.sh` — post-build image verification. Asserts binary presence, opencode startup, entrypoint correctness, config generation idempotency, and image size thresholds. Used by both CI workflows. - `scripts/smoke-test.sh` — post-build image verification. Asserts binary presence, opencode startup, entrypoint correctness, config generation idempotency, and image size thresholds. Used by both CI workflows.
- `scripts/recreate-sanity-check.sh`**runtime** post-recreate verification (counterpart to the build-time `smoke-test.sh`). Run inside the container after `docker compose up -d --force-recreate` to confirm the new image is live (opencode version matches `Dockerfile.variant`'s `OPENCODE_VERSION`), persisted named volumes survived (mempalace palace, opencode.db, bash-history), omos runtime skill symlinks resolve, shell defaults re-seeded, and `/opt` toolkits intact. Not run by CI or the entrypoint — it needs the running container + volumes that smoke-test.sh (which uses `--entrypoint=""`) cannot see.
- `scripts/generate-dockerhub-md.py` — generates `DOCKER_HUB.md` from a hand-maintained `HUB_TEMPLATE` constant. `--check` fails if the committed file is out of sync (enforced by the `validate` workflow). - `scripts/generate-dockerhub-md.py` — generates `DOCKER_HUB.md` from a hand-maintained `HUB_TEMPLATE` constant. `--check` fails if the committed file is out of sync (enforced by the `validate` workflow).
- `DOCKER_HUB.md`**auto-generated** from `HUB_TEMPLATE` in `scripts/generate-dockerhub-md.py`. Do not edit directly. Pushed to Docker Hub description via CI API call. Must stay under 25 kB. Short description field must be ≤100 bytes. - `DOCKER_HUB.md`**auto-generated** from `HUB_TEMPLATE` in `scripts/generate-dockerhub-md.py`. Do not edit directly. Pushed to Docker Hub description via CI API call. Must stay under 25 kB. Short description field must be ≤100 bytes.
- `README.md` — authoritative source documentation for everything in this repo. Independent of `DOCKER_HUB.md`: the Hub doc is hand-maintained in the generator's `HUB_TEMPLATE` and intentionally slim, linking back to the gitea README for depth. - `README.md` — authoritative source documentation for everything in this repo. Independent of `DOCKER_HUB.md`: the Hub doc is hand-maintained in the generator's `HUB_TEMPLATE` and intentionally slim, linking back to the gitea README for depth.
@@ -73,7 +74,7 @@ When drafting a release CHANGELOG entry, pull notes from the **canonical upstrea
| `opencode-ai` (npm) | <https://github.com/anomalyco/opencode/releases> | Per-version release notes with Core / TUI / Desktop / SDK sections, contributor attributions. Some versions have empty bodies (internal/no-user-visible); most do not. | | `opencode-ai` (npm) | <https://github.com/anomalyco/opencode/releases> | Per-version release notes with Core / TUI / Desktop / SDK sections, contributor attributions. Some versions have empty bodies (internal/no-user-visible); most do not. |
| Other floated tools (gosu, fzf, bat, eza, zoxide, uv, nvim, gitea-mcp, Go, oh-my-opencode-slim) | Each project's own GitHub releases page | Usually less material per release; quote selectively. | | Other floated tools (gosu, fzf, bat, eza, zoxide, uv, nvim, gitea-mcp, Go, oh-my-opencode-slim) | Each project's own GitHub releases page | Usually less material per release; quote selectively. |
**Trap to avoid:** there is a `github.com/sst/opencode` repo that some search results surface; that's a fork (and probably the historical name people associate with opencode given the upstream lineage). It does NOT track the same release timeline. Use `anomalyco/opencode` for opencode release notes. **Where opencode actually lives (read this before you go looking):** the canonical opencode repo is **`github.com/anomalyco/opencode`**. It used to be `github.com/sst/opencode` and was **renamed/moved to `anomalyco/opencode` months ago** — `sst/opencode` is the *same* repo and now issues a `301 → anomalyco/opencode` (verified 2026-06-16). It is **not** a separate fork. Old `sst/opencode` links still resolve via the redirect, but always treat `anomalyco/opencode` as the source of truth for releases, PRs, and issues so search results pointing at the old name don't surprise you. Quick check: `curl -sI https://github.com/sst/opencode | grep -i location` → `anomalyco/opencode`.
Fetch pattern (saved here for muscle memory): Fetch pattern (saved here for muscle memory):
@@ -101,9 +102,11 @@ curl -s https://api.github.com/repos/anomalyco/opencode/releases/tags/v1.15.10 |
- **GitHub/Gitea-sourced binaries float by default** — gosu, fzf, git-lfs, gitleaks, nvim, bat, eza, zoxide, uv, gitea-mcp, Go, oh-my-opencode-slim all default to `latest`. Each build-time install step reads the `/releases/latest` Location redirect (or the go.dev JSON feed for Go) and derives the concrete version. Use the same `ARCH` case-switch pattern for multi-arch support (amd64/arm64) — mind project-specific arch-name deviations (gitleaks uses `x64`, bat/eza/zoxide use `x86_64`/`aarch64`, gosu uses `amd64`/`arm64`). Intentional pins: `OPENCODE_VERSION` (drives the image tag), `NODE_VERSION=22` (major pin), `DEBIAN_VERSION=trixie-slim` (OS base). Adding a new upstream tool: follow the existing floated-version pattern, don't hardcode a specific tag. - **GitHub/Gitea-sourced binaries float by default** — gosu, fzf, git-lfs, gitleaks, nvim, bat, eza, zoxide, uv, gitea-mcp, Go, oh-my-opencode-slim all default to `latest`. Each build-time install step reads the `/releases/latest` Location redirect (or the go.dev JSON feed for Go) and derives the concrete version. Use the same `ARCH` case-switch pattern for multi-arch support (amd64/arm64) — mind project-specific arch-name deviations (gitleaks uses `x64`, bat/eza/zoxide use `x86_64`/`aarch64`, gosu uses `amd64`/`arm64`). Intentional pins: `OPENCODE_VERSION` (drives the image tag), `NODE_VERSION=22` (major pin), `DEBIAN_VERSION=trixie-slim` (OS base). Adding a new upstream tool: follow the existing floated-version pattern, don't hardcode a specific tag.
- **Resolved versions are logged by the smoke test** — `scripts/smoke-test.sh` prints a "Resolved component versions" table as its first step. CI logs always capture what got baked into a given image even when ARGs default to `latest`. - **Resolved versions are logged by the smoke test** — `scripts/smoke-test.sh` prints a "Resolved component versions" table as its first step. CI logs always capture what got baked into a given image even when ARGs default to `latest`.
- **`OMOS_VERSION` MUST be passed by CI as a concrete version**, not left at the `latest` default. The npm install step in `Dockerfile.variant` (`oh-my-opencode-slim@${OMOS_VERSION}`) produces an identical layer-hash when the ARG value is byte-identical across builds; combined with the registry buildcache (`base-buildcache`) the layer gets reused even when `latest` would have resolved to a newer upstream. This is the same class of bug that bit pi-devbox v0.74.0 → v0.75.5 (silent same-bytes-across-releases regression discovered 2026-05-23, fixed in pi-devbox v0.75.5b). It is currently *masked* in opencode-devbox by `OPENCODE_VERSION` being a hard-coded ARG that bumps every release — that bump invalidates the parent-chain cache key for the downstream omos layer — but the masking would fail the moment a `vN.N.Nb` opencode-version-unchanged release ships that only bumps omos. Preventative fix: `.gitea/workflows/docker-publish-split.yml` has a `resolve-versions` job that runs `npm view oh-my-opencode-slim version`, exposing the concrete value as an output that the omos smoke + build jobs consume via build-args. Smoke tests assert via the `EXPECTED_OMOS_VERSION` env var — would catch the regression on the next release rather than several releases later. **If you change the variant build-args list, the resolve-versions job, or the smoke EXPECTED_*_VERSION wiring, audit all affected jobs in lockstep.** - **`OMOS_VERSION` MUST be passed by CI as a concrete version**, not left at the `latest` default. The npm install step in `Dockerfile.variant` (`oh-my-opencode-slim@${OMOS_VERSION}`) produces an identical layer-hash when the ARG value is byte-identical across builds; combined with the registry buildcache (`base-buildcache`) the layer gets reused even when `latest` would have resolved to a newer upstream. This is the same class of bug that bit pi-devbox v0.74.0 → v0.75.5 (silent same-bytes-across-releases regression discovered 2026-05-23, fixed in pi-devbox v0.75.5b). It is currently *masked* in opencode-devbox by `OPENCODE_VERSION` being a hard-coded ARG that bumps every release — that bump invalidates the parent-chain cache key for the downstream omos layer — but the masking would fail the moment a `vN.N.Nb` opencode-version-unchanged release ships that only bumps omos. Preventative fix: `.gitea/workflows/docker-publish-split.yml` has a `resolve-versions` job that runs `npm view oh-my-opencode-slim version`, exposing the concrete value as an output that the omos smoke + build jobs consume via build-args. Smoke tests assert via the `EXPECTED_OMOS_VERSION` env var — would catch the regression on the next release rather than several releases later. **If you change the variant build-args list, the resolve-versions job, or the smoke EXPECTED_*_VERSION wiring, audit all affected jobs in lockstep.**
- **`resolve-versions` also pins `mempalace-toolkit` to a SHA** — `resolve-versions` resolves the `mempalace-toolkit` `main` HEAD to a commit SHA (`mempalace_toolkit_ref` output) via the gitea commits API (`/api/v1/repos/joakimp/mempalace-toolkit/commits?limit=1&sha=main`; gitea allows **unauthenticated** public-repo listing, so no secret is required). Unlike every other dependency, `mempalace-toolkit` is cloned in **`Dockerfile.base`**, not the variant — so the resolve→build-arg→variant plumbing bypasses it. To make a moved toolkit actually land, the resolved SHA is **folded into the `base-decide` hash** (so `base_tag` changes → base rebuilds) AND passed to `build-base` as `--build-arg MEMPALACE_TOOLKIT_REF`. Consequently **`base-decide` now depends on `resolve-versions`** (they no longer run in parallel), and the base clone uses a SHA-capable `git fetch <ref> + checkout FETCH_HEAD` (a `git clone --branch <40-char-SHA>` would fail). Trade-off: `base_tag` now reflects a live gitea lookup — on an API blip it falls back to `main`, triggering one *extra* base rebuild, never a *missed* one. If you touch `resolve-versions`, `base-decide`'s hash inputs, or the `build-base` build-args, audit `.gitea/README.md` Step 1 in lockstep.
- **Registry buildkit cache-export is currently disabled** — do NOT re-add `cache-from`/`cache-to` to the `build-base` step in `.gitea/workflows/docker-publish-split.yml` without first verifying that buildkit's `mode=max` cache-export to `registry-1.docker.io` no longer returns HTTP 400 from the Hub CDN edge. The regression surfaced ~2026-05-23 and broke five consecutive opencode-devbox publish attempts (runs #332/333/334/336 + a rerun); root-caused on 2026-05-28 by a manual host-side publish that reproduced the same 400 only on `--cache-to` while image push worked fine. Failure shape is stable (`Offset:0` in the `_state` token, HTML response body = CDN-tier rejection, not registry backend), repo-specific (we're the only repo writing `:base-buildcache` mode=max), and explains why pinning `setup-buildx-action@v4.0.0` didn't help (action pin doesn't change the bundled buildkit version on the catthehacker runner image). Trade-off: dockerfile.base changes pay a full ~3 min rebuild instead of pulling cached layers; unchanged bases short-circuit at the Hub-probe step in `base-decide` and never re-build anyway. Variants don't use registry cache so they're unaffected. Re-enable condition: upstream moby/buildkit fix lands AND a low-risk test run succeeds without 400s. See CHANGELOG v1.15.12 `Unreleased` block for the full diagnostic chain. Manual escape-hatch publish procedure: `docs/manual-host-publish.md`. - **Registry buildkit cache-export is currently disabled** — do NOT re-add `cache-from`/`cache-to` to the `build-base` step in `.gitea/workflows/docker-publish-split.yml` without first verifying that buildkit's `mode=max` cache-export to `registry-1.docker.io` no longer returns HTTP 400 from the Hub CDN edge. The regression surfaced ~2026-05-23 and broke five consecutive opencode-devbox publish attempts (runs #332/333/334/336 + a rerun); root-caused on 2026-05-28 by a manual host-side publish that reproduced the same 400 only on `--cache-to` while image push worked fine. Failure shape is stable (`Offset:0` in the `_state` token, HTML response body = CDN-tier rejection, not registry backend), repo-specific (we're the only repo writing `:base-buildcache` mode=max), and explains why pinning `setup-buildx-action@v4.0.0` didn't help (action pin doesn't change the bundled buildkit version on the catthehacker runner image). Trade-off: dockerfile.base changes pay a full ~3 min rebuild instead of pulling cached layers; unchanged bases short-circuit at the Hub-probe step in `base-decide` and never re-build anyway. Variants don't use registry cache so they're unaffected. Re-enable condition: upstream moby/buildkit fix lands AND a low-risk test run succeeds without 400s. See CHANGELOG v1.15.12 `Unreleased` block for the full diagnostic chain. Manual escape-hatch publish procedure: `docs/manual-host-publish.md`.
- **Push steps wrap `docker buildx build --push` in a 3-attempt retry loop** (15s, 30s backoff) for transient `registry-1.docker.io` blips — rate limits, brief 5xx, CDN flap. Implemented as inline `shell: bash` steps with `docker buildx build` raw rather than `docker/build-push-action@v7` so the loop is visible and tweakable. Affects the 1 base + 5 variant push steps in `.gitea/workflows/docker-publish-split.yml`; smoke-test builds (`load: true`, no push) are untouched. **This does NOT mask deterministic failures** — a true regression (like the cache-export 400 of 2026-05-23..28) fails all 3 attempts identically and the job still fails. Orthogonal to the cache-export disablement above: cache-export was about a deterministic protocol mismatch, retry is about absorbing genuine transients. Both are belt-and-braces with the `ci-release-watcher` skill's transient-rerun heuristic. If you change the matrix of push steps, keep the retry wrapper consistent across them — the pattern is duplicated rather than factored out because Gitea Actions doesn't support reusable composite shell steps cleanly. - **Push steps wrap `docker buildx build --push` in a 3-attempt retry loop** (15s, 30s backoff) for transient `registry-1.docker.io` blips — rate limits, brief 5xx, CDN flap. Implemented as inline `shell: bash` steps with `docker buildx build` raw rather than `docker/build-push-action@v7` so the loop is visible and tweakable. Affects the 1 base + 5 variant push steps in `.gitea/workflows/docker-publish-split.yml`; smoke-test builds (`load: true`, no push) are untouched. **This does NOT mask deterministic failures** — a true regression (like the cache-export 400 of 2026-05-23..28) fails all 3 attempts identically and the job still fails. Orthogonal to the cache-export disablement above: cache-export was about a deterministic protocol mismatch, retry is about absorbing genuine transients. Both are belt-and-braces with the `ci-release-watcher` skill's transient-rerun heuristic. If you change the matrix of push steps, keep the retry wrapper consistent across them — the pattern is duplicated rather than factored out because Gitea Actions doesn't support reusable composite shell steps cleanly.
- **Shell scripts use `set -euo pipefail`** — both entrypoints are strict. Errors in volume chown or SSH permission operations are intentionally suppressed with `|| true`. - **Shell scripts use `set -euo pipefail`** — both entrypoints are strict. Errors in volume chown or SSH permission operations are intentionally suppressed with `|| true`.
- **MemPalace `diary_write` anyOf workaround — upstream watch target** — `Dockerfile.base` carries a perl RUN block that strips a root-level `anyOf` from `mempalace_diary_write`'s advertised `inputSchema`. Mempalace 3.3.x/3.4.0 advertise `anyOf: [{required:[entry]},{required:[content]}]`, which Anthropic's tools API (and Codex) reject at session start (`input_schema does not support oneOf, allOf, or anyOf at the top level`), making the whole MCP server fail to load. The workaround is idempotent and self-deactivating: when upstream ships the real fix the regex stops matching and the build prints `WARN: ... upstream may have changed shape` — **that WARN is the signal to delete the RUN block.** Upstream status (last checked **2026-06-14**): issue **#1728 is still OPEN**; PR **#1735 is CLOSED UNMERGED (2026-06-11) — do NOT watch it, it is dead**; PR **#1717 is the current live fix candidate**; mempalace PyPI latest is **3.4.0 (== our pin)**, so **no release contains the fix yet** and the workaround must stay. **Removal trigger:** a mempalace release **> 3.4.0** that actually strips the root-level `anyOf` lands on PyPI — then bump `MEMPALACE_VERSION` (in lockstep with pi-devbox) and drop the RUN block. NOTE: `MEMPALACE_VERSION` (the pip pin) and `MEMPALACE_TOOLKIT_REF` (the git ref for the `mempalace-toolkit` clone) are unrelated despite the shared prefix; do not conflate them.
- **MemPalace install path** — installed via `uv tool install` into `/opt/uv-tools/mempalace/`. Both the `mempalace` CLI and the `mempalace-mcp` MCP server binary are shipped as entry points by the mempalace package itself and placed on PATH by uv as shims whose shebangs point at the venv's Python. No hand-rolled wrapper is needed. Do not use `pip install --break-system-packages` — that was the previous approach and has been removed. Do not use `["python3", "-m", "mempalace.mcp_server"]` in `opencode.jsonc` — system Python can't import from the uv venv. - **MemPalace install path** — installed via `uv tool install` into `/opt/uv-tools/mempalace/`. Both the `mempalace` CLI and the `mempalace-mcp` MCP server binary are shipped as entry points by the mempalace package itself and placed on PATH by uv as shims whose shebangs point at the venv's Python. No hand-rolled wrapper is needed. Do not use `pip install --break-system-packages` — that was the previous approach and has been removed. Do not use `["python3", "-m", "mempalace.mcp_server"]` in `opencode.jsonc` — system Python can't import from the uv venv.
- **generate-config.py idempotency** — the script MUST never overwrite an existing `opencode.jsonc` or legacy `opencode.json`. Config persists in the `devbox-opencode-config` named volume; accidentally clobbering that file would destroy hand-edits. The smoke test asserts this. - **generate-config.py idempotency** — the script MUST never overwrite an existing `opencode.jsonc` or legacy `opencode.json`. Config persists in the `devbox-opencode-config` named volume; accidentally clobbering that file would destroy hand-edits. The smoke test asserts this.
- **Skillset auto-deploy** — on every container start, `entrypoint-user.sh` looks for a skillset repo (detection order: `$SKILLSET_CONTAINER_PATH` → `$HOME/skillset` → `/workspace/skillset`) and runs `deploy-skills.sh --bootstrap --prune-stale`. This creates relative symlinks in `~/.agents/skills/` and `~/.config/opencode/instructions/`. Do NOT bind-mount `~/.agents/skills/` from the host — the container manages its own skills with relative symlinks that differ from the host's. The named volume `devbox-opencode-config` persists the deployed config across restarts. - **Skillset auto-deploy** — on every container start, `entrypoint-user.sh` looks for a skillset repo (detection order: `$SKILLSET_CONTAINER_PATH` → `$HOME/skillset` → `/workspace/skillset`) and runs `deploy-skills.sh --bootstrap --prune-stale`. This creates relative symlinks in `~/.agents/skills/` and `~/.config/opencode/instructions/`. Do NOT bind-mount `~/.agents/skills/` from the host — the container manages its own skills with relative symlinks that differ from the host's. The named volume `devbox-opencode-config` persists the deployed config across restarts.
@@ -125,6 +128,23 @@ curl -s https://api.github.com/repos/anomalyco/opencode/releases/tags/v1.15.10 |
- **Step scripts run under `/bin/sh` (dash), not bash.** Avoid bash-isms like `${VAR//a/b}` parameter-pattern substitution; use POSIX alternatives (`tr`, `sed`) or declare `shell: bash` on the step. - **Step scripts run under `/bin/sh` (dash), not bash.** Avoid bash-isms like `${VAR//a/b}` parameter-pattern substitution; use POSIX alternatives (`tr`, `sed`) or declare `shell: bash` on the step.
- **`BUILDKIT_PROGRESS=plain`** is set at workflow level on `docker-publish-split.yml` so arm64-under-QEMU builds log each layer line-by-line. The default collapsed progress UI hides which step is stalled, which made diagnosing earlier hangs expensive. - **`BUILDKIT_PROGRESS=plain`** is set at workflow level on `docker-publish-split.yml` so arm64-under-QEMU builds log each layer line-by-line. The default collapsed progress UI hides which step is stalled, which made diagnosing earlier hangs expensive.
## Gitea API access (env token)
`GITEA_ACCESS_TOKEN` + `GITEA_HOST` are passed into the container from the
host `.env` via `docker-compose.yml` / `docker-compose.shared.yml`
(`${GITEA_ACCESS_TOKEN:-}` / `${GITEA_HOST:-}`), primarily to enable the
`gitea-mcp` server (see `generate-config.py`). They are **not** baked into
the image. When configured, they are also available for **any** direct
Gitea API interaction from inside the container — inspecting CI runs,
checking published tags, listing commits — e.g.
`curl -H "Authorization: token $GITEA_ACCESS_TOKEN" "$GITEA_HOST/api/v1/repos/joakimp/opencode-devbox/actions/runs?limit=5"`.
Prefer this over a short-lived PAT file when the env token is present (the
`ci-release-watcher` skill auto-detects it). Public-repo GET listings work
unauthenticated (see the `resolve-versions` mempalace-toolkit note above), so
the token matters mainly for private repos or rate-limit headroom; its
lifecycle is host-managed, so there is nothing to revoke after use. Never
echo the token value (including into logs).
## Testing changes ## Testing changes
The smoke test (`scripts/smoke-test.sh`) is the canonical check and runs automatically in CI. To run locally: The smoke test (`scripts/smoke-test.sh`) is the canonical check and runs automatically in CI. To run locally:
+181 -2
View File
@@ -2,11 +2,190 @@
All notable changes to the opencode-devbox container image. All notable changes to the opencode-devbox container image.
Tags follow `v{opencode_version}[letter]`bare tag for the first build on a new opencode release, letter suffix (`b`, `c`, …) for container-level rebuilds on the same version. See [AGENTS.md](AGENTS.md#versioning-scheme) for details. Tags follow **independent semver** (since `v2.0.0`)they version *this image*, not the bundled opencode release. MAJOR = breaking run/config changes, MINOR = backward-compatible features, PATCH = opencode/tool version bumps and small fixes. Pre-`v2.0.0` tags used the older `v{opencode_version}[letter]` scheme. See [AGENTS.md](AGENTS.md#versioning-scheme) for details.
--- ---
## Unreleased ## v2.2.0 — 2026-06-19
Ports the build-provenance, CI-hardening, SSH and shell fixes that landed in
the sibling **pi-devbox** repo (v1.1.4v1.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 <host>` 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.
### Added: opencode.json merge-on-recreate — non-destructive `.proposed` sidecar
The pi-devbox v1.1.4 deep-merge into a preserved `settings.json` 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 overwriting or
`jq`-merging a possibly-bind-mounted host config is destructive. Instead,
`generate-config.py` keeps its "never touch an existing config" guarantee and
adds a non-destructive side-channel: when a live config exists, it writes
`opencode.jsonc.proposed` — the config it *would* generate for the current
environment plus this image's defaults — **only when that differs** from the
live config, and removes it once they match. opencode never loads a `.proposed`
file, so it is purely a manual-merge reference (e.g. surfacing a default MCP
server added in a newer image). A one-line hint is logged when one is written;
an unparseable live config surfaces the proposal rather than guessing. The
proposed config is regenerated from env + image defaults, so a diff may reflect
your own past edits as well as new image defaults — the file header says so.
Covered by a new `scripts/smoke-test.sh` assertion (write-on-diff, removal on
match, live config never clobbered).
---
## v2.1.2 — 2026-06-16
Image-semver **patch**: bumps opencode to `1.17.7`. No devbox-side changes
beyond the `OPENCODE_VERSION` ARG, so only the variant layer is rebuilt; the
base is unaffected by this change.
### Bumped: opencode-ai 1.17.6 → 1.17.7
`OPENCODE_VERSION` ARG in `Dockerfile.variant`. Upstream `1.17.7` (published
2026-06-14) is a bugfix-and-minor-improvement patch with no breaking,
runtime-dependency, bundled-Bun, or CPU/AVX changes — a pure version bump on
the devbox side. Upstream highlights:
- **Core (bugfixes):** plugin client requests now reuse the active server
instead of assuming the default local port; ACP shell tool calls show the
command and working directory from the start; plugin-provided shell
environment variables now apply to PTY sessions.
- **Core (improvements):** MCP servers can now receive the current workspace as
a client root.
- **TUI:** MCP debug now uses the SDK's latest protocol version.
- **Desktop:** the new-session route stays scoped to its own draft server, so
prompts and state target the right workspace.
- **SDK:** clients refresh model and provider availability when integrations
change; credential update and remove calls accept `location`.
Full notes:
<https://github.com/anomalyco/opencode/releases/tag/v1.17.7>.
## v2.1.1 — 2026-06-14
Image-semver **patch**: bumps opencode and lands the `mempalace-toolkit`
SHA-resolution CI fix plus two doc corrections that accumulated on `main` since
`v2.1.0`. The toolkit change folds a live SHA into the base-tag hash, so this
release carries a full base rebuild and a `base-latest` re-promote.
### Bumped: opencode-ai 1.17.5 → 1.17.6
`OPENCODE_VERSION` ARG in `Dockerfile.variant`. Upstream `1.17.6` (published
2026-06-13) is a single Core bugfix — *"Improved MCP server compatibility by
declaring OpenCode's supported client capabilities"* — with no breaking,
runtime-dependency, bundled-Bun, or CPU/AVX changes. Pure version bump on the
devbox side. Full notes:
<https://github.com/anomalyco/opencode/releases/tag/v1.17.6>.
### Changed
- **`mempalace-toolkit` is now CI-resolved to a commit SHA.** It is the only
dependency cloned in `Dockerfile.base` (everything else is in the variant),
so it bypassed the `resolve-versions` → build-arg plumbing and its ref stayed
a literal `main` — meaning a toolkit-only fix would silently fail to land
unless `Dockerfile.base` itself changed. Now `resolve-versions` resolves the
`mempalace-toolkit` `main` HEAD to a SHA (new `mempalace_toolkit_ref` output,
via the gitea commits API — unauthenticated, no secret needed), `base-decide`
folds that SHA into the base-tag hash (so a moved toolkit forces a base
rebuild) and now **depends on `resolve-versions`**, and `build-base` passes it
as `--build-arg MEMPALACE_TOOLKIT_REF`. The base clone switched from
`git clone --branch` to a SHA-capable `git fetch <ref> + checkout FETCH_HEAD`.
Trade-off: `base_tag` now reflects a live gitea lookup — an API blip falls
back to `main` and triggers one *extra* rebuild, never a *missed* one. Updated
`.gitea/README.md` Step 1 and `AGENTS.md` Critical conventions in lockstep.
### Docs (no image change)
- Correct the MemPalace `diary_write` anyOf workaround watch-target: upstream
PR #1735 was **closed unmerged** (2026-06-11), so the old “remove once #1735
ships” TODO pointed at a dead PR. Issue #1728 is still open; PR #1717 is the
current live candidate; mempalace PyPI latest is still 3.4.0 (== our pin), so
the workaround stays. Rewrote the `Dockerfile.base` tracking comment and added
a durable note under `AGENTS.md` Critical conventions.
- Fix the quick-start description in `README.md` and the Hub `HUB_TEMPLATE`
(`scripts/generate-dockerhub-md.py`, regenerated `DOCKER_HUB.md`): bare
`docker compose run --rm devbox` lands in a **login shell** (default `CMD` is
`bash -l`), not opencode. The old copy claimed the opposite and had a garbled
"Use `bash` instead of (no command)" half-sentence. Pass `opencode` explicitly
to start the harness directly. Doc-only — does not trigger a new image build.
## v2.1.0 — 2026-06-13 ## v2.1.0 — 2026-06-13
+1 -1
View File
@@ -34,7 +34,7 @@ curl -fsSL https://gitea.jordbo.se/joakimp/opencode-devbox/raw/branch/main/.env.
docker compose run --rm devbox docker compose run --rm devbox
``` ```
This drops you straight into opencode with your project mounted at `/workspace`. Use `bash` as the command (e.g. `docker compose run --rm devbox bash`) to land in a shell first — useful for `aws sso login` or multi-agent workflows. This mounts your project at `/workspace`. With no command (as above) the image's default `CMD` (`bash -l`) drops you into a login shell — run `opencode` to start the harness, or `aws sso login` first, etc. To start opencode directly, pass it as the command: `docker compose run --rm devbox opencode`.
**One-shot run, no persistence:** **One-shot run, no persistence:**
+41 -7
View File
@@ -94,6 +94,15 @@ RUN apt-get update && \
# the last session closes, so consecutive ssh calls in a workflow reuse # the last session closes, so consecutive ssh calls in a workflow reuse
# the same TCP flow. Companion entrypoint-user.sh creates /tmp/sshcm # the same TCP flow. Companion entrypoint-user.sh creates /tmp/sshcm
# (mode 700) on each container start. # (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 <host>` — 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 && \ RUN mkdir -p /etc/ssh/ssh_config.d && \
printf '%s\n' \ printf '%s\n' \
'# Devbox-baked default. See Dockerfile.base "SSH client defaults".' \ '# Devbox-baked default. See Dockerfile.base "SSH client defaults".' \
@@ -285,12 +294,19 @@ RUN if [ "${INSTALL_MEMPALACE}" = "true" ]; then \
# kwarg alias so existing callers still work. # kwarg alias so existing callers still work.
# #
# Idempotent and self-deactivating: once upstream releases the fix the # Idempotent and self-deactivating: once upstream releases the fix the
# regex no longer matches and this RUN is a silent no-op. # regex no longer matches (and the WARN below fires) — that's the signal
# Upstream tracking: # to delete this RUN.
# Upstream status (last checked 2026-06-14):
# issue #1728 — STILL OPEN (root-level anyOf rejected by Anthropic/Codex)
# PR #1735 — CLOSED UNMERGED 2026-06-11; do NOT watch it (dead)
# PR #1717 — open; the current live fix candidate to watch
# mempalace PyPI latest = 3.4.0 (== our pin) → no release contains the fix yet
# https://github.com/MemPalace/mempalace/issues/1728 # https://github.com/MemPalace/mempalace/issues/1728
# https://github.com/MemPalace/mempalace/pull/1735 # https://github.com/MemPalace/mempalace/pull/1717
# TODO: remove this RUN once a mempalace release containing PR #1735 is on # TODO: remove this RUN once a mempalace release > 3.4.0 that actually strips
# PyPI and installed by the line above. # the root-level anyOf ships on PyPI and is installed by the line above.
# Keep MEMPALACE_VERSION in lockstep with pi-devbox when bumping.
# See AGENTS.md “Critical conventions” for the full watch-target rationale.
RUN if [ "${INSTALL_MEMPALACE}" = "true" ]; then \ RUN if [ "${INSTALL_MEMPALACE}" = "true" ]; then \
MP_FILE="$(find /opt/uv-tools/mempalace -path '*/mempalace/mcp_server.py' | head -n1)" && \ MP_FILE="$(find /opt/uv-tools/mempalace -path '*/mempalace/mcp_server.py' | head -n1)" && \
if [ -z "$MP_FILE" ]; then echo "mempalace mcp_server.py not found" >&2; exit 1; fi && \ if [ -z "$MP_FILE" ]; then echo "mempalace mcp_server.py not found" >&2; exit 1; fi && \
@@ -305,9 +321,27 @@ RUN if [ "${INSTALL_MEMPALACE}" = "true" ]; then \
# ── mempalace-toolkit — bash wrappers for session/docs mining ──────── # ── mempalace-toolkit — bash wrappers for session/docs mining ────────
ARG INSTALL_MEMPALACE_TOOLKIT=true ARG INSTALL_MEMPALACE_TOOLKIT=true
ARG MEMPALACE_TOOLKIT_REF=main 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
# --branch <40-char-SHA>` fails ("Remote branch not found"), so use
# `git fetch <ref> + checkout FETCH_HEAD`, which works for name and SHA.
RUN if [ "${INSTALL_MEMPALACE}" = "true" ] && [ "${INSTALL_MEMPALACE_TOOLKIT}" = "true" ]; then \ RUN if [ "${INSTALL_MEMPALACE}" = "true" ] && [ "${INSTALL_MEMPALACE_TOOLKIT}" = "true" ]; then \
git clone --depth 1 --branch "${MEMPALACE_TOOLKIT_REF}" \ rm -rf /opt/mempalace-toolkit && mkdir -p /opt/mempalace-toolkit && \
https://gitea.jordbo.se/joakimp/mempalace-toolkit.git /opt/mempalace-toolkit && \ git -C /opt/mempalace-toolkit init -q && \
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; \
echo "git fetch mempalace-toolkit@${MEMPALACE_TOOLKIT_REF} failed (attempt $i/5), retrying in $((i*5))s..."; \
sleep $((i*5)); \
done; \
[ "$ok" = "1" ] && \
ln -sf /opt/mempalace-toolkit/bin/mempalace-session /usr/local/bin/mempalace-session && \ ln -sf /opt/mempalace-toolkit/bin/mempalace-session /usr/local/bin/mempalace-session && \
ln -sf /opt/mempalace-toolkit/bin/mempalace-docs /usr/local/bin/mempalace-docs && \ ln -sf /opt/mempalace-toolkit/bin/mempalace-docs /usr/local/bin/mempalace-docs && \
chmod +x /opt/mempalace-toolkit/bin/mempalace-session /opt/mempalace-toolkit/bin/mempalace-docs && \ chmod +x /opt/mempalace-toolkit/bin/mempalace-session /opt/mempalace-toolkit/bin/mempalace-docs && \
+55 -1
View File
@@ -39,7 +39,7 @@ ARG USER_NAME=developer
# edit, so the cache-hit class of bug that bit pi-devbox v0.74.0.. # edit, so the cache-hit class of bug that bit pi-devbox v0.74.0..
# v0.75.5 cannot apply here. # v0.75.5 cannot apply here.
ARG INSTALL_OPENCODE=true ARG INSTALL_OPENCODE=true
ARG OPENCODE_VERSION=1.17.5 ARG OPENCODE_VERSION=1.17.8
RUN if [ "${INSTALL_OPENCODE}" = "true" ]; then \ RUN if [ "${INSTALL_OPENCODE}" = "true" ]; then \
NPM_CONFIG_PREFIX=/usr npm install -g opencode-ai@${OPENCODE_VERSION} && \ NPM_CONFIG_PREFIX=/usr npm install -g opencode-ai@${OPENCODE_VERSION} && \
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}; \ NPM_CONFIG_PREFIX=/usr npm install -g oh-my-opencode-slim@${OMOS_VERSION}; \
fi 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. # WORKDIR / ENTRYPOINT / CMD inherited from base.
+44 -5
View File
@@ -27,7 +27,7 @@ $EDITOR .env
docker compose run --rm devbox docker compose run --rm devbox
``` ```
This pulls `joakimp/opencode-devbox:latest` from Docker Hub, mounts `WORKSPACE_PATH` at `/workspace`, and drops you straight into opencode. Use `bash` instead of (no command) to land in a shell first — useful for `aws sso login`, `omos`, etc. This pulls `joakimp/opencode-devbox:latest` from Docker Hub and mounts `WORKSPACE_PATH` at `/workspace`. With no command (as above) the image's default `CMD` (`bash -l`) drops you into a **login shell** — from there run `opencode` to start the harness, or do `aws sso login` first, launch `omos`, etc. To start opencode directly and skip the shell, pass it as the command: `docker compose run --rm devbox opencode`.
**Want to hack on the image itself, follow upstream changes, or rebuild from source?** Clone the repo: **Want to hack on the image itself, follow upstream changes, or rebuild from source?** Clone the repo:
@@ -157,7 +157,7 @@ The devbox works the same way whether the host is **native Linux Docker** or a *
- **Native Linux Docker:** the host NATs container egress onto its LAN, so other devices on your LAN are reachable directly. Nothing to configure. - **Native Linux Docker:** the host NATs container egress onto its LAN, so other devices on your LAN are reachable directly. Nothing to configure.
- **VM-backed (macOS / Docker Desktop):** the container runs in a Linux VM behind the host's network stack. The host's *directly-attached* LAN peers are **not** bridged into the container by default — only the host itself and *routed* subnets are reachable. - **VM-backed (macOS / Docker Desktop):** the container runs in a Linux VM behind the host's network stack. The host's *directly-attached* LAN peers are **not** bridged into the container by default — only the host itself and *routed* subnets are reachable.
On every start the entrypoint detects which case applies. On VM-backed hosts it generates a writable `~/.ssh-local/config` that uses the **host as an SSH jump** to reach LAN peers; on native Linux it does nothing. The jump keypair lives in `~/.ssh-local`, which is persisted by the `devbox-ssh-local` named volume — so it's generated **once** and reused across container updates. On every start the entrypoint runs `setup-lan-access.sh`, which always writes a writable `~/.ssh-local/config` sidecar. The sidecar does two things regardless of host OS: redirect `ControlPath` into the writable `~/.ssh-local/cm/` (so ControlMaster sockets don't hit the read-only `~/.ssh` bind-mount) and `Include ~/.ssh/config`. On VM-backed hosts it additionally inserts the **SSH-jump-via-host block** so you can reach LAN peers; on native Linux that block is omitted (LAN is reachable directly) but the sidecar is still written — so `dssh`/`dscp` and ControlMaster work on native Linux with a read-only `~/.ssh` too. The jump keypair lives in `~/.ssh-local`, persisted by the `devbox-ssh-local` named volume — generated **once** and reused across container updates.
**To enable it on a VM-backed host (one-time setup per machine):** **To enable it on a VM-backed host (one-time setup per machine):**
@@ -209,7 +209,7 @@ Host my-remote
Opencode configuration is persisted automatically via the named volume `devbox-opencode-config`. This volume is mounted at `/home/developer/.config/opencode` by default — no host directory setup required. Changes to `opencode.jsonc` and (on the OMOS variant) `oh-my-opencode-slim.json` survive container recreation. Auto-deployed skills are *not* stored here — skillset and OMOS skills are symlinked into `~/.agents/skills/` and rebuilt on every start (see [Custom skills](#custom-skills) and [docs/omos-skills.md](docs/omos-skills.md)). Opencode configuration is persisted automatically via the named volume `devbox-opencode-config`. This volume is mounted at `/home/developer/.config/opencode` by default — no host directory setup required. Changes to `opencode.jsonc` and (on the OMOS variant) `oh-my-opencode-slim.json` survive container recreation. Auto-deployed skills are *not* stored here — skillset and OMOS skills are symlinked into `~/.agents/skills/` and rebuilt on every start (see [Custom skills](#custom-skills) and [docs/omos-skills.md](docs/omos-skills.md)).
When an existing `opencode.jsonc` is found in the volume, the `OPENCODE_PROVIDER` auto-config is skipped. When an existing `opencode.jsonc` is found in the volume, the `OPENCODE_PROVIDER` auto-config is skipped — the live config is never overwritten. However, on each start `generate-config.py` checks whether the config it *would* generate for your current environment differs from the live one, and if so writes a **`opencode.jsonc.proposed`** sidecar in the same directory. This is a manual-merge reference only — opencode never loads `.proposed` files. It is removed automatically once your live config matches the current image defaults. A one-line hint is logged when one is present. Differences may reflect new image defaults (e.g. a newly-added MCP server) *or* your own past edits — the file header explains both.
**Alternative: host bind-mount** — if you specifically want to share config from the host (e.g. to version-control it or sync across machines), replace the named volume with a bind mount: **Alternative: host bind-mount** — if you specifically want to share config from the host (e.g. to version-control it or sync across machines), replace the named volume with a bind mount:
@@ -422,7 +422,7 @@ docker compose build --build-arg NVIM_VERSION=0.12.1 # pin to a specific versi
|---|---|---| |---|---|---|
| `INSTALL_GO` | `false` | Go toolchain (resolves latest stable from go.dev when `GO_VERSION=latest`) | | `INSTALL_GO` | `false` | Go toolchain (resolves latest stable from go.dev when `GO_VERSION=latest`) |
| `INSTALL_MEMPALACE` | `true` | [MemPalace](https://github.com/MemPalace/mempalace) local AI memory system (~300 MB — disable to shrink image if you don't need MCP memory) | | `INSTALL_MEMPALACE` | `true` | [MemPalace](https://github.com/MemPalace/mempalace) local AI memory system (~300 MB — disable to shrink image if you don't need MCP memory) |
| `INSTALL_MEMPALACE_TOOLKIT` | `true` | [mempalace-toolkit](https://gitea.jordbo.se/joakimp/mempalace-toolkit) bash wrappers (`mempalace-session`, `mempalace-docs`). Cloned at build time from `MEMPALACE_TOOLKIT_REF` (default `main`). Requires `INSTALL_MEMPALACE=true`. | | `INSTALL_MEMPALACE_TOOLKIT` | `true` | [mempalace-toolkit](https://gitea.jordbo.se/joakimp/mempalace-toolkit) bash wrappers (`mempalace-session`, `mempalace-docs`). Cloned at build time from `MEMPALACE_TOOLKIT_REPO` at ref `MEMPALACE_TOOLKIT_REF` (default `main`). Requires `INSTALL_MEMPALACE=true`. |
| `INSTALL_OMOS` | `false` | [oh-my-opencode-slim](https://github.com/alvinunreal/oh-my-opencode-slim) multi-agent orchestration (installs Bun and plugin) | | `INSTALL_OMOS` | `false` | [oh-my-opencode-slim](https://github.com/alvinunreal/oh-my-opencode-slim) multi-agent orchestration (installs Bun and plugin) |
| `INSTALL_OPENCODE` | `true` | Install opencode. Set `false` to build a base with no harness (still includes Bun if `INSTALL_OMOS=true`). | | `INSTALL_OPENCODE` | `true` | Install opencode. Set `false` to build a base with no harness (still includes Bun if `INSTALL_OMOS=true`). |
| `OPENCODE_VERSION` | *(pinned per release)* | opencode npm version. Drives the image tag and is intentionally not floated. | | `OPENCODE_VERSION` | *(pinned per release)* | opencode npm version. Drives the image tag and is intentionally not floated. |
@@ -603,7 +603,46 @@ Both wrappers are idempotent and dedup-aware — re-running them on unchanged in
For weekly automated runs, the toolkit ships ready-to-use scheduler templates (systemd user timer, launchd user agent, cron) in its [`contrib/`](https://gitea.jordbo.se/joakimp/mempalace-toolkit/src/branch/main/contrib) directory. The `*-devbox` variants are designed for this container: host-side schedulers that `docker exec` into the running opencode-devbox. For weekly automated runs, the toolkit ships ready-to-use scheduler templates (systemd user timer, launchd user agent, cron) in its [`contrib/`](https://gitea.jordbo.se/joakimp/mempalace-toolkit/src/branch/main/contrib) directory. The `*-devbox` variants are designed for this container: host-side schedulers that `docker exec` into the running opencode-devbox.
Disable the toolkit (keeps mempalace itself) with `--build-arg INSTALL_MEMPALACE_TOOLKIT=false`. Pin to a specific ref with `--build-arg MEMPALACE_TOOLKIT_REF=v0.3.0` once tagged releases exist. Disable the toolkit (keeps mempalace itself) with `--build-arg INSTALL_MEMPALACE_TOOLKIT=false`. Pin to a specific ref with `--build-arg MEMPALACE_TOOLKIT_REF=v0.3.0` once tagged releases exist. Repoint the clone URL with `--build-arg MEMPALACE_TOOLKIT_REPO=<url>` for forked or air-gapped builds (see below).
### Building a fork / relocated build
The canonical build clones `mempalace-toolkit` from `gitea.jordbo.se`. That URL is an overridable build-arg (defaulting to the canonical origin), so a fork or a build on a host that can't reach that gitea can repoint it at a mirror, another host, or a local `file://` path **without editing the Dockerfiles**:
| Build-arg | Default | Dockerfile |
|---|---|---|
| `MEMPALACE_TOOLKIT_REPO` | `https://gitea.jordbo.se/joakimp/mempalace-toolkit.git` | base |
Each companion also has a matching `*_REF` arg (branch name or commit SHA). Example — build against a forked mempalace-toolkit:
```bash
# base first
docker build -f Dockerfile.base -t myorg/opencode-devbox:base-dev \
--build-arg MEMPALACE_TOOLKIT_REPO=https://github.com/myorg/mempalace-toolkit.git .
# then the variant FROM that base
docker build -f Dockerfile.variant -t myorg/opencode-devbox:dev \
--build-arg BASE_IMAGE=myorg/opencode-devbox:base-dev \
--build-arg OPENCODE_VERSION=1.17.8 .
```
Note: mempalace-toolkit clones anonymously (no token needed). Only the `resolve-versions` CI job calls the gitea API (which needs a token for public repos). A plain `docker build` like the above skips that job entirely, so no credentials are required.
Provenance build-args (all optional; populate the OCI labels and `/etc/opencode-devbox/build-manifest.json` — see below): `RELEASE_TAG`, `BUILD_DATE`, `SOURCE_REVISION`. CI sets these automatically; a manual build leaves them at harmless defaults.
### Build provenance (labels + manifest)
Every published image is self-describing. Inspect the OCI labels without pulling the filesystem:
```bash
docker inspect --format '{{json .Config.Labels}}' joakimp/opencode-devbox:latest | jq .
```
`org.opencontainers.image.{version,revision,created}` plus `se.jordbo.opencode-devbox.{opencode-version,install-omos,omos-version,mempalace-toolkit-ref}` record the intended opencode version, omos status, and companion refs. The on-disk `/etc/opencode-devbox/build-manifest.json` records **ground truth** — the live `opencode --version`, the installed `oh-my-opencode-slim` version (or `null` in the base variant), and the actual checked-out HEAD of `/opt/mempalace-toolkit` — so a tag is reconstructable after CI logs rotate:
```bash
docker run --rm --entrypoint= joakimp/opencode-devbox:latest cat /etc/opencode-devbox/build-manifest.json
```
### Storage ### Storage
+2 -1
View File
@@ -219,7 +219,8 @@ Add (for `with-pi`/`omos-with-pi`/pi-devbox):
2. **Latest-but-pinned**: track latest (master HEAD), resolve to SHA in CI build-arg. ✓ 2. **Latest-but-pinned**: track latest (master HEAD), resolve to SHA in CI build-arg. ✓
3. **Refactor**: pi-devbox/Dockerfile -> `FROM` the with-pi variant; pi-install in ONE place. ✓ 3. **Refactor**: pi-devbox/Dockerfile -> `FROM` the with-pi variant; pi-install in ONE place. ✓
4. **LAN default** `DEVBOX_LAN_ACCESS=auto`: generate config + print authorize hint when 4. **LAN default** `DEVBOX_LAN_ACCESS=auto`: generate config + print authorize hint when
`HOST_SSH_USER` unset; silent no-op on native Linux. ✓ `HOST_SSH_USER` unset; silent no-op on native Linux. ✓ *(v2.2.0: sidecar now rendered on
all OSes; native Linux no longer skipped — jump block still omitted there)*
5. **No `DEVBOX_LAN_HOSTS`**: rely on user's bind-mounted `~/.ssh/config` (`ProxyJump host`). ✓ 5. **No `DEVBOX_LAN_HOSTS`**: rely on user's bind-mounted `~/.ssh/config` (`ProxyJump host`). ✓
## Remaining verify-before-merge items ## Remaining verify-before-merge items
+15 -9
View File
@@ -12,12 +12,16 @@ set -euo pipefail
mkdir -p /tmp/sshcm mkdir -p /tmp/sshcm
chmod 700 /tmp/sshcm chmod 700 /tmp/sshcm
# ── LAN access: generic host-OS-agnostic reachability helper ──────── # ── LAN access + writable SSH sidecar: host-OS-agnostic helper ──────
# On VM-backed hosts (macOS OrbStack / Docker Desktop) the container can't # Generates the writable ~/.ssh-local/config on EVERY host OS: a `Host *`
# reach the host's directly-attached LAN peers by default; this generates a # ControlPath redirect into ~/.ssh-local/cm (so `ssh -F` / dssh / dscp work
# writable ~/.ssh-local/config that uses the host as an SSH jump. On native # even when ~/.ssh is bind-mounted read-only) plus `Include ~/.ssh/config`. On
# Linux (LAN reachable directly) it is a no-op. Controlled by DEVBOX_LAN_ACCESS # VM-backed hosts (macOS OrbStack / Docker Desktop) it ALSO adds an
# (auto|jump|off) + HOST_SSH_USER. Always non-fatal. See the script header. # 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 if [ -r /usr/local/lib/opencode-devbox/setup-lan-access.sh ]; then
bash /usr/local/lib/opencode-devbox/setup-lan-access.sh || true bash /usr/local/lib/opencode-devbox/setup-lan-access.sh || true
fi fi
@@ -91,9 +95,11 @@ fi
# ── Generate opencode config from env vars if no config mounted ────── # ── Generate opencode config from env vars if no config mounted ──────
# Delegated to a standalone Python script for clarity and testability. # Delegated to a standalone Python script for clarity and testability.
# The script is idempotent: it never overwrites an existing opencode.json # The script never overwrites an existing opencode.json/.jsonc (bind-mounted
# (bind-mounted from host, persisted in named volume, or previously # from host, persisted in named volume, or previously generated) and no-ops if
# generated) and no-ops if OPENCODE_PROVIDER is unset. # OPENCODE_PROVIDER is unset. When a config already exists it instead writes a
# NON-loaded opencode.jsonc.proposed sidecar (only when newer image defaults
# differ) for manual review/merge.
python3 /usr/local/lib/opencode-devbox/generate-config.py python3 /usr/local/lib/opencode-devbox/generate-config.py
# ── Skillset: deploy skills/instructions from mounted skillset repo ── # ── Skillset: deploy skills/instructions from mounted skillset repo ──
+8 -1
View File
@@ -89,9 +89,16 @@ fi
# we append with a newline separator to avoid the ';;' parse error # we append with a newline separator to avoid the ';;' parse error
# described at the top of this file. Guarded so repeated sourcing # described at the top of this file. Guarded so repeated sourcing
# (e.g. `exec bash`) doesn't stack duplicates. # (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 if [ -z "${DEVBOX_HIST_SET:-}" ]; then
PROMPT_COMMAND="${PROMPT_COMMAND:+$PROMPT_COMMAND$'\n'}history -a" PROMPT_COMMAND="${PROMPT_COMMAND:+$PROMPT_COMMAND$'\n'}history -a"
export DEVBOX_HIST_SET=1 DEVBOX_HIST_SET=1
fi fi
# ── Prompt: show [opencode-devbox] tag so it's obvious you're in the container # ── Prompt: show [opencode-devbox] tag so it's obvious you're in the container
@@ -3,9 +3,12 @@
Generate opencode.json from environment variables on first container start. Generate opencode.json from environment variables on first container start.
Safety guarantees: Safety guarantees:
- NEVER overwrites an existing opencode.json. If the file is present - NEVER overwrites an existing config (opencode.json / opencode.jsonc),
(whether bind-mounted from the host, persisted in a named volume, or whether bind-mounted from the host, persisted in a named volume, or
previously generated), this script exits immediately without writing. previously generated. When a config already exists, this script instead
writes a NON-loaded `opencode.jsonc.proposed` sidecar (only when the
freshly-generated config would differ) so new image defaults can be
reviewed and merged by hand. opencode never loads the .proposed file.
- Requires OPENCODE_PROVIDER to be set. Without it, no file is written. - Requires OPENCODE_PROVIDER to be set. Without it, no file is written.
Environment variables: Environment variables:
@@ -18,13 +21,16 @@ MCP servers are auto-registered for tools detected on PATH:
- mempalace (if installed) enabled - mempalace (if installed) enabled
- gitea-mcp (if installed) registered but disabled by default - gitea-mcp (if installed) registered but disabled by default
Output path: $HOME/.config/opencode/opencode.json Output path: $HOME/.config/opencode/opencode.jsonc
(existing config preserved; newer defaults surfaced as
$HOME/.config/opencode/opencode.jsonc.proposed)
""" """
from __future__ import annotations from __future__ import annotations
import json import json
import os import os
import re
import shutil import shutil
import sys import sys
from pathlib import Path from pathlib import Path
@@ -110,6 +116,113 @@ def register_mcp_servers(config: dict) -> list[str]:
return list(servers.keys()) return list(servers.keys())
def render_config(provider: str, model: str) -> tuple[dict, str, list[str]]:
"""Build the config dict and its JSONC rendering for a provider/model.
Shared by first-generation and the proposed-config side-channel so the
two can never drift. Returns (config_dict, jsonc_text, mcp_servers_added).
"""
config = build_config(provider, model)
added = register_mcp_servers(config)
# Write as JSONC so we can include helpful comments.
content = json.dumps(config, indent=2)
# Insert a comment about the Context7 API key after the context7 url line.
context7_comment = (
' "url": "https://mcp.context7.com/mcp"\n'
" // For higher rate limits, sign up at https://context7.com/dashboard\n"
' // and add: "headers": { "CONTEXT7_API_KEY": "{env:CONTEXT7_API_KEY}" }'
)
content = content.replace(
' "url": "https://mcp.context7.com/mcp"',
context7_comment,
)
return config, content, added
def _loads_jsonc(text: str) -> dict:
"""Parse JSONC (JSON + // line comments), preserving // inside strings.
Uses the same string-aware comment stripper as scripts/smoke-test.sh, so a
value such as an https:// URL is never corrupted. Raises on invalid JSON
(e.g. trailing commas) callers treat that as 'cannot compare'.
"""
pattern = r'"(?:\\.|[^"\\])*"|//[^\n]*'
stripped = re.sub(
pattern,
lambda m: m.group(0) if m.group(0).startswith('"') else "",
text,
)
return json.loads(stripped)
PROPOSED_HEADER = """\
//
// PROPOSED opencode config NOT loaded by opencode.
//
// This is what opencode-devbox would generate for your CURRENT environment
// plus THIS image's defaults. It is written only when it differs from your
// live opencode.jsonc, as a manual-merge reference e.g. a newer image added
// a default MCP server you do not have yet. opencode only loads
// opencode.json / opencode.jsonc, never this .proposed file.
//
// NOTE: this reflects env + image defaults, so a difference may be a new image
// default OR simply one of your own past edits (changed model, gitea
// enabled=true, ). Diff against your live config and merge what you want.
// Delete this file any time it is rewritten on the next start if still
// relevant, and removed automatically once your live config matches.
//
"""
def write_proposed(
proposed_file: Path, live_file: Path, config: dict, content: str
) -> None:
"""Non-destructively surface a newer default config beside the live one.
Writes <proposed_file> ONLY when the freshly-rendered config differs from
the live config (or the live config cannot be parsed for comparison).
Removes a stale proposed file when the live config already matches. NEVER
touches the live config itself.
"""
try:
live = _loads_jsonc(live_file.read_text())
differs = live != config
comparable = True
except (OSError, ValueError):
# Can't read or parse the live config — surface the proposal rather
# than silently guess they are equivalent.
comparable = False
differs = True
if comparable and not differs:
if proposed_file.exists():
try:
proposed_file.unlink()
print(
f"Live opencode config matches image defaults; removed "
f"stale {proposed_file.name}.",
file=sys.stderr,
)
except OSError:
pass
return
try:
proposed_file.write_text(PROPOSED_HEADER + content + "\n")
except OSError as e:
print(f"WARN: could not write {proposed_file}: {e}", file=sys.stderr)
return
why = "" if comparable else " (existing config could not be parsed for comparison)"
print(
f"A newer default opencode config is available at {proposed_file}{why}. "
"It is NOT applied automatically — diff/merge it into your live config "
"manually, or delete it to dismiss.",
file=sys.stderr,
)
def main() -> int: def main() -> int:
provider = os.environ.get("OPENCODE_PROVIDER", "").strip() provider = os.environ.get("OPENCODE_PROVIDER", "").strip()
if not provider: if not provider:
@@ -120,19 +233,7 @@ def main() -> int:
config_dir = home / ".config" / "opencode" config_dir = home / ".config" / "opencode"
config_file = config_dir / "opencode.jsonc" config_file = config_dir / "opencode.jsonc"
config_file_legacy = config_dir / "opencode.json" config_file_legacy = config_dir / "opencode.json"
proposed_file = config_dir / "opencode.jsonc.proposed"
# CRITICAL: never overwrite an existing config. Users may have
# bind-mounted their host config directory, or their config may be
# persisted in a named volume from a previous run.
# Check both .json and .jsonc variants.
if config_file.exists() or config_file_legacy.exists():
existing = config_file if config_file.exists() else config_file_legacy
print(
f"Existing config found at {existing}"
"skipping generation.",
file=sys.stderr,
)
return 0
if provider not in DEFAULT_MODELS: if provider not in DEFAULT_MODELS:
print( print(
@@ -145,30 +246,37 @@ def main() -> int:
provider, FALLBACK_MODEL provider, FALLBACK_MODEL
) )
config, content, added = render_config(provider, model)
# CRITICAL: never overwrite an existing config. Users may have bind-mounted
# their host config directory, or their config may be persisted in a named
# volume from a previous run. When a config already exists we instead
# surface any newer image defaults via a NON-loaded opencode.jsonc.proposed
# sidecar for manual merge (see write_proposed) — the live file is untouched.
existing = None
if config_file.exists():
existing = config_file
elif config_file_legacy.exists():
existing = config_file_legacy
if existing is not None:
print(
f"Existing config found at {existing} — not overwritten.",
file=sys.stderr,
)
write_proposed(proposed_file, existing, config, content)
return 0
print(f"Generating opencode config for provider: {provider}", file=sys.stderr) print(f"Generating opencode config for provider: {provider}", file=sys.stderr)
config = build_config(provider, model)
added = register_mcp_servers(config)
config_dir.mkdir(parents=True, exist_ok=True) config_dir.mkdir(parents=True, exist_ok=True)
# Write as JSONC so we can include helpful comments.
content = json.dumps(config, indent=2)
# Insert a comment about Context7 API key after the context7 url line.
context7_comment = (
' "url": "https://mcp.context7.com/mcp"\n'
" // For higher rate limits, sign up at https://context7.com/dashboard\n"
' // and add: "headers": { "CONTEXT7_API_KEY": "{env:CONTEXT7_API_KEY}" }'
)
content = content.replace(
' "url": "https://mcp.context7.com/mcp"',
context7_comment,
)
with config_file.open("w") as f: with config_file.open("w") as f:
f.write(content) f.write(content)
f.write("\n") f.write("\n")
# The fresh config now equals the image defaults — clear any stale proposal.
if proposed_file.exists():
try:
proposed_file.unlink()
except OSError:
pass
if added: if added:
print( print(
@@ -14,7 +14,9 @@
# The one thing reachable from a container on every OS is the host itself # 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 # (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 # 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), # We ship the MECHANISM (a generic `host` jump alias + writable config),
# never the POLICY: the user's specific target hosts live in their own # never the POLICY: the user's specific target hosts live in their own
@@ -30,7 +32,9 @@
# #
# CONTROLS (env) # CONTROLS (env)
# DEVBOX_LAN_ACCESS = auto (default) | jump | off # 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). # jump → always set up (e.g. native Linux with extra_hosts host-gateway).
# off → do nothing. # off → do nothing.
# HOST_SSH_USER — the username to SSH into the host as. REQUIRED for the # HOST_SSH_USER — the username to SSH into the host as. REQUIRED for the
@@ -84,41 +88,71 @@ is_vm_backed() {
getent hosts "$HOST_ALIAS_HOSTNAME" >/dev/null 2>&1 getent hosts "$HOST_ALIAS_HOSTNAME" >/dev/null 2>&1
} }
if [ "$MODE" = "auto" ] && ! is_vm_backed; then # ── Writable socket dir + sidecar (ALWAYS, every host OS) ─────────────
# Native Linux host: LAN peers are reachable directly. Nothing to do. # The ControlPath redirect in the generated config needs a writable directory
exit 0 # regardless of host OS or jump mode. ~/.ssh is typically read-only, so the
fi # master socket lives under the writable ~/.ssh-local. We create it and render
# the config UNCONDITIONALLY so the redirect (and `Include ~/.ssh/config`) works
# From here: MODE=jump, or MODE=auto on a VM-backed host. # even on native Linux — where we set up no host jump but a read-only ~/.ssh
# would otherwise still break ControlMaster sockets.
command -v ssh-keygen >/dev/null 2>&1 || exit 0
mkdir -p "${SSH_LOCAL}/cm" 2>/dev/null || true mkdir -p "${SSH_LOCAL}/cm" 2>/dev/null || true
chmod 700 "${SSH_LOCAL}" "${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 # 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 # 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 # we DO generate one it must be (re-)authorized on the host, so we flag it and
# print a copy-paste authorize line below. # print a copy-paste authorize line below.
KEY_JUST_GENERATED=0 KEY_JUST_GENERATED=0
if [ ! -f "$KEY" ]; then if [ "$NEED_JUMP" = "1" ] && command -v ssh-keygen >/dev/null 2>&1 && [ ! -f "$KEY" ]; then
ssh-keygen -t ed25519 -N '' -C "devbox-jump@${HOSTNAME:-container}" -f "$KEY" >/dev/null 2>&1 || exit 0 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 chmod 600 "$KEY" 2>/dev/null || true
KEY_JUST_GENERATED=1 KEY_JUST_GENERATED=1
fi
fi fi
# ── Render the writable config ──────────────────────────────────────── # ── Render the writable config ────────────────────────────────────────
USER_LINE="" # Jump-specific blocks (the host alias, host-owned peer overrides, and the
if [ -n "${HOST_SSH_USER:-}" ]; then # optional RFC1918 catch-all) only make sense when a jump is set up; on native
USER_LINE=" User ${HOST_SSH_USER}" # Linux they are all empty and only the ControlPath redirect + Include remain.
fi JUMP_BLOCK=""
# 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"
LAN_CONF_BLOCK="" LAN_CONF_BLOCK=""
if [ -r "$SSH_LAN_CONF" ]; then 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 <<EOF
# 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
EOF
)
# 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"
if [ -r "$SSH_LAN_CONF" ]; then
LAN_CONF_BLOCK=$(cat <<'EOF' LAN_CONF_BLOCK=$(cat <<'EOF'
# Host-owned named-peer jump overrides (bind-mounted; edit on the host). # Host-owned named-peer jump overrides (bind-mounted; edit on the host).
@@ -127,13 +161,12 @@ Host *
Include ~/.config/devbox-shell/ssh-lan.conf Include ~/.config/devbox-shell/ssh-lan.conf
EOF EOF
) )
fi fi
# Optional opt-in RFC1918 catch-all: ProxyJump every private IP through the # Optional opt-in RFC1918 catch-all: ProxyJump every private IP through the
# host. Matches the typed address, never the resolved HostName, so named hosts # host. Matches the typed address, never the resolved HostName, so named hosts
# with their own ProxyJump are unaffected. Network-agnostic → roaming-safe. # with their own ProxyJump are unaffected. Network-agnostic → roaming-safe.
AUTOJUMP_BLOCK="" if [ "${DEVBOX_LAN_AUTOJUMP_PRIVATE:-0}" = "1" ]; then
if [ "${DEVBOX_LAN_AUTOJUMP_PRIVATE:-0}" = "1" ]; then
AUTOJUMP_BLOCK=$(cat <<'EOF' AUTOJUMP_BLOCK=$(cat <<'EOF'
# RFC1918 auto-jump (DEVBOX_LAN_AUTOJUMP_PRIVATE=1): reach any private IP on # RFC1918 auto-jump (DEVBOX_LAN_AUTOJUMP_PRIVATE=1): reach any private IP on
@@ -146,6 +179,7 @@ Host 10.* 192.168.* 172.16.* 172.17.* 172.18.* 172.19.* 172.20.* 172.21.* 172.22
ProxyJump host ProxyJump host
EOF EOF
) )
fi
fi fi
INCLUDE_BLOCK="" INCLUDE_BLOCK=""
@@ -176,17 +210,7 @@ Host *
UserKnownHostsFile ~/.ssh-local/known_hosts UserKnownHostsFile ~/.ssh-local/known_hosts
StrictHostKeyChecking accept-new StrictHostKeyChecking accept-new
ControlPath ~/.ssh-local/cm/%r@%h:%p ControlPath ~/.ssh-local/cm/%r@%h:%p
${JUMP_BLOCK}
# 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
${LAN_CONF_BLOCK} ${LAN_CONF_BLOCK}
${AUTOJUMP_BLOCK} ${AUTOJUMP_BLOCK}
${INCLUDE_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 # 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 # (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. # 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)" PUBKEY_TEXT="$(cat "${KEY}.pub" 2>/dev/null)"
if [ -z "${HOST_SSH_USER:-}" ]; then if [ -z "${HOST_SSH_USER:-}" ]; then
cat <<EOF cat <<EOF
@@ -221,5 +246,6 @@ elif [ "$KEY_JUST_GENERATED" = "1" ]; then
repeat this on container updates — only if that volume is reset. repeat this on container updates — only if that volume is reset.
EOF EOF
fi fi
fi
exit 0 exit 0
+43
View File
@@ -0,0 +1,43 @@
#!/usr/bin/env bash
# check-base-hash.sh — guard the base-rebuild invariant.
#
# Every floating `ARG *_REF` consumed by Dockerfile.base MUST be folded
# into the base_tag hash in the docker-publish workflow. Otherwise a
# ref-only change to that dependency does not change the base hash, the
# Docker Hub probe finds the old base tag, and the base is NOT rebuilt —
# the dependency fix silently fails to land. This is the v1.1.2-class
# staleness footgun (then it was mempalace-toolkit; this guard stops the
# next one before it ships).
#
# Runs in CI (base-decide job) and locally: bash scripts/check-base-hash.sh
set -euo pipefail
cd "$(dirname "$0")/.."
WF=".gitea/workflows/docker-publish-split.yml"
DF="Dockerfile.base"
# Extract the hash-compute block: the `HASH=$( … ) | sha256sum | cut`
# brace-group in the "Compute base tag" step. This lives in a separate
# file from the workflow, so scanning $WF here is free of the self-match
# hazard an inline workflow step would have.
block=$(awk '/HASH=\$\(/{f=1} f{print} f && /cut -c1-12/{exit}' "$WF")
if [ -z "$block" ]; then
echo "::error::could not locate the HASH=\$( … ) | sha256sum block in $WF"
exit 1
fi
refs=$(grep -oE '^ARG [A-Z0-9_]+_REF' "$DF" | awk '{print $2}' | sort -u)
fail=0
for r in $refs; do
lc=$(printf '%s' "$r" | tr '[:upper:]' '[:lower:]')
if ! printf '%s' "$block" | grep -q "outputs.$lc"; then
echo "::error::Dockerfile.base declares '$r' but it is NOT folded into the base_tag hash in $WF."
echo "::error::Add echo \"\${{ needs.resolve-versions.outputs.$lc }}\" inside the HASH=\$( … ) | sha256sum block, or a $r-only change will silently fail to rebuild the base."
fail=1
fi
done
if [ "$fail" = 0 ]; then
echo "OK: all Dockerfile.base *_REF args are folded into base_tag (${refs:-none})."
fi
exit $fail
+1 -1
View File
@@ -88,7 +88,7 @@ curl -fsSL https://gitea.jordbo.se/joakimp/opencode-devbox/raw/branch/main/.env.
docker compose run --rm devbox docker compose run --rm devbox
``` ```
This drops you straight into opencode with your project mounted at `/workspace`. Use `bash` as the command (e.g. `docker compose run --rm devbox bash`) to land in a shell first useful for `aws sso login` or multi-agent workflows. This mounts your project at `/workspace`. With no command (as above) the image's default `CMD` (`bash -l`) drops you into a login shell — run `opencode` to start the harness, or `aws sso login` first, etc. To start opencode directly, pass it as the command: `docker compose run --rm devbox opencode`.
**One-shot run, no persistence:** **One-shot run, no persistence:**
+212
View File
@@ -0,0 +1,212 @@
#!/usr/bin/env bash
# Runtime post-recreate verification for opencode-devbox.
#
# Verifies that after `docker compose up -d --force-recreate`:
# - The new image is actually live (opencode version matches Dockerfile.variant)
# - Persisted named volumes survived (mempalace palace, opencode.db, bash-history)
# - OMOS runtime skill symlinks resolve (omos variant only)
# - Shell defaults re-seeded from /etc/skel-devbox
# - /opt toolkits intact
# - Known expected-absences don't regress
#
# This is repo/maintainer tooling — the runtime peer of smoke-test.sh. It is
# NOT baked into the published Docker Hub image; run it from a checkout of the
# opencode-devbox repo (which a maintainer already has for CI builds). A plain
# `docker pull` consumer is not the audience and will not have this file.
#
# Usage: ./scripts/recreate-sanity-check.sh [--expected-version X.Y.Z] [--variant base|omos]
#
# Exit codes:
# 0 all checks passed
# 1 one or more checks failed
# 2 usage error
set -euo pipefail
EXPECTED_VERSION=""
VARIANT=""
REPO_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
# Parse arguments
while [[ $# -gt 0 ]]; do
case "$1" in
--expected-version)
EXPECTED_VERSION="$2"
shift 2
;;
--variant)
VARIANT="$2"
shift 2
;;
*)
echo "usage: $0 [--expected-version X.Y.Z] [--variant base|omos]" >&2
exit 2
;;
esac
done
FAILED=0
pass() { echo "$1"; }
fail() { echo "$1" >&2; FAILED=$((FAILED + 1)); }
warn() { echo "$1" >&2; }
# Determine expected opencode version from Dockerfile.variant if not provided
if [ -z "$EXPECTED_VERSION" ]; then
EXPECTED_VERSION="$(grep -oE 'OPENCODE_VERSION=[0-9.]+' "$REPO_DIR/Dockerfile.variant" | head -1 | cut -d= -f2)"
if [ -z "$EXPECTED_VERSION" ]; then
echo "error: could not determine OPENCODE_VERSION from $REPO_DIR/Dockerfile.variant" >&2
exit 2
fi
fi
# Auto-detect variant if not provided
if [ -z "$VARIANT" ]; then
if command -v bun >/dev/null 2>&1 || [ -d /usr/lib/node_modules/oh-my-opencode-slim ] || [ -d /usr/local/lib/node_modules/oh-my-opencode-slim ]; then
VARIANT="omos"
else
VARIANT="base"
fi
fi
# Print header with git context
echo "=== Recreate sanity check (variant: $VARIANT) ==="
if GIT_TAG=$(git -C "$REPO_DIR" describe --tags 2>/dev/null); then
echo " Repo HEAD: $GIT_TAG (version-match only meaningful when image tag matches)"
else
echo " Repo HEAD: (not a git repo or no tags)"
fi
echo
echo "-- opencode version --"
if ACTUAL_VERSION=$(opencode --version 2>&1 | head -1); then
if [ "$ACTUAL_VERSION" = "$EXPECTED_VERSION" ]; then
pass "opencode version $ACTUAL_VERSION"
else
fail "opencode version mismatch: expected $EXPECTED_VERSION, got $ACTUAL_VERSION"
fi
else
fail "opencode --version failed"
fi
echo
echo "-- Persisted named volumes (must survive --force-recreate) --"
# mempalace palace volume
if [ -f "$HOME/.mempalace/palace/chroma.sqlite3" ]; then
SIZE=$(du -h "$HOME/.mempalace/palace/chroma.sqlite3" | cut -f1)
if [ -s "$HOME/.mempalace/palace/chroma.sqlite3" ]; then
pass "~/.mempalace/palace/chroma.sqlite3 exists ($SIZE)"
else
fail "~/.mempalace/palace/chroma.sqlite3 exists but is empty"
fi
else
fail "~/.mempalace/palace/chroma.sqlite3 missing"
fi
# opencode session history volume
if [ -f "$HOME/.local/share/opencode/opencode.db" ]; then
SIZE=$(du -h "$HOME/.local/share/opencode/opencode.db" | cut -f1)
if [ -s "$HOME/.local/share/opencode/opencode.db" ]; then
pass "~/.local/share/opencode/opencode.db exists ($SIZE)"
else
fail "~/.local/share/opencode/opencode.db exists but is empty"
fi
else
fail "~/.local/share/opencode/opencode.db missing"
fi
# bash-history volume mount point (empty .bash_history right after recreate is NORMAL)
if [ -d "$HOME/.cache/bash" ]; then
pass "~/.cache/bash exists as directory"
else
fail "~/.cache/bash missing or not a directory"
fi
echo
echo "-- omos runtime skill symlinks (omos variant only; skip on base) --"
if [ "$VARIANT" = "omos" ]; then
SKILLS_OK=0
SKILLS_TOTAL=5
for skill in clonedeps codemap deepwork oh-my-opencode-slim simplify; do
SKILL_PATH="$HOME/.agents/skills/$skill"
if [ -L "$SKILL_PATH" ]; then
TARGET=$(readlink -f "$SKILL_PATH")
# Check if target resolves to a real directory and contains the expected path
if [ -d "$TARGET" ] && echo "$TARGET" | grep -q "node_modules/oh-my-opencode-slim/src/skills/$skill"; then
SKILLS_OK=$((SKILLS_OK + 1))
else
fail "~/.agents/skills/$skill symlink target invalid: $TARGET"
fi
else
fail "~/.agents/skills/$skill missing or not a symlink"
fi
done
if [ "$SKILLS_OK" -eq "$SKILLS_TOTAL" ]; then
pass "$SKILLS_OK/$SKILLS_TOTAL omos skill symlinks resolve"
fi
# Migration marker
if [ -f "$HOME/.config/opencode/.omos-skills-migrated" ]; then
pass "~/.config/opencode/.omos-skills-migrated exists"
else
fail "~/.config/opencode/.omos-skills-migrated missing"
fi
else
echo " - skipped (base variant)"
fi
echo
echo "-- Shell defaults re-seeded from /etc/skel-devbox --"
if [ -f "$HOME/.bash_aliases" ]; then
pass "~/.bash_aliases exists"
else
fail "~/.bash_aliases missing"
fi
if [ -f "$HOME/.inputrc" ]; then
pass "~/.inputrc exists"
else
fail "~/.inputrc missing"
fi
echo
echo "-- cli_utils bind-mount --"
if [ -d /workspace/cli_utils ] && [ -d /workspace/cli_utils/.git ]; then
pass "/workspace/cli_utils exists with .git subdir"
else
fail "/workspace/cli_utils missing or .git subdir absent"
fi
echo
echo "-- Baked /opt toolkits --"
if [ -d /opt/mempalace-toolkit ]; then
if MEMPALACE_SESSION_PATH=$(command -v mempalace-session 2>/dev/null); then
RESOLVED=$(readlink -f "$MEMPALACE_SESSION_PATH")
pass "/opt/mempalace-toolkit exists, mempalace-session resolves to $RESOLVED"
else
fail "/opt/mempalace-toolkit exists but mempalace-session not on PATH"
fi
else
fail "/opt/mempalace-toolkit missing"
fi
echo
echo "-- Known expected-absences (regressions vs by-design) --"
if [ ! -d "$HOME/.local/bin" ]; then
warn "~/.local/bin absent — expected; mempalace toolkit relocated to /opt (not a wrapper-loss regression)"
else
pass "~/.local/bin exists (toolkit may have been installed locally)"
fi
if ! command -v go >/dev/null 2>&1; then
warn "go absent — expected unless image built with INSTALL_GO=true"
else
pass "go is on PATH"
fi
echo
if [ "$FAILED" -gt 0 ]; then
echo "=== FAILED: $FAILED check(s) ===" >&2
exit 1
fi
echo "=== PASSED ==="
+59
View File
@@ -197,6 +197,34 @@ else
fi fi
fi fi
echo
echo "-- Build provenance (manifest + OCI labels) --"
run "/etc/opencode-devbox/build-manifest.json present" \
"test -f /etc/opencode-devbox/build-manifest.json"
run_expect "manifest records opencode component" \
"cat /etc/opencode-devbox/build-manifest.json" '"opencode"'
run_expect "manifest records opencode_version" \
"cat /etc/opencode-devbox/build-manifest.json" '"opencode_version"'
run_expect "manifest records mempalace-toolkit component" \
"cat /etc/opencode-devbox/build-manifest.json" '"mempalace-toolkit"'
# Every resolved component must be a real value, never the 'unknown'
# sentinel that rev()/version lookups emit on failure. (oh-my-opencode-slim
# is JSON null in the base variant — that is expected, not 'unknown'.)
run "manifest has no unresolved ('unknown') components" \
"! grep -q '\"unknown\"' /etc/opencode-devbox/build-manifest.json"
if [ "$VARIANT" = "omos" ]; then
run "manifest omos component is resolved (not null) in omos variant" \
"! grep -q '\"oh-my-opencode-slim\": null' /etc/opencode-devbox/build-manifest.json"
fi
# OCI labels live in the image config, not the container fs — inspect them
# from the host docker rather than via `docker run`.
LBL=$(docker inspect --format '{{ index .Config.Labels "se.jordbo.opencode-devbox.opencode-version" }}' "$IMAGE" 2>/dev/null || true)
if [ -n "$LBL" ] && [ "$LBL" != "<no value>" ]; 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
echo "-- Entrypoint behaviour --" echo "-- Entrypoint behaviour --"
@@ -278,6 +306,37 @@ if docker run --rm \
else else
fail "$label: existing config was modified!" fail "$label: existing config was modified!"
fi fi
# Proposed-config side-channel: when a config already exists, a NEWER default
# config is surfaced as a NON-loaded opencode.jsonc.proposed (write-on-diff,
# removed once the live config matches). The live config is never touched.
label="generate-config writes .proposed only when config differs"
if docker run --rm \
-e OPENCODE_PROVIDER=anthropic \
-e HOME=/tmp/home \
--entrypoint="" \
"$IMAGE" sh -c '
set -e
d=/tmp/home/.config/opencode
mkdir -p "$d"
gc=/usr/local/lib/opencode-devbox/generate-config.py
# (a) differing existing config → proposed written, live NOT clobbered
printf "{\n \"model\": \"old/model\"\n}\n" > "$d/opencode.jsonc"
python3 "$gc" 2>/dev/null
test -f "$d/opencode.jsonc.proposed"
grep -q "old/model" "$d/opencode.jsonc"
# (b) live matches defaults + stale proposed present → proposed removed
rm -f "$d/opencode.jsonc" "$d/opencode.jsonc.proposed"
python3 "$gc" 2>/dev/null
cp "$d/opencode.jsonc" "$d/opencode.jsonc.proposed"
python3 "$gc" 2>/dev/null
test ! -f "$d/opencode.jsonc.proposed"
echo ok
' 2>/dev/null | grep -q ok; then
pass "$label"
else
fail "$label: proposed-config behaviour incorrect"
fi
rm -rf "$tmp" rm -rf "$tmp"
echo echo