Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 29b62093f0 | |||
| cbd7cf5c67 | |||
| 7c00dd6001 | |||
| 7649d53f3b | |||
| ade58131d6 | |||
| ffd44ad9cf |
+53
-3
@@ -12,16 +12,59 @@ SSH_KEY_PATH=~/.ssh
|
||||
# ── MemPalace memory (local by default) ───────────────────────────
|
||||
# By default the mempalace.ts extension spawns a LOCAL mempalace-mcp stdio
|
||||
# server (palace at ~/.mempalace). Uncomment the devbox-palace volume in
|
||||
# docker-compose.yml to persist it across container recreation.
|
||||
# docker-compose.yml to persist it across container recreation — that one
|
||||
# volume now covers the mined conversation transcripts too, since the pi and
|
||||
# opencode feeders stage inside the palace root (<palace-root>/pi-stage), so
|
||||
# the staged files and the palace dedup keys pointing at them cannot be
|
||||
# separated.
|
||||
#
|
||||
# To instead share ONE MemPalace across containers/harnesses (pi + opencode
|
||||
# + native), set the URL below. When set, the extension connects over HTTP
|
||||
# and NO local mempalace-mcp is spawned; the devbox-palace volume is then
|
||||
# irrelevant. MEMPALACE_REMOTE_TOKEN, if set, is sent as a bearer token.
|
||||
# Serve it with: mempalace-mcp --transport http --host 0.0.0.0 --port 8765
|
||||
# MEMPALACE_REMOTE_URL=http://mempalace.lan:8765/mcp
|
||||
#
|
||||
# Serve it with: mempalace serve --host 172.17.0.1 --port 8765
|
||||
#
|
||||
# NOT `mempalace-mcp --transport http --host 0.0.0.0`: `serve` is the turnkey
|
||||
# wrapper that mints/keeps a bearer token (0600, passed via env so it stays out
|
||||
# of `ps`) and can terminate TLS. Two binds to avoid:
|
||||
# 0.0.0.0 - exposes the palace to the whole LAN.
|
||||
# 127.0.0.1 - behind a tunnel this 403s every proxied request (the Host pin
|
||||
# is only enforced on loopback binds) AND silently starts with
|
||||
# no token at all, since auto-minting is gated on the bind being
|
||||
# non-loopback. Bind the docker0 gateway: reachable from the host
|
||||
# and its containers (so a newt/proxy container works), not from
|
||||
# the LAN. Set MEMPALACE_MCP_HTTP_TOKEN explicitly server-side.
|
||||
# MEMPALACE_REMOTE_URL=https://mempalace.example.com/mcp
|
||||
# MEMPALACE_REMOTE_TOKEN=
|
||||
|
||||
# ── MemPalace: automatic capture of pi sessions ───────────────────────
|
||||
# The mempalace.ts extension feeds this container's pi transcripts into the
|
||||
# palace by itself: on session_shutdown, and on a debounced agent_settled so a
|
||||
# crash loses at most one window rather than the whole session. The entrypoint
|
||||
# also runs a catch-up at container start, which is the only thing that can
|
||||
# recover transcripts after a hard kill (no handler runs on SIGKILL).
|
||||
# Nothing below is required for the local-palace case; the defaults work.
|
||||
#
|
||||
# MEMPALACE_FEED=0 # disable automatic capture entirely
|
||||
# MEMPALACE_FEED_DEBOUNCE_MS=600000 # min gap between mid-session feeds (10 min)
|
||||
# MEMPALACE_FEED_WING=wing_conversations
|
||||
#
|
||||
# REMOTE PALACE ONLY (MEMPALACE_REMOTE_URL set above): the palace is on another
|
||||
# host, and `mempalace_mine` resolves its source path in the SERVER process, so
|
||||
# the server cannot see this container's transcripts. The feeder therefore
|
||||
# rsyncs its staged exports into a per-device inbox on the palace host and asks
|
||||
# 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).
|
||||
# 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
|
||||
# the container path if the server runs in Docker
|
||||
# (see docker-compose.mempalace.yml)
|
||||
# MEMPALACE_PI_DEVICE inbox subdirectory for this machine (default: hostname)
|
||||
# MEMPALACE_PI_SSH_TARGET=user@palace-host:/srv/mempalace-feed
|
||||
# MEMPALACE_PI_REMOTE_PATH=/data/feed
|
||||
# MEMPALACE_PI_DEVICE=
|
||||
|
||||
# ── LAN access from the container (host-OS-agnostic) ─────────────────
|
||||
# On VM-backed hosts (macOS OrbStack / Docker Desktop) the container can't
|
||||
# reach the host's directly-attached LAN peers by default. The entrypoint
|
||||
@@ -52,6 +95,13 @@ SSH_KEY_PATH=~/.ssh
|
||||
# DEVBOX_ATELIER=1
|
||||
|
||||
# ── Git Configuration ────────────────────────────────────────────────
|
||||
# Set BOTH. If unset, every repo inside the container fails with
|
||||
# "Author identity unknown" on first commit, and an agent asked to commit
|
||||
# will guess an identity from git log — often the wrong one. The e-mail is
|
||||
# per-machine (work machines use the corporate address, personal machines the
|
||||
# private one), so it belongs in this per-machine .env, never in a skill or a
|
||||
# repo-local override. Consumed by entrypoint-user.sh -> ~/.gitconfig, which is
|
||||
# NOT persistent across container recreate — this file is the source of truth.
|
||||
GIT_USER_NAME=
|
||||
GIT_USER_EMAIL=
|
||||
|
||||
|
||||
+227
@@ -11,6 +11,233 @@ Pre-v1.0.0 tags followed the pi npm version (`v{pi_version}[letter]`).
|
||||
|
||||
---
|
||||
|
||||
## v1.8.0 — 2026-08-15
|
||||
|
||||
Minor release. Headline: **pi sessions now feed MemPalace by themselves.** The
|
||||
image already shipped `mempalace-toolkit`, but its pi feeder
|
||||
(`mempalace-pi-session`) was never symlinked onto `PATH`, so nothing ever mined
|
||||
pi's transcripts — the palace only ever contained what an agent remembered to
|
||||
file by hand. A container that gets recreated regularly has no other memory, so
|
||||
a missed wind-down was a permanently lost session.
|
||||
|
||||
Also here: **pi 0.84.1 → 0.84.2 and pi-atelier v0.8.0 → v0.8.1**, bumped
|
||||
together. The pi bump closes the Amazon Bedrock tool-argument poison pill that
|
||||
v1.6.4 recorded as unfixed upstream; the atelier bump is the matching companion,
|
||||
since both sides changed fullscreen input handling in the same fortnight. Audits
|
||||
for both are below.
|
||||
|
||||
*Why event-driven and not a timer:* there is nothing schedulable inside the
|
||||
container — PID 1 is `bash -l`, with no systemd and no cron — and anything
|
||||
installed would not survive recreate anyway. The triggers therefore live where
|
||||
the events already are: pi's own lifecycle, plus container start.
|
||||
|
||||
### Added
|
||||
|
||||
- **`mempalace-pi-session` symlinked onto `PATH`** (`Dockerfile.base`,
|
||||
alongside its `mempalace-session` / `mempalace-docs` siblings, with the same
|
||||
`--help` build-time check). `entrypoint-user.sh` also self-heals the symlink
|
||||
into `~/.local/bin` (already ahead of `/usr/local/bin` on `PATH`, and
|
||||
writable by `developer`) so the feature works on images whose base predates
|
||||
this change.
|
||||
- **Container-start catch-up feed** (`entrypoint-user.sh`, backgrounded). pi's
|
||||
mempalace extension feeds the palace on `session_shutdown` and on a debounced
|
||||
`agent_settled`, but a hard kill (`docker kill`, OOM, host reboot) runs no
|
||||
handler at all; this is the only trigger that can recover the previous life's
|
||||
transcripts. Skipped when a remote palace is configured without an inbox to
|
||||
ship to — **and the skip now says so** (see Changed) — and skippable entirely
|
||||
with `MEMPALACE_FEED=0`.
|
||||
- **`MEMPALACE_PI_STAGE` no longer needs pinning here — the feeder's default
|
||||
was fixed upstream instead.** It used to stage under `~/.cache`, which is
|
||||
disposable in a container; the first cut of this change pinned the env var
|
||||
into the persisted `~/.pi` volume. That was the wrong fix: it created a second
|
||||
convention that could still diverge from the palace (keep the palace volume,
|
||||
drop `devbox-pi-config`, and a scoped `mempalace sync` prunes every
|
||||
conversation drawer, because dedup keys on the *staged* path). The feeder now
|
||||
defaults to `<palace-root>/pi-stage`, resolved with mempalace's own
|
||||
precedence (`$MEMPALACE_PALACE_PATH` → `$MEMPAL_PALACE_PATH` →
|
||||
`~/.mempalace/config.json` → `~/.mempalace/palace`), so the stage inherits
|
||||
whatever persistence the palace has and the two cannot be separated by
|
||||
accident. No `ENV` and no entrypoint export: adding one back would
|
||||
re-introduce exactly the split it removes.
|
||||
- **Transcript inbox mount in `docker-compose.mempalace.yml`**
|
||||
(`${MEMPALACE_FEED_DIR:-./feed}:/data/feed:ro`). A client cannot mine into a
|
||||
remote palace directly: `mempalace_mine` expands its source path in the
|
||||
*server* process, so the server can only see paths inside its own container.
|
||||
Clients rsync their staged exports to a per-device subdirectory and then ask
|
||||
the server to mine `/data/feed/<device>`. Read-only because mining only reads
|
||||
sources — all locks live palace-side.
|
||||
- **Smoke tests** for the above: `mempalace-pi-session` on `PATH`, two
|
||||
assertions that the stage resolves next to the palace (default, and following
|
||||
`$MEMPALACE_PALACE_PATH`), and two behavioural guards that feed the exporter a
|
||||
synthetic pi session — one that must be captured, one abandoned session that
|
||||
must not be. The second matters because pi expands skills/context into the
|
||||
user prompt, so an abandoned session can look substantial by byte count while
|
||||
containing no assistant output; and if pi's JSONL shape ever changes, the
|
||||
exporter would silently capture nothing.
|
||||
- **`.env.example`**: documents `MEMPALACE_FEED`,
|
||||
`MEMPALACE_FEED_DEBOUNCE_MS`, `MEMPALACE_FEED_WING`, and the remote-palace
|
||||
shipping vars `MEMPALACE_PI_SSH_TARGET`, `MEMPALACE_PI_REMOTE_PATH`,
|
||||
`MEMPALACE_PI_DEVICE`.
|
||||
|
||||
### Changed
|
||||
|
||||
- **The "remote palace, no inbox" skip announces itself instead of vanishing**
|
||||
(`entrypoint-user.sh`). When `MEMPALACE_REMOTE_URL` is set but
|
||||
`MEMPALACE_PI_SSH_TARGET` is not, there is genuinely nothing the feeder can
|
||||
ship to, so skipping is correct — but the branch was a bare `:`, and the skip
|
||||
happens *before* the subshell that writes `~/.pi/agent/mempalace-catchup.log`.
|
||||
A container in that state therefore contributed nothing to the palace and left
|
||||
**no artifact at all**, not even an empty log, to explain why — indistinguish-
|
||||
able from a healthy run that had nothing to file. Found while flipping the
|
||||
first client onto the shared palace (2026-08-12), where it is the single most
|
||||
likely way to end up quietly memory-less. The notice now goes to both the
|
||||
container start output and that log path, names the two variables that fix it,
|
||||
states that MCP tools still work (only *this* container's transcripts go
|
||||
nowhere), and points at `MEMPALACE_FEED=0` for anyone who meant it.
|
||||
Deliberately incapable of breaking startup: an unwritable `~/.pi` — root-owned
|
||||
volume, a classic Docker accident — would make `mkdir -p` fail under `set -e`
|
||||
and abort the whole entrypoint, so it degrades to stdout-only. That was a real
|
||||
new risk, since this branch previously touched no filesystem whatsoever.
|
||||
Covered by two smoke assertions against the entrypoint as shipped in the image
|
||||
(the branch only runs at container start, so a `docker run` one-shot cannot
|
||||
reach it).
|
||||
|
||||
### Bumped: pi 0.84.1 → 0.84.2
|
||||
|
||||
- **`ARG PI_VERSION=0.84.2`** (`Dockerfile.variant`), with the audit the pin
|
||||
policy in that file requires.
|
||||
|
||||
**Headline for this image: the Bedrock tool-argument poison pill is FIXED
|
||||
upstream.** The v1.6.4 entry below recorded it as *"Not fixed upstream … still
|
||||
replayed unsanitised"* — that note is now superseded. pi-ai 0.84.2 adds a
|
||||
recursive `sanitizeBedrockDocument()` and applies it at exactly the site that
|
||||
entry named ([#7882](https://github.com/earendil-works/pi/pull/7882)):
|
||||
|
||||
```diff
|
||||
- toolUse: { toolUseId: c.id, name: c.name, input: c.arguments },
|
||||
+ toolUse: { toolUseId: c.id, name: c.name, input: sanitizeBedrockDocument(c.arguments) },
|
||||
```
|
||||
|
||||
(`dist/api/bedrock-converse-stream.js` — line 692 in pi-ai 0.84.1, 704 in
|
||||
0.84.2; it was 644 in 0.83.0 and 634 in 0.82.1.) The sanitiser drops object
|
||||
members whose key is the empty string, recursing through arrays and nested
|
||||
objects and preserving every valid value. It runs while the request is built,
|
||||
so it covers the live turn *and* a resume: a session already bricked by an
|
||||
empty-key tool argument now replays instead of dying on a Bedrock
|
||||
`ValidationException`. **`pi-session-repair` (in `cli_utils`) is therefore no
|
||||
longer the recovery path on this image.** It stays useful for older images and
|
||||
for inspecting a transcript, because the stored `.jsonl` is still malformed —
|
||||
the fix sanitises what is *sent*, not what was *recorded*.
|
||||
|
||||
**Why bumping `PI_VERSION` is the only way to get it:** pi publishes an
|
||||
`npm-shrinkwrap.json`, which pins transitive dependencies *exactly*. pi
|
||||
0.84.1's shrinkwrap pins `@earendil-works/pi-ai` to **0.84.1**, so although
|
||||
0.84.1's `package.json` range is `^0.84.1` — which would otherwise admit
|
||||
0.84.2 — rebuilding the old pin can never pick the fix up. Transitive upstream
|
||||
fixes do not leak into this image; `PI_VERSION` is the whole gate.
|
||||
|
||||
Rest of the audit, against the integration surface the pin policy names:
|
||||
|
||||
- **Session `.jsonl` format — unchanged.** Identical
|
||||
`migrateV1ToV2`/`migrateV2ToV3` ladder in both versions, so existing sessions
|
||||
on the named volume load as-is and `pi-session-repair`'s parse target is
|
||||
untouched.
|
||||
- **Node engine floor — unchanged** at `>=22.19.0` (image ships 22.23.2).
|
||||
- **pi-atelier — no change needed.** The pin stays `v0.8.0`: the hard floor is
|
||||
"never pair < 0.7.1 with pi >= 0.84", this bump does not leave 0.84.x, and
|
||||
atelier's `peerDependencies` (`>=0.80.7`) are satisfied. pi-atelier **0.8.1**
|
||||
is published but deliberately NOT adopted here — one variable at a time, and
|
||||
atelier is the component that has drawn blood at startup.
|
||||
- **Directly relevant to `pi --ssh` use of this image:** 0.84.2 fixes split
|
||||
`Alt+Enter` over SSH being misread as Escape, and adds `PI_TUI_ESC_TIMEOUT`
|
||||
for high-latency terminals.
|
||||
- **Keybindings — one surface worth knowing.** `pi-toolkit` ships exactly one
|
||||
override, `tui.input.newLine: [shift+enter, ctrl+j, alt+j]`. 0.84.2's new
|
||||
fullscreen transcript search (`Ctrl+Shift+F`) binds `Shift+Enter` to
|
||||
*previous match* while its overlay is focused. Different context, so no
|
||||
conflict is expected — but it is the one place the override meets a new
|
||||
default, and the first place to look if "shift+enter stopped inserting a
|
||||
newline" is ever reported.
|
||||
- **New `defaultTools` setting** (choose startup built-in tools globally or per
|
||||
project) is additive; `pi-toolkit`'s `settings.example.json` does not set it,
|
||||
so the bootstrap template needs no change.
|
||||
|
||||
### Bumped: pi-atelier v0.8.0 → v0.8.1
|
||||
|
||||
- **`ARG PI_ATELIER_REF` / `ARG PI_ATELIER_VERSION` = `v0.8.1`**
|
||||
(`Dockerfile.variant`), bumped *together* with `PI_VERSION` as that pin's
|
||||
comment requires — and this pairing is a good advert for the rule, because both
|
||||
sides touched fullscreen input handling within three days of each other.
|
||||
|
||||
atelier 0.8.1 (2026-08-12) is two changes, only one of them code: *"Preserve
|
||||
fullscreen transcript mouse-wheel scrolling after Sidebar resize and visibility
|
||||
changes by leaving Pi's persistent mouse reporting enabled"*, plus a README
|
||||
simplification. The single source file that differs from 0.8.0 is
|
||||
`src/split-pane.ts`. It extracts an `isPiFullscreenRenderer()` predicate and,
|
||||
under pi's fullscreen renderer, stops writing its own
|
||||
`\e[?1002h\e[?1006h` / `\e[?1006l\e[?1002l` pair around a sidebar resize —
|
||||
previously it enabled mouse reporting on grab and disabled it on release, which
|
||||
tore down the reporting **pi itself** had switched on and left the wheel dead
|
||||
afterwards. Outside fullscreen it manages mouse mode exactly as before. It also
|
||||
now captures the terminal it enabled mouse on and writes the disable sequence to
|
||||
*that* terminal instead of to whatever `tui` currently points at.
|
||||
|
||||
**The audit that matters is the private-internals coupling**, since that is what
|
||||
hung startup at 0.6.0/0.7.0. atelier reaches into three pi internals; all three
|
||||
are unchanged in pi 0.84.2:
|
||||
|
||||
- **`TuiAltScreen`** — detected *by constructor name*, so a rename would
|
||||
silently disable both the resize-input prioritisation and the new mouse
|
||||
behaviour, with no error. Still
|
||||
`class TuiAltScreen extends TuiBase implements ViewportTUI`.
|
||||
- **`tui.inputListeners`** — a private `Set` that atelier deletes from and
|
||||
re-adds to, to get its resize handler ahead of pi's viewport listener (which
|
||||
"consumes every mouse event for text selection"). Still `inputListeners = new
|
||||
Set()`, at the identical line 103 of `pi-tui/dist/tui.js` in both versions,
|
||||
and still a `Set` — atelier guards with `instanceof Set`.
|
||||
- **the prototype `render` descriptor** it wraps via `findPrototypeRender`.
|
||||
Still an own `render(width)` on `TuiAltScreen`.
|
||||
|
||||
pi's mouse sequences are byte-identical between 0.84.1 and 0.84.2 (same
|
||||
`1002h`/`1006h`/`1002l`/`1006l`/`1003h` occurrence counts), so atelier's
|
||||
assumption about what pi leaves enabled still holds. `pi-tui`'s base class
|
||||
changed additively only (one new `isOverlayFocused()`), and `TuiAltScreen`'s own
|
||||
changes are the new search feature (`activeSearch`, `openSearch`/`closeSearch`,
|
||||
the two search match styles, `copySelection`).
|
||||
|
||||
**Caveat, stated plainly:** pi 0.84.2 adds a focused fullscreen *search overlay*
|
||||
that participates in input handling, while atelier reorders input listeners
|
||||
around pi's viewport listener. The two look convergent — 0.84.2 separately fixes
|
||||
*"focused fullscreen overlays not receiving mouse wheel or viewport scroll
|
||||
keys"* — but this pairing is reasoned from the diffs, **not proven by
|
||||
execution**: the CI smoke test does not drive the TUI, so a fullscreen
|
||||
interaction regression would not be caught before pull. Worth an `alt+a` plus a
|
||||
sidebar resize and a wheel scroll in fullscreen on first use of this image.
|
||||
|
||||
Version metadata is unchanged: `engines.node >=22.19.0`, `peerDependencies`
|
||||
still the uninformative `>=0.80.7` on both pi packages (so still nothing in npm
|
||||
metadata encodes the real floor), and still zero runtime dependencies — so the
|
||||
"no `npm install` step" note above stays true. The GitHub tag `v0.8.1` exists
|
||||
(commit `c31d7439`), which is what CI resolves to a SHA.
|
||||
|
||||
### Notes
|
||||
|
||||
- The `Dockerfile.base` change moves the base hash, so this needs a base
|
||||
rebuild; the `~/.local/bin` self-heal exists so the feature does not have to
|
||||
wait for one. The skip-notice change is in `entrypoint-user.sh`, which is
|
||||
`COPY`d in `Dockerfile.base` too, so it rides the same rebuild — until then,
|
||||
older images keep skipping silently and the two commands in the toolkit's
|
||||
`phase-1-exposure-runbook.md` §3.7 are the way to tell.
|
||||
- Requires the matching `mempalace-toolkit` change (`--prepare` two-phase
|
||||
split, remote transport, and the auto-feed triggers in
|
||||
`extensions/pi/mempalace.ts`). The split exists because the palace is
|
||||
single-writer: a live pi session holds it through the extension's own
|
||||
`mempalace-mcp`, so a CLI `mempalace mine` during a session fails with
|
||||
"palace ... is held by PID". Staging is therefore done by the CLI and the
|
||||
mine itself by whichever process already holds the palace.
|
||||
|
||||
---
|
||||
|
||||
## v1.7.0 — 2026-08-07
|
||||
|
||||
Minor release. Headline: **pi-atelier is now part of the image** — the TUI
|
||||
|
||||
+4
-1
@@ -424,9 +424,12 @@ RUN if [ "${INSTALL_MEMPALACE}" = "true" ] && [ "${INSTALL_MEMPALACE_TOOLKIT}" =
|
||||
[ "$ok" = "1" ] && \
|
||||
ln -sf /opt/mempalace-toolkit/bin/mempalace-session /usr/local/bin/mempalace-session && \
|
||||
ln -sf /opt/mempalace-toolkit/bin/mempalace-docs /usr/local/bin/mempalace-docs && \
|
||||
chmod +x /opt/mempalace-toolkit/bin/mempalace-session /opt/mempalace-toolkit/bin/mempalace-docs && \
|
||||
ln -sf /opt/mempalace-toolkit/bin/mempalace-pi-session /usr/local/bin/mempalace-pi-session && \
|
||||
chmod +x /opt/mempalace-toolkit/bin/mempalace-session /opt/mempalace-toolkit/bin/mempalace-docs \
|
||||
/opt/mempalace-toolkit/bin/mempalace-pi-session && \
|
||||
mempalace-session --help >/dev/null && \
|
||||
mempalace-docs --help >/dev/null && \
|
||||
mempalace-pi-session --help >/dev/null && \
|
||||
echo "mempalace-toolkit installed at $(cd /opt/mempalace-toolkit && git rev-parse --short HEAD)" ; \
|
||||
fi
|
||||
|
||||
|
||||
+3
-3
@@ -56,7 +56,7 @@ ARG USER_NAME=developer
|
||||
# current when it was first populated (shipped the same bytes for pi-devbox
|
||||
# v0.74.0..v0.75.5; discovered + fixed in v0.75.5b, 2026-05-23). The `latest`
|
||||
# branch below is kept only for a deliberate local `docker build` override.
|
||||
ARG PI_VERSION=0.84.1
|
||||
ARG PI_VERSION=0.84.2
|
||||
ARG PI_TOOLKIT_REF=main
|
||||
ARG PI_EXTENSIONS_REF=main
|
||||
# Repo URLs default to the canonical gitea origin but are overridable so a
|
||||
@@ -92,9 +92,9 @@ ARG PI_OBSMEM_REF=master
|
||||
# the /opt checkout. Adding an install here would be a no-op that only costs
|
||||
# build time.
|
||||
ARG PI_ATELIER_REPO=https://github.com/michaelmjhhhh/pi-atelier.git
|
||||
ARG PI_ATELIER_REF=v0.8.0
|
||||
ARG PI_ATELIER_REF=v0.8.1
|
||||
# Human-readable tag PI_ATELIER_REF was resolved from; recorded as a label.
|
||||
ARG PI_ATELIER_VERSION=v0.8.0
|
||||
ARG PI_ATELIER_VERSION=v0.8.1
|
||||
|
||||
RUN set -e && \
|
||||
# git_fetch_ref: clone-equivalent helper that accepts EITHER a branch name
|
||||
|
||||
@@ -882,12 +882,31 @@ cat ~/.ssh-local/mypeer_devbox_ed25519.pub
|
||||
# ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIEXAMPLE0000EXAMPLE0000EXAMPLE0000ex devbox-0d11ec7731c7
|
||||
```
|
||||
|
||||
**2. On the peer** — authorize it narrowly rather than bare:
|
||||
**2. On the peer** — append that public key to `~/.ssh/authorized_keys` **of
|
||||
the account you will log in as** (the `User` from step 3), narrowly authorized
|
||||
rather than bare:
|
||||
|
||||
```
|
||||
```bash
|
||||
mkdir -p ~/.ssh && chmod 700 ~/.ssh
|
||||
cat >> ~/.ssh/authorized_keys <<'KEY'
|
||||
from="192.168.1.0/24,192.168.4.0/24,10.8.0.7",restrict ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIEXAMPLE0000EXAMPLE0000EXAMPLE0000ex devbox-mymachine
|
||||
KEY
|
||||
chmod 600 ~/.ssh/authorized_keys
|
||||
```
|
||||
|
||||
Both lines are safe on a peer that is already set up: `mkdir -p` is a no-op
|
||||
when the directory exists, the `chmod`s only tighten, and appending never
|
||||
touches keys already listed. Use `>>`, never `>` — one stray truncation
|
||||
revokes every other key on that account. The options prefix must sit on the
|
||||
**same physical line** as the key, comma-separated with no spaces: a paste
|
||||
that wrapped is the likeliest reason a key that looks right is refused.
|
||||
`ssh-copy-id` cannot add that prefix, so append by hand (or let it copy the
|
||||
bare key and edit the line afterwards). If authentication still fails with no
|
||||
clear reason, suspect permissions — sshd's `StrictModes` silently ignores
|
||||
`authorized_keys` when the home directory, `~/.ssh` or the file itself is
|
||||
group- or world-writable, and says why only in the peer's own log
|
||||
(`journalctl -u ssh`, `/var/log/auth.log`).
|
||||
|
||||
`restrict` disables pty, agent/X11 and port forwarding; append
|
||||
`port-forwarding` and `permitopen="127.0.0.1:<port>"` after it if you need one
|
||||
specific tunnel. `from=` must list the **host's** addresses, not the
|
||||
@@ -970,8 +989,8 @@ resolved to `latest` at build time:
|
||||
|
||||
| Component | Pin | Where |
|
||||
|---|---|---|
|
||||
| pi | `0.84.1` | `ARG PI_VERSION` — `Dockerfile.variant` |
|
||||
| pi-atelier | `v0.8.0` | `ARG PI_ATELIER_REF` — `Dockerfile.variant` |
|
||||
| pi | `0.84.2` | `ARG PI_VERSION` — `Dockerfile.variant` |
|
||||
| pi-atelier | `v0.8.1` | `ARG PI_ATELIER_REF` — `Dockerfile.variant` |
|
||||
| mempalace | `3.6.0` | `ARG MEMPALACE_VERSION` — `Dockerfile.base` |
|
||||
|
||||
The objective is **not** to freeze versions. Bumping is routine — usually one
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
# Point every client at it by setting, in that client's .env:
|
||||
#
|
||||
# MEMPALACE_REMOTE_URL=http://<reachable-host>:8765/mcp
|
||||
# MEMPALACE_REMOTE_TOKEN=<the shared bearer token>
|
||||
#
|
||||
# (see .env.example). When set, the client connects over HTTP and does NOT
|
||||
# spawn its own local mempalace-mcp.
|
||||
@@ -18,12 +19,21 @@
|
||||
# (both are pinned by the same image build). Override with a slimmer image via
|
||||
# MEMPALACE_SERVER_IMAGE if you prefer (it must provide `mempalace-mcp`).
|
||||
#
|
||||
# ⚠ SECURITY: mempalace-mcp's HTTP transport has NO authentication of its own.
|
||||
# Do NOT expose port 8765 to an untrusted network. The default below binds to
|
||||
# 127.0.0.1 (host loopback) only. To let sibling containers reach it, either
|
||||
# attach them to the shared `mempalace-net` network (container-to-container, no
|
||||
# host port needed — use http://mempalace-server:8765/mcp), or front it with a
|
||||
# reverse proxy that enforces MEMPALACE_REMOTE_TOKEN as `Authorization: Bearer`.
|
||||
# ⚠ SECURITY: the HTTP transport IS authenticated as of mempalace 3.6.0 — an
|
||||
# earlier version of this comment said otherwise and was wrong. The server
|
||||
# compares `Authorization: Bearer <token>` with hmac.compare_digest and
|
||||
# **refuses to start on a non-loopback bind without a token**, so
|
||||
# MEMPALACE_REMOTE_TOKEN below is required, not optional: without it this
|
||||
# service crash-loops. It also pins `Host` and allowlists `Origin`.
|
||||
#
|
||||
# Still do not publish port 8765 to an untrusted network. The default binds to
|
||||
# 127.0.0.1 (host loopback) only. To let sibling containers reach it, attach
|
||||
# them to the shared `mempalace-net` network (container-to-container, no host
|
||||
# port needed — use http://mempalace-server:8765/mcp). To reach it from
|
||||
# elsewhere, terminate TLS in a tunnel/reverse proxy and let the bearer token be
|
||||
# the authentication — do NOT add browser-shaped auth (SSO/PIN/password) in
|
||||
# front, because every MCP client here is a headless JSON-RPC POST and would
|
||||
# receive a login page where JSON should be.
|
||||
|
||||
name: mempalace-server
|
||||
|
||||
@@ -40,6 +50,11 @@ services:
|
||||
user: "0:0"
|
||||
environment:
|
||||
- HOME=/data
|
||||
# Required: mempalace refuses a non-loopback bind without a token (it
|
||||
# would exit at startup and, with restart:unless-stopped, crash-loop).
|
||||
# `:?` fails fast at `docker compose up` with a readable message instead.
|
||||
# Clients send the same value as MEMPALACE_REMOTE_TOKEN.
|
||||
- MEMPALACE_MCP_HTTP_TOKEN=${MEMPALACE_REMOTE_TOKEN:?set MEMPALACE_REMOTE_TOKEN in .env — the shared palace requires a bearer token}
|
||||
command:
|
||||
- mempalace-mcp
|
||||
- --transport
|
||||
@@ -60,16 +75,28 @@ services:
|
||||
- mempalace-shared:/data/.mempalace
|
||||
# Embedding-model cache (~79 MB, disposable) so search does not re-download.
|
||||
- mempalace-shared-chroma:/data/.cache/chroma
|
||||
# Transcript inbox. Clients cannot mine into a remote palace directly:
|
||||
# `mempalace_mine` expands its source path in THIS process, so it can only
|
||||
# see paths inside this container. Each client rsyncs its staged session
|
||||
# exports to a per-device subdirectory on the host (see
|
||||
# MEMPALACE_PI_SSH_TARGET in .env.example) and then calls mempalace_mine
|
||||
# with the container-side path below (MEMPALACE_PI_REMOTE_PATH=/data/feed).
|
||||
# Read-only: mining only reads sources, and all locks live palace-side.
|
||||
- ${MEMPALACE_FEED_DIR:-./feed}:/data/feed:ro
|
||||
networks:
|
||||
- mempalace-net
|
||||
healthcheck:
|
||||
# A tools/list round-trip proves the server is answering MCP (python3 is
|
||||
# always present — mempalace itself is a python tool in the image).
|
||||
# GET /healthz, which is Host/Origin-gated but deliberately token-free —
|
||||
# so this probe needs no credentials. Do NOT go back to POSTing
|
||||
# `tools/list` here: that carries no Authorization header and now 401s,
|
||||
# marking a perfectly healthy server unhealthy forever. The Host pin is
|
||||
# only enforced on loopback *binds* (this one is 0.0.0.0), so a request to
|
||||
# 127.0.0.1 inside the container passes.
|
||||
test:
|
||||
- CMD
|
||||
- python3
|
||||
- -c
|
||||
- "import urllib.request,json; d=json.dumps({'jsonrpc':'2.0','id':1,'method':'tools/list','params':{}}).encode(); r=urllib.request.Request('http://127.0.0.1:8765/mcp',data=d,headers={'Content-Type':'application/json','Accept':'application/json'}); urllib.request.urlopen(r,timeout=5).read()"
|
||||
- "import urllib.request,sys; sys.exit(0 if urllib.request.urlopen('http://127.0.0.1:8765/healthz',timeout=5).status==200 else 1)"
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
|
||||
@@ -92,6 +92,82 @@ if command -v mempalace &>/dev/null && [ -d /workspace ]; then
|
||||
fi
|
||||
fi
|
||||
|
||||
# ── MemPalace: pi transcript feeder ─────────────────────────────────
|
||||
# mempalace-toolkit ships `mempalace-pi-session`, which mines pi's own JSONL
|
||||
# session transcripts into the palace. pi's mempalace extension drives it on
|
||||
# session_shutdown and on a debounced agent_settled; this is the catch-up for
|
||||
# the one case no handler can cover — a hard kill (docker kill, OOM, host
|
||||
# reboot) runs nothing at all, so without this the previous life's transcripts
|
||||
# are never mined.
|
||||
#
|
||||
# No MEMPALACE_PI_STAGE override here on purpose: the feeder stages next to the
|
||||
# palace it feeds (<palace-root>/pi-stage), so the stage and the dedup keys
|
||||
# referencing it share one lifetime — whatever persistence the palace has, the
|
||||
# stage inherits. Pinning it elsewhere (e.g. into the ~/.pi volume) would
|
||||
# re-introduce the very split that design prevents: palace volume kept, stage
|
||||
# volume dropped, and `mempalace sync` then prunes every conversation drawer.
|
||||
#
|
||||
# Backgrounded: a cold mine can take tens of seconds and must never delay the
|
||||
# shell. Contention with a live session is handled by the tool itself (it exits
|
||||
# 0 and lets the palace holder do the mine).
|
||||
|
||||
# Self-heal onto PATH for images whose base predates the toolkit symlink.
|
||||
# ~/.local/bin is already ahead of /usr/local/bin on PATH (Dockerfile.base sets
|
||||
# it in ENV PATH) and is writable by this (non-root) user, unlike /usr/local/bin.
|
||||
if [ -x /opt/mempalace-toolkit/bin/mempalace-pi-session ] && \
|
||||
! command -v mempalace-pi-session >/dev/null 2>&1; then
|
||||
mkdir -p "$HOME/.local/bin"
|
||||
ln -sf /opt/mempalace-toolkit/bin/mempalace-pi-session "$HOME/.local/bin/mempalace-pi-session"
|
||||
fi
|
||||
|
||||
# Resolve the feeder explicitly rather than trusting PATH: this runs before any
|
||||
# login shell, and a silently-skipped catch-up is exactly the failure we are
|
||||
# here to prevent.
|
||||
MEMPALACE_FEEDER=""
|
||||
if command -v mempalace-pi-session >/dev/null 2>&1; then
|
||||
MEMPALACE_FEEDER="mempalace-pi-session"
|
||||
elif [ -x /opt/mempalace-toolkit/bin/mempalace-pi-session ]; then
|
||||
MEMPALACE_FEEDER="/opt/mempalace-toolkit/bin/mempalace-pi-session"
|
||||
fi
|
||||
|
||||
if [ "${MEMPALACE_FEED:-1}" != "0" ] && [ -n "$MEMPALACE_FEEDER" ]; then
|
||||
if [ -n "${MEMPALACE_REMOTE_URL:-}" ] && [ -z "${MEMPALACE_PI_SSH_TARGET:-}" ]; then
|
||||
# Remote palace, but no inbox to ship transcripts to — the feeder genuinely
|
||||
# cannot do anything here, so skipping is right. Saying so is the point:
|
||||
# this branch used to be a bare `:`, and the skip happens *before* the
|
||||
# subshell below that writes mempalace-catchup.log, so a container in this
|
||||
# state contributed nothing to the palace and left no artifact at all — not
|
||||
# even an empty log — to explain why. That is indistinguishable from a
|
||||
# healthy run that simply had nothing to file. `tee` puts the notice both in
|
||||
# the container's start output (docker logs) and at the path anyone
|
||||
# debugging "why is nothing from this container in the palace?" looks first.
|
||||
# This is an entrypoint: a notice must never be able to stop a container
|
||||
# from starting. An unwritable ~/.pi (root-owned volume — a classic Docker
|
||||
# permission accident) makes `mkdir -p` fail, and under `set -e` that would
|
||||
# abort startup entirely: a brand-new failure mode in precisely the branch
|
||||
# that used to do nothing at all. Degrade to stdout-only instead.
|
||||
_mp_log="$HOME/.pi/agent/mempalace-catchup.log"
|
||||
mkdir -p "$HOME/.pi/agent" 2>/dev/null || _mp_log=/dev/null
|
||||
{
|
||||
echo "MemPalace catch-up skipped: remote palace with no transcript inbox."
|
||||
echo " MEMPALACE_REMOTE_URL is set (${MEMPALACE_REMOTE_URL})"
|
||||
echo " but MEMPALACE_PI_SSH_TARGET is not, so there is nowhere to ship this"
|
||||
echo " container's staged sessions. MCP tools still read and write the shared"
|
||||
echo " palace — but this container's own conversations are mined nowhere."
|
||||
echo " Fix: set MEMPALACE_PI_SSH_TARGET (and MEMPALACE_PI_DEVICE) in .env,"
|
||||
echo " or unset MEMPALACE_REMOTE_URL to keep the palace local."
|
||||
echo " Deliberate? MEMPALACE_FEED=0 turns the feed off and silences this."
|
||||
} | tee "$_mp_log" 2>/dev/null || true
|
||||
unset _mp_log
|
||||
else
|
||||
mkdir -p "$HOME/.pi/agent"
|
||||
(
|
||||
"$MEMPALACE_FEEDER" --reason container-start \
|
||||
>"$HOME/.pi/agent/mempalace-catchup.log" 2>&1 || true
|
||||
) &
|
||||
fi
|
||||
fi
|
||||
|
||||
# ── Git config defaults ──────────────────────────────────────────────
|
||||
if [ -n "${GIT_USER_NAME:-}" ] && ! git config --global user.name &>/dev/null; then
|
||||
git config --global user.name "$GIT_USER_NAME"
|
||||
|
||||
@@ -91,6 +91,70 @@ run "terminfo: modern emulators (ncurses-term)" 'for t in wezterm alacritty foot
|
||||
run "terminfo: xterm-ghostty alias (tic)" "infocmp -x xterm-ghostty >/dev/null 2>&1"
|
||||
run "nvim true-colour default (sysinit.vim)" "nvim --headless -c 'lua os.exit(vim.o.termguicolors and 0 or 1)'"
|
||||
run "mempalace-mcp" "mempalace-mcp --help"
|
||||
run "mempalace-pi-session on PATH" "mempalace-pi-session --help"
|
||||
# The staging dir must sit next to the palace, not in a disposable cache: the
|
||||
# palace keys per-source dedup on the STAGED path, so a stage that can be wiped
|
||||
# while the palace survives lets `mempalace sync` prune every drawer mined from
|
||||
# it. Assert the resolved default, not an env var — the guarantee is "stage
|
||||
# shares the palace's lifetime", which an ENV pin would quietly break.
|
||||
# NOTE: --sessions-dir gets an EMPTY temp dir, never /tmp. The stage banner is
|
||||
# printed before any export, so nothing needs to be found — and pointing a
|
||||
# default-staged run at a populated dir would export whatever transcripts it
|
||||
# finds into the real stage, which is how a synthetic test session ends up
|
||||
# staged for mining as if it were a real conversation.
|
||||
run "pi stage defaults next to the palace (not a cache dir)" '
|
||||
out=$(mempalace-pi-session --dry-run --reason smoke --sessions-dir "$(mktemp -d)" 2>&1) || true
|
||||
echo "$out" | grep -q "stage=/home/developer/.mempalace/pi-stage/"
|
||||
'
|
||||
run "pi stage follows MEMPALACE_PALACE_PATH" '
|
||||
out=$(MEMPALACE_PALACE_PATH=/tmp/alt/.mempalace/palace \
|
||||
mempalace-pi-session --dry-run --reason smoke --sessions-dir "$(mktemp -d)" 2>&1) || true
|
||||
echo "$out" | grep -q "stage=/tmp/alt/.mempalace/pi-stage/"
|
||||
'
|
||||
# Regression guard for the pi transcript exporter. If pi ever changes its
|
||||
# session JSONL shape, the exporter stops recognising sessions and the palace
|
||||
# silently gets nothing (or, worse, raw JSON chunked as prose). Feed it a
|
||||
# synthetic session and assert it is actually exported. Uses --dry-run so no
|
||||
# palace is touched, and a temp stage so nothing real is written.
|
||||
run "pi transcript exporter recognises a pi session" '
|
||||
set -e
|
||||
d=$(mktemp -d); s="$d/sessions/--workspace--"; mkdir -p "$s"
|
||||
{
|
||||
printf "%s\n" "{\"type\":\"session\",\"version\":1,\"id\":\"smoke\",\"cwd\":\"/workspace\",\"timestamp\":\"2026-01-01T00:00:00Z\"}"
|
||||
printf "%s\n" "{\"type\":\"message\",\"message\":{\"role\":\"user\",\"content\":\"question one\"}}"
|
||||
a=$(printf "a%.0s" $(seq 1 1200))
|
||||
printf "%s\n" "{\"type\":\"message\",\"message\":{\"role\":\"assistant\",\"content\":[{\"type\":\"text\",\"text\":\"$a\"}]}}"
|
||||
printf "%s\n" "{\"type\":\"message\",\"message\":{\"role\":\"user\",\"content\":\"question two\"}}"
|
||||
printf "%s\n" "{\"type\":\"message\",\"message\":{\"role\":\"assistant\",\"content\":[{\"type\":\"text\",\"text\":\"short reply\"}]}}"
|
||||
} > "$s/2026-01-01T00-00-00-000Z_smoke.jsonl"
|
||||
out=$(mempalace-pi-session --dry-run --sessions-dir "$d/sessions" --stage "$d/stage" 2>&1)
|
||||
echo "$out" | grep -q "Exported 1 session"
|
||||
'
|
||||
# The same guard from the other side: a session with no real assistant output
|
||||
# (an abandoned prompt, whose bulk is injected skill text) must NOT be filed.
|
||||
run "pi transcript exporter rejects an abandoned session" '
|
||||
set -e
|
||||
d=$(mktemp -d); s="$d/sessions/--workspace--"; mkdir -p "$s"
|
||||
{
|
||||
printf "%s\n" "{\"type\":\"session\",\"version\":1,\"id\":\"smoke2\",\"cwd\":\"/workspace\",\"timestamp\":\"2026-01-01T00:00:00Z\"}"
|
||||
u=$(printf "u%.0s" $(seq 1 13000))
|
||||
printf "%s\n" "{\"type\":\"message\",\"message\":{\"role\":\"user\",\"content\":\"$u\"}}"
|
||||
printf "%s\n" "{\"type\":\"message\",\"message\":{\"role\":\"assistant\",\"content\":[{\"type\":\"text\",\"text\":\"Ready. What would you like to work on?\"}]}}"
|
||||
} > "$s/2026-01-01T00-00-00-000Z_smoke2.jsonl"
|
||||
out=$(mempalace-pi-session --dry-run --sessions-dir "$d/sessions" --stage "$d/stage" 2>&1)
|
||||
echo "$out" | grep -q "no sessions qualified"
|
||||
'
|
||||
# The remote-palace-without-inbox skip must ANNOUNCE itself, not vanish. This
|
||||
# branch of entrypoint-user.sh runs at container start (not reachable from a
|
||||
# `docker run` one-shot), so assert against the entrypoint that actually shipped
|
||||
# in the image. Guards a silent regression back to the bare `:` no-op, which
|
||||
# left a container contributing nothing to the palace with no artifact saying
|
||||
# why — the log it would normally leave is written by the other branch.
|
||||
run_expect "remote-palace-without-inbox skip is announced, not silent" \
|
||||
"grep -o 'MemPalace catch-up skipped' /usr/local/bin/entrypoint-user.sh | head -1" \
|
||||
"MemPalace catch-up skipped"
|
||||
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'"
|
||||
# v1.0.0 base additions — verify presence and basic functionality.
|
||||
run "pandoc" "pandoc --version"
|
||||
run "typst" "typst --version"
|
||||
|
||||
Reference in New Issue
Block a user