Skip to content

feat(plugin): add mempalace-remote — SSH-proxied palace for remote clients - #1190

Closed
messelink wants to merge 8 commits into
MemPalace:developfrom
messelink:feat/remote-plugin
Closed

feat(plugin): add mempalace-remote — SSH-proxied palace for remote clients#1190
messelink wants to merge 8 commits into
MemPalace:developfrom
messelink:feat/remote-plugin

Conversation

@messelink

@messelink messelink commented Apr 25, 2026

Copy link
Copy Markdown
Contributor

Summary

Adds a sibling plugin, mempalace-remote, that mirrors the existing mempalace plugin's MCP tools and auto-save hooks but proxies them to a central host over SSH instead of running locally. Lets a fleet of client machines (VPS, second laptop, dev container, etc.) share one canonical palace without each running its own mempalace install.

The marketplace plugins array gains a second entry, so the same MemPalace/mempalace repo serves both plugins. Existing users are unaffected — claude /plugin install mempalace@mempalace still installs only the local-install variant.

Why

I run mempalace on a home server and use Claude Code from several other machines. The hand-rolled approach was a pair of ~/.local/bin/{mempalace,mempalace-mcp} shell wrappers that ssh ... to the central install. That works for the MCP server but skips the auto-save Stop and PreCompact hooks entirely, since the upstream plugin's hook scripts call the local mempalace CLI directly. Packaging the wrappers + hook proxies as a real plugin makes the central-palace pattern install-and-go for anyone running the same setup.

Discussed (lightly) in #1049 and adjacent threads about non-local mempalace deployments.

Layout

Mirrors the existing .claude-plugin/ structure:

.claude-plugin-remote/
├── plugin.json                       # plugin manifest
├── .mcp.json                         # ${CLAUDE_PLUGIN_ROOT}/bin/mempalace-mcp-ssh.sh
├── README.md
├── bin/
│   └── mempalace-mcp-ssh.sh          # thin SSH wrapper for the MCP server
└── hooks/
    ├── hooks.json                    # registers Stop + PreCompact
    ├── mempal-stop-hook.sh           # ssh-pipe stdin to remote `mempalace hook run`
    └── mempal-precompact-hook.sh     # same for precompact

.claude-plugin/marketplace.json gains a single appended entry pointing at ./.claude-plugin-remote.

Configuration

Env var Required Default Description
MEMPALACE_REMOTE_HOST yes SSH target — alias from ~/.ssh/config or fully qualified hostname
MEMPALACE_REMOTE_BIN no mempalace Path to the mempalace CLI on the remote (used by Stop / PreCompact hooks)
MEMPALACE_REMOTE_MCP_BIN no mempalace-mcp Path to the mempalace-mcp server on the remote (used by the MCP client)

The _BIN overrides cover the SSH non-interactive PATH gotcha where ~/.local/bin isn't on PATH for ssh host command shells. Bare defaults work for system-wide installs.

How it works

  • MCP serverclaude spawns ${CLAUDE_PLUGIN_ROOT}/bin/mempalace-mcp-ssh.sh, which validates the env vars and execs ssh -- $HOST mempalace-mcp. Stdio JSON-RPC rides the SSH channel transparently — the MCP client doesn't know it's remote.
  • Stop / PreCompact hooks — fire on the client, validate env vars, pipe Claude Code's hook JSON to ssh -- $HOST mempalace hook run --hook {stop,precompact} --harness claude-code. The remote mempalace CLI handles all the actual logic; the bash wrapper is intentionally thin.

Coexistence

Don't enable both mempalace and mempalace-remote on the same machine — both register an MCP server named mempalace and the second one to load shadows the first. The README calls this out.

Tested

End-to-end test on a real two-machine setup (VPS client → home-server central palace):

  • claude /plugin marketplace addclaude /plugin install mempalace-remote@mempalace → set env vars → restart Claude Code → working
  • mempalace_status MCP call returned real wing/room counts from the central palace ✓
  • Stop hook fired and landed in ~/.mempalace/hook_state/hook.log on the central host with the correct client session ID ✓
  • PreCompact hook (/compact) fired and landed in the same log ✓
  • Latency: sub-300ms per MCP call without SSH ControlMaster; ControlMaster optional, documented in README.

claude plugin validate passes on both .claude-plugin/plugin.json and .claude-plugin-remote/plugin.json.

Security

Two issues caught in review and fixed:

  • SSH option-parsing of hostMEMPALACE_REMOTE_HOST is now passed after -- so a value starting with - can't be interpreted as ssh options (e.g. -oProxyCommand=... local code execution). Caught by Qodo review.
  • Remote command injection via the _BIN env varsMEMPALACE_REMOTE_BIN and MEMPALACE_REMOTE_MCP_BIN are validated against an allow-list regex (^[A-Za-z0-9_./-]+$) before being concatenated into the SSH remote command string, blocking the BIN='...; rm -rf ~ #' class. The MCP server config goes through the bin/mempalace-mcp-ssh.sh wrapper to apply the same defense the JSON config can't express directly.

Notes for review

  • New env vars use the MEMPALACE_REMOTE_* namespace; no clash with existing MEMPAL_* env vars used by the local plugin.
  • The author field on the new marketplace entry is messelink (mine). Happy to change to whatever attribution convention you prefer.
  • Drift watching: if upstream changes .claude-plugin/hooks/hooks.json (e.g. adds SessionStart), the remote variant should mirror the addition. Same protocol, same CLI signature. Adjacent: Consolidate hooks/mempal_*_hook.sh into thin wrappers delegating to mempalace hook run #1069's proposed consolidation of the legacy hooks/mempal_*_hook.sh scripts uses the same thin-wrapper-to-mempalace hook run pattern this PR follows.
  • Codex variant: not included here — same pattern would work for .codex-plugin-remote/ if there's interest.

messelink and others added 3 commits April 25, 2026 02:54
…ients

Adds a sibling plugin that mirrors the mempalace plugin's MCP tools and
auto-save hooks, but proxies them to a central host over SSH instead of
running locally. Lets a fleet of client machines share one canonical
palace without each running their own mempalace install.

Plugin layout matches the existing .claude-plugin/ structure:

  .claude-plugin-remote/
    plugin.json
    .mcp.json                          ssh ${HOST} mempalace-mcp
    hooks/hooks.json                   registers Stop + PreCompact
    hooks/mempal-stop-hook.sh          ssh-pipe stdin to remote `mempalace hook run`
    hooks/mempal-precompact-hook.sh    same for precompact

The marketplace.json `plugins` array gains a second entry so the existing
`MemPalace/mempalace` marketplace serves both plugins from the same repo.
Existing users are unaffected — `claude /plugin install mempalace@mempalace`
still installs only the local-install variant.

Single required env var: MEMPALACE_REMOTE_HOST (an alias from ~/.ssh/config
or a fully qualified hostname). README documents passwordless-key prereq,
ControlMaster recommendation, and the SSH non-interactive PATH gotcha.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Lets users point at full paths on the central host without symlinking
into /usr/local/bin or fixing the SSH non-interactive PATH. Both vars
are optional and default to bare 'mempalace' / 'mempalace-mcp' for
hosts where those are on PATH already.

Documented as the recommended fix for the PATH gotcha section.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Replace 'raindance' (my central host alias) with the generic 'palace-host'
  in the env var example and ControlMaster snippet.
- 'Strongly recommended: SSH ControlMaster' -> 'Optional' — sub-300ms per
  call without it on a real two-machine test, only worth setting up under
  heavy tool use.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@Qodo-Free-For-OSS

Copy link
Copy Markdown

Hi, MEMPALACE_REMOTE_HOST is passed as the first positional argument to ssh without an option terminator, so a value starting with "-" will be parsed as ssh options (e.g., ProxyCommand), enabling unintended local command execution under the plugin process. This affects both the MCP server spawn and the Stop/PreCompact hook invocations.

Severity: action required | Category: security

How to fix: Terminate ssh options with --

Agent prompt to fix - you can give this to your LLM of choice:

Issue description

MEMPALACE_REMOTE_HOST is passed to ssh as the destination without terminating option parsing. If it starts with -, OpenSSH will interpret it as options (e.g. -o ProxyCommand=...), which can lead to unexpected local command execution.

Issue Context

This affects both:

  • the MCP server spawn (plugin.json / .mcp.json), and
  • the Stop/PreCompact hook scripts.

Fix Focus Areas

  • .claude-plugin-remote/hooks/mempal-stop-hook.sh[1-4]
  • .claude-plugin-remote/hooks/mempal-precompact-hook.sh[1-4]
  • .claude-plugin-remote/plugin.json[10-15]
  • .claude-plugin-remote/.mcp.json[1-6]

Implementation notes

  • Update shell scripts to ssh -- "${MEMPALACE_REMOTE_HOST:?…}" ....
  • Update JSON args to include "--" before the host: "args": ["--", "${MEMPALACE_REMOTE_HOST}", ...].
  • (Optional hardening) Reject host values beginning with - explicitly before invoking ssh.

We noticed a couple of other issues in this PR as well - happy to share if helpful.


Spotted by Qodo code review - free for open-source projects.

Per Qodo review on MemPalace#1190: passing MEMPALACE_REMOTE_HOST as the first
positional argument to ssh without an option terminator means a value
starting with '-' would be parsed as ssh options (e.g.
-oProxyCommand=...), enabling local command execution.

Adds -- before the host in both the .mcp.json args array and both hook
scripts. Smoke test still returns {} cleanly.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@messelink

Copy link
Copy Markdown
Contributor Author

Thanks @qodo-ai-reviewer — good catch. Fixed in 3844bbe: added -- before the host in both .mcp.json (and the equivalent block in plugin.json) and both hook scripts.

Yes please do share the other issues you spotted.

Pre-existing concern, surfaced by the same Qodo review on MemPalace#1190 that
caught the host option-parsing issue: MEMPALACE_REMOTE_BIN and
MEMPALACE_REMOTE_MCP_BIN are concatenated into the SSH remote command
string, which the central host's shell then parses. A value like
'mempalace; touch /tmp/X #' lands as 'sh -c "mempalace; touch /tmp/X #
hook run ..."' on the remote — RCE on the central host as the SSH user.

Fix:

- Hook scripts validate HOST and BIN against ^[A-Za-z0-9_./@-]+$ and
  ^[A-Za-z0-9_./-]+$ before invoking ssh; fail loudly with a clear
  message if the env var contains anything outside that set.

- MCP server entry switches from raw 'ssh' + JSON args to a thin wrapper
  bin/mempalace-mcp-ssh.sh that does the same validation. Lets us apply
  defense in JSON config too without trying to do shell-quoting in JSON.
  Both .mcp.json and plugin.json's mcpServers block now point at
  ${CLAUDE_PLUGIN_ROOT}/bin/mempalace-mcp-ssh.sh.

The character classes cover all real paths and hostnames (incl. user@host
in HOST) and reject ;, &, |, $, (, ), <, >, backtick, quote, space, and
shell-glob metas. The '--' option terminator on ssh stays as belt-and-
suspenders against the host-as-option case.

Smoke test: legitimate values still work end-to-end (Stop hook returns
{} cleanly); 'BIN=...; touch /tmp/X #' is rejected before any ssh call.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@messelink

Copy link
Copy Markdown
Contributor Author

Found a related second issue while looking for more — the same env-var injection vector applies to the bin path arguments:

  • MEMPALACE_REMOTE_BIN (in the hook scripts) and MEMPALACE_REMOTE_MCP_BIN (in the MCP server config) are concatenated into the SSH remote command string, which the central host's shell parses. A value like mempalace; touch /tmp/X # becomes sh -c "mempalace; touch /tmp/X # hook run ..." on the remote — RCE on the central host as the SSH user.

Fixed in d6e728b:

  • Hook scripts validate HOST and BIN against allow-list regexes before invoking ssh, fail with a clear message otherwise.
  • MCP server entry now goes through a thin wrapper at bin/mempalace-mcp-ssh.sh that does the same validation, called via ${CLAUDE_PLUGIN_ROOT}/bin/... — lets us apply the same defense in the JSON config without trying to shell-quote inside JSON.
  • The -- option terminator stays as belt-and-suspenders against the host-as-option case from your first finding.

Smoke-tested both: legitimate values still produce {} from the Stop hook end-to-end; injection attempts are rejected before any ssh call.

If you have more findings still queued, please share — happy to keep iterating.

messelink and others added 2 commits April 25, 2026 08:32
Bumps both the plugin manifest and the marketplace entry so installed
clients pick up the security fixes (3844bbe + d6e728b) on the next
'/plugin marketplace update' + '/plugin update' cycle.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The bash wrapper around the MCP server and the bash hook scripts won't
run on bare Windows cmd/PowerShell. Document the requirement explicitly
since this plugin introduces the bash-MCP dependency the local mempalace
plugin doesn't have (it ships a pip-installed mempalace-mcp.exe directly).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…itecture

Empirically demonstrated 2026-05-18 against a ~100K-drawer palace: the
plugin's SSH-proxy pattern spawns a fresh mempalace.mcp_server process
on the central host per remote session, which combined with the
quarantine_stale_hnsw-on-make_client() behavior (PR MemPalace#1173) silently
invalidates other live MCP processes on every Claude Code cold-start
anywhere in the fleet. Subsequent writes from those stale processes
destroy on-disk HNSW segments (data_level0.bin zeroed,
index_metadata.pickle deleted) within seconds.

Adding SSH on top of a per-session-spawn MCP model doesn't help — it
gives more clients a way to add more concurrent MCP processes to the
race. The plugin architecture needs to be replaced with a single-
process gateway (palace-daemon by rboarescu is one such), not patched.

Filed upstream as MemPalace#1533 (post-swap stale destruction) and MemPalace#1545
(rebuild-time auto-quarantine destroying the recovery tool itself).
This README warning surfaces the architectural limitation before any
user reaches the install steps below.

PR MemPalace#1190 stays open as documentation of what doesn't work and why.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@messelink
messelink marked this pull request as draft May 18, 2026 14:38
@messelink

Copy link
Copy Markdown
Contributor Author

Self-flagging: this PR is now a draft, do not merge. The architectural premise no longer holds.

The README's new "DO NOT USE" callout (just pushed at 6a1767f) has the full story; the short version:

Empirically demonstrated 2026-05-18 against a ~100K-drawer palace under sustained multi-session use — this plugin's SSH-proxy pattern spawns a fresh mempalace.mcp_server on the central host per remote session, which combined with quarantine_stale_hnsw() firing on every make_client() cold-start (per #1173) silently invalidates other live MCP processes' in-memory state. The next write from any "stale" process zeroes data_level0.bin and deletes index_metadata.pickle on disk. No rebuild, no swap, no operator action required — just normal Claude Code use with multiple long-running sessions, and the destruction is inevitable given enough time.

Adding SSH on top of a per-session-spawn MCP model doesn't help — it gives more clients a way to add more concurrent MCP processes to the race. The architecture needs to be replaced (palace-daemon-style single-process gateway) rather than patched.

Related upstream issues: #1533 (post-swap stale-FD destruction) and #1545 (rebuild-time auto-quarantine destroying the recovery tool itself). Both were filed after this PR was opened; together they describe a structural problem that this plugin would only worsen.

Leaving the PR open as documentation of what doesn't work and why. Will close or fundamentally rework once upstream lands a single-writer architecture (the #1337 HttpChromaBackend direction is one shape that could work).

@messelink

Copy link
Copy Markdown
Contributor Author

Closing — I'm no longer running mempalace via SSH proxy. My own deployment migrated to a pgvector backend with TLS, which serves the same "fleet of clients sharing one canonical palace" use case more cleanly (no per-call SSH overhead, native concurrent writers, no special hook routing). Leaving the branch in place if anyone wants to revive the SSH-proxy approach for a chroma-only setup; I just don't have the motivation to keep iterating on it since my own pain point is solved differently now.

@messelink messelink closed this Jun 28, 2026
@messelink
messelink deleted the feat/remote-plugin branch June 29, 2026 09:48
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/cli CLI commands area/install pip/uv/pipx/plugin install and packaging enhancement New feature or request storage

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants