Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f645e6654f | |||
| 657b1ad856 | |||
| ebd0de0be2 | |||
| 4f1aa0d0dd | |||
| 9e744d701f | |||
| 2b8c3a4db4 | |||
| cb7b8ad2ae |
@@ -142,6 +142,7 @@ jobs:
|
||||
image: catthehacker/ubuntu:act-latest
|
||||
outputs:
|
||||
pi_version: ${{ steps.resolve.outputs.pi_version }}
|
||||
mempalace_version: ${{ steps.resolve.outputs.mempalace_version }}
|
||||
fork_ref: ${{ steps.resolve.outputs.fork_ref }}
|
||||
obsmem_ref: ${{ steps.resolve.outputs.obsmem_ref }}
|
||||
toolkit_ref: ${{ steps.resolve.outputs.toolkit_ref }}
|
||||
@@ -242,6 +243,54 @@ jobs:
|
||||
fi
|
||||
echo "pi_version=${PI_VERSION}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
# ── mempalace core: same audit as pi, from Dockerfile.base ────
|
||||
# Until now this pin had NO CI-side audit at all — a literal string
|
||||
# in Dockerfile.base with zero references in this workflow, while
|
||||
# PI_VERSION got a concreteness gate, a published-on-registry check
|
||||
# and a drift warning. It is the same class of risk: the palace's MCP
|
||||
# tool schema is the agent-facing contract, and a client/server skew
|
||||
# against the shared central palace is a fleet-wide, not local,
|
||||
# problem. Read from Dockerfile.base (not duplicated here) so a local
|
||||
# `docker build` and CI install the same version by construction.
|
||||
MEMPALACE_VERSION=$(sed -n 's/^ARG MEMPALACE_VERSION=\([^[:space:]]*\).*/\1/p' Dockerfile.base | head -n1)
|
||||
if ! printf '%s' "${MEMPALACE_VERSION:-}" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+$'; then
|
||||
echo "::error::ARG MEMPALACE_VERSION in Dockerfile.base is not a concrete version (got '${MEMPALACE_VERSION:-<empty>}'). CI refuses to build from a floating palace version — see the pin policy comment above that ARG."
|
||||
exit 1
|
||||
fi
|
||||
# One fetch, two gates. `curl -sf` exits non-zero and prints nothing
|
||||
# on 404 (PyPI's answer for an unpublished version), so an empty body
|
||||
# lands in the "not published" branch with its own message.
|
||||
MEMPALACE_PYPI=$(curl -sf "https://pypi.org/pypi/mempalace/${MEMPALACE_VERSION}/json" || true)
|
||||
MEMPALACE_PUBLISHED=$(printf '%s' "$MEMPALACE_PYPI" | jq -r '.info.version // empty' 2>/dev/null || true)
|
||||
if [ "${MEMPALACE_PUBLISHED:-}" != "${MEMPALACE_VERSION}" ]; then
|
||||
echo "::error::Pinned mempalace version ${MEMPALACE_VERSION} is not published on PyPI (registry returned '${MEMPALACE_PUBLISHED:-<empty>}'). Fix ARG MEMPALACE_VERSION in Dockerfile.base."
|
||||
exit 1
|
||||
fi
|
||||
# A yanked release still installs when pinned exactly (PEP 592), so
|
||||
# `uv tool install mempalace==X` would succeed silently and ship a
|
||||
# version upstream has withdrawn to the whole fleet. The escape hatch
|
||||
# is the same one-line bump that got us here.
|
||||
MEMPALACE_YANKED=$(printf '%s' "$MEMPALACE_PYPI" | jq -r '.info.yanked // false' 2>/dev/null || true)
|
||||
if [ "${MEMPALACE_YANKED:-false}" = "true" ]; then
|
||||
# Reason hoisted into its own variable rather than inlined as a
|
||||
# $(...) inside the message: a jq program nested in a substitution
|
||||
# inside a double-quoted string needs escaping that silently breaks
|
||||
# the FILTER (jq compile error) while the surrounding `exit 1` still
|
||||
# fires, so the gate looks correct and reports garbage. Caught by
|
||||
# the mutation test, not by review.
|
||||
MEMPALACE_YANK_REASON=$(printf '%s' "$MEMPALACE_PYPI" | jq -r '.info.yanked_reason // "no reason given"' 2>/dev/null || true)
|
||||
echo "::error::Pinned mempalace version ${MEMPALACE_VERSION} is YANKED on PyPI (${MEMPALACE_YANK_REASON:-no reason given}). An exact pin installs a yanked release without complaint — bump ARG MEMPALACE_VERSION in Dockerfile.base."
|
||||
exit 1
|
||||
fi
|
||||
# Informational only, exactly like pi's npm drift warning: a newer
|
||||
# palace must never be adopted implicitly. `|| true` so a transient
|
||||
# PyPI failure cannot fail a release whose pin is already verified.
|
||||
MEMPALACE_PYPI_LATEST=$(curl -sf "https://pypi.org/pypi/mempalace/json" | jq -r '.info.version // empty' 2>/dev/null || true)
|
||||
if [ -n "${MEMPALACE_PYPI_LATEST:-}" ] && [ "${MEMPALACE_PYPI_LATEST}" != "${MEMPALACE_VERSION}" ]; then
|
||||
echo "::warning::mempalace ${MEMPALACE_PYPI_LATEST} is published; this build ships the audited pin ${MEMPALACE_VERSION}. To adopt it: read the upstream CHANGELOG for MCP tool-schema changes (the agent-facing contract) and for sync/delete semantics, check the skew it introduces against the central palace host's server version, then bump ARG MEMPALACE_VERSION in Dockerfile.base and note the audit in CHANGELOG.md."
|
||||
fi
|
||||
echo "mempalace_version=${MEMPALACE_VERSION}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
# pi-fork / pi-observational-memory (GitHub) → commit SHAs.
|
||||
FORK_REF=$(curl -sf -H "Accept: application/vnd.github.sha" \
|
||||
"https://api.github.com/repos/elpapi42/pi-fork/commits/master" || true)
|
||||
@@ -337,6 +386,7 @@ jobs:
|
||||
echo "studio_tag=${STUDIO_TAG}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
echo "Resolved PI_VERSION=${PI_VERSION} (pinned in Dockerfile.variant; npm latest is ${PI_NPM_LATEST:-unknown})"
|
||||
echo "Resolved MEMPALACE_VERSION=${MEMPALACE_VERSION} (pinned in Dockerfile.base; PyPI latest is ${MEMPALACE_PYPI_LATEST:-unknown})"
|
||||
echo "Resolved PI_ATELIER_REF=${ATELIER_REF} (pi-atelier ${ATELIER_TAG}, pinned)"
|
||||
echo "Resolved PI_FORK_REF=${FORK_REF}, PI_OBSMEM_REF=${OBSMEM_REF}"
|
||||
echo "Resolved PI_TOOLKIT_REF=${TOOLKIT_REF}, PI_EXTENSIONS_REF=${EXTENSIONS_REF}"
|
||||
@@ -471,6 +521,7 @@ jobs:
|
||||
- name: Smoke test (amd64)
|
||||
env:
|
||||
EXPECTED_PI_VERSION: ${{ needs.resolve-versions.outputs.pi_version }}
|
||||
EXPECTED_MEMPALACE_VERSION: ${{ needs.resolve-versions.outputs.mempalace_version }}
|
||||
run: bash scripts/smoke-test.sh pi-devbox:smoke
|
||||
|
||||
# ── Phase 3b: amd64 smoke for the studio variant ────────────────────
|
||||
@@ -533,6 +584,7 @@ jobs:
|
||||
- name: Smoke test studio (amd64)
|
||||
env:
|
||||
EXPECTED_PI_VERSION: ${{ needs.resolve-versions.outputs.pi_version }}
|
||||
EXPECTED_MEMPALACE_VERSION: ${{ needs.resolve-versions.outputs.mempalace_version }}
|
||||
run: bash scripts/smoke-test.sh pi-devbox:smoke-studio
|
||||
|
||||
# ── Phase 4: multi-arch publish ─────────────────────────────────────
|
||||
|
||||
@@ -52,6 +52,55 @@ jobs:
|
||||
apt-get update
|
||||
apt-get install -y --no-install-recommends shellcheck python3-yaml
|
||||
|
||||
- name: "Shellcheck + syntax-check repository scripts (severity: error)"
|
||||
# Gap being closed: everything else in this job shellchecks workflow
|
||||
# `run:` steps ONLY, via actionlint. The repo's own shell scripts —
|
||||
# entrypoint.sh, scripts/*.sh, and the extensionless tools under
|
||||
# rootfs/usr/local/bin/ — have never been shellchecked. That exact gap
|
||||
# (a sibling repo with no shell-script lint at all) is how a defect
|
||||
# shipped invisibly for two months: `echo "$json" | python3 <<'EOF'
|
||||
# ... json.load(sys.stdin)` cannot work — with no script argument
|
||||
# python reads its SCRIPT from stdin, so the heredoc IS stdin and the
|
||||
# json.load call hits EOF. shellcheck flags exactly this at severity
|
||||
# ERROR (SC2259, "This redirection overrides piped input"); nothing
|
||||
# ever ran it. Measured before adding this gate: `-S error` is 0
|
||||
# findings across every shell file in THIS repo today, so it is free
|
||||
# to add. `-S warning` is NOT free here (19x SC2088 tilde-in-quotes in
|
||||
# scripts/recreate-sanity-check.sh, plus assorted SC2016 — both
|
||||
# intentional), so warning-level would train people to ignore the job;
|
||||
# hence error-only, matching the SHELLCHECK_OPTS philosophy below.
|
||||
#
|
||||
# Discovery is *.sh UNION a shebang scan, because rootfs/usr/local/
|
||||
# bin/{pi-devbox-version,devbox-skill-reconcile,dot-watch,studio-expose}
|
||||
# are shell scripts with no extension. -print0/mapfile -d '' so a path
|
||||
# with a space cannot silently split, and the file count is asserted
|
||||
# non-zero — a green tick over an empty file set is not a check.
|
||||
run: |
|
||||
# Union of two signals, because either alone misses a real case:
|
||||
# a shebang scan misses a sourced fragment with no shebang, and a
|
||||
# *.sh glob misses the extensionless tools in rootfs/usr/local/bin/.
|
||||
# Silent skipping is precisely the failure mode this gate exists to
|
||||
# prevent, so err toward over-collecting.
|
||||
mapfile -d '' -t all_files < <(find . -not -path './.git/*' -type f -print0)
|
||||
sh_files=()
|
||||
for f in "${all_files[@]}"; do
|
||||
case "$f" in *.sh) sh_files+=("$f"); continue;; esac
|
||||
if head -n1 "$f" 2>/dev/null | grep -qE '^#!.*\b(bash|sh)\b'; then
|
||||
sh_files+=("$f")
|
||||
fi
|
||||
done
|
||||
echo "Checking ${#sh_files[@]} shell file(s)"
|
||||
if [ "${#sh_files[@]}" -eq 0 ]; then
|
||||
echo "::error::no shell files found — the shebang scan or the checkout is wrong"
|
||||
exit 1
|
||||
fi
|
||||
shellcheck -S error -f gcc "${sh_files[@]}"
|
||||
rc=0
|
||||
for f in "${sh_files[@]}"; do
|
||||
bash -n "$f" || { echo "::error file=$f::bash -n failed"; rc=1; }
|
||||
done
|
||||
exit "$rc"
|
||||
|
||||
- name: Gitea shell guard (catches the actionlint blind spot)
|
||||
# actionlint models GitHub Actions, where the default run shell is
|
||||
# bash, so it does NOT flag bash syntax in a step that merely OMITS
|
||||
|
||||
+307
-2
@@ -11,6 +11,307 @@ Pre-v1.0.0 tags followed the pi npm version (`v{pi_version}[letter]`).
|
||||
|
||||
---
|
||||
|
||||
## v1.8.7 — 2026-08-25
|
||||
|
||||
Patch release, and the fastest turnaround in the series (~9 h after v1.8.6) for
|
||||
one reason: **v1.8.6 shipped a container that cannot tell you which machine it
|
||||
is running on**, and that anonymity produced a real misattribution the same
|
||||
evening — a session on tor-ms22 read *another host's* diary out of the shared
|
||||
palace, reported its verification as its own, and built a causal inference on
|
||||
top of the coincidence. The client-side half of the fix lives in
|
||||
`mempalace-toolkit`, which the image clones **at build time**, so it can only
|
||||
reach the fleet through a tag. The CI-hardening work that had accumulated since
|
||||
v1.8.6 rides along.
|
||||
|
||||
Gitea-hosted refs re-resolved immediately before tagging (2026-08-25T22:35Z):
|
||||
pi-toolkit `0e1369e6` and pi-extensions `20228878` **unchanged** since v1.8.6;
|
||||
mempalace-toolkit `0fe64c48` → `553d8657` (the provenance change below). CI
|
||||
re-resolves pi-fork / pi-observational-memory / pi-atelier / pi-studio at build
|
||||
time as usual. **Base rebuild is forced twice over** — `Dockerfile.base` changed
|
||||
(the `MEMPALACE_VERSION` audit) *and* `base_tag` deliberately folds in the
|
||||
mempalace-toolkit SHA ("otherwise a toolkit-only fix never lands") — so expect
|
||||
~67 min, and note that either cause alone would have sufficed.
|
||||
|
||||
⚠️ **The first tag of this version did not publish.** Run 589 built the base
|
||||
fine, then **both** smoke jobs failed 81-passed/1-failed on a single assertion —
|
||||
`mempalace skill snapshot is current`, a canary pinning a phrase from the
|
||||
vendored skill. The phrase it pinned was the heading of the very instruction this
|
||||
release *withdraws*, so refreshing the snapshot without re-pinning the canary
|
||||
made it fire correctly on a healthy image. Every publish job was skipped, so
|
||||
nothing reached the registry and the version was never consumed; the tag was
|
||||
moved to include the fix below. Fixing the canary is what this release is
|
||||
*for*, in miniature: the gate was right and the expectation was stale.
|
||||
|
||||
### Added
|
||||
|
||||
- **Palace writes now carry the device that made them, and diary entries say so
|
||||
in text.** The container is host-anonymous by construction — `hostname` is a
|
||||
Docker hash, `$DEVBOX_HOST_ALIAS` is generic, the virtiofs source tag is
|
||||
generic, and two hosts in this fleet are both `aarch64` — so nothing inside it
|
||||
distinguished tor-ms22 from EMB-7KJ4VR4G. In a *local* palace that costs
|
||||
nothing (one origin, so origin is a property of the whole store). In the
|
||||
**shared** palace it means every drawer and all 621 diary entries read as
|
||||
though written here, which is exactly how a v1.8.6 verification performed on
|
||||
EMB was reported as tor-ms22's own.
|
||||
|
||||
Two halves, arriving by different routes:
|
||||
|
||||
| Half | Where it lives | How it gets into this image |
|
||||
|---|---|---|
|
||||
| the writer — stamps `<harness>@<device>` on `add_drawer`/`checkpoint`/`mine`/`event_append`/`artifact_put`, and prefixes diary entries with `HOST:<device>\|` | `mempalace-toolkit` `extensions/pi/mempalace.ts` (553d8657) | cloned in `Dockerfile.base` at `MEMPALACE_TOOLKIT_REF`, whose SHA is folded into `base_tag` |
|
||||
| the consumer skill — stops telling the agent to do it by hand, adds the read-side warning | vendored `rootfs/…/skills/mempalace/SKILL.md`, refreshed from skillset `73c7c8e6` | `rootfs/*` is hashed into `base_tag` too |
|
||||
|
||||
Three design points worth recording, because each was arrived at the hard way:
|
||||
|
||||
- **The stamp goes in the *client*, not the agent.** RFC 001 §7.3.2 ranks
|
||||
"agent stamps it via a skill instruction" as the ❌ *worst possible* place,
|
||||
and the skill had carried exactly that instruction since 2026-08-23. It
|
||||
failed as predicted: the agent that wrote the instruction then filed its own
|
||||
provenance drawer without it. 199 rows reached the palace unresolvable.
|
||||
One `execute()` wrapper cannot forget.
|
||||
- **The diary marker is in the entry TEXT on purpose.** `diary_write` has no
|
||||
metadata parameter, but the deeper reason is that mempalace's `search`
|
||||
projects a fixed key set and `diary_read` returns content — **metadata is
|
||||
invisible to the agent who will later read the entry**, so no metadata-only
|
||||
fix, not even a server-authoritative one, would have prevented the
|
||||
misattribution. The marker is an AAAK field, so it is machine-parseable
|
||||
*and* the first thing a reader sees. The wake-up preamble now also names the
|
||||
device and warns that `diary_read` interleaves every machine's diary.
|
||||
- **A solitary devbox stamps nothing.** Gated on `MEMPALACE_PI_DEVICE` **and**
|
||||
`MEMPALACE_REMOTE_URL` — both set only when the palace is actually shared
|
||||
(RFC 001 R1). Unset either and behaviour is byte-identical to v1.8.6.
|
||||
|
||||
Never injected into `diary_write` or `kg_add`: mempalace 3.8.0 hard-rejects
|
||||
undeclared arguments with JSON-RPC `-32602` rather than dropping them (the
|
||||
behaviour changed since the RFC's 2026-08-09 note, now corrected), so a
|
||||
blanket injection would **break** those two calls instead of being ignored.
|
||||
The allowlist is per tool for that reason.
|
||||
|
||||
- **CI now shellchecks the repo's own shell scripts, not just workflow `run:` steps.** `.gitea/workflows/lint.yml`'s `actionlint` job already shellchecks every workflow step, but nothing had ever pointed shellcheck at `entrypoint.sh`, `scripts/*.sh`, or the extensionless tools under `rootfs/usr/local/bin/` (`pi-devbox-version`, `devbox-skill-reconcile`, `dot-watch`, `studio-expose`). The gap is not hypothetical: a sibling repo (skillset's `ci-release-watcher` templates) shipped `echo "$json" | python3 <<'EOF' ... json.load(sys.stdin)` for two months without anyone noticing it silently returned nothing — with no script argument python reads its *script* from stdin, so the heredoc is stdin and the JSON load hits EOF. shellcheck flags exactly this at severity **error** (`SC2259`, "This redirection overrides piped input"); it had been available to catch it the whole time, just never run.
|
||||
|
||||
New step in the `actionlint` job, `Shellcheck + syntax-check repository scripts`, runs `shellcheck -S error` plus `bash -n` over every shell file in the repo, **discovered by `*.sh` union a shebang scan** (neither alone suffices) so the extensionless `rootfs/usr/local/bin/*` tools are covered too. Measured before adding it: `-S error` is 0 findings across all 11 shell files today, so the gate is green on arrival with no cleanup. `-S warning` is *not* free (19× `SC2088` tilde-in-quotes in `scripts/recreate-sanity-check.sh`, plus assorted `SC2016`, both intentional here) — a warning-level gate would train people to ignore it, so it stays error-only, same reasoning as the existing `SHELLCHECK_OPTS` exclusions on the actionlint step. File-count guard included: the step fails loudly if the shebang scan matches zero files, since a green check over an empty set is not a check.
|
||||
|
||||
- **`MEMPALACE_VERSION` now gets the same CI audit as `PI_VERSION`** — closing
|
||||
the item v1.8.6 (and v1.8.5 before it) listed as "Still open". The pin was a
|
||||
literal string in `Dockerfile.base` with **zero** references anywhere in
|
||||
`.gitea/workflows/docker-publish.yml`, while `PI_VERSION` had ~20: a
|
||||
concreteness gate, a published-on-registry check, and a never-silently-adopt
|
||||
drift warning. `resolve-versions` now applies all of them to the palace pin,
|
||||
read from `Dockerfile.base` (not duplicated in the workflow, so a local
|
||||
`docker build` and CI install the same version by construction):
|
||||
|
||||
| Gate | Behaviour |
|
||||
|---|---|
|
||||
| not a concrete `X.Y.Z` | **error** — no floating palace version, same policy as pi |
|
||||
| not published on PyPI | **error** at resolve time, instead of a `uv tool install` failure mid-build |
|
||||
| **yanked** on PyPI | **error** — an exact pin installs a yanked release silently under PEP 592, so `mempalace==X` would have shipped a withdrawn client to the whole fleet |
|
||||
| newer release exists | **warning** naming what to audit before adopting (MCP tool-schema = the agent-facing contract; client/server skew against the central palace) |
|
||||
|
||||
Plus one smoke assertion, `installed mempalace matches CI's audited pin`,
|
||||
gated on a new `EXPECTED_MEMPALACE_VERSION` env threaded into both the `smoke`
|
||||
and `smoke-studio` jobs. It is **not** redundant with the existing `manifest
|
||||
mempalace_version matches the installed core`: that one compares two
|
||||
properties of a single image and therefore cannot notice that *both* are the
|
||||
wrong version. The failure mode this one covers is a variant built `FROM` a
|
||||
cached base whose `MEMPALACE_VERSION` pin was older — internally consistent,
|
||||
silently stale, invisible to every other assertion (the risk
|
||||
`scripts/check-base-hash.sh` exists to reduce but cannot eliminate).
|
||||
|
||||
**Mutation-tested rather than reasoned about**, by extracting the shipped
|
||||
block out of the YAML and running it with a stubbed `curl`: 9 cases —
|
||||
`latest` / `3.8` / absent ARG refused; 404 and a registry echoing a different
|
||||
version refused; a yanked release refused *with its reason*; a newer release
|
||||
warning without failing; a transient PyPI outage not failing a build whose pin
|
||||
is already verified; happy path silent and emitting the job output. Then once
|
||||
more end-to-end against live PyPI with the real `Dockerfile.base`. **This
|
||||
found a genuine defect in the first draft**: the yank message inlined a jq
|
||||
program inside a `$(...)` inside a double-quoted string, where the escaping
|
||||
broke the *filter* (jq compile error) while the surrounding `exit 1` still
|
||||
fired — a gate that looked correct and reported garbage. The reason is now
|
||||
hoisted into its own variable. The new smoke assertion was checked the same
|
||||
way, through the real `run` helper's `sh -c` quoting path: passes on `3.8.0`,
|
||||
fails on `3.7.1` *and* on `3.8.01` (exact equality, not the substring match
|
||||
the pi assertion uses), and skips cleanly when the env is unset so a local
|
||||
`smoke-test.sh` run is unaffected.
|
||||
|
||||
⚠️ **Costs a base rebuild on the next tag**: `Dockerfile.base` is hashed
|
||||
wholesale into `base_tag`, and its now-false "Known gap, carried forward"
|
||||
comment had to be corrected in place (leaving a comment that says the audit
|
||||
does not exist would repeat the shipped-false-claim mistake corrected below).
|
||||
Expect ~67 min, as for v1.8.5/v1.8.6.
|
||||
|
||||
- **The SSH sidecar now defaults to connection multiplexing, without overriding
|
||||
anyone's explicit choice.** `~/.ssh-local/config` already forced `ControlPath`
|
||||
into the writable sidecar dir, but nothing supplied `ControlMaster` for targets
|
||||
coming from the user's own bind-mounted `~/.ssh/config`. An entry that never
|
||||
mentioned it therefore opened a **fresh TCP connection per `ssh` call** — and
|
||||
an agent doing a dozen calls in a few minutes is exactly the traffic shape that
|
||||
trips fail2ban or a CGNAT flow-table cap. Observed 2026-08-25 on this fleet:
|
||||
~12 connections to one host in 15 minutes, after which port 22 stopped
|
||||
answering while HTTPS to the same estate stayed healthy in 0.44 s (that
|
||||
asymmetry is the tell for rate-limiting rather than an outage).
|
||||
|
||||
**The fix is where the block sits, not what it says.** `ssh_config` is
|
||||
first-value-wins, so position encodes intent, and the two settings need
|
||||
opposite treatment:
|
||||
|
||||
| Setting | Position | Meaning | Why |
|
||||
|---|---|---|---|
|
||||
| `ControlPath` | **before** `Include ~/.ssh/config` | override | the user's value points at read-only `~/.ssh`; it cannot work here, so it must lose |
|
||||
| `ControlMaster auto` + `ControlPersist 10m` | **after** the `Include` | default | an explicit per-host `ControlMaster no` must keep winning; we only supply an opinion where the user expressed none |
|
||||
|
||||
*Force what is broken, default what is merely absent.* The first draft of this
|
||||
put both in the leading block, which would have silently overridden an explicit
|
||||
`ControlMaster no` — the counterfactual is in the test below.
|
||||
|
||||
Verified with `ssh -G` (the resolved-config oracle) rather than by reading the
|
||||
man page, against a fixture with one host set to `no`, one silent, one set to
|
||||
`auto`: the explicit `no` resolves to `controlmaster false` **and** still gets
|
||||
the writable `ControlPath`, the silent host resolves to `auto`, and the same
|
||||
fixture under the rejected layout flips the `no` host to `auto` — so the test
|
||||
discriminates the *position*, not merely the presence of the block. Then
|
||||
end-to-end: the real script rendered in a sandbox `HOME`, block last, `bash -n`
|
||||
clean, `shellcheck -S error` clean (the gate added in v1.8.7).
|
||||
|
||||
Measured effect on the author's own config (41 host aliases): **22 were silent
|
||||
about `ControlMaster` and gain `auto` + 10 m persist; 0 are overridden**, since
|
||||
the fleet contains no explicit `no`. Worth noting *how* the one deliberate
|
||||
exception is written — `proxmox002-vpn` carries `# No ControlMaster — VPN means
|
||||
direct route, no CGNAT flow cap`, i.e. the intent is expressed as **absence
|
||||
plus a comment**, which `ssh` cannot distinguish from "no opinion". That host
|
||||
does now get multiplexing; its comment says multiplexing is *unnecessary*
|
||||
there, not harmful. Anything that must stay unmultiplexed needs a literal
|
||||
`ControlMaster no`.
|
||||
|
||||
Why the ordering matters beyond this one config: `~/.ssh/config` is
|
||||
**per-machine**, differs across the fleet, and future machines' versions do not
|
||||
exist yet to be audited. A default-not-override design is correct without
|
||||
needing to inspect any of them.
|
||||
|
||||
`ControlPersist` is deliberately short (10 m idle, and each new session resets
|
||||
the idle timer — long enough to collapse an agent's burst, short enough that an
|
||||
abandoned socket ages out). A per-host entry that sets its own value keeps it:
|
||||
hosts already specifying `ControlPersist 4h` still resolve to 4 h. The known
|
||||
cost of multiplexing is the **stale master** — socket present, daemon gone,
|
||||
after a suspend or network change — which makes every later `ssh` to that host
|
||||
hang; recovery is `ssh -F ~/.ssh-local/config -O exit <host>`, now documented
|
||||
in the `pi-devbox-environment` skill along with `-O check`.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **The vendored-snapshot canary was one-way, and pinned a phrase the same
|
||||
release deleted.** `mempalace skill snapshot is current` grepped for
|
||||
*"Attribute what you file yourself"* — the heading of the hand-stamping
|
||||
instruction withdrawn above. It therefore did its job (snapshot changed,
|
||||
expectation did not) and blocked an otherwise-green build. Two changes rather
|
||||
than a string bump: the assertion is now **bidirectional** (the new phrase must
|
||||
be present **and** the withdrawn one absent, so a re-vendored *stale* snapshot
|
||||
fails as loudly as a forgotten bump — verified by running it against v1.8.6's
|
||||
snapshot, which correctly fails), and the comment now states the structural
|
||||
limit: a phrase canary can only detect *"older than what I remembered to pin"*,
|
||||
never *"older than skillset main"*.
|
||||
|
||||
- **Four build-provenance smoke assertions verified the presence of a manifest
|
||||
field name and never looked at its value.** The originals were literally:
|
||||
|
||||
```sh
|
||||
run_expect "manifest records pi_version" "cat …build-manifest.json" '"pi_version"'
|
||||
```
|
||||
|
||||
which passes on `{"pi_version": ""}` and on `{"pi_version": null}`. The tell
|
||||
was sitting in the passing output all along — `✅ manifest records pi_version
|
||||
(got "pi_version")` echoes the *key* back as the thing it claims to have
|
||||
found — and it was spotted while reading run 579's smoke log to confirm the
|
||||
new v1.8.6 assertions had actually executed.
|
||||
|
||||
Replaced with checks against the values, and against ground truth where
|
||||
ground truth exists:
|
||||
|
||||
| Assertion | What it now enforces |
|
||||
|---|---|
|
||||
| `manifest declares every required component key` | all seven components present by name, failing with *which* key vanished |
|
||||
| `manifest component values are resolved 40-hex commits` | each value is a full 40-hex SHA; `null` allowed for `pi-studio` alone (absent in the non-studio variant) |
|
||||
| `manifest pi_version matches the installed pi` | manifest value equals `pi --version`, same ground-truth shape as the mempalace check |
|
||||
| `manifest top-level fields are well-formed, not merely present` | `release_tag` non-empty; `source_revision` 40-hex *when populated*; `build_date` ISO-8601 *when populated* |
|
||||
| `pi-devbox-version --json round-trips the manifest byte-for-byte` | actual string equality with the file, since `--json` is a verbatim `cat` |
|
||||
|
||||
**Why five value-checks replace four name-checks (total assertion count
|
||||
unchanged at 61), and specifically why key presence and value shape are kept
|
||||
apart:** an "every component value is a valid SHA" loop passes **vacuously** on `components:{}`, because jq's `all()`
|
||||
over an empty list is true. A single combined check would therefore go green
|
||||
on a manifest that had lost every component — which is the same shape of hole
|
||||
as the three false greens already recorded in this file. They are separate on
|
||||
purpose.
|
||||
|
||||
Mutation-tested rather than reasoned about, twice: nine fabricated manifests
|
||||
through the raw jq filters, then twelve through the shipped assertions using
|
||||
the real `run` helper's `sh -c` quoting path (the quoting is load-bearing here
|
||||
— a jq filter that dies on a quoting error exits non-zero and *looks* like a
|
||||
caught defect). Measured against the old assertions on the same twelve
|
||||
defects: **old caught 3, missed 9; new catches 12.** The three the old set
|
||||
caught were key *disappearance* (grepping for a key name does fail when the
|
||||
key is gone) and the literal string `"unknown"`; every value-level defect —
|
||||
empty string, `null`, a 12-hex truncation, a wrong-but-plausible version, a
|
||||
malformed `source_revision` — was invisible. Three legitimate variations are
|
||||
correctly *not* flagged: empty `source_revision` and empty `build_date` (both
|
||||
default empty on a plain local `docker build`, so demanding them would fail
|
||||
honest local smoke runs) and `pi-studio: null`.
|
||||
|
||||
- **Dropped the now-redundant `manifest has no unresolved ('unknown')
|
||||
components` assertion.** The 40-hex value check strictly subsumes it:
|
||||
`"unknown"` is not 40-hex, and only `rev()` in Dockerfile.variant ever emits
|
||||
that string, feeding `components{}` exclusively. Removed rather than left in
|
||||
place, because a redundant check that can never fail independently is one more
|
||||
green tick that means nothing.
|
||||
|
||||
- **Corrected a factually wrong "Still open" bullet in the v1.8.6 entry below**
|
||||
(see the strikethrough there). It claimed `pi-devbox-version`'s human output
|
||||
does not display `mempalace_version` and that only `--json` surfaces it. Both
|
||||
halves are false — v1.8.6 shipped a `palace:` line with the same live-vs-baked
|
||||
drift annotation `pi` already had. Verified by running the shipped script
|
||||
against fabricated manifests: matching versions print `palace: 3.7.1`, a skew
|
||||
prints `palace: 3.7.1 (baked as 3.8.0 — drift detected)`, and a pre-v1.8.6
|
||||
manifest with no baked field prints the live value un-annotated. The bullet
|
||||
appears to describe an intermediate state of the working tree and was never
|
||||
re-checked before tagging. Left visible as a struck-through correction rather
|
||||
than deleted, since v1.8.6 is already published and someone may have read it.
|
||||
|
||||
---
|
||||
|
||||
### Still open
|
||||
|
||||
- **Make the vendored-snapshot check automatic instead of a remembered string.**
|
||||
Tonight's failure is the third iteration of the same maintenance burden (v1.8.4:
|
||||
phrase present in both copies; v1.8.7: phrase deleted by the release that
|
||||
refreshed the snapshot). A phrase canary structurally cannot answer *"is this
|
||||
snapshot older than skillset main?"* — only a diff can. Proposed: a lint job
|
||||
that clones the skillset repo and compares
|
||||
`rootfs/usr/local/share/pi-devbox/skills/mempalace/SKILL.md` against it,
|
||||
failing with the diff when they drift. Open question first: the skillset repo is
|
||||
**private**, so this needs a CI clone credential, which is a policy decision
|
||||
rather than a code change.
|
||||
|
||||
- **Provenance stops at Chroma's metadata.** The hourly reconciler on the palace
|
||||
host stamps `device`/`agent_kind` in `chroma.sqlite3`, but knowledge-graph
|
||||
triples and coordination events live in *separate* SQLite files
|
||||
(`knowledge_graph.sqlite3`, `logstream.sqlite3`) it cannot reach. 156 triples
|
||||
carry no origin field at all; `logstream`'s `from_agent` is free-form and
|
||||
already inconsistent (`pi@tor-ms22`, `pi@emb-7kj4vr4g`, and bare `pi` in the
|
||||
same table). Tracked in RFC 001 §7.3.1.
|
||||
- **The stamp is self-asserted, and cannot be otherwise yet.** mempalace 3.8.0
|
||||
authenticates with a *single scalar* bearer token and has zero device concept,
|
||||
so a verified stamp needs per-device credentials plus an origin field in six
|
||||
write paths across three databases. Deferred to RFC 001 Phase 4, where it is
|
||||
now motivated primarily by **revocation** (one shared token covers every
|
||||
device, so cutting off one laptop means rotating the fleet) rather than by
|
||||
provenance. Forward-compatible by design: every stamp records *how* it was
|
||||
determined, so an authoritative pass overwrites with `device_source='token'`
|
||||
and nothing has to be undone.
|
||||
- **`tor-ms22` and `tor-ms22-native` are one machine with two device values**
|
||||
(4,680 and 3,826 rows). That is the hostname-as-identity cost RFC 001 §7.3.4
|
||||
warned about, now visible in data: a rename splits one device's history
|
||||
silently. Repairing it means a device-identity mapping, not a relabel.
|
||||
|
||||
## v1.8.6 — 2026-08-25
|
||||
|
||||
Patch release. Adopts the drift that accumulated in the ~2 days since v1.8.5
|
||||
@@ -144,7 +445,7 @@ and did not require a toolkit-side change.
|
||||
on drift; `MEMPALACE_VERSION` is a literal Dockerfile string with zero
|
||||
references in `.gitea/workflows/docker-publish.yml`. Flagged in v1.8.5's
|
||||
audit as a gap; still a gap.
|
||||
- **`pi-devbox-version`'s human-readable output does not display
|
||||
- ~~**`pi-devbox-version`'s human-readable output does not display
|
||||
`mempalace_version`.** Its render path is a fixed sequence
|
||||
(`release_tag`, `build_date`, `source_revision`, `pi`, then `components{}`)
|
||||
and the new top-level field isn't in it — only `--json` mode (which `cat`s
|
||||
@@ -152,7 +453,11 @@ and did not require a toolkit-side change.
|
||||
`rootfs/usr/local/bin/pi-devbox-version` would fix this; deferred since the
|
||||
field's stated purpose (correlating a palace bug to an image) is already
|
||||
served by `--json`, but worth doing in a follow-up if this becomes a
|
||||
routine manual check.
|
||||
routine manual check.~~
|
||||
**CORRECTION (2026-08-25, post-tag):** this bullet is wrong and was never
|
||||
true of the tagged tree. `pi-devbox-version` *does* print a `palace:` line
|
||||
in human mode, with live-vs-baked drift detection, degrading quietly on
|
||||
pre-v1.8.6 manifests. Nothing is open here. See the Unreleased entry above.
|
||||
|
||||
**Resolved during this release, not left open:** the feeder `--agent`
|
||||
default behavioural hook initially looked like it might need a
|
||||
|
||||
+10
-6
@@ -419,12 +419,16 @@ ARG INSTALL_MEMPALACE=true
|
||||
# CLI commands against a running server), a different scenario #2307
|
||||
# does not touch.
|
||||
#
|
||||
# Known gap, carried forward (flagged in prior release notes, not fixed here):
|
||||
# unlike PI_VERSION, which CI's resolve-versions job verifies is published and
|
||||
# warns — never silently adopts — on npm drift, MEMPALACE_VERSION has NO
|
||||
# equivalent CI-side audit (confirmed: zero references to MEMPALACE_VERSION in
|
||||
# .gitea/workflows/docker-publish.yml). This is a literal Dockerfile string
|
||||
# with no automated freshness or publish check.
|
||||
# CI-side audit (added after v1.8.6, closing that release's "Still open" item):
|
||||
# resolve-versions now treats this pin exactly as it treats PI_VERSION — it
|
||||
# reads the ARG from THIS file, refuses a non-concrete value, verifies the
|
||||
# version is published on PyPI, refuses a YANKED release (an exact pin installs
|
||||
# one silently under PEP 592), and WARNS — never silently adopts — when PyPI has
|
||||
# a newer release. smoke-test.sh then asserts the installed core equals that
|
||||
# audited pin, which catches a stale cached base layer that no manifest-internal
|
||||
# check can see. So a bump here is now gated end to end; what remains manual is
|
||||
# the JUDGEMENT above (MCP schema review, server/client sequencing), which is
|
||||
# the part that should stay manual.
|
||||
#
|
||||
# Deployment sequencing note for whoever ships this bump: synlig (the shared
|
||||
# central palace host) currently serves mempalace 3.7.1 SERVER-SIDE via
|
||||
|
||||
@@ -197,6 +197,45 @@ EOF
|
||||
)
|
||||
fi
|
||||
|
||||
# ── Multiplexing default, deliberately LAST ───────────────────────────
|
||||
# Why this block exists: ControlPath above is forced, but ControlMaster is not
|
||||
# set anywhere for targets that come from the user's own ~/.ssh/config. A target
|
||||
# whose entry omits ControlMaster therefore opens a NEW TCP connection per ssh
|
||||
# call, and an agent doing a dozen calls in a few minutes can trip fail2ban or a
|
||||
# CGNAT flow-table cap on the far end — observed 2026-08-25: ~12 connections in
|
||||
# 15 min and port 22 stopped answering while HTTPS to the same estate stayed fine.
|
||||
#
|
||||
# WHY IT IS AT THE BOTTOM, and ControlPath is at the top. ssh_config is
|
||||
# first-value-wins, so position encodes intent:
|
||||
# * BEFORE the Include = an OVERRIDE. Correct for ControlPath, whose value in
|
||||
# the user's config points at read-only ~/.ssh and simply cannot work here.
|
||||
# * AFTER the Include = a DEFAULT. Correct for ControlMaster, because an
|
||||
# explicit per-host 'ControlMaster no' (or 'auto', or any value) in the
|
||||
# user's own config must keep winning. We are supplying an opinion only
|
||||
# where the user expressed none.
|
||||
# That asymmetry is the whole design: force what is broken, default what is
|
||||
# merely absent. It also means this needs no audit of anyone's ~/.ssh/config —
|
||||
# which matters because that file is per-machine, differs across the fleet, and
|
||||
# future machines' versions do not exist yet to be audited.
|
||||
#
|
||||
# Caveat worth knowing (and documented in the pi-devbox-environment skill): a
|
||||
# stale master socket — file present, daemon gone, e.g. after the host suspends
|
||||
# or changes network — makes every later ssh to that host hang. Recovery is
|
||||
# 'ssh -F ~/.ssh-local/config -O exit <host>'. ControlPersist is deliberately
|
||||
# short (10m idle, and each new session resets the idle timer) so an abandoned
|
||||
# socket ages out on its own rather than lingering for hours.
|
||||
MULTIPLEX_DEFAULT_BLOCK=$(cat <<'EOF'
|
||||
|
||||
# Multiplexing DEFAULT — intentionally after the Include above, so any explicit
|
||||
# per-host ControlMaster in your own ~/.ssh/config still wins (first-value-wins).
|
||||
# Applies only to targets that never mentioned ControlMaster at all.
|
||||
# Stale socket after a suspend/network change? ssh -O exit <host>.
|
||||
Host *
|
||||
ControlMaster auto
|
||||
ControlPersist 10m
|
||||
EOF
|
||||
)
|
||||
|
||||
cat > "$CONFIG" <<EOF
|
||||
# AUTO-GENERATED by setup-lan-access.sh on every container start. Do not edit
|
||||
# by hand — edits are overwritten. Used via: ssh -F ~/.ssh-local/config <host>
|
||||
@@ -216,6 +255,7 @@ ${JUMP_BLOCK}
|
||||
${LAN_CONF_BLOCK}
|
||||
${AUTOJUMP_BLOCK}
|
||||
${INCLUDE_BLOCK}
|
||||
${MULTIPLEX_DEFAULT_BLOCK}
|
||||
EOF
|
||||
chmod 600 "$CONFIG" 2>/dev/null || true
|
||||
|
||||
|
||||
@@ -79,6 +79,79 @@ 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.
|
||||
|
||||
#### Search Before You *Probe*
|
||||
|
||||
The rule above covers **questions**. This one covers **actions** — and it is the one
|
||||
that actually gets skipped, because mid-task the impulse is to go and *look* rather
|
||||
than to remember. The palace is a **fleet** record: another machine's agent has
|
||||
usually already paid the cost of discovering how this environment is wired, and its
|
||||
notes include the corrections that came afterwards, which a fresh probe cannot show
|
||||
you.
|
||||
|
||||
**Before you SSH somewhere to find out how it is set up, enumerate infrastructure,
|
||||
or derive a deployment — search.** Concrete triggers, all meaning *search first*:
|
||||
|
||||
- about to run `ssh <host> …`, `docker ps`, `systemctl list-units`, `ip addr` to
|
||||
discover how something is deployed or connected
|
||||
- about to establish topology: which hosts/runners/services exist, where they live,
|
||||
which of them can reach which
|
||||
- about to conclude "this isn't documented anywhere" or "there's no way to know"
|
||||
- about to assert an environment fact you learned **earlier in this same session**
|
||||
|
||||
**That last trigger is the sharp edge.** A compacted session summary is lossy by
|
||||
design, and a belief you formed 40 turns ago may already be *retracted* in the
|
||||
palace by another machine. Trusting your own context over the shared record is how a
|
||||
withdrawn claim gets re-published as fact.
|
||||
|
||||
Search broadly before narrowing — fleet knowledge often sits in another machine's
|
||||
wing, or inside a mined conversation, not where you would file it yourself:
|
||||
|
||||
```
|
||||
mempalace_search(query="<topic> <host> <mechanism>") # no wing filter first
|
||||
mempalace_search(query="…", wing="<likely-wing>") # then narrow
|
||||
```
|
||||
|
||||
Two or three searches cost seconds. Re-deriving infrastructure costs minutes **and
|
||||
can be wrong**: a probe shows one host's present state, while the palace records
|
||||
intent, history, and what was already disproved.
|
||||
|
||||
> **Worked example (real, 2026-08-25).** An agent evaluating whether to add an ARM
|
||||
> CI runner probed hosts directly instead of searching. It concluded "the runner
|
||||
> lives on synlig" — there are **four** — and that "synlig is on the home LAN" —
|
||||
> it is an OpenStack VM with a public floating IP that cannot reach the home LAN at
|
||||
> all. Both facts were already in the palace, the second one as an **explicit
|
||||
> retraction of the very same mistake** made weeks earlier. The palace also held
|
||||
> the runner labels and the deliberate `capacity: 1` setting, which the probe never
|
||||
> revealed. Cost: a wrong recommendation written into the palace twice, then
|
||||
> corrected twice.
|
||||
|
||||
|
||||
**A search that comes back empty is not an answer — least of all about recent work.**
|
||||
Semantic search is weakest exactly where the fleet record is freshest: a drawer filed
|
||||
minutes ago is unranked against a keyword-shaped query, and the drawer you most need
|
||||
is *by construction* the newest one, because the other machine files its release,
|
||||
handoff and correction drawers at the **end** of its session. So a single miss proves
|
||||
nothing. **If the work is 0-2 days old and the first search looks stale or empty,
|
||||
enumerate before concluding:**
|
||||
|
||||
```
|
||||
mempalace_list_drawers(wing="<wing>", since="<today>") # or room=, or no filter
|
||||
mempalace_diary_read(agent_name="<you>", wing="<wing>") # the other machine's handoff
|
||||
```
|
||||
|
||||
Enumeration is exact where embeddings are probabilistic. Treat "I searched and found
|
||||
nothing" as a hypothesis you have not yet tested, and never as licence to go probing.
|
||||
|
||||
> **Worked example (real, 2026-08-25, same fleet as above).** An agent asked to
|
||||
> orient on an in-flight release *did* search first — `"v1.8.6 release run 579
|
||||
> Docker Hub verification"` — and got back only v1.6.4 / v0.78.0 era hits, because
|
||||
> the release drawer it needed was **58 seconds old**. It accepted the miss and went
|
||||
> off to probe Docker Hub and the Gitea API. The user had to prompt "maybe there is a
|
||||
> note in mempalace"; `list_drawers(wing="pi-devbox", since=<today>)` then returned
|
||||
> the drawer immediately, along with the diary entry naming the exact open item. The
|
||||
> rule above was present and correct in this very file at the time — the failure was
|
||||
> not knowing to *retry differently* after a bad first hit.
|
||||
|
||||
#### Mine New Projects
|
||||
|
||||
When working on a new codebase for the first time:
|
||||
@@ -290,13 +363,14 @@ Zechner's pi-coding-agent). Implications:
|
||||
- **Session feeders run on different schedules.** Pi sessions are fed Tue 03:00, opencode sessions Mon 03:00 (launchd `Weekday`: `0`/`7`=Sunday, `1`=Monday, `2`=Tuesday — misreading this by one day is easy). 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.
|
||||
|
||||
When the palace is **central** (shared across machines), five more things apply:
|
||||
When the palace is **central** (shared across machines), these further things apply:
|
||||
|
||||
- **Check which machine a conversation came from.** Transcripts are fed per device, so `source_path` reads `…/mempalace-feed/<device>/pi_<uuid>.jsonl` while the displayed `source_file` is only the basename. One search can legitimately return hits from several machines at once — look at the device segment before attributing a decision to *this* project.
|
||||
- **Attribute what you file yourself.** Drawers now carry `device` and `agent_kind` metadata (plus `device_source`/`agent_kind_source` recording *how* each was determined, so an inference is never mistaken for a fact). Mined content gets these for free — the inbox path gives the device, the filename shape gives the harness — and a timer on the palace host re-stamps hourly, because live re-mining replaces metadata rows and silently drops earlier stamps. But for anything **you** file by hand, the only signal is what you pass: set `added_by="<harness>@<device>"` (e.g. `pi@emb-7kj4vr4g`, from `$MEMPALACE_PI_DEVICE`) on `add_drawer`/`checkpoint`/`mine`. Skip it and your drawer joins the ~16k historic `/workspace` project mines that are permanently unattributable, because `/workspace` exists identically on every devbox. Note the palace preserves `source_file` in full (see `source_path`) but *displays* only the basename — so a device prefix there survives storage even though it looks stripped.
|
||||
- **Provenance is stamped for you — leave it alone.** Drawers carry `device` and `agent_kind` metadata (plus `device_source`/`agent_kind_source` recording *how* each was determined, so an inference is never mistaken for a fact). You do **not** set these, and you no longer set `added_by` either: the pi bridge defaults the writer field to `<harness>@<device>` on `add_drawer`/`checkpoint`/`mine`/`event_append`/`artifact_put`, and prefixes diary entries with `HOST:<device>|`, from host-supplied `$MEMPALACE_PI_DEVICE`. RFC 001 §7.3.2 ranks "agent stamps it via a skill instruction" as the *worst possible* place for exactly the reason you would expect — it is per-call boilerplate that gets forgotten, and it did: the agent who wrote the previous version of this bullet then filed its own provenance drawer as `added_by=checkpoint`. Two things remain yours: pass `source_drawer_id` on `kg_add` (triples have no provenance field, so that pointer is the only path back to a device), and pass an explicit `added_by` **only** when deliberately filing on behalf of another device. Never invent values for `device`/`agent_kind`/`origin_device` — a fabricated value is worse than a blank, because it silently corrupts a future merge.
|
||||
- **Metadata is invisible to search — so check the text, not the fields.** `search` results are built from a fixed key list and `diary_read` returns content, so neither ever shows `device`/`added_by`. Only `mempalace_get_drawer` reveals them. This is why diary entries carry an in-text `HOST:<device>` marker: it is the only attribution a reader actually sees. **A diary entry with no `HOST:` marker predates the convention and may be from any machine — do not assume it is this one's history.**
|
||||
- **Mined drawers carry the MINE date, not the session date.** When history is imported, or re-mined on the palace host, `filed_at`/`created_at` is the *import* time — so sorting by them does not give chronological order. Real session time is recoverable from the UUIDv7 in `pi_<uuid>.jsonl`: the first 12 hex digits are milliseconds since the epoch (and UUIDv7 sorts lexicographically in time order, so a plain filename sort is already chronological). Agent-authored drawers and diaries have no such backdoor — for those `filed_at` is the only chronology, which is why it must never be restamped.
|
||||
- **Beware the timezone mismatch when you combine those.** Palace `filed_at`/`created_at` are naive timestamps in the palace host's local time, while a UUIDv7 decodes to UTC. Comparing them directly introduces a silent offset (2 h for a CEST host). Normalise before drawing conclusions about ordering.
|
||||
- **`agent_name` is not device-scoped.** `mempalace_diary_read(agent_name="pi")` returns *every* machine's `pi` diary, interleaved. Read the entry before assuming it is your own history.
|
||||
- **`agent_name` is not device-scoped.** `mempalace_diary_read(agent_name="pi")` returns *every* machine's `pi` diary, interleaved. Read the entry before assuming it is your own history — and note that a container cannot tell you which machine it is on (`hostname` is a docker hash, `$DEVBOX_HOST_ALIAS` is generic). `$MEMPALACE_PI_DEVICE` is the cheap answer; `ssh -F ~/.ssh-local/config host hostname` is the independent one.
|
||||
- **One writer, no queue.** A concurrent mine returns a structured `already-running` error rather than waiting its turn, and one large mine can make the palace unresponsive to every client for minutes. After another client's mine, call `mempalace_reconnect` to see the new drawers. A client-side timeout is not evidence of failure — verify before retrying, or you file a duplicate.
|
||||
|
||||
### Rooms
|
||||
@@ -330,10 +404,13 @@ Entity-relationship triples with temporal validity. Query with `mempalace_kg_que
|
||||
## Anti-Patterns
|
||||
|
||||
- **Don't guess when you can search.** If a question touches past work, search first.
|
||||
- **Don't probe what the fleet already knows.** Before SSH-ing into a host, enumerating infrastructure, or deriving how something is deployed, search the palace. A probe reveals one host's present state; the palace holds intent, history and prior corrections — including the ones that contradict what you are about to conclude.
|
||||
- **Don't trust this session's context over the palace.** A compacted summary is lossy, and another machine may have corrected the fact since. Verify load-bearing environment claims against the shared record before acting on them.
|
||||
- **Don't take one empty search as proof the palace is silent.** Fresh drawers rank worst, and the drawer that matters is usually the newest one. For anything 0-2 days old, enumerate with `mempalace_list_drawers(since=…)` and read the other machine's diary before you go and probe.
|
||||
- **Don't infer elapsed time from session or container boundaries.** A restart isn't a new day. Compare the actual timestamp (`timestamp` / `created_at`) against the current date/time before saying "yesterday", "last week", etc.
|
||||
- **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.
|
||||
- **Don't hand-craft provenance.** Leave `added_by` alone (and never put a machine name in a diary's `agent_name` — it becomes the wing name and hides your entries from `diary_read`). Recording *which device* wrote a record is client/server infrastructure, not your job: a hostname or container ID is not a stable identity, and an invented value is worse than none because it silently corrupts any future palace merge. If you find notes in the palace describing an `origin_device` scheme, that is a design for the client to implement — not an instruction for you to start stamping.
|
||||
- **Don't invent provenance metadata, and don't hand-stamp it either.** An earlier version of this list told you to set `added_by="<harness>@<device>"` by hand; that instruction has been withdrawn, because RFC 001 §7.3.2 places provenance at the client/server boundary and the pi bridge now does it uniformly (see *Provenance is stamped for you* above). DO NOT invent values for the palace's own metadata fields (`device`, `agent_kind`, `origin_device`): those are stamped by infrastructure that also records *how* each was determined, and a fabricated value is worse than none because it silently corrupts a future merge. DO pass `source_drawer_id` on `kg_add`. And never put a machine name in a diary's `agent_name` — it becomes the wing name and hides your entries from `diary_read`.
|
||||
|
||||
@@ -185,6 +185,16 @@ entrypoint's `setup-lan-access.sh` writes a **writable SSH sidecar** at
|
||||
- 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`.
|
||||
- A **trailing** `Host *` block supplying `ControlMaster auto` + `ControlPersist
|
||||
10m` as a *default*. Position is the design: `ControlPath` sits **before** the
|
||||
`Include` (an override — the value in your own config points at read-only
|
||||
`~/.ssh` and cannot work here), while `ControlMaster` sits **after** it (a
|
||||
default — an explicit per-host `ControlMaster no`/`auto` in your own config
|
||||
still wins, because ssh_config is first-value-wins). **Force what is broken,
|
||||
default what is merely absent.** Without this, a target whose entry never
|
||||
mentioned `ControlMaster` opens a fresh TCP connection per `ssh` call, and an
|
||||
agent making a dozen calls in a few minutes can trip fail2ban or a CGNAT
|
||||
flow-table cap on the far end.
|
||||
- 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
|
||||
@@ -199,6 +209,25 @@ 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)
|
||||
```
|
||||
|
||||
**Always go through the sidecar, never `-F ~/.ssh/config`.** This is the single
|
||||
easiest way to break SSH from inside the container, and the failure actively
|
||||
misleads: the read-only path makes the master socket uncreatable, so
|
||||
multiplexing appears *impossible* rather than misconfigured. What follows is a
|
||||
burst of fresh connections and, on a rate-limiting peer, a block that looks like
|
||||
an outage. The tell that it is rate-limiting and not an outage: HTTPS to the same
|
||||
estate keeps working while port 22 stops answering. (Recorded 2026-08-25 — an
|
||||
agent hit exactly this, concluded "ControlMaster is impossible here", disabled
|
||||
multiplexing, and filed that as a lesson. The sidecar had solved it since v1.4.)
|
||||
|
||||
If every `ssh` to one host suddenly hangs, suspect a **stale master** — socket
|
||||
file present, daemon gone, typically after the host suspended or changed
|
||||
network. Check and clear it:
|
||||
|
||||
```sh
|
||||
ssh -F "$HOME/.ssh-local/config" -O check <host> # "Master running (pid=…)" or no master
|
||||
ssh -F "$HOME/.ssh-local/config" -O exit <host> # tear down a stale one
|
||||
```
|
||||
|
||||
Two related mechanisms (don't reinvent them):
|
||||
|
||||
- **ControlMaster multiplexing** is preconfigured (`/tmp/sshcm/`) to survive
|
||||
|
||||
+109
-14
@@ -5,6 +5,7 @@
|
||||
#
|
||||
# Verifies:
|
||||
# - pi binary present and (if EXPECTED_PI_VERSION set) matches CI's resolved version
|
||||
# - mempalace core matches the audited pin (if EXPECTED_MEMPALACE_VERSION set)
|
||||
# - new v1.0.0 base additions (pandoc, graphviz, imagemagick, yq, tealdeer)
|
||||
# - typst PDF engine for pandoc (Unreleased) — `pandoc --pdf-engine=typst`
|
||||
# - non-modal editors nano + micro (alongside nvim)
|
||||
@@ -348,12 +349,71 @@ echo ""
|
||||
echo "── Build provenance ──"
|
||||
run "/etc/pi-devbox/build-manifest.json present" \
|
||||
"test -f /etc/pi-devbox/build-manifest.json"
|
||||
run_expect "manifest records pi-extensions component" \
|
||||
"cat /etc/pi-devbox/build-manifest.json" '"pi-extensions"'
|
||||
run_expect "manifest records pi-atelier" \
|
||||
"cat /etc/pi-devbox/build-manifest.json" '"pi-atelier"'
|
||||
run_expect "manifest records pi_version" \
|
||||
"cat /etc/pi-devbox/build-manifest.json" '"pi_version"'
|
||||
# These next checks replace three that grepped the manifest for the FIELD NAME
|
||||
# and never looked at the value:
|
||||
#
|
||||
# run_expect "manifest records pi_version" "cat …manifest.json" '"pi_version"'
|
||||
#
|
||||
# which passes on {"pi_version": ""} and on {"pi_version": null}. The tell was
|
||||
# visible in its own passing output — `✅ manifest records pi_version (got
|
||||
# "pi_version")` echoes the key back as the thing it claims to have found.
|
||||
# Two failure modes were therefore invisible: a key that survives with an empty
|
||||
# or garbage value, and a key that vanishes from the manifest while every
|
||||
# remaining value still looks fine.
|
||||
#
|
||||
# Those two need SEPARATE assertions, and the reason is a trap worth keeping in
|
||||
# writing: an "every component value is a valid SHA" loop passes VACUOUSLY on
|
||||
# components:{} — jq's all() over an empty list is true — so the value check
|
||||
# alone would go green on a manifest that lost every component. Mutation-tested
|
||||
# 2026-08-25 across nine fabricated manifests (empty map, deleted key, "",
|
||||
# null, "unknown", 12-hex truncation, 40 non-hex chars, legit null pi-studio).
|
||||
run "manifest declares every required component key" '
|
||||
req="pi-toolkit pi-extensions pi-fork pi-observational-memory pi-atelier mempalace-toolkit pi-studio"
|
||||
for k in $req; do
|
||||
jq -e --arg k "$k" "(.components|has(\$k))" /etc/pi-devbox/build-manifest.json >/dev/null \
|
||||
|| { echo "manifest lost component key: $k" >&2; exit 1; }
|
||||
done
|
||||
'
|
||||
# Subsumes the old `! grep -q \"unknown\"` check ("unknown" is not 40-hex), and
|
||||
# also catches "", null and truncated SHAs, which that grep let through. null is
|
||||
# legitimate for pi-studio alone: the non-studio variant has no such clone.
|
||||
run "manifest component values are resolved 40-hex commits" '
|
||||
jq -e "
|
||||
.components
|
||||
| to_entries
|
||||
| all(if .key == \"pi-studio\" and .value == null then true
|
||||
else (.value|type) == \"string\" and (.value|test(\"^[0-9a-f]{40}\$\")) end)
|
||||
" /etc/pi-devbox/build-manifest.json >/dev/null
|
||||
'
|
||||
# pi_version against ground truth, same shape as the mempalace check below.
|
||||
# Chains with the "pi version matches build arg" assertion earlier in this file:
|
||||
# together they tie build arg -> installed binary -> recorded manifest, so a
|
||||
# manifest written from a stale variable cannot pass by agreeing with itself.
|
||||
run "manifest pi_version matches the installed pi" '
|
||||
m=$(jq -r ".pi_version // empty" /etc/pi-devbox/build-manifest.json)
|
||||
b=$(pi --version 2>/dev/null | head -n1 | tr -d "\r")
|
||||
echo "manifest=[$m] installed=[$b]" >&2
|
||||
[ -n "$m" ] && [ "$m" = "$b" ]
|
||||
'
|
||||
# Top-level provenance fields: assert the SHAPE of each value, and only when the
|
||||
# field is populated. source_revision and build_date legitimately default to
|
||||
# empty (Dockerfile.variant ARGs) on a plain local `docker build`, so demanding
|
||||
# them would fail honest local smoke runs; a populated-but-malformed value is
|
||||
# the actual defect. release_tag defaults to "dev", so empty means a broken write.
|
||||
run "manifest top-level fields are well-formed, not merely present" '
|
||||
j=/etc/pi-devbox/build-manifest.json
|
||||
t=$(jq -r ".release_tag // empty" $j)
|
||||
r=$(jq -r ".source_revision // empty" $j)
|
||||
d=$(jq -r ".build_date // empty" $j)
|
||||
echo "release_tag=[$t] source_revision=[$r] build_date=[$d]" >&2
|
||||
[ -n "$t" ] || { echo "release_tag empty (ARG default is dev)" >&2; exit 1; }
|
||||
if [ -n "$r" ]; then
|
||||
printf "%s" "$r" | grep -qxE "[0-9a-f]{40}" || { echo "source_revision not a 40-hex commit" >&2; exit 1; }
|
||||
fi
|
||||
if [ -n "$d" ]; then
|
||||
printf "%s" "$d" | grep -qE "^[0-9]{4}-[0-9]{2}-[0-9]{2}T" || { echo "build_date not ISO-8601" >&2; exit 1; }
|
||||
fi
|
||||
'
|
||||
# mempalace CORE was absent from the manifest through v1.8.5: the toolkit SHA
|
||||
# was recorded but the palace version behind the MCP tools was not, so a palace
|
||||
# bug could not be correlated to an image version. Assert the field exists AND
|
||||
@@ -368,18 +428,39 @@ run "manifest mempalace_version matches the installed core" '
|
||||
echo "manifest=[$m] installed=[$b]" >&2
|
||||
[ -n "$m" ] && [ "$m" = "$b" ]
|
||||
'
|
||||
# ... and, when CI supplies it, that the installed core is the version CI
|
||||
# actually AUDITED (published + not yanked on PyPI, in resolve-versions). This
|
||||
# does NOT duplicate the check above, which compares two properties of one
|
||||
# image and so cannot notice that BOTH are the wrong version. The live failure
|
||||
# mode it covers: the variant builds `FROM` a base tag chosen by base-decide's
|
||||
# content hash, so a bug in that hashing (the reason scripts/check-base-hash.sh
|
||||
# exists) could reuse a cached base built from an OLDER MEMPALACE_VERSION pin —
|
||||
# internally consistent, silently stale, invisible to every other assertion.
|
||||
if [ -n "${EXPECTED_MEMPALACE_VERSION:-}" ]; then
|
||||
run "installed mempalace matches CI's audited pin (${EXPECTED_MEMPALACE_VERSION})" "
|
||||
b=\$(mempalace --version 2>/dev/null | head -n1 | tr -d '\r'); b=\${b##* }
|
||||
echo \"installed=[\$b] audited_pin=[${EXPECTED_MEMPALACE_VERSION}]\" >&2
|
||||
[ \"\$b\" = \"${EXPECTED_MEMPALACE_VERSION}\" ]
|
||||
"
|
||||
fi
|
||||
# Every component must be a resolved commit (or null for pi-studio in the
|
||||
# non-studio variant) — 'unknown' means a clone silently failed to resolve.
|
||||
run "manifest has no unresolved ('unknown') components" \
|
||||
"! grep -q '\"unknown\"' /etc/pi-devbox/build-manifest.json"
|
||||
# pi-devbox-version wraps the manifest into a human-first command (this
|
||||
# PR); verify the binary is present, executable, and both output modes work.
|
||||
# non-studio variant) — now enforced by the 40-hex value check above, which
|
||||
# strictly subsumes the old whole-file grep for '"unknown"'. Only rev() ever
|
||||
# emits "unknown" and rev() feeds components only, so nothing is lost.
|
||||
# pi-devbox-version wraps the manifest into a human-first command; verify the
|
||||
# binary is present, executable, and that all three output modes work.
|
||||
run "pi-devbox-version binary present + executable" \
|
||||
"test -x /usr/local/bin/pi-devbox-version"
|
||||
run_expect "pi-devbox-version human output shows release tag" \
|
||||
"pi-devbox-version" "pi-devbox "
|
||||
run_expect "pi-devbox-version --json round-trips the manifest" \
|
||||
"pi-devbox-version --json" '"release_tag"'
|
||||
# --json is a verbatim `cat` of the manifest, so "round-trips" is assertable
|
||||
# literally. The old form grepped the output for the string "release_tag" — the
|
||||
# key name again — which would pass on a truncated or re-serialised dump.
|
||||
run "pi-devbox-version --json round-trips the manifest byte-for-byte" '
|
||||
a=$(cat /etc/pi-devbox/build-manifest.json)
|
||||
b=$(pi-devbox-version --json)
|
||||
[ "$a" = "$b" ] || { echo "--json output differs from the manifest on disk" >&2; exit 1; }
|
||||
'
|
||||
run_expect "pi-devbox-version --quiet is a compact one-liner" \
|
||||
"pi-devbox-version --quiet | wc -l" "1"
|
||||
# OCI labels live in the image config, not the container fs — inspect them
|
||||
@@ -446,7 +527,21 @@ exec_test "mempalace skill linked (fallback)" 'test -L $HOME/.agents/skills
|
||||
# multiple harnesses", a phrase present in BOTH the stale and the fresh copy.
|
||||
# A snapshot canary must pin the NEWEST section, so update this string whenever
|
||||
# the snapshot is refreshed — that is the point of it.
|
||||
exec_test "mempalace skill snapshot is current" 'grep -q "Attribute what you file yourself" $HOME/.agents/skills/mempalace/SKILL.md && echo ok'
|
||||
#
|
||||
# v1.8.7: this fired for real, and on the release that changed the snapshot. The
|
||||
# pinned phrase was "Attribute what you file yourself", the heading of the
|
||||
# instruction telling agents to hand-stamp added_by — which that same release
|
||||
# WITHDREW (RFC 001 §7.3.2 ranks agent-side stamping worst-possible; the bridge
|
||||
# now does it). So the canary correctly reported "snapshot changed, expectation
|
||||
# did not", and blocked publication of an otherwise-green build (81 passed, 1
|
||||
# failed, twice). Two lessons kept in the assertion itself:
|
||||
# * it is now BIDIRECTIONAL — the new phrase must be present AND the withdrawn
|
||||
# one absent, so a re-vendored stale snapshot fails just as loudly as a
|
||||
# forgotten bump. A one-way canary only catches half the drift.
|
||||
# * a phrase canary can only ever detect "older than what I remembered to pin",
|
||||
# never "older than skillset main". The real fix is a CI job diffing this
|
||||
# file against the skillset repo — see the Unreleased changelog note.
|
||||
exec_test "mempalace skill snapshot is current" 'f=$HOME/.agents/skills/mempalace/SKILL.md; grep -q "Provenance is stamped for you" "$f" && ! grep -q "Attribute what you file yourself" "$f" && echo ok'
|
||||
# Link TARGETS, not just link existence: with no skillset mounted (as here) the
|
||||
# baked tree must be what resolves, for all three vendored skills.
|
||||
exec_test "vendored skills resolve to the baked tree (no skillset mounted)" \
|
||||
|
||||
Reference in New Issue
Block a user