Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b9039f577e | |||
| 992cb6702f | |||
| 9b1e8c0b30 | |||
| 1f0d06444b | |||
| af11c32f4f | |||
| 1c4239e9b0 |
@@ -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"
|
||||
|
||||
@@ -18,8 +18,8 @@ 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.
|
||||
@@ -45,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.
|
||||
|
||||
+178
@@ -6,6 +6,184 @@ Tags follow **independent semver** (since `v2.0.0`) — they version *this image
|
||||
|
||||
---
|
||||
|
||||
## 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
|
||||
the sibling **pi-devbox** repo (v1.1.4–v1.1.6) into opencode-devbox, adapted to
|
||||
this image's companions and two-variant (`base`/`omos`) shape. Also bumps
|
||||
opencode. Defaults are unchanged, so the canonical CI build stays byte-identical
|
||||
apart from the opencode bump and the (cache-free) provenance layer.
|
||||
|
||||
### Fixed: read-only `~/.ssh` ControlPath / LAN sidecar on native Linux
|
||||
|
||||
`rootfs/usr/local/lib/opencode-devbox/setup-lan-access.sh` previously
|
||||
`exit 0`-ed early on native-Linux hosts (`auto` mode, not VM-backed) **before**
|
||||
rendering the writable `~/.ssh-local/config` sidecar. On such hosts with a
|
||||
read-only `~/.ssh` bind-mount, `dssh`/`dscp` got no config and the `Host *`
|
||||
ControlPath redirect into `~/.ssh-local/cm` never happened, so a user
|
||||
`~/.ssh/config` carrying the CGNAT idiom `ControlPath ~/.ssh/cm/%r@%h:%p`
|
||||
broke ControlMaster. The sidecar (ControlPath redirect + `Include
|
||||
~/.ssh/config`) is now rendered on **every** host OS; only the jump-specific
|
||||
blocks (host alias, key generation, peer overrides, RFC1918 catch-all) stay
|
||||
gated behind a new `NEED_JUMP` flag. `Dockerfile.base` and `entrypoint-user.sh`
|
||||
comments updated to document the always-render behavior and the
|
||||
plain-`ssh <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
|
||||
|
||||
+32
-38
@@ -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
@@ -39,7 +39,7 @@ ARG USER_NAME=developer
|
||||
# edit, so the cache-hit class of bug that bit pi-devbox v0.74.0..
|
||||
# v0.75.5 cannot apply here.
|
||||
ARG INSTALL_OPENCODE=true
|
||||
ARG OPENCODE_VERSION=1.17.7
|
||||
ARG OPENCODE_VERSION=1.17.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.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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"
|
||||
|
||||
|
||||
@@ -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 ~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
+43
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user