Compare commits

..

10 Commits

Author SHA1 Message Date
pi b9039f577e release: v2.3.0 — image-baked fallback skills + opencode 1.17.10 + mempalace 3.5.0
Validate / base-change-warning (push) Successful in 14s
Publish Docker Image / resolve-versions (push) Successful in 9s
Publish Docker Image / base-decide (push) Successful in 13s
Validate / validate-base (push) Failing after 3m26s
Validate / validate-omos (push) Failing after 4m28s
Publish Docker Image / build-base (push) Successful in 37m26s
Publish Docker Image / smoke-omos (push) Successful in 4m36s
Publish Docker Image / smoke-base (push) Successful in 7m41s
Publish Docker Image / build-variant-base (push) Successful in 13m47s
Publish Docker Image / build-variant-omos (push) Successful in 19m24s
Publish Docker Image / promote-base-latest (push) Successful in 8s
Validate / docs-check (push) Successful in 6s
Publish Docker Image / update-description (push) Successful in 9s
- Add image-baked fallback skills (opencode-devbox-environment, mempalace) +
  harness instruction (instructions/opencode-devbox.md) under
  /usr/local/share/opencode-devbox/, symlinked in by entrypoint-user.sh
  (skills only-when-absent; instruction symlink to image, never copied into the
  devbox-opencode-config volume). Ported from pi-devbox v1.2.0/v1.2.1, adapted
  to opencode's ~/.config/opencode/instructions/ auto-load model. No
  pi-extensions skill (opencode has no fork/recall).
- Bump opencode 1.17.8 -> 1.17.10.
- Bump mempalace 3.4.0 -> 3.5.0 (lockstep with pi-devbox v1.2.2); remove the
  obsolete diary_write anyOf perl workaround (fixed upstream, issue #1728).
- Fix stale ssh-lan.conf ProxyJump guidance comment in setup-lan-access.sh
  (mirrors pi-devbox 8de0fad); comment-only.
- smoke-test.sh + recreate-sanity-check.sh assert baked source + resolved links.
- Docs: README Custom skills, AGENTS.md duties + MINOR example, CHANGELOG.
2026-06-25 09:58:13 +02:00
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
18 changed files with 1657 additions and 145 deletions
+42 -5
View File
@@ -59,6 +59,9 @@ jobs:
- name: Checkout
uses: actions/checkout@v4
- name: Guard — base *_REF args must be folded into the base hash
run: bash scripts/check-base-hash.sh
- name: Compute base tag from Dockerfile.base + dependencies
id: compute
run: |
@@ -130,14 +133,32 @@ jobs:
steps:
- name: Resolve omos version from npm registry
id: resolve
shell: bash
run: |
set -eu
set -euo pipefail
# Fail loud rather than silently shipping a floating ref or a bad
# version. A transient network/API failure must ABORT the release,
# not bake an unpinned ref that defeats both cache-busting AND
# after-the-fact reproducibility. (Previously the gitea lookup fell
# back to `main` via `|| echo`, and the npm lookup had no guard.)
# NOTE: shell: bash is REQUIRED — `set -o pipefail` is illegal in
# the runner's default dash/sh and aborts the step immediately.
require_sha() { # $1=label $2=value
if ! printf '%s' "${2:-}" | grep -qiE '^[0-9a-f]{40}$'; then
echo "::error::Could not resolve $1 to a commit SHA (got '${2:-<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`.
# catthehacker/ubuntu:act-latest ships Node/npm under /opt/acttoolcache/
# and adds it to PATH only via /etc/environment — which act_runner never
# sources (it reads the Docker image's ENV instructions, not /etc/environment).
# curl and jq are both guaranteed present in every job in this workflow.
OMOS_VERSION=$(curl -sf "https://registry.npmjs.org/oh-my-opencode-slim/latest" | jq -r '.version')
OMOS_VERSION=$(curl -sf "https://registry.npmjs.org/oh-my-opencode-slim/latest" | jq -r '.version' 2>/dev/null || true)
if ! printf '%s' "${OMOS_VERSION:-}" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+'; then
echo "::error::Could not resolve oh-my-opencode-slim version from npm (got '${OMOS_VERSION:-<empty>}'). Refusing to build with an unresolved version."
exit 1
fi
echo "omos_version=${OMOS_VERSION}" >> "$GITHUB_OUTPUT"
echo "Resolved OMOS_VERSION=${OMOS_VERSION}"
# Resolve mempalace-toolkit main HEAD to a commit SHA. Unlike omos
@@ -150,8 +171,8 @@ jobs:
# env vars are unset (degrades to anon, still HTTP 200).
MEMPALACE_TOOLKIT_REF=$(curl -sf -H "Authorization: token ${GITEA_BUILD_TOKEN:-${GITHUB_TOKEN:-}}" \
"https://gitea.jordbo.se/api/v1/repos/joakimp/mempalace-toolkit/commits?limit=1&sha=main" \
| jq -r '.[0].sha // "main"' 2>/dev/null || echo "main")
[ -n "$MEMPALACE_TOOLKIT_REF" ] || MEMPALACE_TOOLKIT_REF=main
| jq -r '.[0].sha // empty' 2>/dev/null || true)
require_sha MEMPALACE_TOOLKIT_REF "$MEMPALACE_TOOLKIT_REF"
echo "mempalace_toolkit_ref=${MEMPALACE_TOOLKIT_REF}" >> "$GITHUB_OUTPUT"
echo "Resolved MEMPALACE_TOOLKIT_REF=${MEMPALACE_TOOLKIT_REF}"
@@ -288,6 +309,8 @@ jobs:
BASE_IMAGE=${{ env.IMAGE }}:${{ needs.base-decide.outputs.base_tag }}
INSTALL_OPENCODE=true
INSTALL_OMOS=false
RELEASE_TAG=smoke
SOURCE_REVISION=${{ github.sha }}
- name: Smoke test (amd64)
run: bash scripts/smoke-test.sh opencode-devbox:smoke-base --variant base
@@ -331,6 +354,8 @@ jobs:
INSTALL_OPENCODE=true
INSTALL_OMOS=true
OMOS_VERSION=${{ needs.resolve-versions.outputs.omos_version }}
RELEASE_TAG=smoke
SOURCE_REVISION=${{ github.sha }}
- env:
EXPECTED_OMOS_VERSION: ${{ needs.resolve-versions.outputs.omos_version }}
run: bash scripts/smoke-test.sh opencode-devbox:smoke-omos --variant omos
@@ -338,7 +363,7 @@ jobs:
# ── Phase 4: multi-arch publish per variant ────────────────────────
build-variant-base:
needs: [base-decide, smoke-base]
needs: [base-decide, smoke-base, resolve-versions]
runs-on: ubuntu-latest
container:
image: catthehacker/ubuntu:act-latest
@@ -377,8 +402,10 @@ jobs:
env:
TAGS: ${{ steps.tags.outputs.tags }}
BASE_IMAGE_FULL: ${{ env.IMAGE }}:${{ needs.base-decide.outputs.base_tag }}
MEMPALACE_TOOLKIT_REF: ${{ needs.resolve-versions.outputs.mempalace_toolkit_ref }}
run: |
set -euo pipefail
BUILD_DATE=$(date -u +%Y-%m-%dT%H:%M:%SZ)
TAG_FLAGS=()
while IFS= read -r t; do [[ -n "$t" ]] && TAG_FLAGS+=( -t "$t" ); done <<< "${TAGS}"
# 3-attempt retry around `docker buildx build --push` (see build-base
@@ -392,6 +419,10 @@ jobs:
--build-arg "BASE_IMAGE=${BASE_IMAGE_FULL}" \
--build-arg "INSTALL_OPENCODE=true" \
--build-arg "INSTALL_OMOS=false" \
--build-arg "MEMPALACE_TOOLKIT_REF=${MEMPALACE_TOOLKIT_REF}" \
--build-arg "RELEASE_TAG=${RELEASE_TAG}" \
--build-arg "BUILD_DATE=${BUILD_DATE}" \
--build-arg "SOURCE_REVISION=${GITHUB_SHA:-}" \
"${TAG_FLAGS[@]}" \
.; then
echo "==> Attempt ${attempt} succeeded"
@@ -447,8 +478,10 @@ jobs:
TAGS: ${{ steps.tags.outputs.tags }}
BASE_IMAGE_FULL: ${{ env.IMAGE }}:${{ needs.base-decide.outputs.base_tag }}
OMOS_VERSION: ${{ needs.resolve-versions.outputs.omos_version }}
MEMPALACE_TOOLKIT_REF: ${{ needs.resolve-versions.outputs.mempalace_toolkit_ref }}
run: |
set -euo pipefail
BUILD_DATE=$(date -u +%Y-%m-%dT%H:%M:%SZ)
TAG_FLAGS=()
while IFS= read -r t; do [[ -n "$t" ]] && TAG_FLAGS+=( -t "$t" ); done <<< "${TAGS}"
# 3-attempt retry (see build-base step for rationale). Variant: omos.
@@ -462,6 +495,10 @@ jobs:
--build-arg "INSTALL_OPENCODE=true" \
--build-arg "INSTALL_OMOS=true" \
--build-arg "OMOS_VERSION=${OMOS_VERSION}" \
--build-arg "MEMPALACE_TOOLKIT_REF=${MEMPALACE_TOOLKIT_REF}" \
--build-arg "RELEASE_TAG=${RELEASE_TAG}" \
--build-arg "BUILD_DATE=${BUILD_DATE}" \
--build-arg "SOURCE_REVISION=${GITHUB_SHA:-}" \
"${TAG_FLAGS[@]}" \
.; then
echo "==> Attempt ${attempt} succeeded"
+23 -4
View File
@@ -18,10 +18,11 @@ Docker image packaging [opencode](https://opencode.ai) into a production-ready d
- `Dockerfile.base` — variant-independent layers (apt, locales, AWS CLI, Node.js, mempalace, gitea-mcp, user setup, chromadb prewarm, ENVs, entrypoints). Published as `joakimp/opencode-devbox:base-<sha12>`. Rebuilt only when its content hash changes.
- `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-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`.
- `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/`), image-baked fallback-skills reconcile (symlinks `/usr/local/share/opencode-devbox/skills/*` into `~/.agents/skills/` only-when-absent) + harness-instruction reconcile (symlinks `/usr/local/share/opencode-devbox/instructions/*.md` into `~/.config/opencode/instructions/`), OMOS config setup.
- `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).
- `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).
- `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.
@@ -44,7 +45,8 @@ repo, which decoupled from the pi tool version at its own `v1.0.0`.
reference example.
- **MINOR** — backward-compatible features: new variants/tags, new opt-in
behavior, new env vars, or changed-but-compatible semantics. Example: `v2.1.0`
added the OMOS bundled-skills image-symlink mechanism.
added the OMOS bundled-skills image-symlink mechanism; `v2.3.0` added the
image-baked fallback skills + harness-instruction mechanism.
- **PATCH** — opencode/tool version bumps and small fixes that don't change the
contract. When a release pairs a tool bump with a feature, the feature wins
and it's a minor.
@@ -73,7 +75,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. |
| 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):
@@ -127,6 +129,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.
- **`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
The smoke test (`scripts/smoke-test.sh`) is the canonical check and runs automatically in CI. To run locally:
+207 -1
View File
@@ -2,10 +2,216 @@
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.
---
## v2.3.0 — 2026-06-25
Minor release. Adds an **image-baked fallback skills + harness-instruction**
mechanism (ported from pi-devbox v1.2.0/v1.2.1, adapted to opencode's
`instructions/` model), bumps **opencode `1.17.8` → `1.17.10`** and **mempalace
`3.4.0``3.5.0`** (dropping the now-obsolete `diary_write` schema
workaround), and ports the one outstanding doc fix from pi-devbox's SSH sidecar
work. Defaults are unchanged when a skillset is mounted, so the canonical CI
build differs only by the version bumps and the additive image layer.
### Added: image-baked fallback skills + harness instruction
The image now ships two skills and one harness instruction under
`/usr/local/share/opencode-devbox/`, linked into place by `entrypoint-user.sh`
on every start, so a container behaves correctly **even with no `skillset` repo
mounted**:
- **`opencode-devbox-environment`** (authored here) — the container-shaped facts
an agent needs: the three persistence tiers (and why image-owned content must
live under `/usr` rather than a home dir the `devbox-opencode-config` volume
shadows), the interactive-vs-tool-shell alias gotcha (`dssh`/`dscp`/`cat``bat`
only exist in interactive bash), host + LAN SSH reachability via the
`~/.ssh-local` sidecar and ControlMaster, split-horizon DNS, uv-first Python,
and the OMOS variant. Adapted from pi-devbox's `pi-devbox-environment`, minus
the pi-only pieces (pi-studio, fork/recall).
- **`mempalace`** — a vendored snapshot of the skillset's consumer skill
(memory-continuity protocol). No `pi-extensions` skill is carried over —
opencode has no `fork`/`recall` extensions.
- **`instructions/opencode-devbox.md`** — symlinked into
`~/.config/opencode/instructions/`, which opencode auto-loads as a
session-start system prompt. It proactively points the agent at the
`opencode-devbox-environment` skill and the MemPalace continuity protocol, so
a fresh container picks them up rather than relying on description-matching.
Precedence is preserved: skills link **only-when-absent** (a mounted skillset or
an OMOS-bundled skill of the same name always wins), and the instruction uses a
distinct filename so it never collides with a mounted skillset's instructions.
Link targets live in the image, so `docker compose pull` + recreate refreshes
them for free — nothing is copied into the persistent config volume (which would
freeze it; cf. the OMOS-skills history in `docs/omos-skills.md`). Build-time
(`smoke-test.sh`) and runtime (`recreate-sanity-check.sh`) assertions cover both
the baked source and the resolved links. See
`rootfs/usr/local/share/opencode-devbox/skills/VENDORED.md`.
### Changed
- **opencode `1.17.8``1.17.10`.** Highlights across 1.17.91.17.10: MCP
server instructions are now added to session context; MCP resource template
listing + resource read tools; a new `--mini` CLI mode; skill base
directories emitted as filesystem paths instead of `file://` URLs; assorted
MCP/OAuth and agent-step-limit bugfixes. (Source: `anomalyco/opencode`
releases.)
- **mempalace `3.4.0``3.5.0`** (lockstep with pi-devbox v1.2.2). 3.5.0 ships
the upstream fix for the top-level-`anyOf` `diary_write` schema (issue #1728 /
PR #1717, merged 2026-06-14): the advertised schema is now
`"required": ["agent_name"]` with entry/content enforced at dispatch, which
the Anthropic tools API accepts.
### Removed
- **The `diary_write` top-level-`anyOf` workaround in `Dockerfile.base`.** The
`perl` patch of the installed `mcp_server.py` is gone now that 3.5.0 fixes it
at the source (verified against the published wheel).
### Fixed
- **Stale `ssh-lan.conf` guidance comment** in `setup-lan-access.sh`. The
`INCLUDE_BLOCK` comment previously implied LAN-peer `ProxyJump` overrides go
in `~/.ssh/config` (typically bind-mounted read-only); corrected to point at
the host-owned `~/.config/devbox-shell/ssh-lan.conf` (mirrors pi-devbox
`8de0fad`). Comment-only; no behavior change.
---
## 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`
+32 -38
View File
@@ -94,6 +94,15 @@ RUN apt-get update && \
# the last session closes, so consecutive ssh calls in a workflow reuse
# the same TCP flow. Companion entrypoint-user.sh creates /tmp/sshcm
# (mode 700) on each container start.
#
# CAVEAT (and why dssh/dscp are handled elsewhere): a user per-host override
# that points ControlPath BACK under the read-only ~/.ssh (e.g. the common
# CGNAT idiom `ControlPath ~/.ssh/cm/%r@%h:%p`) re-introduces the
# unwritable-socket failure for a plain `ssh <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 && \
printf '%s\n' \
'# Devbox-baked default. See Dockerfile.base "SSH client defaults".' \
@@ -263,7 +272,13 @@ ARG INSTALL_MEMPALACE=true
# diary_write top-level-anyOf schema (3.3.x/3.4.0) that breaks the
# Anthropic tools API; pinning makes every bump a deliberate, reviewable
# diff. Bump this in lockstep with pi-devbox's MEMPALACE_VERSION.
ARG MEMPALACE_VERSION=3.4.0
#
# 3.5.0 (2026-06) ships the upstream fix for that top-level-anyOf schema
# (issue #1728 / PR #1717, merged 2026-06-14): diary_write now advertises
# `"required": ["agent_name"]` with entry/content enforced at dispatch, which
# the Anthropic tools API accepts — so the perl mcp_server.py workaround that
# used to live below is gone. (pi-devbox dropped it in its v1.2.2.)
ARG MEMPALACE_VERSION=3.5.0
ENV UV_TOOL_DIR=/opt/uv-tools
ENV UV_TOOL_BIN_DIR=/usr/local/bin
RUN if [ "${INSTALL_MEMPALACE}" = "true" ]; then \
@@ -272,46 +287,19 @@ RUN if [ "${INSTALL_MEMPALACE}" = "true" ]; then \
/opt/uv-tools/mempalace/bin/python -c "import mempalace; print('mempalace', mempalace.__version__ if hasattr(mempalace, '__version__') else 'installed')" ; \
fi
# ── workaround: strip top-level anyOf from mempalace_diary_write schema ──
# Mempalace 3.3.x/3.4.0 advertise diary_write's input_schema with a
# top-level `anyOf: [{required:[entry]}, {required:[content]}]` to express
# "either entry or content must be supplied". Anthropic's tools API rejects
# top-level anyOf/oneOf/allOf, so pi/Claude fail at session start with
# `tools.<n>.custom.input_schema: input_schema does not support oneOf,
# allOf, or anyOf at the top level`.
#
# Patch the advertised schema to require ["agent_name", "entry"] and remove
# the anyOf block. The handler keeps accepting `content` server-side as a
# kwarg alias so existing callers still work.
#
# Idempotent and self-deactivating: once upstream releases the fix the
# regex no longer matches (and the WARN below fires) — that's the signal
# 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/pull/1717
# TODO: remove this RUN once a mempalace release > 3.4.0 that actually strips
# 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 \
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 && \
perl -0777 -i -pe 's/(?:[ \t]*\#[^\n]*\n)*[ \t]*"required":\s*\[\s*"agent_name"\s*\]\s*,\s*\n[ \t]*"anyOf":\s*\[\s*\n[ \t]*\{\s*"required":\s*\[\s*"entry"\s*\]\s*\}\s*,\s*\n[ \t]*\{\s*"required":\s*\[\s*"content"\s*\]\s*\}\s*,?\s*\n[ \t]*\]\s*,\s*\n/ "required": ["agent_name", "entry"],\n/s' "$MP_FILE" && \
if grep -q '"required": \["agent_name", "entry"\]' "$MP_FILE"; then \
echo "mempalace diary_write anyOf workaround: applied (or already clean)"; \
else \
echo "WARN: mempalace diary_write anyOf workaround did not match expected schema — upstream may have changed shape" >&2; \
fi ; \
fi
# (The mempalace diary_write top-level-anyOf workaround that patched
# mcp_server.py here was removed when MEMPALACE_VERSION moved to 3.5.0 —
# fixed upstream via issue #1728 / PR #1717 (merged 2026-06-14). Mirrors
# pi-devbox v1.2.2. See CHANGELOG.md.)
# ── mempalace-toolkit — bash wrappers for session/docs mining ────────
ARG INSTALL_MEMPALACE_TOOLKIT=true
ARG MEMPALACE_TOOLKIT_REF=main
# MEMPALACE_TOOLKIT_REPO is overridable so a relocated/forked build can repoint
# the clone without editing this Dockerfile (matches the *_REPO pattern used by
# other companions). Defaults to the canonical gitea origin; the default CI
# build is byte-identical.
ARG MEMPALACE_TOOLKIT_REPO=https://gitea.jordbo.se/joakimp/mempalace-toolkit.git
# MEMPALACE_TOOLKIT_REF accepts EITHER a branch name OR a commit SHA. CI
# resolves it to a SHA (resolve-versions job) and folds that SHA into the
# base-decide hash so the base rebuilds when the toolkit moves. `git clone
@@ -320,7 +308,7 @@ ARG MEMPALACE_TOOLKIT_REF=main
RUN if [ "${INSTALL_MEMPALACE}" = "true" ] && [ "${INSTALL_MEMPALACE_TOOLKIT}" = "true" ]; then \
rm -rf /opt/mempalace-toolkit && mkdir -p /opt/mempalace-toolkit && \
git -C /opt/mempalace-toolkit init -q && \
git -C /opt/mempalace-toolkit remote add origin https://gitea.jordbo.se/joakimp/mempalace-toolkit.git && \
git -C /opt/mempalace-toolkit remote add origin "${MEMPALACE_TOOLKIT_REPO}" && \
ok=0; for i in 1 2 3 4 5; do \
if git -C /opt/mempalace-toolkit fetch --depth 1 origin "${MEMPALACE_TOOLKIT_REF}" && \
git -C /opt/mempalace-toolkit checkout -q FETCH_HEAD; then ok=1; break; fi; \
@@ -444,6 +432,12 @@ COPY rootfs/home/developer/.inputrc /etc/skel-devbox/.inputrc
# ── Entrypoint ────────────────────────────────────────────────────────
COPY rootfs/usr/local/lib/opencode-devbox/ /usr/local/lib/opencode-devbox/
# Image-baked skills + harness instruction. Under /usr/local so a named volume
# over a home dir (e.g. devbox-opencode-config on ~/.config/opencode) can't
# shadow them; entrypoint-user.sh links them into ~/.agents/skills/ and
# ~/.config/opencode/instructions/ on every start. See
# rootfs/usr/local/share/opencode-devbox/skills/VENDORED.md.
COPY rootfs/usr/local/share/opencode-devbox/ /usr/local/share/opencode-devbox/
COPY entrypoint.sh /usr/local/bin/entrypoint.sh
COPY entrypoint-user.sh /usr/local/bin/entrypoint-user.sh
RUN chmod +x /usr/local/bin/entrypoint.sh /usr/local/bin/entrypoint-user.sh \
+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..
# v0.75.5 cannot apply here.
ARG INSTALL_OPENCODE=true
ARG OPENCODE_VERSION=1.17.6
ARG OPENCODE_VERSION=1.17.10
RUN if [ "${INSTALL_OPENCODE}" = "true" ]; then \
NPM_CONFIG_PREFIX=/usr npm install -g opencode-ai@${OPENCODE_VERSION} && \
opencode --version ; \
@@ -91,4 +91,58 @@ RUN if [ "${INSTALL_OMOS}" = "true" ]; then \
NPM_CONFIG_PREFIX=/usr npm install -g oh-my-opencode-slim@${OMOS_VERSION}; \
fi
# ── Build provenance: OCI labels + on-disk manifest ──────────────────
# These ARGs are declared LAST, immediately before the layer that uses
# them, so a changing BUILD_DATE / RELEASE_TAG / SOURCE_REVISION never
# invalidates the expensive npm-install layers above. OPENCODE_VERSION,
# OMOS_VERSION and INSTALL_OMOS are already in scope from earlier in this
# stage and need no re-declaration; MEMPALACE_TOOLKIT_REF is consumed in
# Dockerfile.base, so it is re-declared here only to land in the labels.
ARG RELEASE_TAG=dev
ARG BUILD_DATE=
ARG SOURCE_REVISION=
ARG MEMPALACE_TOOLKIT_REF=main
LABEL org.opencontainers.image.version="${RELEASE_TAG}" \
org.opencontainers.image.revision="${SOURCE_REVISION}" \
org.opencontainers.image.created="${BUILD_DATE}" \
se.jordbo.opencode-devbox.opencode-version="${OPENCODE_VERSION}" \
se.jordbo.opencode-devbox.install-omos="${INSTALL_OMOS}" \
se.jordbo.opencode-devbox.omos-version="${OMOS_VERSION}" \
se.jordbo.opencode-devbox.mempalace-toolkit-ref="${MEMPALACE_TOOLKIT_REF}"
# The manifest is written from GROUND TRUTH — the live `opencode --version`,
# the omos package's installed version (when present), and the actual
# checked-out HEAD of /opt/mempalace-toolkit (cloned in the base) — not
# merely the intended build-args. That way it also exposes a dependency
# that silently resolved to something other than the requested value.
# oh-my-opencode-slim is present only in the omos variant (JSON null
# otherwise). NOTE: omos is installed under prefix /usr at build time, so
# we resolve its dir via `npm root -g` with that prefix rather than the
# runtime NPM_CONFIG_PREFIX the base sets for the developer volume.
RUN set -e; \
mkdir -p /etc/opencode-devbox; \
rev() { git -C "$1" rev-parse HEAD 2>/dev/null || echo "unknown"; }; \
OPENCODE_V="$(opencode --version 2>/dev/null | head -n1 | tr -d '\r\n')"; \
OMOS_REV='null'; \
if [ "${INSTALL_OMOS}" = "true" ]; then \
OMOS_DIR="$(NPM_CONFIG_PREFIX=/usr npm root -g 2>/dev/null)/oh-my-opencode-slim"; \
OMOS_V="$(node -e "process.stdout.write(require('${OMOS_DIR}/package.json').version)" 2>/dev/null || echo unknown)"; \
OMOS_REV="\"${OMOS_V}\""; \
fi; \
{ \
echo '{'; \
echo " \"release_tag\": \"${RELEASE_TAG}\","; \
echo " \"build_date\": \"${BUILD_DATE}\","; \
echo " \"source_revision\": \"${SOURCE_REVISION}\","; \
echo " \"opencode_version\": \"${OPENCODE_V}\","; \
echo " \"components\": {"; \
echo " \"opencode\": \"${OPENCODE_V}\","; \
echo " \"oh-my-opencode-slim\": ${OMOS_REV},"; \
echo " \"mempalace-toolkit\": \"$(rev /opt/mempalace-toolkit)\""; \
echo " }"; \
echo '}'; \
} > /etc/opencode-devbox/build-manifest.json; \
echo "── build manifest ──"; cat /etc/opencode-devbox/build-manifest.json
# WORKDIR / ENTRYPOINT / CMD inherited from base.
+63 -4
View File
@@ -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.
- **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):**
@@ -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)).
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:
@@ -234,6 +234,26 @@ When a skillset repo is detected, its skills are symlinked into `~/.agents/skill
On the OMOS variant, the five skills bundled with oh-my-opencode-slim are also symlinked into `~/.agents/skills/` on each start — **from the image**, so pulling a newer image updates them with no installer run and no config reset. See [docs/omos-skills.md](docs/omos-skills.md).
#### Image-baked fallback skills (work with no skillset mounted)
Even with **no skillset repo mounted**, the image ships two skills and one
harness instruction so a fresh container still knows how to behave here:
- `opencode-devbox-environment` and `mempalace` skills are baked under
`/usr/local/share/opencode-devbox/skills/` and symlinked into
`~/.agents/skills/` on start — **only when a skill of that name is not already
present**, so a mounted skillset or an OMOS-bundled skill always wins.
- `instructions/opencode-devbox.md` is symlinked into
`~/.config/opencode/instructions/` (which opencode auto-loads at session
start). It points the agent at the `opencode-devbox-environment` skill and the
MemPalace continuity protocol. It uses a distinct filename, so it never
collides with a mounted skillset's own instructions.
Because the link targets live in the image (not the persistent
`devbox-opencode-config` volume), `docker compose pull` + recreate refreshes
them for free. See
[`rootfs/usr/local/share/opencode-devbox/skills/VENDORED.md`](rootfs/usr/local/share/opencode-devbox/skills/VENDORED.md).
### Neovim configuration
The image includes neovim 0.12 with `EDITOR=nvim` set by default. To use your own neovim config (and have plugins auto-install via lazy.nvim on first start), mount it from the host:
@@ -422,7 +442,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_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_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. |
@@ -603,7 +623,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.
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
+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. ✓
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
`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`). ✓
## Remaining verify-before-merge items
+65 -9
View File
@@ -12,12 +12,16 @@ set -euo pipefail
mkdir -p /tmp/sshcm
chmod 700 /tmp/sshcm
# ── LAN access: generic host-OS-agnostic reachability helper ────────
# On VM-backed hosts (macOS OrbStack / Docker Desktop) the container can't
# reach the host's directly-attached LAN peers by default; this generates a
# writable ~/.ssh-local/config that uses the host as an SSH jump. On native
# Linux (LAN reachable directly) it is a no-op. Controlled by DEVBOX_LAN_ACCESS
# (auto|jump|off) + HOST_SSH_USER. Always non-fatal. See the script header.
# ── LAN access + writable SSH sidecar: host-OS-agnostic helper ──────
# Generates the writable ~/.ssh-local/config on EVERY host OS: a `Host *`
# ControlPath redirect into ~/.ssh-local/cm (so `ssh -F` / dssh / dscp work
# even when ~/.ssh is bind-mounted read-only) plus `Include ~/.ssh/config`. On
# VM-backed hosts (macOS OrbStack / Docker Desktop) it ALSO adds an
# SSH-jump-via-host block so the container can reach the host's
# directly-attached LAN peers; on native Linux (LAN reachable directly) the
# jump block is omitted but the sidecar is still rendered. Controlled by
# DEVBOX_LAN_ACCESS (auto|jump|off) + HOST_SSH_USER. Always non-fatal. See the
# script header.
if [ -r /usr/local/lib/opencode-devbox/setup-lan-access.sh ]; then
bash /usr/local/lib/opencode-devbox/setup-lan-access.sh || true
fi
@@ -91,9 +95,11 @@ fi
# ── Generate opencode config from env vars if no config mounted ──────
# Delegated to a standalone Python script for clarity and testability.
# The script is idempotent: it never overwrites an existing opencode.json
# (bind-mounted from host, persisted in named volume, or previously
# generated) and no-ops if OPENCODE_PROVIDER is unset.
# The script never overwrites an existing opencode.json/.jsonc (bind-mounted
# from host, persisted in named volume, or previously generated) and no-ops if
# 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
# ── Skillset: deploy skills/instructions from mounted skillset repo ──
@@ -178,6 +184,56 @@ if [ "${OMOS_SKILLS:-true}" = "true" ]; then
fi
fi
# ── Image-baked fallback skills + harness instruction ────────────
# Baked under /usr/local/share/opencode-devbox/ (see that dir's VENDORED.md).
# Linked into place on every start so a container behaves correctly even with
# NO skillset mounted. Targets live in the image, so `docker compose pull` +
# recreate refreshes them for free. Whole block is non-fatal (`{ … } || true`):
# a transient link failure must never brick container startup.
#
# Precedence is intentional: this runs AFTER the skillset deploy and the OMOS
# bundled-skills block above, and the skill links are ONLY-WHEN-ABSENT, so a
# mounted skillset or an OMOS-bundled skill of the same name always wins; the
# baked copies merely fill gaps.
DEVBOX_SKILLS_SRC=/usr/local/share/opencode-devbox/skills
if [ -d "$DEVBOX_SKILLS_SRC" ]; then
{
mkdir -p "$HOME/.agents/skills"
for _sk in "$DEVBOX_SKILLS_SRC"/*/; do
[ -d "$_sk" ] || continue
_skname=$(basename "$_sk")
# Only-when-absent: never clobber a skillset/OMOS/user skill of this name.
if [ ! -e "$HOME/.agents/skills/$_skname" ]; then
ln -s "${_sk%/}" "$HOME/.agents/skills/$_skname"
fi
done
} || true
fi
# Harness instructions: opencode auto-loads ~/.config/opencode/instructions/*.md
# as a session-start system prompt. ~/.config/opencode is the persistent
# devbox-opencode-config volume, so we SYMLINK to the fixed image path (never
# copy — a copy would freeze in the volume) and refresh our own symlink each
# start. A real file of the same name (e.g. one a user dropped) is left alone;
# the baked filename (opencode-devbox.md) is distinct from skillset's
# instructions, so there is no collision with a mounted skillset.
DEVBOX_INSTR_SRC=/usr/local/share/opencode-devbox/instructions
if [ -d "$DEVBOX_INSTR_SRC" ]; then
{
mkdir -p "$HOME/.config/opencode/instructions"
for _instr in "$DEVBOX_INSTR_SRC"/*.md; do
[ -f "$_instr" ] || continue
_iname=$(basename "$_instr")
_dst="$HOME/.config/opencode/instructions/$_iname"
# Leave a real file alone; (re)create our own symlink otherwise.
if [ -e "$_dst" ] && [ ! -L "$_dst" ]; then
continue
fi
ln -sfn "$_instr" "$_dst"
done
} || true
fi
CONFIG_DIR="$HOME/.config/opencode"
OMOS_CONFIG="$CONFIG_DIR/oh-my-opencode-slim.json"
+8 -1
View File
@@ -89,9 +89,16 @@ fi
# we append with a newline separator to avoid the ';;' parse error
# described at the top of this file. Guarded so repeated sourcing
# (e.g. `exec bash`) doesn't stack duplicates.
#
# The guard MUST stay shell-local (NOT exported): if it leaks into child
# processes, every nested shell -- crucially each tmux pane, which inherits
# the tmux server's env -- skips installing `history -a` and only persists
# history on a clean exit. Abrupt termination (docker stop, tmux kill-server,
# SIGKILL) then loses that shell's in-memory history. Keeping it unexported
# means each new interactive shell re-installs its own per-prompt flush.
if [ -z "${DEVBOX_HIST_SET:-}" ]; then
PROMPT_COMMAND="${PROMPT_COMMAND:+$PROMPT_COMMAND$'\n'}history -a"
export DEVBOX_HIST_SET=1
DEVBOX_HIST_SET=1
fi
# ── Prompt: show [opencode-devbox] tag so it's obvious you're in the container
@@ -3,9 +3,12 @@
Generate opencode.json from environment variables on first container start.
Safety guarantees:
- NEVER overwrites an existing opencode.json. If the file is present
(whether bind-mounted from the host, persisted in a named volume, or
previously generated), this script exits immediately without writing.
- NEVER overwrites an existing config (opencode.json / opencode.jsonc),
whether bind-mounted from the host, persisted in a named volume, or
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.
Environment variables:
@@ -18,13 +21,16 @@ MCP servers are auto-registered for tools detected on PATH:
- mempalace (if installed) enabled
- 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
import json
import os
import re
import shutil
import sys
from pathlib import Path
@@ -110,6 +116,113 @@ def register_mcp_servers(config: dict) -> list[str]:
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:
provider = os.environ.get("OPENCODE_PROVIDER", "").strip()
if not provider:
@@ -120,19 +233,7 @@ def main() -> int:
config_dir = home / ".config" / "opencode"
config_file = config_dir / "opencode.jsonc"
config_file_legacy = config_dir / "opencode.json"
# 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
proposed_file = config_dir / "opencode.jsonc.proposed"
if provider not in DEFAULT_MODELS:
print(
@@ -145,30 +246,37 @@ def main() -> int:
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)
config = build_config(provider, model)
added = register_mcp_servers(config)
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:
f.write(content)
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:
print(
@@ -14,7 +14,9 @@
# The one thing reachable from a container on every OS is the host itself
# (host.docker.internal). So on VM-backed hosts we generate a writable SSH
# config that reaches the host and lets the user ProxyJump onward to LAN
# peers the host can reach. On native Linux we do nothing.
# peers the host can reach. On native Linux we render the same writable
# config (for the ControlPath redirect + Include ~/.ssh/config) but emit no
# jump block, since LAN peers are reachable directly there.
#
# We ship the MECHANISM (a generic `host` jump alias + writable config),
# never the POLICY: the user's specific target hosts live in their own
@@ -30,7 +32,9 @@
#
# CONTROLS (env)
# DEVBOX_LAN_ACCESS = auto (default) | jump | off
# auto → set up the jump config only on VM-backed hosts; no-op on Linux.
# auto → set up the host jump only on VM-backed hosts. The writable
# sidecar config (ControlPath redirect + Include) is always
# rendered, on every OS.
# jump → always set up (e.g. native Linux with extra_hosts host-gateway).
# off → do nothing.
# HOST_SSH_USER — the username to SSH into the host as. REQUIRED for the
@@ -84,40 +88,70 @@ is_vm_backed() {
getent hosts "$HOST_ALIAS_HOSTNAME" >/dev/null 2>&1
}
if [ "$MODE" = "auto" ] && ! is_vm_backed; then
# Native Linux host: LAN peers are reachable directly. Nothing to do.
exit 0
fi
# From here: MODE=jump, or MODE=auto on a VM-backed host.
command -v ssh-keygen >/dev/null 2>&1 || exit 0
# ── Writable socket dir + sidecar (ALWAYS, every host OS) ─────────────
# The ControlPath redirect in the generated config needs a writable directory
# regardless of host OS or jump mode. ~/.ssh is typically read-only, so the
# master socket lives under the writable ~/.ssh-local. We create it and render
# the config UNCONDITIONALLY so the redirect (and `Include ~/.ssh/config`) works
# even on native Linux — where we set up no host jump but a read-only ~/.ssh
# would otherwise still break ControlMaster sockets.
mkdir -p "${SSH_LOCAL}/cm" 2>/dev/null || true
chmod 700 "${SSH_LOCAL}" "${SSH_LOCAL}/cm" 2>/dev/null || true
# ── Jump key (generated once; preserved across restarts) ──────────────
# ── Decide whether to set up the host jump ────────────────────────────
# Jump = reach the container host (host.docker.internal) as an SSH ProxyJump
# onward to the host's LAN peers. Needed on VM-backed hosts (macOS / Docker
# Desktop) or when forced with DEVBOX_LAN_ACCESS=jump. On native Linux LAN
# peers are reachable directly, so NEED_JUMP=0 and we emit no jump block — but
# we still render the config for the ControlPath redirect + Include.
NEED_JUMP=0
if [ "$MODE" = "jump" ] || { [ "$MODE" = "auto" ] && is_vm_backed; }; then
NEED_JUMP=1
fi
# ── Jump key (only when a jump is needed; generated once, preserved) ──
# Persisted via a named volume on ~/.ssh-local (see compose), so a fresh key
# is generated only on the very first start (or if the volume is wiped). When
# we DO generate one it must be (re-)authorized on the host, so we flag it and
# print a copy-paste authorize line below.
KEY_JUST_GENERATED=0
if [ ! -f "$KEY" ]; then
ssh-keygen -t ed25519 -N '' -C "devbox-jump@${HOSTNAME:-container}" -f "$KEY" >/dev/null 2>&1 || exit 0
if [ "$NEED_JUMP" = "1" ] && command -v ssh-keygen >/dev/null 2>&1 && [ ! -f "$KEY" ]; then
if ssh-keygen -t ed25519 -N '' -C "devbox-jump@${HOSTNAME:-container}" -f "$KEY" >/dev/null 2>&1; then
chmod 600 "$KEY" 2>/dev/null || true
KEY_JUST_GENERATED=1
fi
fi
# ── Render the writable config ────────────────────────────────────────
# Jump-specific blocks (the host alias, host-owned peer overrides, and the
# optional RFC1918 catch-all) only make sense when a jump is set up; on native
# Linux they are all empty and only the ControlPath redirect + Include remain.
JUMP_BLOCK=""
LAN_CONF_BLOCK=""
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"
LAN_CONF_BLOCK=""
if [ -r "$SSH_LAN_CONF" ]; then
LAN_CONF_BLOCK=$(cat <<'EOF'
@@ -132,7 +166,6 @@ fi
# Optional opt-in RFC1918 catch-all: ProxyJump every private IP through the
# host. Matches the typed address, never the resolved HostName, so named hosts
# with their own ProxyJump are unaffected. Network-agnostic → roaming-safe.
AUTOJUMP_BLOCK=""
if [ "${DEVBOX_LAN_AUTOJUMP_PRIVATE:-0}" = "1" ]; then
AUTOJUMP_BLOCK=$(cat <<'EOF'
@@ -147,6 +180,7 @@ Host 10.* 192.168.* 172.16.* 172.17.* 172.18.* 172.19.* 172.20.* 172.21.* 172.22
EOF
)
fi
fi
INCLUDE_BLOCK=""
if [ -r "${HOME}/.ssh/config" ]; then
@@ -154,7 +188,9 @@ if [ -r "${HOME}/.ssh/config" ]; then
# Your own target hosts. Scope reset to match-all so this Include applies to
# every target (an Include is otherwise scoped to the enclosing Host block).
# Add 'ProxyJump host' to LAN entries here (or in ssh-lan.conf above).
# To make a LAN peer jump via the host, add 'ProxyJump host' to its entry in
# the host-owned ~/.config/devbox-shell/ssh-lan.conf (Included above) — NOT
# here in ~/.ssh/config, which is typically bind-mounted read-only.
Host *
Include ~/.ssh/config
EOF
@@ -176,17 +212,7 @@ Host *
UserKnownHostsFile ~/.ssh-local/known_hosts
StrictHostKeyChecking accept-new
ControlPath ~/.ssh-local/cm/%r@%h:%p
# The container host (OrbStack / Docker Desktop). 'host' and 'mac' are aliases.
Host host mac
HostName ${HOST_ALIAS_HOSTNAME}
${USER_LINE}
IdentityFile ~/.ssh-local/devbox_jump_ed25519
IdentitiesOnly yes
ControlMaster auto
ControlPath ~/.ssh-local/cm/%r@%h:%p
ControlPersist 4h
ServerAliveInterval 30
${JUMP_BLOCK}
${LAN_CONF_BLOCK}
${AUTOJUMP_BLOCK}
${INCLUDE_BLOCK}
@@ -199,6 +225,7 @@ chmod 600 "$CONFIG" 2>/dev/null || true
# host won't recognize. With ~/.ssh-local persisted via a named volume, case
# (b) fires only on first-ever start (or after the volume is reset) — so this
# is normally a one-time, one-line step per machine, with no file to locate.
if [ "$NEED_JUMP" = "1" ]; then
PUBKEY_TEXT="$(cat "${KEY}.pub" 2>/dev/null)"
if [ -z "${HOST_SSH_USER:-}" ]; then
cat <<EOF
@@ -221,5 +248,6 @@ elif [ "$KEY_JUST_GENERATED" = "1" ]; then
repeat this on container updates — only if that volume is reset.
EOF
fi
fi
exit 0
@@ -0,0 +1,37 @@
<!-- opencode-devbox:managed-instruction — baked into the image at
/usr/local/share/opencode-devbox/instructions/opencode-devbox.md and
symlinked into ~/.config/opencode/instructions/ by entrypoint-user.sh on
every container start (opencode auto-loads instructions/*.md as a
session-start system prompt). A mounted skillset deploys its own
instructions under different filenames, so this never collides. Keep this
short: it is a pointer, the depth lives in the skills. -->
## Running inside opencode-devbox
If the directory `/usr/local/lib/opencode-devbox/` exists (or your shell prompt
is prefixed `[devbox]`, or `~/.ssh-local/config` is present), you are in an
**opencode-devbox container** — a Docker environment whose persistence,
networking, DNS, host/LAN reachability, and Python/REPL behaviour differ from a
normal workstation. Before any task that touches **reaching the host or its
LAN, SSH, DNS/name resolution, what survives container recreate, running
Python/REPLs, or the oh-my-opencode-slim variant**, read
`~/.agents/skills/opencode-devbox-environment/SKILL.md`.
Key reflex from that skill: **the deployment specifics are not universal** — the
host OS, hostnames, internal domains, and nameservers vary per instance and must
be discovered at runtime, never assumed. And interactive shell aliases
(`dssh`, `dscp`, `cat``bat`) do **not** exist in your non-interactive bash
tool, so spell out the underlying command (e.g.
`ssh -F "$HOME/.ssh-local/config" mac …`).
## Session start: load the mempalace skill
If MemPalace MCP tools (e.g. `mempalace_status`, `mempalace_search`,
`mempalace_diary_write`) are in your tool list, **read
`~/.agents/skills/mempalace/SKILL.md` before doing non-trivial work** and follow
its protocol: search the palace before answering about past work, and write a
diary entry before the session ends. This is especially load-bearing here — an
opencode-devbox container is frequently recreated, so the palace is your only
memory across recreates. Without the habit it is just storage, not memory.
(The skill is the consumer side; feeding the palace is the separate
`opencode-mempalace-bridge` skill, if present.)
@@ -0,0 +1,47 @@
# Vendored fallback skills + harness instruction
This tree is **image-baked** content that `entrypoint-user.sh` links into place
on container start so the container behaves correctly **even when no private
`skillset` repo is mounted**:
- `skills/*` → symlinked into `~/.agents/skills/` (only when a skill of the same
name is not already present, so a mounted `skillset` or the OMOS bundled
skills always win).
- `instructions/*.md` → symlinked into `~/.config/opencode/instructions/`, which
opencode auto-loads as a session-start system prompt (only when a real file of
the same name is not already there).
| item | owner | how it gets here |
|------|-------|------------------|
| `skills/opencode-devbox-environment` | opencode-devbox (this repo) | authored here; the canonical copy |
| `skills/mempalace` | the `skillset` repo | **vendored fallback** (snapshot only) |
| `instructions/opencode-devbox.md` | opencode-devbox (this repo) | authored here; the proactive-load pointer |
## Why fallbacks exist
opencode discovers skills under `~/.agents/skills/` and loads harness
instructions from `~/.config/opencode/instructions/*.md` at session start. A
container started **without** the private `skillset` repo mounted would have
neither — so the agent would not know the container-shaped facts and would not
follow the MemPalace continuity protocol. Baking `opencode-devbox-environment`
and `mempalace` closes that *availability* gap, and the baked
`instructions/opencode-devbox.md` adds the matching *proactive-load* directive
(read those two skills at session start) so a fresh container actually picks
them up rather than relying on description-matching.
Note there is **no `pi-extensions` skill here**: opencode has no `fork`/`recall`
extensions (that is a pi-only concern), so the pi-devbox vendored set does not
carry over 1:1.
## Filename discipline
`instructions/opencode-devbox.md` deliberately uses a name distinct from the
skillset's `instructions/mempalace.md`. Both can be deployed at once (a mounted
skillset adds its own); the distinct name means our fallback is collision-free
and the entrypoint's never-overwrite-a-real-file guard never has to arbitrate.
## Refreshing the snapshots
cp <skillset>/skills/mempalace/SKILL.md skills/mempalace/SKILL.md
Snapshot provenance at last refresh: skillset `8e8db64`.
@@ -0,0 +1,301 @@
---
name: mempalace
description: MemPalace agent memory protocol. Use on every session to maintain continuity across conversations — search before answering about past work, write diary entries before session ends, and mine new projects into the palace. Load this skill at session start.
---
# MemPalace Agent Memory Protocol
## Overview
MemPalace gives you persistent memory across sessions via an MCP server. It stores project knowledge (mined from files), conversation summaries (diary entries), and entity relationships (knowledge graph). Without this protocol, you have tools but no habits — and memory without habits is just storage.
**Core principle:** Storage is not memory. Storage + protocol = memory.
## When to Load This Skill
- At the **start of every session** (proactively, before the user asks)
- When the user mentions **past conversations, decisions, or work**
- When working on a **new project or repository** for the first time
- When the user asks about **people, projects, or relationships**
## Session Lifecycle
### Phase 1: Wake Up (session start)
Run these immediately when a session begins, before responding to the user:
1. **Load palace overview:**
```
mempalace_status
```
This returns wing/room counts, the AAAK spec, and the memory protocol reminder.
2. **Read your recent diary:**
```
mempalace_diary_read(agent_name="<your_agent_name>", last_n=5)
```
Scan for context about recent sessions — what was worked on, what matters, what's pending.
3. **Check the knowledge graph** for the user or active project if relevant:
```
mempalace_kg_query(entity="<project_or_person>")
```
Do NOT announce this to the user. Just do it silently to orient yourself.
### Phase 2: Active Session (during work)
#### Search Before You Speak
Before answering questions about past work, decisions, people, or projects:
```
mempalace_search(query="<keywords>", wing="<project>")
```
**Never guess about facts that might be in the palace.** Wrong is worse than slow. Say "let me check" and query.
#### Mine New Projects
When working on a new codebase for the first time:
1. Check if it's already mined:
```
mempalace_list_wings
```
2. **Decide what to mine — docs first, code never (by default).**
The palace is for *context and intent*, not code recall. Code is better read from the working tree via `Read`/`Grep`/`glob` — always authoritative, never stale. Embedding source code produces thousands of low-signal drawers (e.g. `def __init__(self, ...)` across every class) that pollute search for years.
**Mine by default:**
- `*.md`, `*.rst`, `*.txt` — docs, READMEs, CHANGELOGs, architecture notes
- `AGENTS.md`, `CLAUDE.md`, `CONTRIBUTING.md`, design/decision docs — highest signal per byte
- `*.sh`, `Dockerfile`, `Makefile`, entrypoints — small, intent-bearing
- `*.yml`, `*.yaml`, `*.toml`, selective `*.json` (`docker-compose`, `pyproject`, `mkdocs.yml`, CI workflows) — skip lockfiles
**Do NOT mine by default:**
- `*.py`, `*.ts`, `*.tsx`, `*.js`, `*.go`, `*.rs`, `*.java`, `*.cpp`, `*.c`, `*.rb` — raw source code
- Test files, fixtures, generated code
- `node_modules/`, `.venv/`, `__pycache__/`, `.mypy_cache/`, `.pytest_cache/`, `.ruff_cache/` (the miner respects `.gitignore` but double-check)
Exception: if a code file *is* the documentation (e.g. a heavily-commented reference script, or a protocol definition), file it manually via `mempalace_add_drawer`.
3. **Before mining**, inspect the repo to estimate drawer count:
```bash
# Quick audit — what will actually get mined?
find <dir> -type f \
-not -path '*/.git/*' -not -path '*/node_modules/*' \
-not -path '*/.venv/*' -not -path '*/__pycache__/*' \
\( -name '*.md' -o -name '*.sh' -o -name '*.yml' -o -name '*.yaml' \
-o -name '*.toml' -o -name 'Dockerfile*' -o -name 'Makefile' \) | wc -l
```
A docs-heavy repo should produce ~510 drawers per file. If a mine produces >15 drawers/file on average, code leaked in — investigate.
4. Run the mine:
```bash
mempalace init --yes <directory>
mempalace mine <directory> --agent <your_agent_name>
```
The miner currently lacks a `--docs-only` or `--exclude-ext` flag (as of v3.3.3). Until it does, either:
- (a) Add a `mempalace.yaml` at the repo root with explicit include globs, OR
- (b) Mine everything, then surgically remove code-sourced drawers via SQL on `~/.mempalace/palace/chroma.sqlite3` (delete by `embedding_metadata.source_file LIKE '%.py'`), followed by `mempalace repair --yes`.
5. If the CLI miner misses a file you *do* want (e.g., `.zsh`, an undocumented extension), file it manually:
```
mempalace_add_drawer(wing="<project>", room="<aspect>", content="<verbatim content>", source_file="<path>")
```
6. After mining, reconnect to pick up the new embeddings:
```
mempalace_reconnect
```
If search errors occur after mining ("Error finding id"), repair the index:
```bash
mempalace repair --yes
```
#### Track Facts in the Knowledge Graph
When you learn new facts about people, projects, or relationships:
```
mempalace_kg_add(subject="ProjectX", predicate="uses", object="PostgreSQL")
mempalace_kg_add(subject="Alice", predicate="owns", object="ProjectX", valid_from="2026-01-15")
```
When facts change (ended, no longer true):
```
mempalace_kg_invalidate(subject="Alice", predicate="works_at", object="OldCorp", ended="2026-03-01")
```
#### Cross-Reference with Tunnels
When content in one project relates to another, create a tunnel:
```
mempalace_create_tunnel(
source_wing="project_api", source_room="endpoints",
target_wing="project_db", target_room="schema",
label="API endpoints map to these DB tables"
)
```
#### Feeding opencode session history (opencode + mempalace-toolkit only)
MemPalace has no upstream integration with [opencode](https://github.com/anomalyco/opencode) as of v3.3.3 — `hooks_cli.py` only supports `claude-code` and `codex` harnesses. Opencode persists every turn in a local SQLite DB at `~/.local/share/opencode/opencode.db`, but nothing moves that data into the palace automatically.
On a machine with opencode + the [`mempalace-toolkit`](https://gitea.jordbo.se/joakimp/mempalace-toolkit) installed, session history is fed into `wing_conversations` via `mempalace-session` — either manually, or on a weekly systemd user timer / cron schedule shipped in `mempalace-toolkit/contrib/`. If this is missing, opencode conversations exist only in the local SQLite DB and are invisible to `mempalace_search`.
**How to tell if it's set up:**
```
mempalace_list_wings
```
If `wing_conversations` exists and has a drawer count comparable to the user's opencode session count, session feeding is working. If it's empty or suspiciously small, suggest:
1. Check if the toolkit is installed: `which mempalace-session`.
2. If installed, suggest running `mempalace-session --dry-run` to preview and `mempalace-session` to file.
3. If not installed, point the user at `gitea.jordbo.se/joakimp/mempalace-toolkit` for setup.
**Don't try to paper over the gap by dumping turn-level content into the palace manually via `mempalace_add_drawer`** — that reinvents what `mempalace-session` does with normalization and dedup. Use the tool.
Full routine (triggers, cadence, automation) is in the [`opencode-mempalace-bridge`](https://gitea.jordbo.se/joakimp/mempalace-toolkit) skill and the toolkit's `ARCHITECTURE.md` §5. The two skills pair: this one (`mempalace`) covers using the palace; that one (`opencode-mempalace-bridge`) covers feeding it from opencode.
### Phase 3: Wind Down (session end)
**Always write a diary entry before the session ends.** This is the most important habit.
```
mempalace_diary_write(
agent_name="<your_agent_name>",
entry="<AAAK compressed summary>",
topic="session-summary"
)
```
#### Why still write diaries when sessions may be mined automatically?
On machines running opencode + `mempalace-toolkit`, every session is mined into `wing_conversations` on a weekly (or user-defined) schedule. A common and incorrect conclusion: *"since every turn is captured automatically, writing a diary entry is redundant."* It isn't.
Session mining captures **what was said** (every turn, verbatim). A diary captures **what the session meant** — editorial judgment by the agent who lived it:
- Lessons learned, patterns noticed, pending items rolled forward
- Meta-observations that were never said aloud during the session
- Aggregate counts (commits shipped, bugs fixed, hours spent)
- A compressed, recency-scannable summary for the *next* agent's wake-up
Mining raw turns cannot surface these because the words don't exist verbatim — they're the agent's reflection at wind-down. Think of the split as *release notes* (diary) vs. *git log with diffs* (session mine): a repo keeps both because they answer different questions. So does the palace.
**Practical rule:** automated mining does not replace Phase 3. Both systems cover each other's failure modes — a skipped diary is recovered from the raw turns; a missed mine is recovered from the diary summary. For the full treatment (comparison table, retrieval patterns, token economics), see [`mempalace-toolkit/ARCHITECTURE.md` §5 → "Diary vs session mine: why keep both?"](https://gitea.jordbo.se/joakimp/mempalace-toolkit/src/branch/main/ARCHITECTURE.md#diary-vs-session-mine-why-keep-both).
#### AAAK Diary Format
Write diary entries in compressed AAAK format for efficiency. Structure:
```
SESSION:<date>|<what.you.worked.on>|
TASKS:
1.<task.description>→<outcome>|
2.<task.description>→<outcome>|
DISCOVERED:<unexpected.findings>|
ENTITIES:<people.or.projects.encountered>|
<importance: one to five stars>
```
Example:
```
SESSION:2026-04-28|api.refactor+db.migration|
TASKS:
1.refactored.auth.endpoints→split.into.3.modules|
2.added.user.roles.migration→postgres.enum.type|
DISCOVERED:legacy.session.table.unused.since.v2|
ENTITIES:ProjectX;Alice(reviewer)|
***
```
Rules:
- Use dots instead of spaces within phrases
- Use pipes as field separators
- Use arrows for cause/effect or transitions
- Stars indicate session importance (one to five)
- Keep it tight — a future agent should get the gist in seconds
#### What to Capture
Prioritize recording:
- **Decisions made** and their rationale
- **Discoveries** — things that surprised you or that a future session needs to know
- **Unfinished work** — what's pending, what was deferred
- **User preferences** observed during the session
- **Entities encountered** — people, projects, tools, services
### Phase 4: Fact Updates
If facts changed during the session, update the knowledge graph before writing the diary:
```
mempalace_kg_invalidate(subject="...", predicate="...", object="...", ended="<today>")
mempalace_kg_add(subject="...", predicate="...", object="...", valid_from="<today>")
```
## Palace Structure
### Wings
Wings are top-level categories, typically one per project or domain:
- Named after the project directory (e.g., `cli_utils`, `opencode_devbox`)
- Agent diaries live in `wing_<agent_name>` (e.g., `wing_orchestrator`, `wing_pi`)
#### Multi-harness palace
A single palace can be fed by multiple coding-agent harnesses. On this machine the palace is shared between **opencode** and **pi** (Mario Zechner's pi-coding-agent). Implications:
- **`wing_conversations` mixes sources.** Both harnesses' session feeders write into the same wing. To tell them apart, look at the `source_file` metadata on each drawer:
- `pi_<uuid>.jsonl` → pi session
- `<slug>_ses_<id>.jsonl` → opencode session
- The first chunk of each session also carries a `| source: opencode` or `| source: pi` marker in the synthetic header line.
- **Other wings may belong to other harnesses.** For example `wing_pi` is pi's diary, not opencode's. Don't assume every diary entry was written by you — check `agent_name` on the entry.
- **Session feeders run on different schedules.** Pi sessions are fed Tue 03:00, opencode sessions Mon 03:00. Recent sessions from either harness can lag the palace by up to a week, so absence-of-evidence in `wing_conversations` is not evidence-of-absence for recent work.
- **Reading another harness's diary is useful.** When orienting after a gap, `mempalace_diary_read agent_name=pi` (or whichever sibling agent has been active) often gives a fresher picture than waiting for the conversations feeder to catch up.
### Rooms
Rooms are aspects within a wing:
- `fzf`, `scripts`, `configuration`, `general` — whatever the miner detects
- Diary entries go into rooms by topic tag
### Drawers
Drawers hold verbatim content — never summarized, always searchable.
### Tunnels
Cross-wing connections linking related content across projects.
### Knowledge Graph
Entity-relationship triples with temporal validity. Query with `mempalace_kg_query`, browse with `mempalace_kg_timeline`.
## Troubleshooting
| Problem | Fix |
|---|---|
| "No palace found" | Run `mempalace init <dir>` then `mempalace mine <dir>` |
| "Error finding id" after mining | Run `mempalace repair --yes` then `mempalace_reconnect` |
| Search returns irrelevant results | Use `max_distance=1.0` for stricter matching; add `wing` filter |
| Miner skips file types | File manually with `mempalace_add_drawer` or use `--no-gitignore` |
| Stale results after external changes | Call `mempalace_reconnect` |
## Anti-Patterns
- **Don't guess when you can search.** If a question touches past work, search first.
- **Don't skip the diary.** A session without a diary entry is a session forgotten.
- **Don't summarize drawer content.** File verbatim — the embedding model needs the original words.
- **Don't mine .git directories or node_modules.** The CLI miner respects .gitignore by default.
- **Don't create duplicate drawers.** Use `mempalace_check_duplicate` before adding manually.
- **Don't treat the palace as a task list.** It's for knowledge and context, not todos.
@@ -0,0 +1,210 @@
---
name: opencode-devbox-environment
description: >-
Operate correctly inside an opencode-devbox container. Load when running
inside opencode-devbox (detection: the directory `/usr/local/lib/opencode-devbox/`
exists, the shell prompt is prefixed `[devbox]`, or `~/.ssh-local/config` is
present) and the task touches any of: reaching the Docker host or its LAN,
SSH, DNS name resolution, what survives container recreate (persistence vs
ephemerality), running Python or other REPLs, or the oh-my-opencode-slim
(OMOS) variant. Covers the persistence model (and why image-owned content
must live under /usr, not a home dir a named volume shadows), the
interactive-vs-tool-shell alias gotcha (dssh/dscp/cat=bat exist only in
interactive bash), host + LAN SSH reachability and ControlMaster, split-horizon
DNS mechanisms, and uv-first Python. This skill teaches MECHANISMS only —
concrete hostnames, usernames, internal domains, nameservers, and even the
host OS vary per deployment and MUST be discovered at runtime, never assumed
or hardcoded.
---
# opencode-devbox environment
You are (or may be) running inside **opencode-devbox**: a Docker container that
ships opencode, MemPalace, and a curated tool stack, with the host source tree
mounted at `/workspace`. This skill is about the *container-shaped* facts that
change how you should act — things that are easy to get wrong because they
differ from a normal workstation shell.
> **Golden rule: this environment is a template, not a fixed deployment.**
> The host could be macOS, Windows, or Linux. There may or may not be LAN
> peers, a VPN, split-DNS, a skillset mount, or the `-omos` variant. Detect
> and verify the specifics live (commands below) — do **not** assume any
> particular hostname, domain, nameserver, or OS. Where this skill shows
> example values they are illustrative placeholders.
## 0. Am I in opencode-devbox, and what's true *here*?
Cheap detection signals (any one is sufficient):
```sh
[ -d /usr/local/lib/opencode-devbox ] && echo "opencode-devbox image"
[ -r "$HOME/.ssh-local/config" ] && echo "LAN/host SSH sidecar present"
case "$PS1" in *'[devbox]'*) echo "interactive devbox shell";; esac
```
Then orient before acting:
```sh
cat /etc/os-release | head -2 # container distro (usually Debian)
ls -la /usr/local/lib/opencode-devbox/ # which devbox helpers exist
sed -n '/^Host /,$p' ~/.ssh-local/config 2>/dev/null # host/LAN reachability, if any
mount | grep -E ' /workspace | /home/\S+/\.ssh ' # what's bind-mounted
```
## 1. Persistence vs ephemerality — know before you write
The container has **three storage tiers with very different lifetimes**. Pick
the right one or work is silently lost on the next recreate/update.
| Tier | Examples | Survives `down`? | Survives `down -v`? | Survives image update / `--force-recreate`? |
|---|---|---|---|---|
| **Host bind-mount** | `/workspace`, usually `~/.ssh` (ro), optionally `~/.mempalace` | yes | yes (lives on host) | yes |
| **Named volume** | `~/.config/opencode`, `~/.local/share/opencode`, `~/.local/state/opencode`, `~/.ssh-local`, `~/.cache/bash`, `~/.local/share/{uv,nvim,zoxide}` | yes | **no** | yes |
| **Writable container layer** | anything else: `sudo apt install …`, `rustup`/`ghc`/`R` toolchains, files in `/tmp`, `/opt` edits | yes | **no** | **no** |
Practical consequences:
- **Durable work goes in `/workspace`** (it's the host filesystem, UID-aligned —
what you write appears with the user's normal ownership on the host).
- **Runtime-installed system packages and language toolchains are ephemeral.**
If a task needs them reproducibly, it belongs in the image (Dockerfile) or a
project manifest, not an ad-hoc `apt install`. Tell the user when you install
something that won't survive.
- **`~/.config/opencode` is a named volume** (`devbox-opencode-config`), so
things baked into the *image* under `/home/<user>/...` are **shadowed** by the
volume on existing containers and only seen on a fresh volume. This is exactly
why image-owned content that must always be live (the OMOS bundled skills, the
image-baked fallback skills, the harness instructions) lives under an image
path like `/usr/local/share/opencode-devbox/...` and is **symlinked** in by
the entrypoint on every start — never copied into a home dir a volume covers.
Copying image content into `~/.config/opencode` *freezes* it: a later
`docker compose pull` will not refresh it.
## 2. Interactive shell vs. your tool shell (a real footgun)
The conveniences below are defined in `~/.bash_aliases` and **only exist in an
interactive login shell.** Your `bash` *tool* runs non-interactively, so these
are "command not found" there — you must spell out the underlying command.
| Interactive alias | Non-interactive equivalent to actually run |
|---|---|
| `dssh <host>` | `ssh -F "$HOME/.ssh-local/config" <host>` |
| `dscp …` | `scp -F "$HOME/.ssh-local/config" …` |
| `cat file` (→ `bat`) | `cat file` works, but output differs; use `command cat` for raw |
| `ll`, `la` (→ `eza`/`ls`) | `ls -lh`, `ls -lha` |
If a command "works in my terminal but not when the agent runs it," this alias
gap is the first thing to suspect.
## 3. Reaching the Docker host and its LAN over SSH
When the host is VM-backed (e.g. OrbStack / Docker Desktop on macOS) the
entrypoint's `setup-lan-access.sh` writes a **writable SSH sidecar** at
`~/.ssh-local/config`. It always provides:
- A `Host *` block redirecting `ControlPath` into the writable `~/.ssh-local/cm`
(because `~/.ssh` is typically bind-mounted **read-only**, so a master socket
can't be created under it), plus `Include ~/.ssh/config`.
- Aliases **`host` / `mac`** → `host.docker.internal` (user comes from
`HOST_SSH_USER`) — i.e. SSH back into the Docker host.
- On VM-backed hosts only: an **SSH-jump-via-host** block so the container can
reach the host's directly-attached LAN peers (`ProxyJump host`). On a native
Linux host the LAN is usually reachable directly and this jump block is
omitted — **so don't assume a jump path exists; read the sidecar.**
Use it (remember §2 — spell it out in tool bash):
```sh
ssh -F "$HOME/.ssh-local/config" mac 'hostname; whoami' # reach the host
ssh -F "$HOME/.ssh-local/config" <lan-peer> '…' # reach a LAN peer (if configured)
```
Related mechanisms (don't reinvent them):
- **ControlMaster multiplexing** is preconfigured (sockets under
`~/.ssh-local/cm`) to survive CGNAT per-destination flow caps on residential
ISPs. If `~/.ssh/config` pins a `ControlPath` under the read-only `~/.ssh`,
override with `-o ControlPath=none` (or use the sidecar, which already
redirects it).
- To name a LAN peer (give it a stable alias + `ProxyJump host`), put the block
in the host-owned `~/.config/devbox-shell/ssh-lan.conf` (bind-mounted in and
`Include`d), **not** in the read-only `~/.ssh/config`. opencode has no
built-in "run my tools on a remote host" rewiring — the sidecar + plain `ssh`
is the path.
## 4. DNS / name resolution — environment-specific, verify live
How a name resolves here is **not universal** and depends on the host's
networking. The container's own resolver is just `/etc/resolv.conf`, but the
*host* (which you reach via §3, and whose DNS the container may inherit) can use
**split-horizon DNS** to send certain internal domains to specific nameservers
while everything else goes to a default resolver/VPN gateway. The mechanism is
OS-specific and **may not be present at all**:
- **macOS host:** per-domain files in `/etc/resolver/<domain>`, each listing
`nameserver` lines. Reading them (over `ssh … mac`) is a fine way to learn the
real split-DNS map — *for that one machine.*
- **Linux host:** typically `systemd-resolved` split DNS (per-link `Domains=`
routing) or `/etc/resolv.conf` `search`/`nameserver`.
- **Windows host:** the NRPT (Name Resolution Policy Table) plays the per-suffix
role; WSL2 inherits host resolution via mirrored networking + DNS tunneling.
Operating rules:
1. **Never hardcode a domain→nameserver mapping or a specific nameserver IP**
it is per-deployment and changes between users and even VPN states.
2. **Verify by reading the live config**, e.g. `cat /etc/resolv.conf` in the
container, or `ssh … mac 'cat /etc/resolver/* 2>/dev/null'` on a macOS host.
3. **Reachability needs both DNS *and* a route.** A name resolving to an
internal address is useless if packets to that subnet don't have a path
(e.g. via the VPN or the §3 jump). Check both when something "resolves but
won't connect."
4. If you discover deployment-specific facts (a domain, a nameserver, a
reachable peer), prefer recording them in MemPalace over baking them into
code or this skill.
## 5. Python and other languages: uv-first, toolchains are ephemeral
- A system `python3` exists, but **prefer `uv`** for REPLs and project envs —
it's installed and its store (`~/.local/share/uv`) is a persisted volume.
- Throwaway REPL: `uv run --with ipython ipython`
- Project env: `cd /workspace/proj && uv init && uv add <pkgs> && uv run …`
(the `pyproject.toml` + `uv.lock` travel with the repo — the durable choice).
- Other language toolchains (Rust via rustup, R, GHC, Clojure, Go) are
**runtime opt-ins on the ephemeral layer** unless baked into the image — they
do not survive `down -v` or an image update. Flag this when installing.
## 6. The oh-my-opencode-slim (OMOS) variant
Present only in the `-omos` image (detection: `/usr/lib/node_modules/oh-my-opencode-slim`
exists, or `ENABLE_OMOS=true`). It adds the bun runtime and multi-agent
orchestration. Two environment facts matter:
- **OMOS skills are symlinked from the image** into `~/.agents/skills/` by the
entrypoint on every start — they are **not** installed into the
`~/.config/opencode` volume (that froze them historically; see §1). A
`docker compose pull` + recreate refreshes them for free. Don't run the OMOS
installer with `--skills` to "fix" missing skills; the symlinks are the
mechanism.
- Optional tmux orchestration is gated by `OMOS_TMUX`; the OMOS config lives at
`~/.config/opencode/oh-my-opencode-slim.json` and is regenerated only via
`OMOS_RESET=true`.
## 7. MemPalace is the shared brain
MemPalace data is usually a **host bind-mount** (or an optional `devbox-palace`
named volume), so an opencode on the host and one in this container can share
one palace (SQLite WAL: many readers, one writer). Use it to persist the
deployment-specific facts this skill deliberately refuses to hardcode. Details
are in the `mempalace` skill — and the harness instruction shipped with this
image points you at it at session start.
## Checklist before acting in this environment
- [ ] Writing durable output? → `/workspace`, not the ephemeral layer.
- [ ] Using `dssh`/`dscp`/`ll` in the bash tool? → spell out the real command.
- [ ] Assuming a hostname / domain / nameserver / host OS? → stop, detect it.
- [ ] "Resolves but won't connect"? → check route *and* DNS (§3 + §4).
- [ ] `apt`/toolchain install? → tell the user it's ephemeral unless imaged.
- [ ] Shipping image-owned content opencode reads from `~/.config/opencode`? →
symlink it from `/usr/...`, never copy into the volume (§1).
+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
+234
View File
@@ -0,0 +1,234 @@
#!/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 "-- Image-baked fallback skills + harness instruction (both variants) --"
# Baked under /usr/local/share and linked in by entrypoint-user.sh.
# opencode-devbox-environment uses a name unlikely to be overridden, so it is a
# reliable probe that the skills reconcile ran; the instruction confirms the
# ~/.config/opencode/instructions symlink survived the named volume.
if [ -e "$HOME/.agents/skills/opencode-devbox-environment/SKILL.md" ]; then
pass "~/.agents/skills/opencode-devbox-environment resolves"
else
fail "~/.agents/skills/opencode-devbox-environment missing"
fi
if [ -e "$HOME/.agents/skills/mempalace/SKILL.md" ]; then
pass "~/.agents/skills/mempalace resolves"
else
fail "~/.agents/skills/mempalace missing"
fi
if [ -e "$HOME/.config/opencode/instructions/opencode-devbox.md" ]; then
pass "~/.config/opencode/instructions/opencode-devbox.md resolves"
else
fail "~/.config/opencode/instructions/opencode-devbox.md missing"
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 ==="
+71
View File
@@ -197,6 +197,46 @@ else
fi
fi
echo
echo "-- Image-baked fallback skills + harness instruction --"
# Baked under /usr/local/share (base image, both variants). entrypoint-user.sh
# symlinks these into ~/.agents/skills/ and ~/.config/opencode/instructions/ on
# container start; assert the SOURCE here (smoke runs with --entrypoint="").
run "baked opencode-devbox-environment skill" \
"test -f /usr/local/share/opencode-devbox/skills/opencode-devbox-environment/SKILL.md && echo ok"
run "baked mempalace fallback skill" \
"test -f /usr/local/share/opencode-devbox/skills/mempalace/SKILL.md && echo ok"
run "baked harness instruction (opencode-devbox.md)" \
"test -f /usr/local/share/opencode-devbox/instructions/opencode-devbox.md && echo ok"
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 "-- Entrypoint behaviour --"
@@ -278,6 +318,37 @@ if docker run --rm \
else
fail "$label: existing config was modified!"
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"
echo