Compare commits

...

5 Commits

Author SHA1 Message Date
Joakim Persson a55f6369b3 AGENTS.md: three damage-prevention rules for a shared central palace
Lint / hadolint (push) Successful in 13s
Lint / actionlint (push) Successful in 28s
The managed block already tells agents to load the mempalace skill, but said
nothing about the palace being shared with other machines. Three failure modes
observed tonight while onboarding tor-ms22's feeders, all of which do damage
rather than merely confuse:

- `mempalace sync` prunes drawers whose source files look gitignored, deleted
  or moved. On a central palace that describes most of the content, including
  every other machine's. Compounded by RFC-001 7.2: feeders now stage inside
  the palace root, so a scoped sync can delete the drawers it just filed.
- A client-side timeout is not a failure. The palace is single-writer and one
  large mine blocks every client for minutes, so
  `[mempalace ext] feed (tick) failed: mine timed out after 30000ms` usually
  means the mine COMPLETED. Verified: drawers from a timed-out tick were
  present 35 s after the client gave up. A blind retry files a duplicate.
- The `mempalace` CLI has zero references to MEMPALACE_REMOTE_URL, so it always
  opens a palace on local disk and can silently disagree with the MCP tools.

Orientation depth stays in the skill; only damage-prevention belongs here,
because this file is always read and the skill's later sections often are not.
2026-08-16 22:32:09 +02:00
Joakim Persson ffd54750b9 docs: CHANGELOG for v1.8.2 — the silent transcript-feed failure and its guard
Publish Docker Image / resolve-versions (push) Successful in 25s
Lint / actionlint (push) Successful in 30s
Publish Docker Image / base-decide (push) Successful in 8s
Lint / hadolint (push) Successful in 46s
Publish Docker Image / build-base (push) Successful in 41m26s
Publish Docker Image / smoke (push) Successful in 4m45s
Publish Docker Image / smoke-studio (push) Successful in 5m11s
Publish Docker Image / build-variant-studio (push) Successful in 17m44s
Publish Docker Image / build-variant (push) Successful in 23m41s
Publish Docker Image / update-description (push) Successful in 6s
Publish Docker Image / promote-base-latest (push) Successful in 10s
2026-08-16 00:53:46 +02:00
Joakim Persson d8b745c164 smoke+docs: pin the feeder's failed-remote-mine detection; REMOTE_PATH is server-visible
Lint / actionlint (push) Successful in 30s
Lint / hadolint (push) Successful in 40s
First boot of the 2026-08-15 image on EMB-7KJ4VR4G shipped 7 transcripts to the
palace host and filed none. Two causes, neither visible in the log:

- MEMPALACE_PI_REMOTE_PATH was unset, so the feeder used its /data/feed default,
  which assumes a CONTAINERIZED palace server. That fleet's primary runs
  natively (systemd user unit + uv tool), so it only sees host paths and the
  mine died with "source directory not found". rsync had already succeeded.
- The feeder decided success with `'"error"' in body`, but MCP escapes the
  tool's JSON inside result.content[].text, so the check was blind and the
  catch-up log said "Done. Wing updated." Fixed in mempalace-toolkit 6e1f4f3,
  which ships `--self-test` with fixtures pinning that exact response body.

- smoke-test.sh: run `mempalace-pi-session --self-test` against the BAKED
  toolkit, so a stale or reverted MEMPALACE_TOOLKIT_REF cannot reintroduce a
  feeder that mines nothing while reporting success.
- .env.example: spell out that MEMPALACE_PI_REMOTE_PATH is the path the SERVER
  PROCESS can open — container path for a dockerized server, and identical to
  the ssh-target path for a native one — and that a mismatch fails quietly.
2026-08-16 00:30:51 +02:00
Joakim Persson ae13c2264e ci: survive a revoked GITEA_BUILD_TOKEN on public commit reads
Lint / actionlint (push) Successful in 14s
Lint / hadolint (push) Successful in 13s
Follow-up to a2f0a4a, which documented the hazard; this removes it.

resolve-versions read three PUBLIC Gitea repos with `curl -sf -H "$AUTH_HEADER"`.
Gitea rejects an invalid token rather than ignoring it, so the token turned a
read that works anonymously into a hard failure:

  no Authorization header    200
  empty token (secret unset) 200   <- absent secret was always safe
  garbage/revoked token      401   <- stale secret broke the release

A revoked GITEA_BUILD_TOKEN therefore failed resolve-versions via require_sha,
presenting as connectivity or an API fault, on data any anonymous client could
fetch. Hit exactly that failure mode today with an expired PAT.

New gitea_sha() helper tries authed, and on 401/403 retries anonymously with a
loud stderr warning naming the token as the cause. Deliberate choices:

- non-200 after the retry emits nothing and returns 0, so require_sha still
  raises the explicit abort — the helper never invents a fallback ref, which is
  the property the surrounding code exists to guarantee
- warnings go to stderr, NOT as ::warning:: annotations: the function's stdout
  IS the SHA, so an annotation there would be captured into the ref
- the header is still sent first, so a private repo keeps working

Verified by extracting the function from the YAML step body (so the test ran the
committed text, not a copy) and calling it against live Gitea:

  valid token        -> 0e1369e6b496 (pi-toolkit)
  REVOKED token      -> warns, retries anon, f60cf9c73205 (mempalace-toolkit)
  unset secret       -> 98eb07bce60a (pi-extensions)
  nonexistent repo   -> empty + HTTP 404 warning, so require_sha aborts

Behaviour unchanged on the happy path: all three SHAs are byte-identical to the
ones the v1.8.1 release run resolved with the old curl code. `bash -n` clean on
the extracted step body; YAML re-parsed.
2026-08-15 14:27:18 +02:00
Joakim Persson a2f0a4a441 ci: correct the false "Gitea requires auth for public reads" comment
Lint / hadolint (push) Successful in 13s
Lint / actionlint (push) Successful in 15s
resolve-versions claimed "Gitea API requires auth even for public-repo commit
listing" above the pi-toolkit / pi-extensions curls. Measurably false for the
repos it guards. Verified 2026-08-15, unauthenticated vs authenticated GET of
/api/v1/repos/joakimp/<repo>/commits?limit=1&sha=main:

  pi-toolkit         private=false  unauth=200 auth=200  sha 0e1369e6b496 identical
  pi-extensions      private=false  unauth=200 auth=200  sha 98eb07bce60a identical
  mempalace-toolkit  private=false  unauth=200 auth=200  sha f60cf9c73205 identical

Only /api/v1/repos/*/actions/* refuses anonymous reads with 401 — almost
certainly what the claim was over-generalised from. (Same over-generalisation I
nearly committed to opencode-devbox's AGENTS.md today; 69fc80a there narrowed it
to the actions endpoints for the same reason.)

Checked the three repos actually queried rather than reusing the pi-devbox
result — if any had been private the comment would have been TRUE, and the
correction wrong.

Behaviour deliberately unchanged: the header still gets passed. It survives a
repo being flipped private, and an unset secret degrades cleanly because Gitea
ignores an empty `token ` value and serves anonymously:

  no header                 200
  empty token (secret unset) 200
  garbage token             401

That last row is the fragility now documented: a REVOKED or malformed token
returns 401 where anonymous returns 200, so a stale GITEA_BUILD_TOKEN converts a
healthy public read into a require_sha failure that presents as an API or
network fault. Encountered exactly that today with an expired PAT on the actions
endpoints, so the note tells the next reader to suspect the token first.

Comment-only: no non-comment line changed, YAML re-parsed.
2026-08-15 14:21:25 +02:00
5 changed files with 165 additions and 14 deletions
+12 -3
View File
@@ -57,9 +57,18 @@ SSH_KEY_PATH=~/.ssh
# the server to mine its own local copy. Without MEMPALACE_PI_SSH_TARGET the # the server to mine its own local copy. Without MEMPALACE_PI_SSH_TARGET the
# feeder is skipped (a remote palace with no inbox has nothing to mine). # feeder is skipped (a remote palace with no inbox has nothing to mine).
# MEMPALACE_PI_SSH_TARGET where to rsync to, as user@host:path # MEMPALACE_PI_SSH_TARGET where to rsync to, as user@host:path
# MEMPALACE_PI_REMOTE_PATH what that inbox is called ON THE SERVER — must be # MEMPALACE_PI_REMOTE_PATH what that inbox is called ON THE SERVER — i.e. the
# the container path if the server runs in Docker # path the SERVER PROCESS can open. If the palace
# (see docker-compose.mempalace.yml) # server runs in Docker, that is the container path
# (see docker-compose.mempalace.yml). If it runs
# NATIVELY (systemd unit / uv tool / plain
# `mempalace serve`), it sees host paths, so this
# must equal the path half of
# MEMPALACE_PI_SSH_TARGET. Getting this wrong is
# quiet: rsync still succeeds and only the mine
# fails with "source directory not found", so
# transcripts ship and are filed nowhere. The feeder
# warns in preflight when the two paths disagree.
# MEMPALACE_PI_DEVICE inbox subdirectory for this machine (default: hostname) # MEMPALACE_PI_DEVICE inbox subdirectory for this machine (default: hostname)
# MEMPALACE_PI_SSH_TARGET=user@palace-host:/srv/mempalace-feed # MEMPALACE_PI_SSH_TARGET=user@palace-host:/srv/mempalace-feed
# MEMPALACE_PI_REMOTE_PATH=/data/feed # MEMPALACE_PI_REMOTE_PATH=/data/feed
+55 -11
View File
@@ -177,6 +177,40 @@ jobs:
fi fi
} }
# Read a commit SHA from Gitea, surviving a bad build token.
#
# These repos are public (see the note at the call sites), so auth is
# a convenience, not a requirement — but Gitea REJECTS an invalid
# token (401) rather than ignoring it, so a revoked or malformed
# GITEA_BUILD_TOKEN could fail an entire release on reads that work
# fine anonymously. An ABSENT secret was always safe (Gitea ignores an
# empty `token ` value and serves the request, 200); a STALE one was
# not. So: try authed, and on 401/403 retry anonymously.
#
# A non-200 after that emits nothing and returns 0 deliberately, so
# require_sha raises the loud explicit abort rather than this helper
# inventing a fallback ref.
#
# Messages go to STDERR, not as ::warning:: annotations: this
# function's stdout IS the SHA, so anything written there would be
# captured into the ref by the command substitution.
gitea_sha() { # $1=repo
local repo="$1" url resp code
url="https://gitea.jordbo.se/api/v1/repos/joakimp/${repo}/commits?limit=1&sha=main"
resp=$(curl -s -w '\n%{http_code}' -H "$AUTH_HEADER" "$url" || printf '\n000')
code=${resp##*$'\n'}
if [ "$code" = "401" ] || [ "$code" = "403" ]; then
printf 'WARNING: Gitea rejected the build token for %s (HTTP %s); retrying anonymously. The read should succeed (public repo), but GITEA_BUILD_TOKEN is stale or malformed and should be rotated.\n' "$repo" "$code" >&2
resp=$(curl -s -w '\n%{http_code}' "$url" || printf '\n000')
code=${resp##*$'\n'}
fi
if [ "$code" != "200" ]; then
printf 'WARNING: Gitea commit lookup for %s returned HTTP %s\n' "$repo" "$code" >&2
return 0
fi
printf '%s' "${resp%$'\n'*}" | jq -r '.[0].sha // empty' 2>/dev/null || true
}
# ── pi version: from the PIN, not from npm `latest` ─────────── # ── pi version: from the PIN, not from npm `latest` ───────────
# Until v1.7.0 this followed npm `latest`, which meant every release # Until v1.7.0 this followed npm `latest`, which meant every release
# silently adopted whatever pi had shipped that morning — unaudited — # silently adopted whatever pi had shipped that morning — unaudited —
@@ -238,15 +272,27 @@ jobs:
echo "atelier_ref=${ATELIER_REF}" >> "$GITHUB_OUTPUT" echo "atelier_ref=${ATELIER_REF}" >> "$GITHUB_OUTPUT"
echo "atelier_tag=${ATELIER_TAG}" >> "$GITHUB_OUTPUT" echo "atelier_tag=${ATELIER_TAG}" >> "$GITHUB_OUTPUT"
# pi-toolkit / pi-extensions (Gitea) → commit SHAs. Gitea API # pi-toolkit / pi-extensions (Gitea) → commit SHAs. All three Gitea
# requires auth even for public-repo commit listing. # repos read in this step are PUBLIC: an unauthenticated GET of these
TOOLKIT_REF=$(curl -sf -H "$AUTH_HEADER" \ # commit endpoints returns 200 with the IDENTICAL sha (verified
"https://gitea.jordbo.se/api/v1/repos/joakimp/pi-toolkit/commits?limit=1&sha=main" \ # 2026-08-15 for pi-toolkit, pi-extensions and mempalace-toolkit).
| jq -r '.[0].sha // empty' 2>/dev/null || true) # The comment that used to sit here claimed the Gitea API "requires
# auth even for public-repo commit listing" — it does not. Only
# /api/v1/repos/*/actions/* refuses anonymous reads (401), which is
# what that claim was almost certainly generalised from.
#
# The header is still passed on purpose: it keeps working if a repo is
# ever flipped private, and an ABSENT secret degrades cleanly, because
# Gitea ignores an empty `token ` value and serves the request
# anonymously (200). The real hazard is the opposite one — a REVOKED or
# malformed token returns 401 where anonymous would have returned 200,
# so a stale GITEA_BUILD_TOKEN turns a healthy public read into a
# require_sha failure that reads like an API or network fault. If this
# step ever fails on a repo you can browse anonymously, suspect the
# token before you suspect Gitea.
TOOLKIT_REF=$(gitea_sha pi-toolkit)
require_sha PI_TOOLKIT_REF "$TOOLKIT_REF" require_sha PI_TOOLKIT_REF "$TOOLKIT_REF"
EXTENSIONS_REF=$(curl -sf -H "$AUTH_HEADER" \ EXTENSIONS_REF=$(gitea_sha pi-extensions)
"https://gitea.jordbo.se/api/v1/repos/joakimp/pi-extensions/commits?limit=1&sha=main" \
| jq -r '.[0].sha // empty' 2>/dev/null || true)
require_sha PI_EXTENSIONS_REF "$EXTENSIONS_REF" require_sha PI_EXTENSIONS_REF "$EXTENSIONS_REF"
echo "toolkit_ref=${TOOLKIT_REF}" >> "$GITHUB_OUTPUT" echo "toolkit_ref=${TOOLKIT_REF}" >> "$GITHUB_OUTPUT"
echo "extensions_ref=${EXTENSIONS_REF}" >> "$GITHUB_OUTPUT" echo "extensions_ref=${EXTENSIONS_REF}" >> "$GITHUB_OUTPUT"
@@ -256,9 +302,7 @@ jobs:
# into the base-decide hash (see that job) to force a base rebuild # into the base-decide hash (see that job) to force a base rebuild
# when the toolkit moves — otherwise a toolkit-only fix silently # when the toolkit moves — otherwise a toolkit-only fix silently
# fails to land unless Dockerfile.base itself changes. # fails to land unless Dockerfile.base itself changes.
MEMPALACE_TOOLKIT_REF=$(curl -sf -H "$AUTH_HEADER" \ MEMPALACE_TOOLKIT_REF=$(gitea_sha mempalace-toolkit)
"https://gitea.jordbo.se/api/v1/repos/joakimp/mempalace-toolkit/commits?limit=1&sha=main" \
| jq -r '.[0].sha // empty' 2>/dev/null || true)
require_sha MEMPALACE_TOOLKIT_REF "$MEMPALACE_TOOLKIT_REF" require_sha MEMPALACE_TOOLKIT_REF "$MEMPALACE_TOOLKIT_REF"
echo "mempalace_toolkit_ref=${MEMPALACE_TOOLKIT_REF}" >> "$GITHUB_OUTPUT" echo "mempalace_toolkit_ref=${MEMPALACE_TOOLKIT_REF}" >> "$GITHUB_OUTPUT"
+59
View File
@@ -11,6 +11,65 @@ Pre-v1.0.0 tags followed the pi npm version (`v{pi_version}[letter]`).
--- ---
## v1.8.2 — 2026-08-16
Patch release. **Ships the fix for a silent transcript-feed failure**, plus the
smoke assertion that stops it coming back. No image pins changed from v1.8.1
(pi `0.84.2`, pi-atelier `v0.8.1`); what moves is the baked `mempalace-toolkit`
ref and one new smoke check.
**The bug this closes** (found on the first boot of the v1.8.1 image, on
EMB-7KJ4VR4G, 2026-08-15): the container-start catch-up rsynced seven pi session
transcripts to the palace host correctly, then asked the server to mine
`/data/feed/<device>` — the feeder's default `MEMPALACE_PI_REMOTE_PATH`, which
assumes a *containerized* palace server. That fleet's primary runs **natively**
(a systemd user unit + uv tool), so it only ever sees host paths and the mine
died with `source directory not found`. rsync had already succeeded, so the
inbox looked healthy.
It stayed invisible because of the second half: the feeder decided success with
`'"error"' in body`. MCP answers a hard tool failure with HTTP 200 and a
JSON-RPC *result* whose `content[].text` carries the tool's own JSON as an
**escaped string** — the bytes are `\"error\"`, so the substring could never
match. `~/.pi/agent/mempalace-catchup.log` printed
`Done. Wing 'wing_conversations' updated.` directly beneath the error JSON and
exited 0. A feeder whose only artifact claims success is worse than one that
crashes: nothing in the container disagreed with it.
Shipped here:
- **`mempalace-toolkit``b609cf5`** baked (CI resolves the ref at build time):
`classify()` parses the MCP envelope instead of grepping it (JSON-RPC error,
MCP `isError`, inner `success=false`/`error`), and separates "verified ok"
from "unverified: no JSON tool payload" rather than assuming the good case.
A preflight warning fires when the rsync destination and
`MEMPALACE_PI_REMOTE_PATH` disagree — in *preflight*, so `--dry-run` and
`--prepare` surface it too. Remote mode also stops previewing NEW/SKIP from
the *local* palace, which had been reporting "6 already filed" about a palace
it was not feeding; the tags are now `[?]` and the summary names who decides.
- **New smoke assertion** — `mempalace-pi-session --self-test` run against the
**baked** toolkit. It replays six recorded MCP responses (fixture 1 is the
verbatim 2026-08-15 failure body) plus a regression guard asserting the old
substring check is blind to it. A stale or reverted `MEMPALACE_TOOLKIT_REF`
can therefore no longer ship a feeder that mines nothing while reporting
success.
- **`.env.example`** now spells out that `MEMPALACE_PI_REMOTE_PATH` is the path
the *server process* can open — the container path for a dockerized server,
identical to the ssh-target path for a native one — and that a mismatch fails
quietly, with rsync succeeding and only the mine failing.
**The `--self-test` assertion is deliberately bare** (`mempalace-pi-session
--self-test`, no `HOME=…` prefix). `run()` invokes
`docker run --entrypoint="" $IMAGE sh -c …` and no Dockerfile sets `USER` or
`ENV HOME`, so it executes with **no `HOME` at all** — the same condition that
made v1.8.0's stage assertion unsatisfiable. The feeder is `set -u` with
HOME-anchored defaults, so it used to die with `HOME: unbound variable` there;
`b609cf5` derives `HOME` from the passwd database (what python's `expanduser()`
falls back to) instead. Keeping the call bare means smoke also proves the feeder
runs in a bare container, rather than papering over it with an env prefix.
---
## v1.8.1 — 2026-08-15 ## v1.8.1 — 2026-08-15
Patch release. **Unblocks v1.8.0, which never shipped.** Its `smoke` and Patch release. **Unblocks v1.8.0, which never shipped.** Its `smoke` and
@@ -41,3 +41,32 @@ especially load-bearing here — a pi-devbox container is frequently recreated,
the palace is your only memory across recreates. Without the habit it is just 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 storage, not memory. (The skill is the consumer side; feeding the palace is the
separate `opencode-mempalace-bridge` skill, if present.) separate `opencode-mempalace-bridge` skill, if present.)
### If the palace is central, it is shared — three rules
If `MEMPALACE_REMOTE_URL` is set, the MCP tools write to a **central palace
shared with other machines**, not to a local one. Your drawers are not the only
ones in there, and most drawers' `source_file` paths do not exist on this host.
The skill covers the orientation side (provenance, chronology, whose diary is
whose); these three are here instead because getting them wrong does *damage*
rather than merely confusing you:
- **Never run `mempalace sync` / `mempalace_sync` against a shared palace.** It
prunes drawers whose source files look gitignored, deleted, or moved — and on
a shared palace that describes most of the content, including every other
machine's. Compounding it (RFC-001 §7.2): feeders now stage *inside* the
palace root, so a scoped sync can delete the very drawers it just filed.
`mempalace_delete_by_source` is exact-match rather than existence-based, but
its blast radius is now the whole fleet's palace — leave it on its default
`dry_run=true` and confirm the match count before committing.
- **A timeout is not a failure.** The palace is single-writer, and one large
mine can block every client for minutes, so a write or mine that exceeds the
client's deadline has usually *completed* server-side. Verify with
`mempalace_get_drawer` or `mempalace_search` before retrying — a blind retry
files a duplicate. `[mempalace ext] feed (tick) failed: mine timed out after
30000ms` is the common benign instance: the transcript is already in the
server's inbox and the mine is idempotent, so nothing is lost either way.
- **The `mempalace` CLI is not remote-aware.** It always opens a palace on
local disk, so `mempalace search` can return older and different results than
the MCP tools while both look correct. Use the MCP tools for the central
palace; the CLI only for a local one.
+10
View File
@@ -195,6 +195,16 @@ run_expect "remote-palace-without-inbox skip is announced, not silent" \
"MemPalace catch-up skipped" "MemPalace catch-up skipped"
run "...and the skip notice names the variable that fixes it" \ run "...and the skip notice names the variable that fixes it" \
"grep -A6 'MemPalace catch-up skipped' /usr/local/bin/entrypoint-user.sh | grep -q 'MEMPALACE_PI_SSH_TARGET'" "grep -A6 'MemPalace catch-up skipped' /usr/local/bin/entrypoint-user.sh | grep -q 'MEMPALACE_PI_SSH_TARGET'"
# A remote mine that FAILS must not report success. MCP answers a hard tool
# failure with HTTP 200 and the tool's own JSON escaped inside
# result.content[].text, so the feeder's old `'\"error\"' in body` check could
# never see it: on 2026-08-15 a mine that died with "source directory not found:
# '/data/feed/...'" logged "Done. Wing updated." and exited 0, and this
# container's transcripts were filed nowhere for a whole session. The feeder
# carries fixtures for that exact body; run them against the baked toolkit so a
# stale/reverted toolkit ref can't reintroduce a silent feed.
run "baked feeder detects a failed remote mine (no silent false success)" \
"mempalace-pi-session --self-test"
# v1.0.0 base additions — verify presence and basic functionality. # v1.0.0 base additions — verify presence and basic functionality.
run "pandoc" "pandoc --version" run "pandoc" "pandoc --version"
run "typst" "typst --version" run "typst" "typst --version"