29e660e18f
Staging default moves out of ~/.cache to <palace-root>/pi-stage (pi) and <palace-root>/opencode-stage (opencode), resolved with mempalace's own palace-path precedence ($MEMPALACE_PALACE_PATH -> $MEMPAL_PALACE_PATH -> ~/.mempalace/config.json -> ~/.mempalace/palace), then dirname. Why: the convos miner keys dedup on the *staged* path, so a wiped stage plus a sync scoped to include it prunes the drawers mined from those sources -- deleting memories, not a cache. Under ~/.cache that state was reachable by anything treating a cache as disposable. Staging inside the palace makes the coupling structural: the stage cannot be wiped without touching the palace itself. Overrides ($MEMPALACE_PI_STAGE / $MEMPALACE_SESSION_STAGE, --stage) are unchanged. Note the old default had never been created on any host, so this closed a latent hazard, not a live one. Measured, and the docs now claim only this much: sync prunes only within the scope it is given -- wing-only, 1299 scanned / 1299 out of scope / 0 removed; scoped at the palace root, 651 kept / 648 out of scope. The previous blanket "sync prunes every drawer" wording overstated it, which is a liability: the next reader disproves the overstatement and discards the real constraint with it. Also in this change: - cron log dir ~/.cache/mempalace-session -> ~/.cache/mempalace-logs. The stage left that namespace, so the old name now read as "the stage". - AGENTS.md: the convos miner *does* check mtime (verified against upstream convo_miner.py); the previous "no mtime check" claim was wrong. - smoke-test assertions use `mktemp -d` for --sessions-dir. One pointed at /tmp, which still held earlier synthetic transcripts, so a --dry-run exported a fake session into the real stage: --dry-run skips the mine, not the export. docs/phase-1-exposure-runbook.md -- the newt/DNS/auth step that RFC 001 and the synlig runbook leave open (runbook section 4, items 2 and 5). Port 8765 at /mcp, newt targets 172.17.0.1, and the authentication is the single shared bearer token (RFC 6.2, decided 2026-08-09) rather than per-device proxy users. The latter cannot work today: mempalace validates exactly one token, and Pangolin's SSO/PIN/password are browser-shaped while every client here is a headless JSON-RPC POST -- enabling that protection breaks the clients it protects. The per-device axis that *does* exist is the feeder's SSH key + per-device inbox. New finding recorded there: a loopback bind does not merely 403 behind a tunnel (already known, runbook 2.4) -- it also silently starts the server with no token at all, because auto-minting is gated on the bind being non-loopback. extensions/pi/README.md: the HTTP transport IS authenticated as of mempalace 3.6.0; the "sessionless and unauthenticated" note dated from the v1.3.0 era. Closes the RFC section 8 Phase-0 hygiene item.
169 lines
9.3 KiB
Markdown
169 lines
9.3 KiB
Markdown
# synlig primary — Phase 0 runbook and handoff
|
||
|
||
Companion to [`rfc-001-global-palace.md`](./rfc-001-global-palace.md). Records what was actually done
|
||
on the primary, with verified evidence, so the next session (or the next machine) does not re-derive it.
|
||
|
||
**Status 2026-08-10 00:30 — Phase 0 prep complete. Not serving. Nothing exposed.**
|
||
Blocked on two things, both deliberately left to Joakim: the Pangolin update on nyvaken, and one `sudo`.
|
||
|
||
---
|
||
|
||
## 1. What synlig is (discovered, not assumed)
|
||
|
||
| Fact | Value |
|
||
| --- | --- |
|
||
| SSH | `synlig` → `synlig.erdc.ericsson.net`, user `ecsjper` (from `~/.ssh/config`) |
|
||
| OS | Ubuntu 24.04.4 LTS, 7.8 GiB RAM, 78 G disk (**29 G free**), uptime 12 d |
|
||
| Python / uv | system `python3` 3.12.3; `uv` at `~/.local/bin/uv` (**not** on the non-login `PATH`) |
|
||
| Interfaces | `lo` 127.0.0.1, `ens3` 10.0.0.4/16, `docker0` 172.17.0.1/16, `br-…` 172.19.0.1/16 |
|
||
| Already listening | 22, 80, 443, 3000 (node), 3389 + 3350 + 4822 (xrdp/guacamole), 631 |
|
||
| Docker | present; running `act_runner-runner-1` (**Gitea Actions runner**) and `digikam` |
|
||
| Pre-existing MemPalace | **none** — no `mempalace` binary, no `~/.mempalace`. Greenfield. |
|
||
|
||
The Gitea Actions runner living here is worth remembering: synlig is not a dedicated appliance, and CI
|
||
load competes with the palace for the same 7.8 GiB.
|
||
|
||
## 2. Done tonight
|
||
|
||
### 2.1 MemPalace installed, pinned to the fleet version
|
||
|
||
```sh
|
||
~/.local/bin/uv tool install "mempalace==3.6.0" # → mempalace, mempalace-mcp
|
||
```
|
||
|
||
Pinned deliberately: the clients run 3.6.0, and the id recipes / idempotency probes this RFC leans on are
|
||
version-specific. Reversible with `uv tool uninstall mempalace`.
|
||
|
||
### 2.2 Embedder model pre-warmed — the corporate-network risk that wasn't
|
||
|
||
The first embed pulls `all-MiniLM-L6-v2` ONNX (79.3 MB) from the chroma CDN into
|
||
`~/.cache/chroma/onnx_models/` (167 M on disk once unpacked). **This was the main unknown** — an
|
||
egress-filtered work VM would have failed here, at the worst possible moment (first client write).
|
||
It downloaded at ~20 MB/s with no proxy interference. Done in a throwaway palace, since deleted, so the
|
||
real palace never saw it. Same model as the clients use, so the semantic space matches.
|
||
|
||
### 2.3 Palace created with the §7.1 landmine structurally removed
|
||
|
||
`~/.mempalace/palace` — the **stock default**, so no `MEMPALACE_PALACE_PATH` and no `config.json` is
|
||
needed anywhere on synlig. One less thing to drift.
|
||
|
||
RFC §7.1 says to `mv` three HOME-anchored stores into the palace dir before first `serve`. **On a
|
||
greenfield primary there is nothing to move — but the hazard is not actually a migration hazard, and the
|
||
RFC understated it:** with *stock defaults* `palace_path` is `~/.mempalace/palace` while `DEFAULT_KG_PATH`
|
||
is `~/.mempalace/knowledge_graph.sqlite3`. Those differ, so the split is the **out-of-the-box** behaviour,
|
||
not a consequence of a custom path. It is permanent, not one-time: `serve` always passes `--palace` (KG
|
||
inside the palace), while any CLI command run *without* `--palace` uses the HOME path. Two KGs on one box,
|
||
forever, silently.
|
||
|
||
Fixed by making both resolution rules land on one inode:
|
||
|
||
```sh
|
||
ln -sfn palace/knowledge_graph.sqlite3 ~/.mempalace/knowledge_graph.sqlite3
|
||
ln -sfn palace/known_entities.json ~/.mempalace/known_entities.json
|
||
```
|
||
|
||
Relative targets, so a home-directory move survives. `hallways.json` is deliberately **not** symlinked:
|
||
it is already palace-derived, and its HOME path is a warning-only legacy probe (`hallways.py:73-95`) that
|
||
never auto-migrates.
|
||
|
||
Verified the symlink assumption rather than trusting it (`python3 sqlite3` on synlig, temp dir):
|
||
|
||
| Check | Result |
|
||
| --- | --- |
|
||
| Dangling symlink + `sqlite3.connect` | creates the target |
|
||
| `-wal` / `-shm` placement | next to the **target**, inside the palace dir — *not* beside the symlink |
|
||
| Write via symlink → read via palace path | same data, **same inode** |
|
||
|
||
The WAL placement is the part that mattered: it keeps the palace directory a single self-contained
|
||
backup/bind-mount unit.
|
||
|
||
### 2.4 §6.2's Host/Origin policy verified by experiment, not by reading
|
||
|
||
Ran on synlig, loopback and docker0 binds, then stopped. **11/11 as predicted:**
|
||
|
||
| # | Bind | Request | Expected | Got |
|
||
| --- | --- | --- | --- | --- |
|
||
| A1 | 127.0.0.1 | `/healthz`, correct Host | 200 | ✅ 200 |
|
||
| A2 | 127.0.0.1 | `/healthz`, `Host: palace.example.com` | **403** | ✅ 403 |
|
||
| A3 | 127.0.0.1 | `/healthz`, `Origin: https://evil.example` | 403 | ✅ 403 |
|
||
| A4 | 127.0.0.1 | `POST /mcp`, no token | 401 | ✅ 401 |
|
||
| A5 | 127.0.0.1 | `POST /mcp`, wrong token | 401 | ✅ 401 |
|
||
| A6 | 127.0.0.1 | `POST /mcp`, correct token | 200 | ✅ 200 (`tools/list` → **36 tools**) |
|
||
| B1 | 172.17.0.1 | `/healthz`, bound-host Host | 200 | ✅ 200 |
|
||
| B2 | 172.17.0.1 | `/healthz`, `Host: palace.example.com` | **200** | ✅ 200 |
|
||
| B3 | 172.17.0.1 | `/healthz`, `Origin: https://evil.example` | 403 | ✅ 403 |
|
||
| B4 | 172.17.0.1 | `/healthz`, loopback Origin | 200 | ✅ 200 |
|
||
| B5 | 172.17.0.1 | `POST /mcp`, foreign Host + token | 200 | ✅ 200 |
|
||
|
||
**Operational conclusions:**
|
||
|
||
1. **Do not bind loopback behind the tunnel.** A2 vs B2 is the whole story: the reflex "bind 127.0.0.1,
|
||
it's safer" produces a 403 that looks like a Pangolin misconfiguration and is not one.
|
||
2. **Bind `172.17.0.1` (docker0).** Non-loopback, so the Host pin relaxes — but reachable only from
|
||
synlig and its containers, so a newt container on this box can reach it while the LAN cannot. This is
|
||
strictly better than `0.0.0.0` here. It is what `contrib/systemd/mempalace-serve.service` uses.
|
||
3. **`Origin` is never relaxed** (B3). No browser-based MCP client, and no proxy that injects `Origin`.
|
||
4. `/healthz` is Host/Origin-gated but token-free — a usable liveness probe for the tunnel.
|
||
|
||
Test script kept at `/tmp/synlig-phase0-test.sh` on this container (ephemeral — re-create from the table
|
||
above if needed; it starts, probes and stops the server, and asserts nothing is left listening).
|
||
|
||
### 2.5 A start unit, written but not installed
|
||
|
||
`contrib/systemd/mempalace-serve.service` — user unit, follows the existing `contrib/systemd/` style,
|
||
carries the bind rationale inline so nobody "fixes" it back to loopback. A copy is already staged on synlig
|
||
at `~/.config/systemd/user/mempalace-serve.service.staged` — **the `.staged` suffix is deliberate**:
|
||
systemd only reads `*.service`, so the file cannot be activated by accident, not even by a stray
|
||
`daemon-reload`. **Not** installed, **not** enabled: it needs one `sudo loginctl enable-linger`, and
|
||
standing up a network-reachable service while you were asleep was not mine to decide.
|
||
|
||
## 3. Deliberately NOT done
|
||
|
||
- **Nothing is serving.** No listener on 8765; no mempalace process. Re-verified at the end of the run.
|
||
- **No client `.env` was touched.** Your working setup is exactly as you left it (R6: reversible).
|
||
- **No data joined.** The palace is empty. The §4.4 join needs the diary-dedup decision (§7.6) first —
|
||
replaying diaries today duplicates them, and the primary is the one place that must stay clean.
|
||
- **nyvaken untouched.** Read nothing, changed nothing.
|
||
- **No sudo.** `sudo -n` on synlig requires a password.
|
||
|
||
## 4. Tomorrow, in order
|
||
|
||
> **2026-08-12: items 1–2 and 5 now have their own runbook —**
|
||
> [`phase-1-exposure-runbook.md`](./phase-1-exposure-runbook.md). Pangolin on nyvaken is updated (done).
|
||
> newt is still missing on synlig. That doc also records why per-device Pangolin users are the wrong layer,
|
||
> and an additional loopback finding: a loopback bind does not merely 403, it also silently starts the
|
||
> server with **no token at all** (auto-minting is gated on the bind being non-loopback).
|
||
|
||
1. **Pangolin update on nyvaken** (yours). ✅ done 2026-08-12.
|
||
2. **⚠️ synlig has no tunnel client.** `docker ps` shows only the Gitea runner and digikam — no `newt`.
|
||
Pangolin on nyvaken cannot reach synlig by itself; synlig needs a newt container (or an equivalent
|
||
outbound tunnel) added. Easy to miss because Pangolin will look healthy on its own side. If newt runs
|
||
in Docker here, the docker0 bind above is already correct for it.
|
||
3. **One sudo, then start** (the unit is already staged; just drop the suffix):
|
||
```sh
|
||
sudo loginctl enable-linger ecsjper
|
||
cd ~/.config/systemd/user && mv mempalace-serve.service.staged mempalace-serve.service
|
||
systemctl --user daemon-reload && systemctl --user enable --now mempalace-serve
|
||
curl -s 172.17.0.1:8765/healthz # ok
|
||
curl -s 127.0.0.1:8765/healthz # 403 — expected, not a bug (§2.4)
|
||
```
|
||
4. **Collect the shared token** (auto-minted on first non-loopback start, stable across restarts):
|
||
```sh
|
||
cat ~/.mempalace/server/f5d849287f6d73f0141b29d7/token
|
||
```
|
||
That directory name is `sha256(realpath(palace))[:24]` — it changes if the palace path ever changes.
|
||
5. **Route it through Pangolin**, then verify `/healthz` end-to-end through the public hostname *before*
|
||
pointing any client at it.
|
||
6. **Then, and only then**, Phase 1 client flip — one machine first, and remember opencode containers
|
||
need the §4.1 sidecar merge (or Phase 1.5) before the `.env` takes effect.
|
||
7. **Before the first join:** settle §7.6 diary dedup, then dry-run §4.4 from **one** palace and check
|
||
counts.
|
||
|
||
## 5. Rollback
|
||
|
||
```sh
|
||
systemctl --user disable --now mempalace-serve # if enabled
|
||
~/.local/bin/uv tool uninstall mempalace
|
||
rm -rf ~/.mempalace # empty today — check before running once it isn't
|
||
```
|