From a30774c96a600f57dd1615abbd5ad63e26d5c9b0 Mon Sep 17 00:00:00 2001 From: MXAntian Date: Mon, 3 Aug 2026 14:24:11 +0800 Subject: [PATCH] fix: bring back three behaviors that only ever existed downstream MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Found by rebasing a downstream runtime onto main and diffing what the runtime had that main does not — not by grepping for the markers that were supposed to delimit the private parts. The marker grep said the port was complete. It was not, three times over. 1. index.mjs — the --record-conversation CLI branch. The per-turn counterpart to scripts/transcript-sweep.mjs: a hook pipes one turn in on stdin as it happens, instead of the sweep catching it later. recordConversationAsync() is exported; only the branch that reaches it was missing, so callers fell through to usage and exited 1. Turns then stop entering the conversations table silently. This branch has been lost to a refactor twice now, and the first loss ran 17 days before anyone connected the ~150 daily engram_failed warnings to it. 2. mcp-server.mjs — session host binding check. A session's host is bound at creation and every write on it is stamped with that host. If a later request on the same session presents a token mapping to a different host, the binding and the evidence disagree — exactly the case channel-derived provenance exists to prevent. Reject under enforce; keep the binding but log loudly otherwise. The silent version is provenance drifting with no signal at all. 3. mcp-server.mjs — an explicitly empty .env.local value now clears the var. `MNEME_QUARANTINE_HOSTS=` exists to turn quarantine back OFF. Under "existing env wins" an inherited value survives that line: the file says off, the env says on, and writes keep landing in a table recall never reads. That rule is right for supplying a value and wrong for withdrawing one. 280 passed / 0 failed. --record-conversation verified end to end against a temp DB (row lands, non-ASCII content intact). Co-authored-by: 千夏 Co-Authored-By: Claude Opus 5 --- index.mjs | 35 +++++++++++++++++++++++++++++++++++ mcp-server.mjs | 29 +++++++++++++++++++++++++++-- 2 files changed, 62 insertions(+), 2 deletions(-) diff --git a/index.mjs b/index.mjs index 422d0b9..bc5a3ca 100644 --- a/index.mjs +++ b/index.mjs @@ -4048,6 +4048,39 @@ if (_isMain) { }) process.stdout.write(`stored compact summary: memory id ${id}\n`) + } else if (hasFlag('--record-conversation')) { + // The per-turn counterpart to scripts/transcript-sweep.mjs: a hook pipes one + // turn in on stdin as it happens, instead of the sweep catching it later. + // + // This branch has now been dropped by a refactor twice, and both times the + // symptom was the same: the CLI falls through to usage, exits 1, and turns + // silently stop entering the conversations table. The first loss ran 17 days + // before anyone connected the ~150 daily engram_failed warnings to it. + // + // hasFlag, not getFlag: callers pass the flag with no value, so getFlag + // would swallow the next token (--platform). + const chunks = [] + for await (const chunk of process.stdin) chunks.push(chunk) + const content = Buffer.concat(chunks).toString('utf-8') + if (!content || content.trim().length === 0) { + process.stderr.write('Error: --record-conversation requires content via stdin\n') + process.exit(1) + } + const id = await recordConversationAsync({ + platform: getFlag('--platform') || 'claude-code', + chatId: getFlag('--chat-id') || 'unknown', + messageId: getFlag('--message-id') || null, + fromId: getFlag('--from-id') || null, + fromName: getFlag('--from-name') || '', + role: getFlag('--role') || 'user', + content, + isReply: hasFlag('--is-reply'), + replyToId: getFlag('--reply-to-id') || null, + }) + // id === null means UNIQUE-dedup (already recorded) — still a success for + // the caller (exit 0), not a failure. + process.stdout.write(`recorded: ${id || 'dedup'}\n`) + } else if (getFlag('--compress') !== null) { const chatId = getFlag('--compress') const days = parseInt(getFlag('--days') || '30', 10) @@ -4083,6 +4116,8 @@ if (_isMain) { ' node index.mjs --context "query" Build injection context', ' node index.mjs --recall "query" Recall memory list', ' node index.mjs --recall "" --limit 20 List recent 20 memories', + ' node index.mjs --record-conversation Record a conversation turn (content via stdin)', + ' [--platform P] [--chat-id C] [--role user|assistant] [--from-name N] [--from-id I] [--is-reply]', ' node index.mjs --store "content" Manually store a memory', ' [--importance 1-10] [--category general|people|project|...]', ' [--type working|short_term|long_term|permanent]', diff --git a/mcp-server.mjs b/mcp-server.mjs index c3c0f5c..c1e68b4 100644 --- a/mcp-server.mjs +++ b/mcp-server.mjs @@ -59,7 +59,16 @@ if (existsSync(envPath)) { readFileSync(envPath, 'utf-8').split('\n').forEach(line => { const m = line.match(/^([A-Z_][A-Z0-9_]*)=(.*?)\r?$/) // Existing env wins — launcher-set values still override the file. - if (m && process.env[m[1]] === undefined) process.env[m[1]] = m[2].trim() + if (m && process.env[m[1]] === undefined) { + process.env[m[1]] = m[2].trim() + } else if (m && m[2].trim() === '') { + // ...except an explicitly empty value, which CLEARS the variable. Without + // this, `MNEME_QUARANTINE_HOSTS=` cannot turn quarantine back off once + // something upstream has set it — the file says off, the env says on, and + // writes keep landing in a table recall never reads. "Existing env wins" + // is right for supplying a value and wrong for withdrawing one. + delete process.env[m[1]] + } }) } @@ -604,7 +613,23 @@ if (useHttp) { const sessionId = req.headers['mcp-session-id'] let entry = sessionId ? sessions.get(sessionId) : null - if (entry) entry.lastUsed = Date.now() // 复用 session:刷新活跃时间 + if (entry) { + entry.lastUsed = Date.now() // 复用 session:刷新活跃时间 + // A session's host is bound at creation and every write on it is stamped + // with that host. If a later request on the same session presents a token + // mapping to a different host, the binding and the evidence disagree — + // exactly the case channel-derived provenance exists to prevent. Reject + // under enforce; keep the binding but say so loudly otherwise, because + // the silent version is provenance drifting with no signal at all. + if (auth.authed && entry.host !== auth.host) { + if (AUTH_MODE === 'enforce') { + res.writeHead(401, { 'Content-Type': 'text/plain' }) + res.end(`Unauthorized: session bound to host '${entry.host}', token maps to '${auth.host}'`) + return + } + console.error(`[mneme] host mismatch on session ${String(sessionId).slice(0, 8)}: bound=${entry.host}, token=${auth.host} (keeping binding)`) + } + } if (!entry) { // New session: open transport + connect a fresh server instance