fix(shim): self-healing canonical path resolution via /run/ump-memory/

LONG-STANDING PROBLEM (now permanently fixed):
  The ump-recall-mcp.js shim spawned a 'npx ump memory' child process
  with UMP_DIR inherited from the parent gateway's env. Profile
  configs (config.yaml) and parent processes had stale UMP_DIR values
  from before the ump store migration, so the child wrote to an empty
  directory. Result: krystie's ump.remember calls silently dropped.

NEW PATTERN:
  1. ump-memory.service publishes its UMP_DIR/HTTP/STORE via
     /run/ump-memory/ump-{dir,port,store}.txt on every (re)start.
     This file is the canonical source of truth.

  2. On every ump child spawn, the shim:
     a. Reads /run/ump-memory/ump-{dir,port,store}.txt (THE source)
     b. Strips UMP_* / SIDECAR_URL from parent's env (so stale values
        from profile configs cannot leak through)
     c. Spawns with the canonical values
     d. Audits the spawned child's env via /proc/<pid>/environ and
        logs FATAL if anything other than the canonical UMP_DIR was
        inherited

This breaks the recurring silent-drop pattern regardless of:
  - which profile is launching the gateway (krystie, default, future)
  - whether env vars are stale or fresh
  - whether the parent process is managed by systemd or launched by hand
  - whether config.yaml is pinned to a stale path

The only way to get the wrong UMP_DIR after this patch is if
/run/ump-memory/ump-dir.txt itself is wrong — and that file is
regenerated by ump-memory.service on every (re)start, so the only way
to make it wrong is to corrupt it intentionally.

Companion: krystie-hermes-gateway.service deploy asset (next commit)
makes krystie's gateway systemd-managed so its shim lifetime matches
ump-memory.service's lifetime.
This commit is contained in:
Hermes Agent
2026-07-13 21:48:54 -07:00
parent 64a0f56453
commit 8e1a88f106
+64 -3
View File
@@ -116,17 +116,64 @@ async function umpCapable() {
let umpChild = null;
let umpChildInitPromise = null;
// Resolve the canonical UMP_DIR published by ump-memory.service via
// /run/ump-memory/ump-dir.txt. This is the ONLY source of truth that
// matters. We trust it before env, before config, before fallbacks.
//
// Why: every previous fix relied on a profile config or in-process env
// being correct. They drift. Disk-config inherits wrong values on
// every restart. The systemd unit publishes the canonical path on every
// (re)start, so reading it here makes the child process self-healing.
import fs from "node:fs";
const CANONICAL_UMP_DIR_FILE = "/run/ump-memory/ump-dir.txt";
const CANONICAL_UMP_PORT_FILE = "/run/ump-memory/ump-port.txt";
const CANONICAL_UMP_STORE_FILE = "/run/ump-memory/ump-store.txt";
function readCanonical(key, fallback) {
try {
return fs.readFileSync(key, "utf8").trim() || fallback;
} catch {
return fallback;
}
}
const CANONICAL_UMP_DIR = readCanonical(CANONICAL_UMP_DIR_FILE, "/root/.hermes/state/ump-local");
const CANONICAL_UMP_PORT = readCanonical(CANONICAL_UMP_PORT_FILE, "4317");
const CANONICAL_UMP_STORE = readCanonical(CANONICAL_UMP_STORE_FILE, "json");
function spawnUmpChild() {
if (umpChild && !umpChild.killed) return umpChild;
LOG("spawning canonical UMP subprocess");
LOG(`canonical path from ${CANONICAL_UMP_DIR_FILE}: ${CANONICAL_UMP_DIR}`);
umpChild = spawn("npx", ["-y", "-p", "@universalmemoryprotocol/core", "ump", "memory"], {
stdio: ["pipe", "pipe", "inherit"],
env: {
...process.env,
UMP_DIR: process.env.UMP_DIR || "/root/.hermes/state/ump-local",
UMP_STORE: process.env.UMP_STORE || "json",
// DISCARD the parent's env for the UMP_* trio: anything the parent
// (krystie gateway, default gateway, etc.) might have stale is
// overridden with the canonical published values. This is the
// mechanism that breaks the recurring silent-drop pattern.
...omitUmpEnv(process.env),
UMP_DIR: CANONICAL_UMP_DIR,
UMP_HTTP: CANONICAL_UMP_PORT,
UMP_STORE: CANONICAL_UMP_STORE,
},
});
// Self-heal: verify the spawned child actually inherited the canonical
// UMP_DIR. If somehow the env was filtered or the path was changed,
// fail loudly so the operator sees it (better than silent drop).
setImmediate(() => {
try {
const childEnv = fs.readFileSync(`/proc/${umpChild.pid}/environ`, "utf8")
.split("\0")
.find((s) => s.startsWith("UMP_DIR="));
if (!childEnv || childEnv.split("=")[1] !== CANONICAL_UMP_DIR) {
LOG(`FATAL: spawned child inherited wrong UMP_DIR: ${childEnv} (wanted ${CANONICAL_UMP_DIR})`);
}
} catch (e) {
LOG(`could not audit child env: ${e.message}`);
}
});
umpChild.on("exit", (code) => {
LOG(`UMP subprocess exited code=${code}`);
umpChild = null;
@@ -135,6 +182,20 @@ function spawnUmpChild() {
return umpChild;
}
function omitUmpEnv(env) {
// Strip UMP_DIR / UMP_HTTP / UMP_STORE from a spread'd env so the
// canonical values always take precedence. We only return the safe
// subset of process.env for the child.
const out = {};
for (const [k, v] of Object.entries(env)) {
if (!["UMP_DIR", "UMP_HTTP", "UMP_STORE", "UMP_NAMESPACE", "SIDECAR_URL"].includes(k)) {
out[k] = v;
}
}
return out;
}
let nextReqId = 1;
const pendingRequests = new Map();
let umpServerInfo = null; // populated by initialize handshake