@mnemom/mnemom 0.17.20 → 0.17.22
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.
- package/README.md +26 -0
- package/dist/commands/agent-export.d.ts +53 -0
- package/dist/commands/agent-export.js +127 -0
- package/dist/commands/agent-sessions.d.ts +15 -5
- package/dist/commands/agent-sessions.js +29 -5
- package/dist/commands/agent.d.ts +1 -1
- package/dist/commands/agent.js +21 -3
- package/dist/commands/validate.d.ts +1 -1
- package/dist/commands/validate.js +7 -5
- package/dist/index.d.ts +1 -0
- package/dist/index.js +20 -2
- package/dist/lib/agent-export.d.ts +133 -0
- package/dist/lib/agent-export.js +293 -0
- package/dist/lib/api.d.ts +53 -0
- package/dist/lib/api.js +17 -0
- package/dist/lib/claude-plugin.d.ts +7 -8
- package/dist/lib/claude-plugin.js +8 -9
- package/dist/lib/config.d.ts +13 -2
- package/dist/lib/config.js +22 -6
- package/dist/lib/first-run/governed.js +7 -4
- package/dist/lib/retired-env-guard.d.ts +7 -0
- package/dist/lib/retired-env-guard.js +46 -0
- package/dist/rc-proxy.mjs +83 -13
- package/dist/rc-state.mjs +257 -0
- package/package.json +5 -1
- package/plugins/.claude-plugin/marketplace.json +20 -0
- package/plugins/mnemom/.claude-plugin/plugin.json +47 -0
- package/plugins/mnemom/.mcp.json +12 -0
- package/plugins/mnemom/hooks/hooks.json +3 -0
- package/plugins/mnemom/hooks/register.tsx +1476 -0
- package/plugins/mnemom/skills/about-mnemom/SKILL.md +88 -0
- package/plugins/mnemom/types/index.d.ts +142 -0
package/dist/rc-proxy.mjs
CHANGED
|
@@ -14,9 +14,10 @@
|
|
|
14
14
|
// WHAT IT DOES
|
|
15
15
|
// * Listens on 127.0.0.1 (ephemeral port) as an HTTP CONNECT proxy.
|
|
16
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
|
|
18
|
-
//
|
|
19
|
-
// byte-for-byte (telemetry, Datadog,
|
|
17
|
+
// memory only) minted at startup into a private 0700 dir under ~/.mnemom/rc (removed on
|
|
18
|
+
// exit; see rc-state.mjs for why not the OS temp dir), and a 7-day leaf re-minted every 24 h
|
|
19
|
+
// and hot-swapped (see below). Every other host is tunnelled byte-for-byte (telemetry, Datadog,
|
|
20
|
+
// MCP servers…).
|
|
20
21
|
// * Inside api.anthropic.com, only `POST /v1/messages[?…]` is rewritten to the Mnemom
|
|
21
22
|
// gateway door: the real Anthropic key rides as `x-api-key` (API-key billing), plus
|
|
22
23
|
// `x-mnemom-agent`, `x-mnemom-conversation-id`, and optionally `x-mnemom-api-key`,
|
|
@@ -90,6 +91,7 @@ import path from "node:path";
|
|
|
90
91
|
import crypto from "node:crypto";
|
|
91
92
|
import { execFileSync } from "node:child_process";
|
|
92
93
|
import { TURN_HEADER, TurnOrigins, turnHeaderEnabled, turnHeaderFor } from "./turn-origin.mjs";
|
|
94
|
+
import { OWNER_FILE, ensureRcStateRoot, keepPrivateDir, sweepRcDirs } from "./rc-state.mjs";
|
|
93
95
|
|
|
94
96
|
const MITM_HOST = "api.anthropic.com";
|
|
95
97
|
const env = process.env;
|
|
@@ -125,7 +127,19 @@ const PORT = Number(env.MNEMOM_AGENT_RC_PORT || 0);
|
|
|
125
127
|
// transcript. Implicit goal mode only — a sealed contract does not infer a goal from the user.
|
|
126
128
|
const TURNS = GOAL_MODE && turnHeaderEnabled(env) ? new TurnOrigins() : null;
|
|
127
129
|
|
|
128
|
-
|
|
130
|
+
// If something deleted the log's dir, recreate it (once per line) rather than spill log lines onto
|
|
131
|
+
// the terminal Claude Code is drawing on (the launcher's stderr). True when the retry may go ahead.
|
|
132
|
+
const remakeLogDir = (e) => {
|
|
133
|
+
if (e.code !== "ENOENT") return false;
|
|
134
|
+
try {
|
|
135
|
+
fs.mkdirSync(path.dirname(LOGFILE), { recursive: true, mode: 0o700 });
|
|
136
|
+
return true;
|
|
137
|
+
} catch {
|
|
138
|
+
return false;
|
|
139
|
+
}
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
const log = (line, retry = true) => {
|
|
129
143
|
// One record per line: gateway header values and error bodies are spliced into log lines, so a
|
|
130
144
|
// CR/LF inside them must not fabricate extra records (log injection).
|
|
131
145
|
const s = `${new Date().toISOString()} ${String(line).replace(/[\r\n]+/g, " ")}\n`;
|
|
@@ -133,8 +147,9 @@ const log = (line) => {
|
|
|
133
147
|
try {
|
|
134
148
|
fs.appendFileSync(LOGFILE, s);
|
|
135
149
|
return;
|
|
136
|
-
} catch {
|
|
137
|
-
|
|
150
|
+
} catch (e) {
|
|
151
|
+
// The retry goes through the one append above: there is a single write sink for log lines.
|
|
152
|
+
if (retry && remakeLogDir(e)) return log(line, false);
|
|
138
153
|
}
|
|
139
154
|
}
|
|
140
155
|
process.stderr.write(s);
|
|
@@ -157,7 +172,7 @@ if (SUBSCRIPTION) {
|
|
|
157
172
|
} else if (!secrets.anthropic) die("the Anthropic key (stdin line 1) is empty");
|
|
158
173
|
|
|
159
174
|
// --- ephemeral CA + rotating leaf for api.anthropic.com ---------------------------------
|
|
160
|
-
// Private 0700 dir under
|
|
175
|
+
// Private 0700 dir under ~/.mnemom/rc (rc-state.mjs), removed on exit. Claude Code reads NODE_EXTRA_CA_CERTS
|
|
161
176
|
// once at process start, so the CA it trusts can never change mid-session: the CA is valid for
|
|
162
177
|
// CA_DAYS and its private key lives in memory only (read into a Buffer, then deleted from disk).
|
|
163
178
|
// The leaf is short-lived and re-minted from that key every LEAF_ROTATE_MS — and on demand when
|
|
@@ -166,17 +181,42 @@ if (SUBSCRIPTION) {
|
|
|
166
181
|
// startup broke every session older than 48 h with "SSL certificate has expired".) openssl only
|
|
167
182
|
// takes the CA key from a file, so each signing writes it 0600 into caDir and deletes it right
|
|
168
183
|
// after. Works with macOS's system LibreSSL (3.3+) and OpenSSL 1.1.1+ (`-addext`).
|
|
169
|
-
//
|
|
184
|
+
//
|
|
185
|
+
// Nothing may take the CA files mid-session. In the OS temp dir, macOS's dirhelper deleted
|
|
186
|
+
// ca.pem / leaf.ext from sessions older than ~3-4 days (files not accessed for 3 days; openssl
|
|
187
|
+
// reading them at each rotation does not move their atime), every later rotation failed, and
|
|
188
|
+
// Claude Code reported UNABLE_TO_VERIFY_LEAF_SIGNATURE until relaunch (2026-10-03). The dir now
|
|
189
|
+
// lives under ~/.mnemom/rc, which no cleaner touches (the OS temp dir only if home is not
|
|
190
|
+
// writable). Belt and braces: the CA PEM, the leaf.ext text and the owner pid live in memory too,
|
|
191
|
+
// and keepCaDir() — every CA_KEEPALIVE_MS and before every mint — recreates caDir if it is gone,
|
|
192
|
+
// restores any of the three that is missing or differs (ca.pem byte-identical at the same path:
|
|
193
|
+
// NODE_EXTRA_CA_CERTS points at it), and refreshes the atime/mtime of the dir and everything in
|
|
194
|
+
// it. At startup, sibling dirs left by dead sessions are swept (rc-state.mjs).
|
|
195
|
+
// MNEMOM_RC_LEAF_ROTATE_MS / MNEMOM_RC_LEAF_DAYS / MNEMOM_RC_CA_KEEPALIVE_MS exist for tests only.
|
|
170
196
|
const CA_DAYS = 397;
|
|
171
197
|
const LEAF_DAYS = Number(env.MNEMOM_RC_LEAF_DAYS || 7);
|
|
172
198
|
const LEAF_ROTATE_MS = Number(env.MNEMOM_RC_LEAF_ROTATE_MS || 24 * 60 * 60 * 1000);
|
|
173
199
|
const LEAF_MIN_LEFT_MS = 24 * 60 * 60 * 1000;
|
|
174
|
-
const
|
|
200
|
+
const CA_KEEPALIVE_MS = Number(env.MNEMOM_RC_CA_KEEPALIVE_MS || 6 * 60 * 60 * 1000);
|
|
201
|
+
let rcRoot;
|
|
202
|
+
try {
|
|
203
|
+
rcRoot = ensureRcStateRoot();
|
|
204
|
+
const swept = sweepRcDirs(rcRoot, { maxAgeMs: CA_DAYS * 24 * 60 * 60 * 1000 });
|
|
205
|
+
if (swept.length) log(`swept ${swept.length} dir(s) left by dead sessions under ${rcRoot}`);
|
|
206
|
+
} catch (e) {
|
|
207
|
+
rcRoot = os.tmpdir();
|
|
208
|
+
log(`cannot use ~/.mnemom/rc (${e.code || e.message}); CA dir falls back to ${rcRoot}`);
|
|
209
|
+
}
|
|
210
|
+
const caDir = fs.mkdtempSync(path.join(rcRoot, "mnemom-code-rc-ca-"));
|
|
175
211
|
fs.chmodSync(caDir, 0o700);
|
|
176
212
|
const p = (f) => path.join(caDir, f);
|
|
213
|
+
const OWNER = `${process.pid}\n`;
|
|
214
|
+
fs.writeFileSync(p(OWNER_FILE), OWNER, { mode: 0o600 });
|
|
177
215
|
let rotateTimer = null;
|
|
216
|
+
let keepAliveTimer = null;
|
|
178
217
|
const cleanup = () => {
|
|
179
218
|
clearInterval(rotateTimer); // a no-op while still null
|
|
219
|
+
clearInterval(keepAliveTimer);
|
|
180
220
|
try {
|
|
181
221
|
fs.rmSync(caDir, { recursive: true, force: true });
|
|
182
222
|
} catch {
|
|
@@ -190,6 +230,8 @@ const ossl = (...a) => execFileSync("openssl", a, { stdio: ["ignore", "ignore",
|
|
|
190
230
|
const osslErr = (e) => (e.stderr && e.stderr.length ? e.stderr.toString().trim() : e.message);
|
|
191
231
|
|
|
192
232
|
let caKey; // Buffer — never on disk outside mintLeaf()'s signing call
|
|
233
|
+
let caPem; // Buffer — the exact bytes of ca.pem, restored from here if the file goes missing
|
|
234
|
+
const LEAF_EXT = `subjectAltName=DNS:${MITM_HOST}\nbasicConstraints=CA:FALSE\nkeyUsage=digitalSignature,keyEncipherment\nextendedKeyUsage=serverAuth\n`;
|
|
193
235
|
try {
|
|
194
236
|
ossl(
|
|
195
237
|
"req",
|
|
@@ -214,17 +256,43 @@ try {
|
|
|
214
256
|
);
|
|
215
257
|
caKey = fs.readFileSync(p("ca.key"));
|
|
216
258
|
fs.rmSync(p("ca.key"));
|
|
217
|
-
fs.
|
|
218
|
-
|
|
219
|
-
`subjectAltName=DNS:${MITM_HOST}\nbasicConstraints=CA:FALSE\nkeyUsage=digitalSignature,keyEncipherment\nextendedKeyUsage=serverAuth\n`,
|
|
220
|
-
);
|
|
259
|
+
caPem = fs.readFileSync(p("ca.pem"));
|
|
260
|
+
fs.writeFileSync(p("leaf.ext"), LEAF_EXT);
|
|
221
261
|
} catch (e) {
|
|
222
262
|
fs.rmSync(p("ca.key"), { force: true });
|
|
223
263
|
die(`could not mint the ephemeral CA with openssl: ${osslErr(e)}`);
|
|
224
264
|
}
|
|
225
265
|
|
|
266
|
+
// Recreate caDir and its long-lived files from memory if something deleted them, and refresh
|
|
267
|
+
// every atime/mtime so the cleaner does not try again (see the block comment above).
|
|
268
|
+
// keepPrivateDir (rc-state.mjs) only ever reuses a dir that is ours: a real 0700 directory owned by
|
|
269
|
+
// this uid, checked on an fd opened without following a symlink. Anything else could hand Claude
|
|
270
|
+
// Code a CA someone else controls. Throws when the dir cannot be made safe (mintLeaf then fails
|
|
271
|
+
// and the current leaf stays); otherwise returns the names it restored.
|
|
272
|
+
const keepCaDir = () =>
|
|
273
|
+
keepPrivateDir(
|
|
274
|
+
caDir,
|
|
275
|
+
[
|
|
276
|
+
["ca.pem", caPem, 0o644],
|
|
277
|
+
["leaf.ext", Buffer.from(LEAF_EXT), 0o644],
|
|
278
|
+
[OWNER_FILE, Buffer.from(OWNER), 0o600],
|
|
279
|
+
],
|
|
280
|
+
{ logfile: LOGFILE },
|
|
281
|
+
);
|
|
282
|
+
|
|
283
|
+
function keepAlive(why) {
|
|
284
|
+
try {
|
|
285
|
+
const restored = keepCaDir();
|
|
286
|
+
if (restored.length) log(`ca keep-alive (${why}): restored ${restored.join(", ")}`);
|
|
287
|
+
} catch (e) {
|
|
288
|
+
log(`ca keep-alive FAILED (${why}): ${e.message}`);
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
226
292
|
// Mint a fresh leaf key + cert signed by the in-memory CA key. Returns { key, cert, notAfter }.
|
|
227
293
|
function mintLeaf() {
|
|
294
|
+
const restored = keepCaDir(); // a rotation must never fail on a deleted file
|
|
295
|
+
if (restored.length) log(`ca keep-alive (mint): restored ${restored.join(", ")}`);
|
|
228
296
|
ossl(
|
|
229
297
|
"req",
|
|
230
298
|
"-newkey",
|
|
@@ -563,6 +631,8 @@ function rotateLeaf(why) {
|
|
|
563
631
|
}
|
|
564
632
|
rotateTimer = setInterval(() => rotateLeaf("interval"), LEAF_ROTATE_MS);
|
|
565
633
|
rotateTimer.unref();
|
|
634
|
+
keepAliveTimer = setInterval(() => keepAlive("interval"), CA_KEEPALIVE_MS);
|
|
635
|
+
keepAliveTimer.unref();
|
|
566
636
|
|
|
567
637
|
const proxy = http.createServer((req, res) => handle(req, res, true));
|
|
568
638
|
proxy.on("connect", (req, sock, head) => {
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
// rc-state — where `mnemom agent --remote-control` keeps its per-session dirs, and the sweep that
|
|
2
|
+
// clears the ones a dead session left behind. Imported by rc-proxy.mjs; commands/agent.ts mirrors
|
|
3
|
+
// rcStateRoot() for the launcher's log dir (tsc emits no .mjs, so it cannot import this file).
|
|
4
|
+
//
|
|
5
|
+
// WHY NOT THE OS TEMP DIR
|
|
6
|
+
// The proxy's ephemeral CA must stay on disk for the whole session: Claude Code's
|
|
7
|
+
// NODE_EXTRA_CA_CERTS points at ca.pem, and every leaf rotation re-reads ca.pem and leaf.ext.
|
|
8
|
+
// OS temp dirs are cleaned behind a running process — macOS's dirhelper deletes $TMPDIR files
|
|
9
|
+
// not accessed for 3 days, and systemd-tmpfiles ages out /tmp on Linux — which broke every RC
|
|
10
|
+
// session older than ~3-4 days with UNABLE_TO_VERIFY_LEAF_SIGNATURE (2026-10-03). So the dirs
|
|
11
|
+
// live under the CLI's per-user state dir, ~/.mnemom (the convention auth.json, config.json and
|
|
12
|
+
// sessions.jsonl already follow on every platform), which nothing cleans. In exchange nothing
|
|
13
|
+
// cleans up after a crashed session either: each dir records its owner's pid in OWNER_FILE, and
|
|
14
|
+
// every proxy start sweeps the dirs whose owner is dead.
|
|
15
|
+
import crypto from "node:crypto";
|
|
16
|
+
import fs from "node:fs";
|
|
17
|
+
import os from "node:os";
|
|
18
|
+
import path from "node:path";
|
|
19
|
+
|
|
20
|
+
/** Every per-session dir under the root starts with one of these: the CA dir, the log dir. */
|
|
21
|
+
export const RC_DIR_PREFIXES = ["mnemom-code-rc-ca-", "mnemom-code-rc."];
|
|
22
|
+
/** The pid of the process that owns (and removes on exit) the dir it sits in. */
|
|
23
|
+
export const OWNER_FILE = "owner.pid";
|
|
24
|
+
|
|
25
|
+
/** ~/.mnemom/rc on every platform. */
|
|
26
|
+
export function rcStateRoot(home = os.homedir()) {
|
|
27
|
+
return path.join(home, ".mnemom", "rc");
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Create the root 0700 (and its parents) and return it. Throws when the home dir is not writable;
|
|
32
|
+
* the caller falls back to the OS temp dir, where the keep-alive in rc-proxy.mjs still applies.
|
|
33
|
+
*/
|
|
34
|
+
export function ensureRcStateRoot(root = rcStateRoot()) {
|
|
35
|
+
fs.mkdirSync(root, { recursive: true, mode: 0o700 });
|
|
36
|
+
fs.chmodSync(root, 0o700); // mkdir's mode is filtered through the umask, and skips an existing dir
|
|
37
|
+
return root;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Signal 0 probes without signalling. EPERM means it exists but is someone else's: alive. */
|
|
41
|
+
export function pidAlive(pid) {
|
|
42
|
+
try {
|
|
43
|
+
process.kill(pid, 0);
|
|
44
|
+
return true;
|
|
45
|
+
} catch (e) {
|
|
46
|
+
return e.code === "EPERM";
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Open flags. O_NOFOLLOW / O_DIRECTORY / O_NONBLOCK are POSIX-only; on Windows they are absent from
|
|
51
|
+
// fs.constants and count as 0 (no symlink race to close there: no POSIX modes, per-user profile).
|
|
52
|
+
const C = fs.constants;
|
|
53
|
+
const NOFOLLOW = C.O_NOFOLLOW || 0;
|
|
54
|
+
const DIRECTORY = C.O_DIRECTORY || 0;
|
|
55
|
+
const NONBLOCK = C.O_NONBLOCK || 0; // a FIFO planted under a known name must not block the open
|
|
56
|
+
|
|
57
|
+
/** This process's uid, or null where there is none (native Windows): no owner check there. */
|
|
58
|
+
export function currentUid(proc = process) {
|
|
59
|
+
return typeof proc.getuid === "function" ? proc.getuid() : null;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Read a regular file without following a symlink at its last component (O_NOFOLLOW), checking
|
|
64
|
+
* the type on the OPEN fd. Returns null when it is missing, a symlink, or not a regular file.
|
|
65
|
+
*/
|
|
66
|
+
export function readNoFollow(file, { fsImpl = fs } = {}) {
|
|
67
|
+
let fd;
|
|
68
|
+
try {
|
|
69
|
+
fd = fsImpl.openSync(file, C.O_RDONLY | NOFOLLOW | NONBLOCK);
|
|
70
|
+
} catch {
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
try {
|
|
74
|
+
return fsImpl.fstatSync(fd).isFile() ? fsImpl.readFileSync(fd) : null;
|
|
75
|
+
} finally {
|
|
76
|
+
fsImpl.closeSync(fd);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Set atime/mtime through an fd opened without following a symlink, so a planted link never moves
|
|
82
|
+
* its target's times. Returns false (and touches nothing) when the entry is missing or a symlink.
|
|
83
|
+
*/
|
|
84
|
+
export function touchNoFollow(file, now, { fsImpl = fs } = {}) {
|
|
85
|
+
let fd;
|
|
86
|
+
try {
|
|
87
|
+
fd = fsImpl.openSync(file, C.O_RDONLY | NOFOLLOW | NONBLOCK);
|
|
88
|
+
} catch {
|
|
89
|
+
return false;
|
|
90
|
+
}
|
|
91
|
+
try {
|
|
92
|
+
fsImpl.futimesSync(fd, now, now);
|
|
93
|
+
return true;
|
|
94
|
+
} finally {
|
|
95
|
+
fsImpl.closeSync(fd);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Write `bytes` to `dir/name` via a fresh O_EXCL|O_NOFOLLOW temp file, then rename it in. */
|
|
100
|
+
function writeAtomic(fsImpl, dir, name, bytes, mode) {
|
|
101
|
+
const tmp = path.join(
|
|
102
|
+
dir,
|
|
103
|
+
`.${name}.${process.pid}.${crypto.randomBytes(6).toString("hex")}.tmp`,
|
|
104
|
+
);
|
|
105
|
+
// O_EXCL: the temp name must not exist, so nothing planted there (a symlink included) is followed.
|
|
106
|
+
const fd = fsImpl.openSync(tmp, C.O_WRONLY | C.O_CREAT | C.O_EXCL | NOFOLLOW, mode);
|
|
107
|
+
try {
|
|
108
|
+
let off = 0;
|
|
109
|
+
while (off < bytes.length) off += fsImpl.writeSync(fd, bytes, off, bytes.length - off);
|
|
110
|
+
fsImpl.fchmodSync(fd, mode); // the open's mode is filtered through the umask
|
|
111
|
+
} finally {
|
|
112
|
+
fsImpl.closeSync(fd);
|
|
113
|
+
}
|
|
114
|
+
// Write-then-rename, so a reader (openssl, a starting Claude Code) never sees half a file.
|
|
115
|
+
fsImpl.renameSync(tmp, path.join(dir, name));
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const refuse = (dir) =>
|
|
119
|
+
new Error(`refusing to reuse ${dir}: not a private directory owned by this user`);
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Keep `dir` and its long-lived `files` ([name, bytes, mode] triples) present and fresh, against an
|
|
123
|
+
* OS cleaner that deletes what it has not seen accessed. Creates `dir` 0700 if it is gone, restores
|
|
124
|
+
* any file that is missing or differs (byte-identical, same path), and refreshes the atime/mtime of
|
|
125
|
+
* the dir, everything in it, and `logfile` (if any). Returns the names it restored ("dir" first if
|
|
126
|
+
* it had to recreate the dir).
|
|
127
|
+
*
|
|
128
|
+
* The dir decides what CA Claude Code trusts, so nothing here acts on a path it checked earlier:
|
|
129
|
+
* * the dir is opened ONCE without following a symlink (O_NOFOLLOW|O_DIRECTORY) and checked on
|
|
130
|
+
* that fd: a real directory, owned by this uid, with no group/other bits. A symlink, a file or
|
|
131
|
+
* a foreign/non-private dir at `dir` is refused (throws), and nothing is read or written.
|
|
132
|
+
* * every file read and every atime refresh opens the entry without following a symlink and
|
|
133
|
+
* checks the open fd; every write goes to a fresh O_EXCL|O_NOFOLLOW temp file and is renamed in.
|
|
134
|
+
* * Node has no openat(), so the per-file opens still resolve `dir` by path. Before returning, the
|
|
135
|
+
* path is checked to still name the directory the held fd was validated on (dev + inode); if
|
|
136
|
+
* anything swapped it in between, this throws. Who could swap it: the parent is ~/.mnemom/rc
|
|
137
|
+
* (0700, ours) or, in the fallback, the OS temp dir, whose sticky bit stops another user from
|
|
138
|
+
* replacing a dir we own; the fd check covers the case where it was deleted and re-planted.
|
|
139
|
+
*/
|
|
140
|
+
export function keepPrivateDir(
|
|
141
|
+
dir,
|
|
142
|
+
files,
|
|
143
|
+
{
|
|
144
|
+
logfile = "",
|
|
145
|
+
now = new Date(),
|
|
146
|
+
fsImpl = fs,
|
|
147
|
+
uid = currentUid(),
|
|
148
|
+
platform = process.platform,
|
|
149
|
+
} = {},
|
|
150
|
+
) {
|
|
151
|
+
const restored = [];
|
|
152
|
+
let created = false;
|
|
153
|
+
try {
|
|
154
|
+
fsImpl.mkdirSync(path.dirname(dir), { recursive: true, mode: 0o700 });
|
|
155
|
+
fsImpl.mkdirSync(dir, { mode: 0o700 });
|
|
156
|
+
created = true;
|
|
157
|
+
restored.push("dir");
|
|
158
|
+
} catch (e) {
|
|
159
|
+
if (e.code !== "EEXIST") throw e;
|
|
160
|
+
}
|
|
161
|
+
let dfd;
|
|
162
|
+
try {
|
|
163
|
+
dfd = fsImpl.openSync(dir, C.O_RDONLY | DIRECTORY | NOFOLLOW);
|
|
164
|
+
} catch (e) {
|
|
165
|
+
// ELOOP: a symlink; ENOTDIR: a file (O_DIRECTORY). Either way not a dir we made.
|
|
166
|
+
if (e.code === "ELOOP" || e.code === "ENOTDIR") throw refuse(dir);
|
|
167
|
+
throw e;
|
|
168
|
+
}
|
|
169
|
+
try {
|
|
170
|
+
const st = fsImpl.fstatSync(dfd);
|
|
171
|
+
if (!st.isDirectory() || (uid !== null && st.uid !== uid)) throw refuse(dir);
|
|
172
|
+
if (created)
|
|
173
|
+
fsImpl.fchmodSync(dfd, 0o700); // mkdir's mode is filtered through the umask
|
|
174
|
+
else if (platform !== "win32" && (st.mode & 0o077) !== 0) throw refuse(dir); // no POSIX modes there
|
|
175
|
+
for (const [name, bytes, mode] of files) {
|
|
176
|
+
const cur = readNoFollow(path.join(dir, name), { fsImpl });
|
|
177
|
+
if (cur && cur.equals(bytes)) continue;
|
|
178
|
+
writeAtomic(fsImpl, dir, name, bytes, mode);
|
|
179
|
+
restored.push(name);
|
|
180
|
+
}
|
|
181
|
+
for (const f of fsImpl.readdirSync(dir)) {
|
|
182
|
+
// false: removed under us (e.g. ca.key between sign and rm), or a symlink: nothing to keep alive.
|
|
183
|
+
touchNoFollow(path.join(dir, f), now, { fsImpl });
|
|
184
|
+
}
|
|
185
|
+
fsImpl.futimesSync(dfd, now, now);
|
|
186
|
+
const at = fsImpl.lstatSync(dir);
|
|
187
|
+
const held = fsImpl.fstatSync(dfd);
|
|
188
|
+
if (at.dev !== held.dev || at.ino !== held.ino)
|
|
189
|
+
throw new Error(`${dir} was replaced while it was being kept alive`);
|
|
190
|
+
} finally {
|
|
191
|
+
fsImpl.closeSync(dfd);
|
|
192
|
+
}
|
|
193
|
+
// The log may live in a temp dir (a user's override). false: not created yet, or gone — log()
|
|
194
|
+
// recreates it.
|
|
195
|
+
if (logfile) touchNoFollow(logfile, now, { fsImpl });
|
|
196
|
+
return restored;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function ownerPid(dir, fsImpl) {
|
|
200
|
+
const b = readNoFollow(path.join(dir, OWNER_FILE), { fsImpl });
|
|
201
|
+
const n = b === null ? NaN : Number(b.toString("utf8").trim());
|
|
202
|
+
return Number.isInteger(n) && n > 0 ? n : null;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** The name a swept dir is moved to before removal. It carries an RC prefix on purpose: if the
|
|
206
|
+
* sweep dies between the move and the removal, the next sweep finds it (its owner is dead). */
|
|
207
|
+
export const SWEEP_PREFIX = "mnemom-code-rc.swept-";
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Remove the per-session dirs under `root` that no live process owns: those whose recorded owner
|
|
211
|
+
* is dead, and those with no readable owner that are older than `maxAgeMs` (a dir is only
|
|
212
|
+
* ownerless for the instant between its mkdtemp and its owner write, or after a crash in that
|
|
213
|
+
* instant). A dir whose owner is alive is never touched, however old. Returns the removed names.
|
|
214
|
+
*
|
|
215
|
+
* Race-safe removal: a candidate is first RENAMED to a fresh name under `root` (rename moves the
|
|
216
|
+
* entry itself and never follows a symlink), and only what was moved is checked (a real directory,
|
|
217
|
+
* via lstat) and removed. Nothing can be swapped in between the check and the removal, because the
|
|
218
|
+
* fresh name was never visible to anyone else. Whatever the move took that is not a directory is
|
|
219
|
+
* moved back. A dir that vanishes or changes mid-sweep (its owner's own cleanup) is skipped.
|
|
220
|
+
*/
|
|
221
|
+
export function sweepRcDirs(
|
|
222
|
+
root,
|
|
223
|
+
{ maxAgeMs, now = Date.now(), isAlive = pidAlive, fsImpl = fs } = {},
|
|
224
|
+
) {
|
|
225
|
+
const removed = [];
|
|
226
|
+
let names;
|
|
227
|
+
try {
|
|
228
|
+
names = fsImpl.readdirSync(root);
|
|
229
|
+
} catch {
|
|
230
|
+
return removed;
|
|
231
|
+
}
|
|
232
|
+
for (const name of names) {
|
|
233
|
+
if (!RC_DIR_PREFIXES.some((pre) => name.startsWith(pre))) continue;
|
|
234
|
+
const dir = path.join(root, name);
|
|
235
|
+
try {
|
|
236
|
+
const st = fsImpl.lstatSync(dir);
|
|
237
|
+
if (!st.isDirectory()) continue;
|
|
238
|
+
const pid = ownerPid(dir, fsImpl);
|
|
239
|
+
if (pid !== null) {
|
|
240
|
+
if (pid === process.pid || isAlive(pid)) continue;
|
|
241
|
+
} else if (!(now - st.mtimeMs > maxAgeMs)) {
|
|
242
|
+
continue;
|
|
243
|
+
}
|
|
244
|
+
const moved = path.join(root, SWEEP_PREFIX + crypto.randomBytes(6).toString("hex"));
|
|
245
|
+
fsImpl.renameSync(dir, moved);
|
|
246
|
+
if (!fsImpl.lstatSync(moved).isDirectory()) {
|
|
247
|
+
fsImpl.renameSync(moved, dir); // swapped for a non-dir after the check: put it back
|
|
248
|
+
continue;
|
|
249
|
+
}
|
|
250
|
+
fsImpl.rmSync(moved, { recursive: true, force: true });
|
|
251
|
+
removed.push(name);
|
|
252
|
+
} catch {
|
|
253
|
+
/* raced with its owner's own cleanup: nothing to do */
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
return removed;
|
|
257
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mnemom/mnemom",
|
|
3
|
-
"version": "0.17.
|
|
3
|
+
"version": "0.17.22",
|
|
4
4
|
"description": "Transparent AI agent tracing",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -34,6 +34,10 @@
|
|
|
34
34
|
"files": [
|
|
35
35
|
"dist",
|
|
36
36
|
"!dist/__tests__",
|
|
37
|
+
"plugins",
|
|
38
|
+
"!plugins/**/*.test.tsx",
|
|
39
|
+
"!plugins/mnemom/README.md",
|
|
40
|
+
"!plugins/mnemom/tsconfig.json",
|
|
37
41
|
"README.md",
|
|
38
42
|
"LICENSE"
|
|
39
43
|
],
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "mnemom",
|
|
3
|
+
"owner": { "name": "Mnemom", "email": "hello@mnemom.ai", "url": "https://www.mnemom.ai" },
|
|
4
|
+
"metadata": {
|
|
5
|
+
"description": "Mnemom plugins for Claude Code: governance, Trust Ratings and the Mnemom gateway.",
|
|
6
|
+
"version": "0.1.0"
|
|
7
|
+
},
|
|
8
|
+
"plugins": [
|
|
9
|
+
{
|
|
10
|
+
"name": "mnemom",
|
|
11
|
+
"source": "./mnemom",
|
|
12
|
+
"description": "Mnemom for Claude Code: the governed-agent band, /mnemom commands, the gateway guard and Mnemom attribution for a session launched by `mnemom agent`.",
|
|
13
|
+
"version": "0.1.0",
|
|
14
|
+
"author": { "name": "Mnemom", "email": "hello@mnemom.ai" },
|
|
15
|
+
"homepage": "https://docs.mnemom.ai",
|
|
16
|
+
"category": "productivity",
|
|
17
|
+
"keywords": ["mnemom", "governance", "trust", "gateway", "agent"]
|
|
18
|
+
}
|
|
19
|
+
]
|
|
20
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "mnemom",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Mnemom for Claude Code: the governed-agent band, the goal (sealed contract or inferred, with guardrails and the gateway's verdict), /mnemom commands, the gateway guard and Mnemom attribution for a session launched by `mnemom agent`.",
|
|
5
|
+
"author": {
|
|
6
|
+
"name": "Mnemom",
|
|
7
|
+
"email": "hello@mnemom.ai",
|
|
8
|
+
"url": "https://www.mnemom.ai"
|
|
9
|
+
},
|
|
10
|
+
"homepage": "https://docs.mnemom.ai",
|
|
11
|
+
"repository": "https://github.com/mnemom/mnemom-platform",
|
|
12
|
+
"license": "Apache-2.0",
|
|
13
|
+
"keywords": ["mnemom", "governance", "trust", "gateway", "agent"],
|
|
14
|
+
"types": "./types/index.d.ts",
|
|
15
|
+
"userConfig": {
|
|
16
|
+
"showBand": {
|
|
17
|
+
"title": "Show the Mnemom band",
|
|
18
|
+
"type": "boolean",
|
|
19
|
+
"default": true,
|
|
20
|
+
"description": "Draw the Mnemom band above the prompt (agent, cell, goal and its verdict, Trust Rating). Hide it for the session with /mnemom hide."
|
|
21
|
+
},
|
|
22
|
+
"requireGovernance": {
|
|
23
|
+
"title": "Require a governed session",
|
|
24
|
+
"type": "boolean",
|
|
25
|
+
"default": false,
|
|
26
|
+
"description": "Refuse every tool call while this session is NOT routed through the Mnemom gateway (i.e. it was not launched by `mnemom agent`). Off: warn only."
|
|
27
|
+
},
|
|
28
|
+
"guardReroute": {
|
|
29
|
+
"title": "Guard the gateway routing",
|
|
30
|
+
"type": "boolean",
|
|
31
|
+
"default": true,
|
|
32
|
+
"description": "In a governed session, deny Bash commands that point ANTHROPIC_BASE_URL away from the Mnemom gateway or unset it."
|
|
33
|
+
},
|
|
34
|
+
"commitTrailer": {
|
|
35
|
+
"title": "Mnemom commit trailers",
|
|
36
|
+
"type": "boolean",
|
|
37
|
+
"default": true,
|
|
38
|
+
"description": "Append Mnemom-Agent / Mnemom-Conversation trailers to the commit attribution in a governed session."
|
|
39
|
+
},
|
|
40
|
+
"spinnerMark": {
|
|
41
|
+
"title": "Mark the spinner and turn line",
|
|
42
|
+
"type": "boolean",
|
|
43
|
+
"default": true,
|
|
44
|
+
"description": "Mark the working spinner and the turn-duration line with the Mnemom sign in a governed session."
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|