Compare commits
5 Commits
v2.2.0
...
f7e23d236c
| Author | SHA1 | Date | |
|---|---|---|---|
| f7e23d236c | |||
| d9ad634d5a | |||
| acb2096406 | |||
| 6639ba5820 | |||
| b9039f577e |
@@ -34,6 +34,17 @@ concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: false
|
||||
|
||||
# Gitea Actions' default step shell is `sh -e {0}` (dash), which rejects
|
||||
# bash-only syntax like `set -o pipefail`, `[[ ]]`, and arrays. Setting the
|
||||
# default to bash workflow-wide eliminates the whole class of "forgot
|
||||
# `shell: bash` on this step" bugs. (Ported from pi-devbox, where this class
|
||||
# bit twice: ed49b8d resolve-versions, b7197e8/b33e9dc promote-base-latest,
|
||||
# run 418.) All existing dash steps use only POSIX syntax, so bash (a
|
||||
# superset) runs them unchanged. Enforced by lint.yml's shell guard.
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
|
||||
env:
|
||||
BUILDKIT_PROGRESS: plain
|
||||
IMAGE: ${{ vars.DOCKERHUB_USERNAME }}/opencode-devbox
|
||||
@@ -519,11 +530,16 @@ jobs:
|
||||
- base-decide
|
||||
- build-variant-base
|
||||
- build-variant-omos
|
||||
# Skip on cache-hit base builds: when need_build=false, base-latest
|
||||
# already points at the same digest as base-<hash>, so the retag is
|
||||
# a tautology and any transient failure of it is purely cosmetic.
|
||||
# Manual workflow_dispatch with promote_latest=true overrides this
|
||||
# gate as an escape hatch (e.g., if base-latest got hand-deleted).
|
||||
# Run on every tag release (and promote_latest=true dispatch). The gate
|
||||
# deliberately does NOT key off need_build anymore: the no-op optimization
|
||||
# for genuine cache-hit releases moved INTO the step as a crane digest
|
||||
# compare (see below). Keying the gate on need_build was wrong because a
|
||||
# prior dry-run dispatch (promote_latest=false) can pre-build+push
|
||||
# base-<hash>, making need_build=false on the subsequent tag run even
|
||||
# though base-latest is still stale — the old gate then skipped promotion
|
||||
# and left base-latest pointing at the PREVIOUS base. (Ported from
|
||||
# pi-devbox b7197e8, which hit exactly this on its v1.2.3 dry-run-first
|
||||
# release, 2026-06-27.)
|
||||
#
|
||||
# `always()` wrapper + explicit base-variant success check protects
|
||||
# against the gitea-Actions default of "skipped need => skip dependent":
|
||||
@@ -532,8 +548,7 @@ jobs:
|
||||
if: |
|
||||
always() &&
|
||||
needs.build-variant-base.result == 'success' &&
|
||||
(inputs.promote_latest == 'true' ||
|
||||
(github.ref_type == 'tag' && needs.base-decide.outputs.need_build == 'true'))
|
||||
(inputs.promote_latest == 'true' || github.ref_type == 'tag')
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: catthehacker/ubuntu:act-latest
|
||||
@@ -558,11 +573,31 @@ jobs:
|
||||
crane auth login docker.io \
|
||||
-u ${{ vars.DOCKERHUB_USERNAME }} \
|
||||
-p "${{ secrets.DOCKERHUB_TOKEN }}"
|
||||
- name: Re-tag base-<hash> as base-latest
|
||||
- name: Re-tag base-<hash> as base-latest (only if stale)
|
||||
env:
|
||||
BASE_HASH_REF: ${{ env.IMAGE }}:${{ needs.base-decide.outputs.base_tag }}
|
||||
BASE_LATEST_REF: ${{ env.IMAGE }}:base-latest
|
||||
run: |
|
||||
crane copy \
|
||||
${{ env.IMAGE }}:${{ needs.base-decide.outputs.base_tag }} \
|
||||
${{ env.IMAGE }}:base-latest
|
||||
set -euo pipefail
|
||||
# Correctness invariant: after a release, base-latest must resolve to
|
||||
# the SAME digest as the base-<hash> the just-built variants were
|
||||
# FROM. Compare digests rather than trusting need_build — a prior
|
||||
# dry-run dispatch can pre-build base-<hash>, so need_build=false on
|
||||
# the tag run does NOT imply base-latest is already current. When the
|
||||
# digests already match (genuine cache-hit release) this is a no-op,
|
||||
# so we skip the crane copy entirely — preserving the original
|
||||
# "don't do a tautological retag" intent and avoiding any cosmetic
|
||||
# transient-failure exposure on releases that change nothing.
|
||||
want=$(crane digest "${BASE_HASH_REF}")
|
||||
have=$(crane digest "${BASE_LATEST_REF}" 2>/dev/null || echo "")
|
||||
echo "base-<hash> digest: ${want}"
|
||||
echo "base-latest digest: ${have:-<absent>}"
|
||||
if [ "${want}" = "${have}" ]; then
|
||||
echo "base-latest already current; nothing to promote."
|
||||
else
|
||||
echo "Promoting base-latest -> ${BASE_HASH_REF}"
|
||||
crane copy "${BASE_HASH_REF}" "${BASE_LATEST_REF}"
|
||||
fi
|
||||
|
||||
# ── Phase 6: update Hub description (only on real release runs) ────
|
||||
update-description:
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
name: Lint workflows
|
||||
|
||||
# Durable guard against CI-workflow bugs — most importantly the "bash-only
|
||||
# syntax under the default `sh`/dash shell" footgun. Ported from pi-devbox,
|
||||
# where this class broke resolve-versions (ed49b8d) and promote-base-latest
|
||||
# (b7197e8 → run 418). actionlint runs shellcheck against each `run:` step
|
||||
# using its *effective* shell, so `set -o pipefail` under dash is flagged as
|
||||
# SC3040 before any expensive build runs. This is cheap (~10s) and independent
|
||||
# of the build pipeline, so it fires on every push/PR — not just on release
|
||||
# tags, which is where docker-publish-split.yml is otherwise only triggered.
|
||||
on:
|
||||
push:
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: lint-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
|
||||
jobs:
|
||||
actionlint:
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: catthehacker/ubuntu:act-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install shellcheck
|
||||
run: |
|
||||
apt-get update
|
||||
apt-get install -y --no-install-recommends shellcheck python3-yaml
|
||||
|
||||
- name: Gitea shell guard (catches the actionlint blind spot)
|
||||
# actionlint models GitHub Actions, where the default run shell is
|
||||
# bash, so it does NOT flag bash syntax in a step that merely OMITS
|
||||
# `shell:` — which is exactly how ed49b8d and b7197e8 manifested on
|
||||
# Gitea (default sh/dash). This guard enforces that every run: step
|
||||
# resolves to bash under Gitea's real defaults. Run it BEFORE
|
||||
# actionlint so the more precise diagnostic surfaces first.
|
||||
run: bash scripts/check-workflow-shell.sh .gitea/workflows
|
||||
|
||||
- name: Install actionlint (pinned)
|
||||
env:
|
||||
ACTIONLINT_VERSION: 1.7.7
|
||||
run: |
|
||||
curl -fsSL \
|
||||
"https://github.com/rhysd/actionlint/releases/download/v${ACTIONLINT_VERSION}/actionlint_${ACTIONLINT_VERSION}_linux_amd64.tar.gz" \
|
||||
| tar -xz -C /usr/local/bin actionlint
|
||||
actionlint --version
|
||||
|
||||
- name: Run actionlint
|
||||
# SHELLCHECK_OPTS excludes pure-style codes (quoting/style opinions)
|
||||
# so the guard stays focused on correctness bugs — crucially the
|
||||
# SC3xxx "not POSIX / wrong shell" family that catches the pipefail
|
||||
# footgun. Do NOT exclude SC3040 (set -o pipefail under sh) or any
|
||||
# other SC3xxx code.
|
||||
env:
|
||||
SHELLCHECK_OPTS: "-e SC2086 -e SC2016 -e SC2129 -e SC2001 -e SC2312"
|
||||
# Pass explicit paths: actionlint's no-arg mode auto-detects a
|
||||
# project by looking for `.github/workflows`, which doesn't exist in
|
||||
# this `.gitea/workflows` repo and hard-fails with exit 3
|
||||
# ("no project was found"). Globbing the workflow files is the
|
||||
# supported way to lint a non-GitHub layout.
|
||||
run: actionlint -color .gitea/workflows/*.yml
|
||||
@@ -35,6 +35,14 @@ on:
|
||||
branches:
|
||||
- main
|
||||
|
||||
# Gitea Actions' default step shell is `sh` (dash); force bash workflow-wide so
|
||||
# no run: step silently falls through to dash. Enforced by lint.yml's
|
||||
# scripts/check-workflow-shell.sh guard, which scans ALL .gitea/workflows/*.yml
|
||||
# (so this file must resolve to bash too, not just docker-publish-split.yml).
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
|
||||
jobs:
|
||||
docs-check:
|
||||
# Fails if DOCKER_HUB.md is out of sync with what generate-dockerhub-md.py
|
||||
|
||||
@@ -18,7 +18,7 @@ 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.
|
||||
- `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.
|
||||
@@ -29,6 +29,7 @@ Docker image packaging [opencode](https://opencode.ai) into a production-ready d
|
||||
- `.gitea/README.md` — **read this first** if you're touching CI. Architectural overview of the build pipeline (production vs split-base), wall-clock estimates, NPM_CONFIG_PREFIX gotcha, runner expectations, migration plan.
|
||||
- `.gitea/workflows/validate.yml` — lightweight amd64 build + smoke test on push to main and PRs. Also runs the DOCKER_HUB.md sync check.
|
||||
- `.gitea/workflows/docker-publish-split.yml` — production CI pipeline on tag push (`v*`). Two-phase split-base: computes base hash, conditionally builds base, runs 2 parallel smoke tests, then 2 parallel multi-arch variant builds, promotes `base-latest` alias, updates Docker Hub description.
|
||||
- `.gitea/workflows/lint.yml` — cheap (~10s) workflow-lint on every push/PR/dispatch: a Gitea-accurate shell guard (`scripts/check-workflow-shell.sh`) plus pinned actionlint + shellcheck. The guard asserts every `run:` step resolves to `bash` under Gitea's `sh` default, closing the actionlint blind spot (actionlint models GitHub, whose default `run` shell is bash). Ported from pi-devbox.
|
||||
|
||||
## Versioning scheme
|
||||
|
||||
@@ -45,7 +46,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.
|
||||
|
||||
+154
@@ -6,6 +6,160 @@ Tags follow **independent semver** (since `v2.0.0`) — they version *this image
|
||||
|
||||
---
|
||||
|
||||
## v2.4.0 — 2026-07-01
|
||||
|
||||
Minor release. Adds two **non-modal editors** (`nano` + `micro`) alongside
|
||||
nvim, ports **CI hardening** from pi-devbox (the sh-vs-bash workflow guard and
|
||||
the `base-latest` digest-based promote fix), bakes a **global gitignore** into
|
||||
the image, and bumps **opencode `1.17.10` → `1.17.13`**. Because
|
||||
`Dockerfile.base` changed (nano + micro), this release rebuilds the base image.
|
||||
|
||||
### Changed
|
||||
|
||||
- **opencode `1.17.10` → `1.17.13`** (three upstream patches). Highlights:
|
||||
session snapshots + revert controls (1.17.11); MCP OAuth reconnect/refresh
|
||||
fixes, a TUI yolo auto-approve mode, and better default small models
|
||||
(1.17.12); forced reasoning mode for OpenAI-compatible reasoning models plus
|
||||
a GitHub Copilot stale-response-ID fix (1.17.13). Full notes:
|
||||
<https://github.com/anomalyco/opencode/releases>.
|
||||
|
||||
### Added
|
||||
|
||||
- **Global gitignore baked into the image.** A `~/.gitignore_global`
|
||||
(`*.bak`, `*.bak.*`, `*~`, `*.orig`, `*.swp`, `*.tmp`) is seeded into the home
|
||||
dir from `/etc/skel-devbox/` on first boot (seed-if-absent, like
|
||||
`.bash_aliases`/`.inputrc`, so user edits survive recreate) and wired via
|
||||
`git config --global core.excludesFile`. Personal/tooling backup artifacts are
|
||||
now ignored across all repos in the container without per-repo `.gitignore`
|
||||
entries. The `core.excludesFile` wiring is skipped if the user already set one.
|
||||
|
||||
- **Non-modal editors `nano` + `micro` alongside `nvim`.** The image shipped
|
||||
only nvim (`EDITOR=nvim`), a modal vi-style editor. Added both a classic and
|
||||
a modern non-modal option for users who don't want vi keybindings:
|
||||
- **nano** (apt): ~2.8 MB installed; its deps (libc6, libncursesw6,
|
||||
libtinfo6) are already present via nvim/less/htop/tmux, so no extra
|
||||
packages are pulled in.
|
||||
- **micro**: ~12 MB single static Go binary from GitHub releases (same
|
||||
pattern as bat/eza/zoxide). Desktop-style keys (Ctrl+S/Ctrl+Q), mouse,
|
||||
syntax highlighting. `ARG MICRO_VERSION` pins; defaults to latest.
|
||||
|
||||
Combined ~15 MB (<0.5% of the image). `EDITOR` stays `nvim`; both are opt-in
|
||||
(`export EDITOR=micro | nano`). Uses the canonical `micro-editor/micro` URL
|
||||
because the old `zyedidia/micro` org rename makes `/releases/latest` redirect
|
||||
to another `/latest`, defeating the tag-parsing latest-resolution idiom.
|
||||
Base-image change, so it lands on the next `base-<hash>` rebuild. Ported from
|
||||
pi-devbox 3a59e15.
|
||||
|
||||
- **Workflow-lint guard (`.gitea/workflows/lint.yml` + `scripts/check-workflow-shell.sh`).**
|
||||
New cheap (~10s) lint workflow that runs on every push/PR (not just release
|
||||
tags): a Gitea-accurate shell guard plus pinned `actionlint` + `shellcheck`.
|
||||
The custom guard asserts every `run:` step in every `.gitea/workflows/*.yml`
|
||||
resolves to an effective shell of `bash`, closing the actionlint blind spot
|
||||
(actionlint models GitHub, whose default `run` shell is bash, so it does not
|
||||
flag bash syntax in a step that merely omits `shell:` — the exact way the
|
||||
sh-vs-bash footgun manifests on Gitea, whose default is `sh`/dash). Ported
|
||||
from pi-devbox.
|
||||
|
||||
### Changed (CI)
|
||||
|
||||
- **Workflow-wide `defaults: run: shell: bash`** added to
|
||||
`docker-publish-split.yml` and `validate.yml`. Gitea Actions' default step
|
||||
shell is `sh` (dash), so bash-only syntax (`set -o pipefail`, `[[ ]]`,
|
||||
arrays) in a step that forgets `shell: bash` fails silently. Setting the
|
||||
default workflow-wide eliminates the whole class. All pre-existing steps use
|
||||
only POSIX syntax, so bash (a superset) runs them unchanged — no behavioural
|
||||
change. Preventive port from pi-devbox, where this class bit twice.
|
||||
|
||||
### Fixed (CI)
|
||||
|
||||
- **`promote-base-latest` re-points `base-latest` by digest, not `need_build`.**
|
||||
The job gate keyed off `need_build == 'true'`, assuming `need_build == false`
|
||||
meant `base-latest` was already current. A dry-run dispatch
|
||||
(`promote_latest=false`) that pre-builds `base-<hash>` falsifies that: the
|
||||
later tag run sees `need_build == false`, skips promotion, and leaves
|
||||
`base-latest` one base behind. The gate now runs on every tag release /
|
||||
promote dispatch, and the no-op optimization moved into the step as a `crane
|
||||
digest` compare — it re-tags only when `base-latest` actually differs from the
|
||||
released `base-<hash>` (genuine cache-hit releases stay a no-op). Workflow-only
|
||||
change; base hash unaffected (no base rebuild). Ported from pi-devbox b7197e8
|
||||
(which hit this on its v1.2.3 release, 2026-06-27).
|
||||
|
||||
---
|
||||
|
||||
## 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.9–1.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
|
||||
|
||||
+53
-39
@@ -35,6 +35,11 @@ ENV DEBIAN_FRONTEND=noninteractive
|
||||
# apt-get upgrade picks up any security/CVE fixes published between
|
||||
# debian:trixie-slim base-image rebuilds. Paired with the index update
|
||||
# and the install in the same layer so we don't bloat image history.
|
||||
# `nano` is included as a small, non-modal terminal editor for users who
|
||||
# don't want vi-style modal editing — a companion to nvim and the `micro`
|
||||
# binary installed further down. ~2.8 MB; its deps (libc6, libncursesw6,
|
||||
# libtinfo6) are already pulled in by nvim/less/htop/tmux, so it adds no
|
||||
# extra packages. EDITOR stays nvim; opt in via `export EDITOR=nano`.
|
||||
RUN apt-get update && \
|
||||
apt-get upgrade -y --no-install-recommends && \
|
||||
apt-get install -y --no-install-recommends \
|
||||
@@ -66,6 +71,7 @@ RUN apt-get update && \
|
||||
rsync \
|
||||
python3-pip \
|
||||
python3-venv \
|
||||
nano \
|
||||
&& ln -s /usr/bin/fdfind /usr/local/bin/fd \
|
||||
&& apt-get clean \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
@@ -204,6 +210,33 @@ RUN ARCH=$(case "${TARGETARCH}" in amd64) echo "x86_64" ;; arm64) echo "arm64" ;
|
||||
ln -s /opt/nvim-linux-${ARCH}/bin/nvim /usr/local/bin/nvim && \
|
||||
nvim --version | head -1
|
||||
|
||||
# micro — modern, non-modal terminal editor. Ships alongside nvim so users
|
||||
# who aren't comfortable with vi-style modal editing have a friendly option:
|
||||
# desktop-style keybindings (Ctrl+S save, Ctrl+Q quit, Ctrl+C/V/X, Ctrl+Z
|
||||
# undo), mouse support, and syntax highlighting out of the box. A single
|
||||
# static Go binary (~12 MB) installed from GitHub releases, exactly like
|
||||
# bat/eza/zoxide below. EDITOR stays nvim (see below); users opt in with
|
||||
# `export EDITOR=micro` or `git config --global core.editor micro`.
|
||||
#
|
||||
# NOTE: upstream moved zyedidia/micro -> micro-editor/micro. The old org URL
|
||||
# still 302s, but its /releases/latest redirect lands on ANOTHER /latest URL
|
||||
# (the org rename), so the tag-parsing idiom below would resolve "latest"
|
||||
# instead of a version. Use the canonical micro-editor/micro URL.
|
||||
# Arch asset naming differs from the others: amd64 -> linux64, arm64 ->
|
||||
# linux-arm64. The tarball extracts to micro-<version>/micro.
|
||||
ARG MICRO_VERSION=latest
|
||||
RUN ARCH=$(case "${TARGETARCH}" in amd64) echo "linux64" ;; arm64) echo "linux-arm64" ;; *) echo "linux64" ;; esac) && \
|
||||
V="${MICRO_VERSION}" && \
|
||||
if [ "$V" = "latest" ]; then \
|
||||
V=$(curl -sI --retry 5 --retry-delay 5 --retry-all-errors "https://github.com/micro-editor/micro/releases/latest" | awk 'tolower($1)=="location:" { sub(/\r$/,"",$2); n=split($2,a,"/"); print a[n] }'); \
|
||||
fi && \
|
||||
V="${V#v}" && [ -n "$V" ] && \
|
||||
echo "Installing micro ${V}" && \
|
||||
curl -fsSL --retry 5 --retry-delay 5 --retry-all-errors "https://github.com/micro-editor/micro/releases/download/v${V}/micro-${V}-${ARCH}.tar.gz" | tar -xz -C /tmp && \
|
||||
install /tmp/micro-${V}/micro /usr/local/bin/micro && \
|
||||
rm -rf /tmp/micro-${V} && \
|
||||
micro --version
|
||||
|
||||
# bat — syntax-highlighted cat replacement
|
||||
ARG BAT_VERSION=latest
|
||||
RUN ARCH=$(case "${TARGETARCH}" in amd64) echo "x86_64" ;; arm64) echo "aarch64" ;; *) echo "x86_64" ;; esac) && \
|
||||
@@ -272,7 +305,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 \
|
||||
@@ -281,42 +320,10 @@ 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
|
||||
@@ -453,11 +460,18 @@ ENV PATH="/home/${USER_NAME}/.config/opencode/npm-global/bin:${PATH}"
|
||||
|
||||
# ── Shell defaults (bash history, aliases, readline) ─────────────────
|
||||
RUN mkdir -p /etc/skel-devbox
|
||||
COPY rootfs/home/developer/.bash_aliases /etc/skel-devbox/.bash_aliases
|
||||
COPY rootfs/home/developer/.inputrc /etc/skel-devbox/.inputrc
|
||||
COPY rootfs/home/developer/.bash_aliases /etc/skel-devbox/.bash_aliases
|
||||
COPY rootfs/home/developer/.inputrc /etc/skel-devbox/.inputrc
|
||||
COPY rootfs/home/developer/.gitignore_global /etc/skel-devbox/.gitignore_global
|
||||
|
||||
# ── 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 \
|
||||
|
||||
+1
-1
@@ -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.8
|
||||
ARG OPENCODE_VERSION=1.17.13
|
||||
RUN if [ "${INSTALL_OPENCODE}" = "true" ]; then \
|
||||
NPM_CONFIG_PREFIX=/usr npm install -g opencode-ai@${OPENCODE_VERSION} && \
|
||||
opencode --version ; \
|
||||
|
||||
@@ -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:
|
||||
@@ -243,6 +263,11 @@ volumes:
|
||||
- ~/.config/nvim:/home/developer/.config/nvim:ro
|
||||
```
|
||||
|
||||
> **Not a vi person?** The image also ships two non-modal editors alongside nvim:
|
||||
> **nano** (classic, minimal) and **micro** (modern — desktop-style `Ctrl+S`/`Ctrl+Q`
|
||||
> keys, mouse, syntax highlighting). `EDITOR` stays `nvim`; opt in per-shell with
|
||||
> `export EDITOR=nano` (or `micro`), or for git with `git config --global core.editor micro`.
|
||||
|
||||
### Python development with uv
|
||||
|
||||
The image includes Python 3.13 (from Debian Trixie) and [uv](https://docs.astral.sh/uv/), a fast Python package manager that replaces pip, venv, and pyenv:
|
||||
@@ -787,7 +812,7 @@ Container (Debian trixie)
|
||||
├── opencode binary
|
||||
├── oh-my-opencode-slim (optional — multi-agent orchestration plugin, includes Bun)
|
||||
├── AWS CLI v2 (SSO + Bedrock auth)
|
||||
├── neovim 0.12, tmux, htop, bat, eza, zoxide, uv, rustup, make, gcc, g++, rsync
|
||||
├── neovim 0.12, nano, micro, tmux, htop, bat, eza, zoxide, uv, rustup, make, gcc, g++, rsync
|
||||
├── git, git-crypt, age, gitleaks, ssh, ripgrep, fd, fzf, jq, curl, tree
|
||||
├── Node.js (for MCP servers)
|
||||
├── Bun (optional — included with oh-my-opencode-slim)
|
||||
|
||||
+57
-1
@@ -33,7 +33,7 @@ fi
|
||||
# directly.
|
||||
SKEL_DIR="/etc/skel-devbox"
|
||||
if [ -d "$SKEL_DIR" ]; then
|
||||
for f in .bash_aliases .inputrc; do
|
||||
for f in .bash_aliases .inputrc .gitignore_global; do
|
||||
if [ -f "$SKEL_DIR/$f" ] && [ ! -e "$HOME/$f" ]; then
|
||||
cp "$SKEL_DIR/$f" "$HOME/$f"
|
||||
fi
|
||||
@@ -92,6 +92,12 @@ fi
|
||||
if [ -n "${GIT_USER_EMAIL:-}" ] && ! git config --global user.email &>/dev/null; then
|
||||
git config --global user.email "$GIT_USER_EMAIL"
|
||||
fi
|
||||
# Global gitignore for personal/tooling artifacts (*.bak, *~, *.orig, ...).
|
||||
# Seeded above into $HOME/.gitignore_global from /etc/skel-devbox. Point git at
|
||||
# it only if the user has not already set their own core.excludesFile.
|
||||
if [ -f "$HOME/.gitignore_global" ] && ! git config --global core.excludesFile &>/dev/null; then
|
||||
git config --global core.excludesFile "$HOME/.gitignore_global"
|
||||
fi
|
||||
|
||||
# ── Generate opencode config from env vars if no config mounted ──────
|
||||
# Delegated to a standalone Python script for clarity and testability.
|
||||
@@ -184,6 +190,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"
|
||||
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
# Global gitignore — personal/tooling artifacts (applies to all repos in the container)
|
||||
# Seeded into $HOME/.gitignore_global by entrypoint-user.sh and wired via
|
||||
# `git config --global core.excludesFile`. Edit freely; it is yours after first boot.
|
||||
|
||||
# backup / editor / merge artifacts
|
||||
*.bak
|
||||
*.bak.*
|
||||
*~
|
||||
*.orig
|
||||
*.swp
|
||||
*.tmp
|
||||
@@ -188,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
|
||||
|
||||
@@ -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 ~5–10 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).
|
||||
Executable
+67
@@ -0,0 +1,67 @@
|
||||
#!/usr/bin/env bash
|
||||
# Gitea-accurate guard against the "bash syntax under the default sh/dash
|
||||
# shell" footgun. Ported from pi-devbox, where this class bit twice
|
||||
# (ed49b8d resolve-versions; b7197e8/b33e9dc promote-base-latest, run 418).
|
||||
# opencode-devbox has not been bitten yet — this is a PREVENTIVE guard so a
|
||||
# future author can't reintroduce the class.
|
||||
#
|
||||
# WHY A CUSTOM CHECK AND NOT JUST actionlint:
|
||||
# actionlint models *GitHub* Actions, whose default `run` shell is bash. It
|
||||
# therefore assumes a step that omits `shell:` runs under bash, and does NOT
|
||||
# flag `set -o pipefail` there. Gitea Actions' default is `sh` (dash), so the
|
||||
# exact bug (omit `shell:`, use bash syntax) is invisible to actionlint.
|
||||
# actionlint only fires when a step *explicitly* declares `shell: sh`.
|
||||
#
|
||||
# THE INVARIANT THIS ENFORCES:
|
||||
# Every `run:` step in every .gitea/workflows/*.yml must resolve to an
|
||||
# effective shell of `bash` — via the step's own `shell:`, a job-level
|
||||
# `defaults.run.shell`, or a workflow-level `defaults.run.shell`. Any step
|
||||
# that would fall through to Gitea's `sh` default is a FAILURE, because a
|
||||
# future author adding bash syntax to it fails silently in CI.
|
||||
#
|
||||
# Pair this with actionlint (which catches explicit `shell: sh` + bash syntax,
|
||||
# expression errors, and much else). Together they cover the class on Gitea.
|
||||
set -euo pipefail
|
||||
|
||||
WF_DIR="${1:-.gitea/workflows}"
|
||||
|
||||
python3 - "$WF_DIR" <<'PY'
|
||||
import sys, glob, os
|
||||
try:
|
||||
import yaml
|
||||
except ImportError:
|
||||
sys.stderr.write("ERROR: python3 yaml module missing (apt install python3-yaml)\n")
|
||||
sys.exit(2)
|
||||
|
||||
wf_dir = sys.argv[1]
|
||||
files = sorted(glob.glob(os.path.join(wf_dir, "*.yml")) + glob.glob(os.path.join(wf_dir, "*.yaml")))
|
||||
if not files:
|
||||
sys.stderr.write(f"ERROR: no workflow files under {wf_dir}\n")
|
||||
sys.exit(2)
|
||||
|
||||
problems = []
|
||||
for f in files:
|
||||
with open(f) as fh:
|
||||
doc = yaml.safe_load(fh) or {}
|
||||
wf_shell = (((doc.get("defaults") or {}).get("run") or {}).get("shell"))
|
||||
jobs = doc.get("jobs") or {}
|
||||
for jname, job in jobs.items():
|
||||
job = job or {}
|
||||
job_shell = (((job.get("defaults") or {}).get("run") or {}).get("shell"))
|
||||
steps = job.get("steps") or []
|
||||
for i, step in enumerate(steps):
|
||||
step = step or {}
|
||||
if "run" not in step:
|
||||
continue # `uses:` steps have no shell
|
||||
eff = step.get("shell") or job_shell or wf_shell or "sh" # Gitea default = sh
|
||||
if eff != "bash":
|
||||
name = step.get("name") or f"step[{i}]"
|
||||
problems.append(f"{f}: job '{jname}' / '{name}': effective shell = '{eff}' (Gitea default is sh; declare shell: bash or a bash default)")
|
||||
|
||||
if problems:
|
||||
sys.stderr.write("Workflow shell guard FAILED — bash default not guaranteed:\n")
|
||||
for p in problems:
|
||||
sys.stderr.write(f" - {p}\n")
|
||||
sys.exit(1)
|
||||
print(f"Workflow shell guard OK — all run: steps in {len(files)} workflow file(s) resolve to bash.")
|
||||
PY
|
||||
@@ -155,6 +155,28 @@ 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
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
#
|
||||
# Verifies:
|
||||
# - Core binaries are on PATH and runnable
|
||||
# - non-modal editors nano + micro are present (alongside nvim)
|
||||
# - opencode itself starts and prints a version
|
||||
# - Entrypoint runs cleanly as non-root after UID adjustment
|
||||
# - Generated opencode.json has the expected shape
|
||||
@@ -118,6 +119,8 @@ run "node" "node --version"
|
||||
run "npm" "npm --version"
|
||||
run "git" "git --version"
|
||||
run "nvim" "nvim --version | head -1"
|
||||
run "nano" "nano --version | head -1"
|
||||
run "micro" "micro --version"
|
||||
run "bat" "bat --version"
|
||||
run "eza" "eza --version | head -1"
|
||||
run "zoxide" "zoxide --version"
|
||||
@@ -197,6 +200,18 @@ 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" \
|
||||
|
||||
Reference in New Issue
Block a user