mempalace: drop the stage ENV pin, fix the shared-server compose
Lint / actionlint (push) Successful in 21s
Lint / hadolint (push) Successful in 29s

The feeder now defaults to <palace-root>/pi-stage upstream, so pinning
MEMPALACE_PI_STAGE into ~/.pi here is unnecessary -- and was actively wrong. It
created a second convention that could still diverge from the palace: keep the
devbox-palace volume, drop devbox-pi-config, and a scoped `mempalace sync`
prunes every conversation drawer, because dedup keys on the staged path. Both
the ENV and the entrypoint export are gone; a comment explains why adding one
back re-introduces the split it was meant to fix.

docker-compose.mempalace.yml was broken on mempalace 3.6.0 in both directions:
  - `--host 0.0.0.0` with no token in the environment makes the server refuse
    to start, crash-looping under `restart: unless-stopped`.
  - Supply a token and the healthcheck's unauthenticated `tools/list` POST 401s,
    marking a perfectly healthy server unhealthy forever.
Now the token is required via ${MEMPALACE_REMOTE_TOKEN:?...} so it fails fast at
`docker compose up` with a readable message, and the healthcheck probes the
deliberately token-free /healthz. The "no authentication of its own" security
note has been stale since 3.6.0 and is replaced with the actual posture
(bearer token + Host pin + Origin allowlist), including why browser-shaped auth
must not be put in front of it.

Dockerfile.base: mempalace-pi-session symlinked onto PATH, with a build-time
`--help` check so a broken feeder fails the image build rather than the first
session.

smoke-test: assert the stage resolves beside the palace (default, and following
$MEMPALACE_PALACE_PATH) instead of asserting the removed ENV pin. The two
behavioural guards -- a synthetic session that must be captured, an abandoned
one that must not be -- are unchanged.

.env.example: recommend `mempalace serve` on the docker0 gateway rather than
`mempalace-mcp --transport http --host 0.0.0.0`, with the two binds to avoid.
This commit is contained in:
Joakim Persson
2026-08-12 17:04:14 +02:00
parent 7649d53f3b
commit 7c00dd6001
6 changed files with 265 additions and 13 deletions
+36 -9
View File
@@ -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