@mnemom/mnemom 0.17.2-next.3 → 0.17.2-next.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,331 @@
1
+ #!/usr/bin/env node
2
+ // mnemom-handoff — the stdio MCP server `mnemom agent` registers (behind the OFF-by-default
3
+ // `context_handoff` setting, MNE-7564) exposing exactly ONE tool: `handoff(note)`.
4
+ //
5
+ // WHY .mjs, NOT .ts
6
+ // This file must exist BYTE-IDENTICAL at the same relative path in both the source tree and
7
+ // the built `dist/` tree, exactly like `rc-proxy.mjs` next to it: `commands/agent.ts`
8
+ // resolves it relative to its OWN `import.meta.url`, which is the SOURCE path under `vitest`
9
+ // (dev/test — no `tsc` has run) and the DIST path once installed. A `tsc`-compiled `.ts` file
10
+ // only exists post-build, so it would be unresolvable in dev/test. Consequently this file is
11
+ // self-contained (only Node builtins + global `fetch`, no local imports) — see
12
+ // `scripts/copy-assets.mjs`, which copies it (and rc-proxy.mjs) verbatim into `dist/`.
13
+ //
14
+ // Deliberately hand-rolled rather than `@modelcontextprotocol/sdk`: MCP over stdio is
15
+ // newline-delimited JSON-RPC 2.0, small enough to hand-roll for one tool in a CLI that
16
+ // otherwise carries four small deps. Only `initialize`, `tools/list` and `tools/call` are
17
+ // implemented — the whole surface Claude Code's MCP client needs for a stdio tool.
18
+ //
19
+ // Config rides on env vars the launcher sets via the `--mcp-config` file's `env` block
20
+ // (`buildHandoffMcpConfig`, cli/src/lib/agent.ts) — never argv, so nothing here is visible to
21
+ // `ps`. The Anthropic key itself is never IN that file, for any launch shape: the launcher hands
22
+ // this process `MNEMOM_HANDOFF_ANTHROPIC_KEY_ENV_NAME` naming a var this process already
23
+ // inherits from its OWN parent env (every stdio MCP server inherits the full parent env —
24
+ // verified experimentally for MNE-7564), and this process reads the key from THAT var at
25
+ // request time instead. A terminal launch names `ANTHROPIC_CUSTOM_HEADERS` (parsed for its
26
+ // `x-api-key:` line); Remote Control names a dedicated `MNEMOM_HANDOFF_KEY` its launcher sets
27
+ // directly, since RC's child env strips every ANTHROPIC_* var (see `HandoffAnthropicKeySource`
28
+ // in cli/src/lib/agent.ts for the full rationale). This process never logs either var's name or
29
+ // value.
30
+
31
+ import { createInterface } from "node:readline";
32
+ import { pathToFileURL } from "node:url";
33
+
34
+ /** The gateway route this tool calls (gateway/src/context-handoff.ts, MNE-7563). */
35
+ export const HANDOFF_PATH = "/mnemom/context/handoff";
36
+
37
+ const TOOL_NAME = "handoff";
38
+
39
+ const TOOL_DESCRIPTION =
40
+ "Hand off to your successor context. Call this at a natural stopping point in your own " +
41
+ "work, OR as soon as you see a '[mnemom context warning]' line from the gateway telling you " +
42
+ "the fold budget is nearly spent — that line means the gateway will fold on its own soon, so " +
43
+ "calling `handoff` first lets YOU choose what your successor reads. A good note is short and " +
44
+ "covers: the goal, what is already done, the next concrete action, and any open questions or " +
45
+ "blockers. The note is stored byte-exact and rendered to your successor verbatim — it is not " +
46
+ "summarized, so write it as you want it read.";
47
+
48
+ const TOOL_INPUT_SCHEMA = {
49
+ type: "object",
50
+ properties: {
51
+ note: {
52
+ type: "string",
53
+ description:
54
+ "Goal, what's done, the next action, and open questions — byte-exact to your successor.",
55
+ },
56
+ },
57
+ required: ["note"],
58
+ additionalProperties: false,
59
+ };
60
+
61
+ /** Required env vars the launcher always sets when it registers this server. */
62
+ const REQUIRED_ENV = [
63
+ "MNEMOM_HANDOFF_GATEWAY_HOST",
64
+ "MNEMOM_HANDOFF_ANTHROPIC_KEY_ENV_NAME",
65
+ "MNEMOM_HANDOFF_AGENT",
66
+ "MNEMOM_HANDOFF_CONVERSATION_ID",
67
+ ];
68
+
69
+ /**
70
+ * Recover the Anthropic key from an env var NAME this process itself inherited from its parent
71
+ * (never a value written into the `--mcp-config` file). `ANTHROPIC_CUSTOM_HEADERS` is a
72
+ * multi-line header BLOCK (`buildCustomHeaders`, cli/src/lib/agent.ts) whose first line is
73
+ * `x-api-key: <key>` — extract just that. Any other name is treated as carrying the key value
74
+ * directly.
75
+ * @param {string} name
76
+ * @param {NodeJS.ProcessEnv} env
77
+ * @returns {string} the key, or "" if the named var is absent/unusable.
78
+ */
79
+ function resolveKeyFromEnvName(name, env) {
80
+ const value = env[name] ?? "";
81
+ if (name === "ANTHROPIC_CUSTOM_HEADERS") {
82
+ const m = value.match(/^x-api-key:\s*(.+)$/im);
83
+ return m ? m[1].trim() : "";
84
+ }
85
+ return value.trim();
86
+ }
87
+
88
+ /**
89
+ * Read the tool's config from env. Throws (message never contains the key value) when a
90
+ * required var is missing, or when the named key-source var resolves to no usable key.
91
+ * @param {NodeJS.ProcessEnv} env
92
+ * @returns {{gatewayHost: string, anthropicKey: string, agent: string, conversationId: string, sessionConfigB64?: string}}
93
+ */
94
+ export function configFromEnv(env) {
95
+ const missing = REQUIRED_ENV.filter((k) => !(env[k] ?? "").trim());
96
+ if (missing.length) {
97
+ throw new Error(`mnemom-handoff: missing required env var(s): ${missing.join(", ")}`);
98
+ }
99
+ const keyEnvName = env.MNEMOM_HANDOFF_ANTHROPIC_KEY_ENV_NAME.trim();
100
+ const anthropicKey = resolveKeyFromEnvName(keyEnvName, env);
101
+ if (!anthropicKey) {
102
+ throw new Error(
103
+ `mnemom-handoff: MNEMOM_HANDOFF_ANTHROPIC_KEY_ENV_NAME names '${keyEnvName}', but this ` +
104
+ "process's own inherited environment has no usable value for it.",
105
+ );
106
+ }
107
+ return {
108
+ gatewayHost: env.MNEMOM_HANDOFF_GATEWAY_HOST.trim(),
109
+ anthropicKey,
110
+ agent: env.MNEMOM_HANDOFF_AGENT.trim(),
111
+ conversationId: env.MNEMOM_HANDOFF_CONVERSATION_ID.trim(),
112
+ sessionConfigB64: env.MNEMOM_HANDOFF_SESSION_CONFIG?.trim() || undefined,
113
+ };
114
+ }
115
+
116
+ /**
117
+ * Build the fetch call. Exported so a test can assert the wire shape without mocking fetch.
118
+ * @param {{gatewayHost: string, anthropicKey: string, agent: string, conversationId: string, sessionConfigB64?: string}} cfg
119
+ * @param {string} note
120
+ */
121
+ export function buildHandoffRequest(cfg, note) {
122
+ const headers = {
123
+ "content-type": "application/json",
124
+ "x-api-key": cfg.anthropicKey,
125
+ "x-mnemom-agent": cfg.agent,
126
+ };
127
+ if (cfg.sessionConfigB64) headers["x-mnemom-session-config"] = cfg.sessionConfigB64;
128
+ return {
129
+ url: `${cfg.gatewayHost.replace(/\/+$/, "")}${HANDOFF_PATH}`,
130
+ init: {
131
+ method: "POST",
132
+ headers,
133
+ body: JSON.stringify({ conversation_id: cfg.conversationId, note }),
134
+ },
135
+ };
136
+ }
137
+
138
+ /** Pull `{code, message}` out of the gateway's canonical error envelope; tolerant of anything else. */
139
+ function extractGatewayError(body) {
140
+ if (!body || typeof body !== "object") return {};
141
+ const err = body.error;
142
+ if (!err || typeof err !== "object") return {};
143
+ return {
144
+ code: typeof err.code === "string" ? err.code : undefined,
145
+ message: typeof err.message === "string" ? err.message : undefined,
146
+ };
147
+ }
148
+
149
+ /**
150
+ * Call the route and render the result. NEVER throws: a network failure, a non-2xx status, or
151
+ * an unparseable body all come back as `{ok:false, message}` for the caller to surface as an
152
+ * MCP tool error — the tool must never crash the server or the session over a bad response.
153
+ * @param {{gatewayHost: string, anthropicKey: string, agent: string, conversationId: string, sessionConfigB64?: string}} cfg
154
+ * @param {string} note
155
+ * @param {typeof fetch} fetchImpl
156
+ */
157
+ export async function callHandoff(cfg, note, fetchImpl) {
158
+ const { url, init } = buildHandoffRequest(cfg, note);
159
+ let res;
160
+ try {
161
+ res = await fetchImpl(url, init);
162
+ } catch (err) {
163
+ return {
164
+ ok: false,
165
+ message: `could not reach the gateway: ${err instanceof Error ? err.message : String(err)}`,
166
+ };
167
+ }
168
+
169
+ const text = await res.text().catch(() => "");
170
+ let body;
171
+ try {
172
+ body = text ? JSON.parse(text) : undefined;
173
+ } catch {
174
+ body = undefined;
175
+ }
176
+
177
+ if (!res.ok) {
178
+ const { code, message } = extractGatewayError(body);
179
+ const detail = message ?? (text || `HTTP ${res.status}`);
180
+ return {
181
+ ok: false,
182
+ message: `handoff request failed (${res.status}${code ? ` ${code}` : ""}): ${detail}`,
183
+ };
184
+ }
185
+
186
+ const parsed = body ?? {};
187
+ const pending = parsed.pending === true;
188
+ const level = typeof parsed.level === "string" ? parsed.level : "none";
189
+ const generation = typeof parsed.generation === "number" ? parsed.generation : 0;
190
+ const line = pending
191
+ ? `handoff queued (level: ${level}, generation ${generation}) — the gateway will fold at your next request`
192
+ : `handoff acknowledged (level: ${level}, generation ${generation}) — no fold scheduled`;
193
+ return { ok: true, line };
194
+ }
195
+
196
+ class MethodNotFoundError extends Error {}
197
+
198
+ /** True for a JSON-RPC NOTIFICATION (no `id` key at all) — never gets a response. */
199
+ function isNotification(msg) {
200
+ return !("id" in msg);
201
+ }
202
+
203
+ function textContent(text) {
204
+ return { content: [{ type: "text", text }] };
205
+ }
206
+
207
+ function errorContent(message) {
208
+ return { content: [{ type: "text", text: message }], isError: true };
209
+ }
210
+
211
+ /**
212
+ * Run the server against injected streams (tests use in-memory ones; `main()` wires real
213
+ * stdio). Handles one message per line; a line that isn't valid JSON, or errors while handling
214
+ * a request, is reported back as a JSON-RPC error for that message's id and never crashes the
215
+ * loop — one bad message must not take the whole tool down.
216
+ * @param {{input: NodeJS.ReadableStream, output: NodeJS.WritableStream, config: object, fetchImpl?: typeof fetch}} opts
217
+ */
218
+ export function runHandoffServer(opts) {
219
+ const fetchImpl = opts.fetchImpl ?? ((url, init) => fetch(url, init));
220
+ const write = (msg) => {
221
+ opts.output.write(`${JSON.stringify(msg)}\n`);
222
+ };
223
+ const respond = (id, result) => {
224
+ if (id === undefined) return; // notification — no response
225
+ write({ jsonrpc: "2.0", id, result });
226
+ };
227
+ const respondError = (id, code, message) => {
228
+ if (id === undefined) return;
229
+ write({ jsonrpc: "2.0", id, error: { code, message } });
230
+ };
231
+
232
+ const rl = createInterface({ input: opts.input, terminal: false });
233
+
234
+ rl.on("line", (raw) => {
235
+ const line = raw.trim();
236
+ if (!line) return;
237
+ let msg;
238
+ try {
239
+ msg = JSON.parse(line);
240
+ } catch {
241
+ return; // not JSON-RPC we can respond to — drop it
242
+ }
243
+ const id = "id" in msg ? msg.id : undefined;
244
+ const method = typeof msg.method === "string" ? msg.method : "";
245
+
246
+ handle(method, msg.params, isNotification(msg))
247
+ .then((result) => respond(id, result))
248
+ .catch((err) => {
249
+ const code = err instanceof MethodNotFoundError ? -32601 : -32603;
250
+ respondError(id, code, err instanceof Error ? err.message : String(err));
251
+ });
252
+ });
253
+
254
+ async function handle(method, params, notification) {
255
+ switch (method) {
256
+ case "initialize":
257
+ return {
258
+ protocolVersion: "2024-11-05",
259
+ capabilities: { tools: {} },
260
+ serverInfo: { name: "mnemom-handoff", version: "1.0.0" },
261
+ };
262
+ case "notifications/initialized":
263
+ return undefined; // no-op; notification anyway (never responded to)
264
+ case "tools/list":
265
+ return {
266
+ tools: [
267
+ { name: TOOL_NAME, description: TOOL_DESCRIPTION, inputSchema: TOOL_INPUT_SCHEMA },
268
+ ],
269
+ };
270
+ case "tools/call":
271
+ return callTool(params);
272
+ case "ping":
273
+ return {};
274
+ default:
275
+ if (notification) return undefined;
276
+ throw new MethodNotFoundError(`unknown method '${method}'`);
277
+ }
278
+ }
279
+
280
+ async function callTool(params) {
281
+ const p = params ?? {};
282
+ if (p.name !== TOOL_NAME) {
283
+ return errorContent(`unknown tool '${String(p.name)}'`);
284
+ }
285
+ const args = p.arguments ?? {};
286
+ const note = args.note;
287
+ if (typeof note !== "string" || note.trim() === "") {
288
+ return errorContent("handoff requires a non-empty string `note` argument.");
289
+ }
290
+ const outcome = await callHandoff(opts.config, note, fetchImpl);
291
+ return outcome.ok ? textContent(outcome.line) : errorContent(outcome.message);
292
+ }
293
+
294
+ return {
295
+ close: () => rl.close(),
296
+ };
297
+ }
298
+
299
+ async function main() {
300
+ let config;
301
+ try {
302
+ config = configFromEnv(process.env);
303
+ } catch (err) {
304
+ process.stderr.write(`${err instanceof Error ? err.message : String(err)}\n`);
305
+ process.exit(1);
306
+ return;
307
+ }
308
+ runHandoffServer({ input: process.stdin, output: process.stdout, config });
309
+ }
310
+
311
+ /**
312
+ * Only auto-start when run directly (`node handoff-server.mjs`), never on import (tests).
313
+ * `argv1`/`moduleUrl` default to the real `process.argv[1]`/`import.meta.url` at the call site
314
+ * (main()'s own check, below) but are accepted as params — like `configFromEnv(env)` above —
315
+ * so a test can drive every branch (no entry, an unconvertible entry, a real match/mismatch)
316
+ * without touching the real process's argv.
317
+ * @param {string | undefined} [argv1]
318
+ * @param {string} [moduleUrl]
319
+ */
320
+ export function isMain(argv1 = process.argv[1], moduleUrl = import.meta.url) {
321
+ if (!argv1) return false;
322
+ try {
323
+ return pathToFileURL(argv1).href === moduleUrl;
324
+ } catch {
325
+ return false;
326
+ }
327
+ }
328
+
329
+ if (isMain()) {
330
+ void main();
331
+ }
package/dist/rc-proxy.mjs CHANGED
@@ -13,9 +13,10 @@
13
13
  //
14
14
  // WHAT IT DOES
15
15
  // * Listens on 127.0.0.1 (ephemeral port) as an HTTP CONNECT proxy.
16
- // * Terminates TLS for api.anthropic.com ONLY, with an ephemeral CA + leaf minted at
17
- // startup into a private 0700 tmpdir (CA key deleted right after signing, dir removed on
18
- // exit). Every other host is tunnelled byte-for-byte (telemetry, Datadog, MCP servers…).
16
+ // * Terminates TLS for api.anthropic.com ONLY, with an ephemeral CA (397 days, key held in
17
+ // memory only) minted at startup into a private 0700 tmpdir (removed on exit), and a 7-day
18
+ // leaf re-minted every 24 h and hot-swapped (see below). Every other host is tunnelled
19
+ // byte-for-byte (telemetry, Datadog, MCP servers…).
19
20
  // * Inside api.anthropic.com, only `POST /v1/messages[?…]` is rewritten to the Mnemom
20
21
  // gateway door: the real Anthropic key rides as `x-api-key` (API-key billing), plus
21
22
  // `x-mnemom-agent`, `x-mnemom-conversation-id`, and optionally `x-mnemom-api-key`,
@@ -79,6 +80,7 @@ import net from "node:net";
79
80
  import fs from "node:fs";
80
81
  import os from "node:os";
81
82
  import path from "node:path";
83
+ import crypto from "node:crypto";
82
84
  import { execFileSync } from "node:child_process";
83
85
 
84
86
  const MITM_HOST = "api.anthropic.com";
@@ -140,14 +142,27 @@ try {
140
142
  }
141
143
  if (!secrets.anthropic) die("the Anthropic key (stdin line 1) is empty");
142
144
 
143
- // --- ephemeral CA + leaf for api.anthropic.com ------------------------------------------
144
- // Private 0700 dir under the OS tmpdir; the CA key is deleted as soon as the leaf is signed
145
- // (only the leaf key is needed to serve sessions) and the whole dir goes on exit. Works with
146
- // macOS's system LibreSSL (3.3+) and OpenSSL 1.1.1+ (`-addext`).
145
+ // --- ephemeral CA + rotating leaf for api.anthropic.com ---------------------------------
146
+ // Private 0700 dir under the OS tmpdir, removed on exit. Claude Code reads NODE_EXTRA_CA_CERTS
147
+ // once at process start, so the CA it trusts can never change mid-session: the CA is valid for
148
+ // CA_DAYS and its private key lives in memory only (read into a Buffer, then deleted from disk).
149
+ // The leaf is short-lived and re-minted from that key every LEAF_ROTATE_MS — and on demand when
150
+ // it has under LEAF_MIN_LEFT_MS left — then hot-swapped with setSecureContext, so a running
151
+ // session picks it up on its next TLS handshake. (A 2-day CA + leaf with the CA key deleted at
152
+ // startup broke every session older than 48 h with "SSL certificate has expired".) openssl only
153
+ // takes the CA key from a file, so each signing writes it 0600 into caDir and deletes it right
154
+ // after. Works with macOS's system LibreSSL (3.3+) and OpenSSL 1.1.1+ (`-addext`).
155
+ // MNEMOM_RC_LEAF_ROTATE_MS / MNEMOM_RC_LEAF_DAYS exist for tests only.
156
+ const CA_DAYS = 397;
157
+ const LEAF_DAYS = Number(env.MNEMOM_RC_LEAF_DAYS || 7);
158
+ const LEAF_ROTATE_MS = Number(env.MNEMOM_RC_LEAF_ROTATE_MS || 24 * 60 * 60 * 1000);
159
+ const LEAF_MIN_LEFT_MS = 24 * 60 * 60 * 1000;
147
160
  const caDir = fs.mkdtempSync(path.join(os.tmpdir(), "mnemom-code-rc-ca-"));
148
161
  fs.chmodSync(caDir, 0o700);
149
162
  const p = (f) => path.join(caDir, f);
163
+ let rotateTimer = null;
150
164
  const cleanup = () => {
165
+ clearInterval(rotateTimer); // a no-op while still null
151
166
  try {
152
167
  fs.rmSync(caDir, { recursive: true, force: true });
153
168
  } catch {
@@ -156,8 +171,12 @@ const cleanup = () => {
156
171
  };
157
172
  process.on("exit", cleanup);
158
173
  for (const sig of ["SIGINT", "SIGTERM", "SIGHUP"]) process.on(sig, () => process.exit(0));
174
+ const ossl = (...a) => execFileSync("openssl", a, { stdio: ["ignore", "ignore", "pipe"] });
175
+ // openssl's own stderr when it has any, else the error itself (spawn / fs failures).
176
+ const osslErr = (e) => (e.stderr && e.stderr.length ? e.stderr.toString().trim() : e.message);
177
+
178
+ let caKey; // Buffer — never on disk outside mintLeaf()'s signing call
159
179
  try {
160
- const ossl = (...a) => execFileSync("openssl", a, { stdio: ["ignore", "ignore", "pipe"] });
161
180
  ossl(
162
181
  "req",
163
182
  "-x509",
@@ -171,7 +190,7 @@ try {
171
190
  "-out",
172
191
  p("ca.pem"),
173
192
  "-days",
174
- "2",
193
+ String(CA_DAYS),
175
194
  "-subj",
176
195
  "/CN=mnemom agent local CA (ephemeral)",
177
196
  "-addext",
@@ -179,6 +198,19 @@ try {
179
198
  "-addext",
180
199
  "keyUsage=critical,keyCertSign,cRLSign",
181
200
  );
201
+ caKey = fs.readFileSync(p("ca.key"));
202
+ fs.rmSync(p("ca.key"));
203
+ fs.writeFileSync(
204
+ p("leaf.ext"),
205
+ `subjectAltName=DNS:${MITM_HOST}\nbasicConstraints=CA:FALSE\nkeyUsage=digitalSignature,keyEncipherment\nextendedKeyUsage=serverAuth\n`,
206
+ );
207
+ } catch (e) {
208
+ fs.rmSync(p("ca.key"), { force: true });
209
+ die(`could not mint the ephemeral CA with openssl: ${osslErr(e)}`);
210
+ }
211
+
212
+ // Mint a fresh leaf key + cert signed by the in-memory CA key. Returns { key, cert, notAfter }.
213
+ function mintLeaf() {
182
214
  ossl(
183
215
  "req",
184
216
  "-newkey",
@@ -193,32 +225,41 @@ try {
193
225
  "-subj",
194
226
  `/CN=${MITM_HOST}`,
195
227
  );
196
- fs.writeFileSync(
197
- p("leaf.ext"),
198
- `subjectAltName=DNS:${MITM_HOST}\nbasicConstraints=CA:FALSE\nkeyUsage=digitalSignature,keyEncipherment\nextendedKeyUsage=serverAuth\n`,
199
- );
200
- ossl(
201
- "x509",
202
- "-req",
203
- "-in",
204
- p("leaf.csr"),
205
- "-CA",
206
- p("ca.pem"),
207
- "-CAkey",
208
- p("ca.key"),
209
- "-CAcreateserial",
210
- "-out",
211
- p("leaf.pem"),
212
- "-days",
213
- "2",
214
- "-extfile",
215
- p("leaf.ext"),
216
- );
217
- fs.rmSync(p("ca.key"));
228
+ try {
229
+ fs.writeFileSync(p("ca.key"), caKey, { mode: 0o600, flag: "wx" });
230
+ ossl(
231
+ "x509",
232
+ "-req",
233
+ "-in",
234
+ p("leaf.csr"),
235
+ "-CA",
236
+ p("ca.pem"),
237
+ "-CAkey",
238
+ p("ca.key"),
239
+ "-CAcreateserial",
240
+ "-out",
241
+ p("leaf.pem"),
242
+ "-days",
243
+ String(LEAF_DAYS),
244
+ "-extfile",
245
+ p("leaf.ext"),
246
+ );
247
+ } finally {
248
+ fs.rmSync(p("ca.key"), { force: true });
249
+ }
250
+ const cert = fs.readFileSync(p("leaf.pem"));
251
+ return {
252
+ key: fs.readFileSync(p("leaf.key")),
253
+ cert,
254
+ notAfter: Date.parse(new crypto.X509Certificate(cert).validTo),
255
+ };
256
+ }
257
+
258
+ let leaf;
259
+ try {
260
+ leaf = mintLeaf();
218
261
  } catch (e) {
219
- die(
220
- `could not mint the ephemeral CA with openssl: ${e.stderr ? e.stderr.toString() : e.message}`,
221
- );
262
+ die(`could not mint the ephemeral CA with openssl: ${osslErr(e)}`);
222
263
  }
223
264
 
224
265
  // --- request handling ---------------------------------------------------------------------
@@ -299,6 +340,23 @@ const THREAD_400 = Buffer.from(
299
340
  }),
300
341
  );
301
342
  let localSeq = 0;
343
+
344
+ // The Claude Code session ids seen on the /v1/messages leg (`x-claude-code-session-id`, not a
345
+ // secret): each NEW one is logged once as `cc-session=<id>`, so the server shape (many sessions in
346
+ // one proxy) gets a local conversation ↔ CC session record. Bounded; the id is clipped to a
347
+ // uuid-ish charset so a hostile header cannot forge log lines.
348
+ const CC_SESSION_SEEN_MAX = 256;
349
+ const ccSessionsSeen = new Set();
350
+ function noteCcSession(raw) {
351
+ const id = String(raw ?? "")
352
+ .replace(/[^A-Za-z0-9._:-]/g, "")
353
+ .slice(0, 64);
354
+ if (!id || ccSessionsSeen.has(id)) return;
355
+ if (ccSessionsSeen.size >= CC_SESSION_SEEN_MAX) ccSessionsSeen.clear();
356
+ ccSessionsSeen.add(id);
357
+ log(`cc-session=${id} conversation=${CONV}`);
358
+ }
359
+
302
360
  function rejectThread(res, tag, thread) {
303
361
  res.writeHead(400, {
304
362
  "content-type": "application/json",
@@ -344,6 +402,7 @@ function handle(req, res, viaPlain) {
344
402
  path: GATEWAY.pathname.replace(/\/$/, "") + urlPath,
345
403
  agent: gatewayAgent,
346
404
  };
405
+ noteCcSession(headers["x-claude-code-session-id"]);
347
406
  delete headers.authorization; // the OAuth bearer stays local
348
407
  headers["x-api-key"] = secrets.anthropic;
349
408
  headers["x-mnemom-agent"] = AGENT;
@@ -398,10 +457,12 @@ function handle(req, res, viaPlain) {
398
457
  }
399
458
  ur.pipe(res);
400
459
  const goalRev = ur.headers["x-mnemom-goal-revision"];
460
+ const goalThread = ur.headers["x-mnemom-goal-thread"];
401
461
  const steer = ur.headers["x-mnemom-goal-steer"];
402
462
  const ident = toGateway
403
463
  ? ` agent=${ur.headers["x-mnemom-agent"] || "-"} verdict=${ur.headers["x-mnemom-goal-verdict"] || "-"}` +
404
464
  (goalRev ? ` goal-rev=${goalRev}` : "") +
465
+ (goalThread ? ` goal-thread=${goalThread}` : "") +
405
466
  (steer ? ` steer=${steer}` : "")
406
467
  : "";
407
468
  ur.on("end", () => {
@@ -459,18 +520,33 @@ function handle(req, res, viaPlain) {
459
520
  }
460
521
  }
461
522
 
462
- const tlsServer = https.createServer(
463
- { key: fs.readFileSync(p("leaf.key")), cert: fs.readFileSync(p("leaf.pem")) },
464
- (req, res) => handle(req, res, false),
523
+ const tlsServer = https.createServer({ key: leaf.key, cert: leaf.cert }, (req, res) =>
524
+ handle(req, res, false),
465
525
  );
466
526
  tlsServer.on("tlsClientError", (e) => log(`tls client error: ${e.code || e.message}`));
467
527
 
528
+ // Re-mint the leaf and hot-swap it; established TLS connections keep the old one, every new
529
+ // handshake gets the new one. A failed re-mint keeps serving the current leaf.
530
+ function rotateLeaf(why) {
531
+ try {
532
+ leaf = mintLeaf();
533
+ tlsServer.setSecureContext({ key: leaf.key, cert: leaf.cert });
534
+ log(`leaf rotated (${why}) notAfter=${new Date(leaf.notAfter).toISOString()}`);
535
+ } catch (e) {
536
+ log(`leaf rotation FAILED (${why}), keeping the current leaf: ${osslErr(e)}`);
537
+ }
538
+ }
539
+ rotateTimer = setInterval(() => rotateLeaf("interval"), LEAF_ROTATE_MS);
540
+ rotateTimer.unref();
541
+
468
542
  const proxy = http.createServer((req, res) => handle(req, res, true));
469
543
  proxy.on("connect", (req, sock, head) => {
470
544
  const [host, portStr] = req.url.split(":");
471
545
  const port = Number(portStr || 443);
472
546
  sock.on("error", () => {});
473
547
  if (host === MITM_HOST && port === 443) {
548
+ // A timer can run late (laptop sleep): never hand out a leaf that is about to expire.
549
+ if (leaf.notAfter - Date.now() < LEAF_MIN_LEFT_MS) rotateLeaf("expiring");
474
550
  sock.write("HTTP/1.1 200 Connection Established\r\n\r\n");
475
551
  if (head && head.length) sock.unshift(head);
476
552
  tlsServer.emit("connection", sock);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mnemom/mnemom",
3
- "version": "0.17.2-next.3",
3
+ "version": "0.17.2-next.4",
4
4
  "description": "Transparent AI agent tracing",
5
5
  "type": "module",
6
6
  "bin": {