@mnemom/mnemom 0.16.2 → 0.17.0-next.0

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.
Files changed (48) hide show
  1. package/README.md +1 -0
  2. package/dist/commands/agents.d.ts +14 -0
  3. package/dist/commands/agents.js +100 -2
  4. package/dist/commands/card.d.ts +43 -0
  5. package/dist/commands/card.js +153 -102
  6. package/dist/commands/code-config.d.ts +17 -0
  7. package/dist/commands/code-config.js +147 -0
  8. package/dist/commands/code-doctor.d.ts +18 -0
  9. package/dist/commands/code-doctor.js +138 -0
  10. package/dist/commands/code-setup.d.ts +97 -0
  11. package/dist/commands/code-setup.js +330 -0
  12. package/dist/commands/code.d.ts +133 -0
  13. package/dist/commands/code.js +661 -0
  14. package/dist/commands/logs.js +11 -1
  15. package/dist/commands/onboard.d.ts +59 -0
  16. package/dist/commands/onboard.js +395 -0
  17. package/dist/commands/org.d.ts +13 -0
  18. package/dist/commands/org.js +63 -2
  19. package/dist/commands/protection.d.ts +10 -0
  20. package/dist/commands/protection.js +109 -0
  21. package/dist/commands/status.js +5 -0
  22. package/dist/commands/try-me.js +9 -0
  23. package/dist/commands/usage.d.ts +35 -0
  24. package/dist/commands/usage.js +265 -0
  25. package/dist/commands/wrap.d.ts +28 -0
  26. package/dist/commands/wrap.js +331 -0
  27. package/dist/index.js +315 -7
  28. package/dist/lib/agent-config.d.ts +27 -0
  29. package/dist/lib/agent-config.js +86 -0
  30. package/dist/lib/api.d.ts +139 -1
  31. package/dist/lib/api.js +132 -183
  32. package/dist/lib/cli-config.d.ts +33 -0
  33. package/dist/lib/cli-config.js +70 -0
  34. package/dist/lib/code-config.d.ts +78 -0
  35. package/dist/lib/code-config.js +281 -0
  36. package/dist/lib/code.d.ts +154 -0
  37. package/dist/lib/code.js +252 -0
  38. package/dist/lib/config.d.ts +10 -0
  39. package/dist/lib/config.js +39 -3
  40. package/dist/lib/keyed-identity.d.ts +35 -0
  41. package/dist/lib/keyed-identity.js +363 -0
  42. package/dist/lib/protection-drift.d.ts +117 -0
  43. package/dist/lib/protection-drift.js +180 -0
  44. package/dist/lib/skills.js +25 -12
  45. package/dist/lib/version-gate.d.ts +37 -0
  46. package/dist/lib/version-gate.js +84 -0
  47. package/dist/rc-proxy.mjs +341 -0
  48. package/package.json +9 -7
@@ -42,23 +42,36 @@ export const SKILLS = [
42
42
  },
43
43
  {
44
44
  name: "onboard",
45
- status: "planned",
46
- ref: "MNE-933",
47
- summary: "Self-onboard the calling agent end-to-end (scan claim declare badge).",
48
- usage: "mnemom onboard",
45
+ status: "available",
46
+ summary: "Self-onboard the calling agent end-to-end (scan → claim → declare → rating → badge).",
47
+ usage: "mnemom onboard [--agent <id>] [--key <key>] [--hash-proof <hex>] [--yes] [--json] [--no-open]",
49
48
  description: "Runs the sovereignty path for the calling agent itself: scan its trust posture, " +
50
- "claim its identity, declare an alignment card, and earn a verifiable Trust Rating — " +
51
- "one command, no manifest. Planned (MNE-933).",
49
+ "claim its identity (as its own device-grant principal), declare a starter alignment " +
50
+ "card, and surface a Trust Rating + public badge URL — one command, no manifest. " +
51
+ "The Trust Rating is provisional until the observer pipeline generates traces (the " +
52
+ "signed rating + rendered badge are computed server-side, post-hoc). Idempotent: " +
53
+ "re-running skips an already-claimed identity and replays an unchanged card.",
54
+ examples: [
55
+ "npx @mnemom/mnemom@latest onboard",
56
+ "mnemom onboard --json",
57
+ "mnemom onboard --agent mnm-... --key <agent-api-key>",
58
+ ],
52
59
  },
53
60
  {
54
61
  name: "wrap",
55
- status: "planned",
56
- ref: "MNE-935",
62
+ status: "available",
57
63
  summary: "Instrument an existing production agent through the Mnemom gateway.",
58
- usage: "mnemom wrap",
59
- description: "Points an existing agent's provider calls at the Mnemom gateway, claims/births its " +
60
- "identity, and seeds starter alignment + protection cards bring-your-own-agent " +
61
- "onboarding. Planned (MNE-935).",
64
+ usage: "mnemom wrap [--provider <anthropic|openai|gemini>] [--framework <python|node>] " +
65
+ "[--name <name>] [--provider-key <key>] [--yes] [--json]",
66
+ description: "Points an existing agent's provider calls at the Mnemom gateway " +
67
+ "(`gateway.mnemom.ai/<provider>` + `x-mnemom-agent` header), births its identity, " +
68
+ "and seeds starter alignment + protection cards — bring-your-own-agent onboarding. " +
69
+ "Emits a drop-in code snippet for your provider SDK and next-step claim instructions.",
70
+ examples: [
71
+ "npx @mnemom/mnemom@latest wrap",
72
+ "mnemom wrap --provider anthropic --framework python --name my-agent",
73
+ "mnemom wrap --json",
74
+ ],
62
75
  },
63
76
  ];
64
77
  /** All registered skills, in registry order. */
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Minimum-CLI-version enforcement.
3
+ *
4
+ * The Mnemom API advertises the lowest fully-supported CLI version on every
5
+ * response via the `X-Mnemom-Min-CLI` header (mnemom-api MIN_CLI_VERSION). This
6
+ * module reads that header off any API response and, if THIS CLI is below the
7
+ * floor, prints a loud upgrade instruction and hard-exits.
8
+ *
9
+ * Why hard-fail: an outdated CLI can silently drop request fields a newer server
10
+ * expects and produce a wrong-but-successful result with no error. The canonical
11
+ * case that motivated this: a CLI predating claim-to-org (ADR-062) accepted
12
+ * `--org` but never sent `org_id`, so `agents claim … --org <slug>` cheerfully
13
+ * landed the agent in the caller's personal org. Failing loudly beats a silent
14
+ * wrong outcome.
15
+ */
16
+ /**
17
+ * Compare dotted numeric versions. Returns true iff `current` is strictly below
18
+ * `floor`. Prerelease/build suffixes (after `-` or `+`) are ignored — the floor
19
+ * is expressed as a plain release, and a prerelease of that release is treated
20
+ * as the release for gating purposes (we don't block `-rc` builds of a good ver).
21
+ */
22
+ export declare function isBelowVersion(current: string, floor: string): boolean;
23
+ /**
24
+ * Enforce the server-advertised minimum CLI version against `response`.
25
+ * No-op when the header is absent (older server, or a non-API response) or when
26
+ * this CLI is at/above the floor. Otherwise writes a clear message to stderr and
27
+ * exits with code 1.
28
+ *
29
+ * @param exit injectable for tests; defaults to process.exit
30
+ * @param write injectable for tests; defaults to process.stderr.write
31
+ */
32
+ export declare function enforceMinCliVersion(response: Pick<Response, "headers">, exit?: (code: number) => never, write?: (chunk: string) => void): void;
33
+ /**
34
+ * Drop-in wrapper around global fetch for Mnemom API calls: issues the request,
35
+ * enforces the advertised minimum CLI version on the response, then returns it.
36
+ */
37
+ export declare function mnemomFetch(input: string | URL, init?: RequestInit): Promise<Response>;
@@ -0,0 +1,84 @@
1
+ /**
2
+ * Minimum-CLI-version enforcement.
3
+ *
4
+ * The Mnemom API advertises the lowest fully-supported CLI version on every
5
+ * response via the `X-Mnemom-Min-CLI` header (mnemom-api MIN_CLI_VERSION). This
6
+ * module reads that header off any API response and, if THIS CLI is below the
7
+ * floor, prints a loud upgrade instruction and hard-exits.
8
+ *
9
+ * Why hard-fail: an outdated CLI can silently drop request fields a newer server
10
+ * expects and produce a wrong-but-successful result with no error. The canonical
11
+ * case that motivated this: a CLI predating claim-to-org (ADR-062) accepted
12
+ * `--org` but never sent `org_id`, so `agents claim … --org <slug>` cheerfully
13
+ * landed the agent in the caller's personal org. Failing loudly beats a silent
14
+ * wrong outcome.
15
+ */
16
+ import { CLI_VERSION } from "../version.js";
17
+ const MIN_CLI_HEADER = "x-mnemom-min-cli";
18
+ /**
19
+ * Compare dotted numeric versions. Returns true iff `current` is strictly below
20
+ * `floor`. Prerelease/build suffixes (after `-` or `+`) are ignored — the floor
21
+ * is expressed as a plain release, and a prerelease of that release is treated
22
+ * as the release for gating purposes (we don't block `-rc` builds of a good ver).
23
+ */
24
+ export function isBelowVersion(current, floor) {
25
+ const parse = (v) => v
26
+ .trim()
27
+ .split(/[-+]/)[0]
28
+ .split(".")
29
+ .map((n) => {
30
+ const parsed = Number.parseInt(n, 10);
31
+ return Number.isNaN(parsed) ? 0 : parsed;
32
+ });
33
+ const c = parse(current);
34
+ const f = parse(floor);
35
+ const len = Math.max(c.length, f.length);
36
+ for (let i = 0; i < len; i++) {
37
+ const cv = c[i] ?? 0;
38
+ const fv = f[i] ?? 0;
39
+ if (cv < fv)
40
+ return true;
41
+ if (cv > fv)
42
+ return false;
43
+ }
44
+ return false; // equal → supported
45
+ }
46
+ // Guard so a command issuing several requests exits exactly once.
47
+ let exited = false;
48
+ /**
49
+ * Enforce the server-advertised minimum CLI version against `response`.
50
+ * No-op when the header is absent (older server, or a non-API response) or when
51
+ * this CLI is at/above the floor. Otherwise writes a clear message to stderr and
52
+ * exits with code 1.
53
+ *
54
+ * @param exit injectable for tests; defaults to process.exit
55
+ * @param write injectable for tests; defaults to process.stderr.write
56
+ */
57
+ export function enforceMinCliVersion(response, exit = process.exit, write = (chunk) => void process.stderr.write(chunk)) {
58
+ // Defensive: some callers/tests provide a Response-like object without a real
59
+ // Headers instance. Treat a missing/incompatible headers bag as "no floor".
60
+ const headers = response?.headers;
61
+ const floor = typeof headers?.get === "function" ? headers.get(MIN_CLI_HEADER) : null;
62
+ if (!floor)
63
+ return;
64
+ if (!isBelowVersion(CLI_VERSION, floor))
65
+ return;
66
+ if (exited)
67
+ return;
68
+ exited = true;
69
+ write(`\n✖ Your Mnemom CLI (v${CLI_VERSION}) is too old for this server (minimum v${floor}).\n` +
70
+ ` Old CLIs can silently drop newer request fields — e.g. \`--org\` on \`mnemom agents claim\`.\n\n` +
71
+ ` Upgrade: npm i -g @mnemom/mnemom@latest\n\n`);
72
+ exit(1);
73
+ }
74
+ /**
75
+ * Drop-in wrapper around global fetch for Mnemom API calls: issues the request,
76
+ * enforces the advertised minimum CLI version on the response, then returns it.
77
+ */
78
+ export async function mnemomFetch(input, init) {
79
+ // Preserve the exact call shape: fetch(input) when no init, so callers and
80
+ // tests that assert a single-arg fetch stay valid.
81
+ const response = init === undefined ? await fetch(input) : await fetch(input, init);
82
+ enforceMinCliVersion(response);
83
+ return response;
84
+ }
@@ -0,0 +1,341 @@
1
+ #!/usr/bin/env node
2
+ // mnemom-code-rc-proxy — the local proxy behind ``mnemom code … --remote-control``.
3
+ //
4
+ // WHY THIS EXISTS
5
+ // Claude Code's `remote-control` subcommand (drive a local session from claude.ai / the
6
+ // mobile app) is gated: it refuses to start unless the CLI believes it talks to
7
+ // api.anthropic.com with a claude.ai (OAuth) login — no ANTHROPIC_BASE_URL, no
8
+ // ANTHROPIC_API_KEY / ANTHROPIC_AUTH_TOKEN, no ANTHROPIC_UNIX_SOCKET. Every knob the
9
+ // normal `mnemom code` launch uses to reach the Mnemom gateway trips that gate.
10
+ // HTTPS_PROXY + NODE_EXTRA_CA_CERTS are honoured and NOT gated (measured 2026-09-13,
11
+ // Claude Code 2.1.269). So `--remote-control` runs Claude Code untouched — OAuth login,
12
+ // first-party base URL — and puts this proxy in front of it.
13
+ //
14
+ // WHAT IT DOES
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…).
19
+ // * Inside api.anthropic.com, only `POST /v1/messages[?…]` is rewritten to the Mnemom
20
+ // gateway door: the real Anthropic key rides as `x-api-key` (API-key billing), plus
21
+ // `x-mnemom-agent`, `x-mnemom-conversation-id`, and optionally `x-mnemom-api-key`,
22
+ // `x-mnemom-provider`, `x-mnemom-egress-key`, `x-mnemom-contract` — the exact header set
23
+ // the non-RC launch sends via ANTHROPIC_CUSTOM_HEADERS. The OAuth `authorization` bearer
24
+ // is STRIPPED on that leg (it never leaves the machine) and `oauth-*` tokens are removed
25
+ // from `anthropic-beta`. Everything else under api.anthropic.com (OAuth refresh, the
26
+ // Remote Control bridge `/v1/code/sessions/*`, `/v1/environments/*/work/poll`, event
27
+ // logging) passes through to the real host untouched.
28
+ // * Serves BOTH request forms: CONNECT tunnels and absolute-form plaintext requests to the
29
+ // proxy port. Remote Control registration and the environment work-poll loop use the
30
+ // latter for https URLs; a CONNECT-only proxy answers 405 and registration fails.
31
+ // * Body handling on the gateway leg (see the mnemom code docs (Remote Control)):
32
+ // - deletes every `cache_control.ttl` — Claude Code on claude.ai auth marks blocks
33
+ // `ttl:"1h"`; the gateway prepends its own 5m-cached system block and Anthropic
34
+ // rejects a 1h block that follows a 5m one (400). Forwarded bodies only.
35
+ // - a body carrying a top-level `thread` (Claude Code's message-threads / "tether"
36
+ // feature, beta `message-threads-2026-08-12`) is ANSWERED LOCALLY with a 400 whose
37
+ // `details.error_code` is `thread_unsupported_request`, and never reaches the
38
+ // gateway. `thread:{type:"create"}` carries the full transcript, but
39
+ // `thread:{type:"continue",previous_message_id}` carries only the messages after
40
+ // the anchor — a delta the API rejects as a whole conversation (400 "unexpected
41
+ // tool_use_id found in tool_result blocks"), one wasted gateway + judge call per
42
+ // tool-using turn. Thread state would have to live at the gateway, cross-org
43
+ // (mnemom-platform#1770). On this 400 Claude Code resends the same turn stateless
44
+ // (full transcript, no `thread`) and stops threading on that model for the rest of
45
+ // the session. The latch is per agent+model, so a subagent or a /model switch takes
46
+ // one more local 400: typically one or two `-> local 400` lines early in the
47
+ // session, each an instant local round trip, and zero gateway 400s. A `thread: null`
48
+ // is not a thread — the key is deleted and the body forwarded.
49
+ // MNEMOM_CODE_RC_THREAD_MODE=strip restores the legacy delete-and-forward behaviour
50
+ // (diagnostic only: it forwards continue-deltas the API rejects).
51
+ //
52
+ // SECRETS
53
+ // Arrive on stdin as three newline-terminated lines: Anthropic key, Mnemom key (may be
54
+ // empty), OpenAI egress key (may be empty). Never argv, never env, never disk, never logged.
55
+ // The log (MNEMOM_CODE_RC_PROXY_LOG, else stderr) carries method/path/status/latency and the
56
+ // gateway's `x-mnemom-agent` / `x-mnemom-goal-verdict` response headers only. Gateway error
57
+ // bodies (status ≥ 400) are logged truncated so a 4xx is diagnosable (compressed ones as
58
+ // `body=<encoding> <N> bytes`). Locally answered thread 400s log the thread type only.
59
+ //
60
+ // CONFIG (env, none secret)
61
+ // MNEMOM_CODE_RC_DOOR gateway door URL (required), e.g. https://gateway.mnemom.ai/anthropic
62
+ // MNEMOM_CODE_RC_AGENT x-mnemom-agent value (required)
63
+ // MNEMOM_CODE_RC_CONVERSATION_ID x-mnemom-conversation-id value (required)
64
+ // MNEMOM_CODE_RC_CONTRACT_B64 base64 contract for x-mnemom-contract (optional)
65
+ // MNEMOM_CODE_RC_PROVIDER x-mnemom-provider directive JSON (optional; router door)
66
+ // MNEMOM_CODE_RC_THREAD_MODE `reject` (default: answer thread-bearing requests locally,
67
+ // see above) | `strip` (legacy: delete `thread`, forward)
68
+ // MNEMOM_CODE_RC_PROXY_LOG append log lines to this file instead of stderr (optional)
69
+ // MNEMOM_CODE_RC_PARENT_PID exit when this pid is gone, so no orphan holds the keys
70
+ // MNEMOM_CODE_RC_PORT listen port (default 0 = ephemeral)
71
+ //
72
+ // stdout: exactly one line, `READY <port> <ca.pem path>`, once listening. Node ≥ 18, no deps.
73
+ import http from 'node:http';
74
+ import https from 'node:https';
75
+ import net from 'node:net';
76
+ import fs from 'node:fs';
77
+ import os from 'node:os';
78
+ import path from 'node:path';
79
+ import { execFileSync } from 'node:child_process';
80
+
81
+ const MITM_HOST = 'api.anthropic.com';
82
+ const env = process.env;
83
+
84
+ function die(msg) { process.stderr.write(`mnemom-code-rc-proxy: ${msg}\n`); process.exit(2); }
85
+
86
+ if (!env.MNEMOM_CODE_RC_DOOR) die('MNEMOM_CODE_RC_DOOR is required');
87
+ let GATEWAY;
88
+ try { GATEWAY = new URL(env.MNEMOM_CODE_RC_DOOR); } catch { die(`MNEMOM_CODE_RC_DOOR is not a URL: ${env.MNEMOM_CODE_RC_DOOR}`); }
89
+ if (GATEWAY.protocol !== 'https:') die('MNEMOM_CODE_RC_DOOR must be https');
90
+ const AGENT = env.MNEMOM_CODE_RC_AGENT || die('MNEMOM_CODE_RC_AGENT is required');
91
+ const CONV = env.MNEMOM_CODE_RC_CONVERSATION_ID || die('MNEMOM_CODE_RC_CONVERSATION_ID is required');
92
+ const CONTRACT_B64 = env.MNEMOM_CODE_RC_CONTRACT_B64 || '';
93
+ const PROVIDER = env.MNEMOM_CODE_RC_PROVIDER || '';
94
+ const THREAD_MODE = env.MNEMOM_CODE_RC_THREAD_MODE || 'reject';
95
+ if (THREAD_MODE !== 'reject' && THREAD_MODE !== 'strip') die(`MNEMOM_CODE_RC_THREAD_MODE must be 'reject' or 'strip', got '${THREAD_MODE}'`);
96
+ const LOGFILE = env.MNEMOM_CODE_RC_PROXY_LOG || '';
97
+ const PARENT = Number(env.MNEMOM_CODE_RC_PARENT_PID || 0);
98
+ const PORT = Number(env.MNEMOM_CODE_RC_PORT || 0);
99
+
100
+ const log = (line) => {
101
+ const s = `${new Date().toISOString()} ${line}\n`;
102
+ if (LOGFILE) { try { fs.appendFileSync(LOGFILE, s); return; } catch { /* fall through */ } }
103
+ process.stderr.write(s);
104
+ };
105
+
106
+ // --- secrets: three lines on stdin ------------------------------------------------------
107
+ let secrets;
108
+ try {
109
+ const lines = fs.readFileSync(0, 'utf8').split('\n');
110
+ secrets = { anthropic: (lines[0] || '').trim(), mnemom: (lines[1] || '').trim(), openai: (lines[2] || '').trim() };
111
+ } catch {
112
+ die('expected three lines of secrets on stdin (anthropic, mnemom, openai)');
113
+ }
114
+ if (!secrets.anthropic) die('the Anthropic key (stdin line 1) is empty');
115
+
116
+ // --- ephemeral CA + leaf for api.anthropic.com ------------------------------------------
117
+ // Private 0700 dir under the OS tmpdir; the CA key is deleted as soon as the leaf is signed
118
+ // (only the leaf key is needed to serve sessions) and the whole dir goes on exit. Works with
119
+ // macOS's system LibreSSL (3.3+) and OpenSSL 1.1.1+ (`-addext`).
120
+ const caDir = fs.mkdtempSync(path.join(os.tmpdir(), 'mnemom-code-rc-ca-'));
121
+ fs.chmodSync(caDir, 0o700);
122
+ const p = (f) => path.join(caDir, f);
123
+ const cleanup = () => { try { fs.rmSync(caDir, { recursive: true, force: true }); } catch { /* best effort */ } };
124
+ process.on('exit', cleanup);
125
+ for (const sig of ['SIGINT', 'SIGTERM', 'SIGHUP']) process.on(sig, () => process.exit(0));
126
+ try {
127
+ const ossl = (...a) => execFileSync('openssl', a, { stdio: ['ignore', 'ignore', 'pipe'] });
128
+ ossl('req', '-x509', '-newkey', 'ec', '-pkeyopt', 'ec_paramgen_curve:prime256v1', '-nodes',
129
+ '-keyout', p('ca.key'), '-out', p('ca.pem'), '-days', '2', '-subj', '/CN=mnemom code local CA (ephemeral)',
130
+ '-addext', 'basicConstraints=critical,CA:TRUE', '-addext', 'keyUsage=critical,keyCertSign,cRLSign');
131
+ ossl('req', '-newkey', 'ec', '-pkeyopt', 'ec_paramgen_curve:prime256v1', '-nodes',
132
+ '-keyout', p('leaf.key'), '-out', p('leaf.csr'), '-subj', `/CN=${MITM_HOST}`);
133
+ fs.writeFileSync(p('leaf.ext'),
134
+ `subjectAltName=DNS:${MITM_HOST}\nbasicConstraints=CA:FALSE\nkeyUsage=digitalSignature,keyEncipherment\nextendedKeyUsage=serverAuth\n`);
135
+ ossl('x509', '-req', '-in', p('leaf.csr'), '-CA', p('ca.pem'), '-CAkey', p('ca.key'), '-CAcreateserial',
136
+ '-out', p('leaf.pem'), '-days', '2', '-extfile', p('leaf.ext'));
137
+ fs.rmSync(p('ca.key'));
138
+ } catch (e) {
139
+ die(`could not mint the ephemeral CA with openssl: ${e.stderr ? e.stderr.toString() : e.message}`);
140
+ }
141
+
142
+ // --- request handling ---------------------------------------------------------------------
143
+ const gatewayAgent = new https.Agent({ keepAlive: false }); // a reused socket to the gateway gave ECONNRESET on retry
144
+ const upstreamAgent = new https.Agent({ keepAlive: true, maxSockets: 32 });
145
+ const HOP = new Set(['connection', 'proxy-connection', 'keep-alive', 'upgrade', 'host', 'te', 'trailer']);
146
+
147
+ const isMessagesPath = (u) => u === '/v1/messages' || u.startsWith('/v1/messages?');
148
+
149
+ // Inspect + normalise a /v1/messages body for the gateway leg. Returns
150
+ // { body: <possibly rewritten Buffer>, thread: null | 'create' | 'continue' | 'other' }
151
+ // `thread` reports whether the body carried a non-null top-level `thread` field (the type
152
+ // is reduced to one of three words so nothing from the body can reach the log). A
153
+ // `thread: null` is semantically thread-less (JSON.stringify keeps null, unlike undefined):
154
+ // the key is deleted and the body forwarded, because Claude Code's own 400 handler would
155
+ // read `.type` off it and throw instead of retrying stateless. Under THREAD_MODE=strip a
156
+ // real thread is deleted from the returned body; under `reject` the body is returned
157
+ // unchanged apart from `cache_control.ttl`, because the caller will not forward it.
158
+ function normalizeBody(body) {
159
+ try {
160
+ const j = JSON.parse(body.toString('utf8'));
161
+ let n = 0;
162
+ const walk = (o) => {
163
+ if (Array.isArray(o)) { for (const x of o) walk(x); return; }
164
+ if (o && typeof o === 'object') {
165
+ if (o.cache_control && typeof o.cache_control === 'object' && 'ttl' in o.cache_control) { delete o.cache_control.ttl; n++; }
166
+ for (const v of Object.values(o)) walk(v);
167
+ }
168
+ };
169
+ walk(j);
170
+ let thread = null;
171
+ if (j && typeof j === 'object' && !Array.isArray(j) && 'thread' in j) {
172
+ if (j.thread === null || j.thread === undefined) {
173
+ delete j.thread; n++; // not a thread: forward the rest
174
+ } else {
175
+ const t = typeof j.thread === 'object' ? j.thread.type : undefined;
176
+ thread = t === 'create' || t === 'continue' ? t : 'other';
177
+ if (THREAD_MODE === 'strip') { delete j.thread; n++; }
178
+ }
179
+ }
180
+ return { body: n ? Buffer.from(JSON.stringify(j)) : body, thread };
181
+ } catch {
182
+ return { body, thread: null }; // not JSON: forward untouched
183
+ }
184
+ }
185
+
186
+ // The local answer for a thread-bearing request. Every branch of Claude Code's thread-400
187
+ // classifier must land on "unsupported_request": `details.error_code` is the primary
188
+ // signal, and the message names the beta, the `thread` param and `previous_message_id`
189
+ // so the fallback regexes agree even if the details path were ever ignored.
190
+ const THREAD_400 = Buffer.from(JSON.stringify({
191
+ type: 'error',
192
+ error: {
193
+ type: 'invalid_request_error',
194
+ message: 'message threads (beta message-threads-2026-08-12: top-level thread param / previous_message_id) are not supported on the mnemom gateway leg; resend the full transcript without a thread',
195
+ details: { error_code: 'thread_unsupported_request' },
196
+ },
197
+ }));
198
+ let localSeq = 0;
199
+ function rejectThread(res, tag, thread) {
200
+ res.writeHead(400, {
201
+ 'content-type': 'application/json',
202
+ 'content-length': String(THREAD_400.length),
203
+ 'request-id': `mnemom-code-rc-proxy-${++localSeq}`,
204
+ });
205
+ res.end(THREAD_400);
206
+ log(`${tag} -> local 400 thread=${thread} (thread_unsupported_request; Claude Code resends this turn stateless and stops threading for this agent+model for the rest of the session — a subagent or model switch takes one more local 400)`);
207
+ }
208
+
209
+ // One handler serves both the MITM'd TLS stream (origin-form URLs, host = api.anthropic.com)
210
+ // and absolute-form plaintext requests sent straight to the proxy port.
211
+ function handle(req, res, viaPlain) {
212
+ let host = MITM_HOST, port = 443, urlPath = req.url, proto = https;
213
+ if (viaPlain) {
214
+ let u;
215
+ try { u = new URL(req.url); } catch { res.writeHead(400); res.end('mnemom-code-rc-proxy: absolute-form URL required\n'); return; }
216
+ host = u.hostname;
217
+ port = Number(u.port || (u.protocol === 'http:' ? 80 : 443));
218
+ urlPath = u.pathname + u.search;
219
+ proto = u.protocol === 'http:' ? http : https;
220
+ }
221
+ const headers = {};
222
+ for (const [k, v] of Object.entries(req.headers)) if (!HOP.has(k)) headers[k] = v;
223
+
224
+ const toGateway = host === MITM_HOST && isMessagesPath(urlPath);
225
+ let target;
226
+ if (toGateway) {
227
+ target = { hostname: GATEWAY.hostname, port: GATEWAY.port || 443,
228
+ path: GATEWAY.pathname.replace(/\/$/, '') + urlPath, agent: gatewayAgent };
229
+ delete headers.authorization; // the OAuth bearer stays local
230
+ headers['x-api-key'] = secrets.anthropic;
231
+ headers['x-mnemom-agent'] = AGENT;
232
+ headers['x-mnemom-conversation-id'] = CONV;
233
+ if (secrets.mnemom) headers['x-mnemom-api-key'] = secrets.mnemom;
234
+ if (PROVIDER) headers['x-mnemom-provider'] = PROVIDER;
235
+ if (secrets.openai) headers['x-mnemom-egress-key'] = secrets.openai;
236
+ if (CONTRACT_B64) headers['x-mnemom-contract'] = CONTRACT_B64;
237
+ if (headers['anthropic-beta']) { // drop the OAuth-only beta tokens
238
+ const b = String(headers['anthropic-beta']).split(',').map((s) => s.trim()).filter((s) => s && !s.startsWith('oauth-'));
239
+ if (b.length) headers['anthropic-beta'] = b.join(','); else delete headers['anthropic-beta'];
240
+ }
241
+ } else {
242
+ target = { hostname: host, port, path: urlPath, agent: proto === https ? upstreamAgent : undefined };
243
+ }
244
+
245
+ const started = Date.now();
246
+ const tag = `${req.method} ${viaPlain ? host : ''}${urlPath}`;
247
+ const leg = toGateway ? 'gateway' : 'upstream';
248
+
249
+ // Open the upstream request only once we know we are forwarding. `body` is the fully
250
+ // buffered (normalised) gateway body; undefined means stream the client request through.
251
+ const forward = (body) => {
252
+ const opts = { ...target, method: req.method, headers };
253
+ if (!net.isIP(target.hostname)) opts.servername = target.hostname; // SNI is for names, not IPs
254
+ const up = (toGateway ? https : proto).request(opts, (ur) => {
255
+ res.writeHead(ur.statusCode, ur.headers);
256
+ // Gateway error bodies are logged (truncated) so a 4xx is diagnosable — unless the
257
+ // body is compressed, in which case only its encoding + size is useful
258
+ // (`identity` is not a compression).
259
+ let errBody = '', errBytes = 0;
260
+ const enc = ur.headers['content-encoding'];
261
+ const errEnc = enc && enc !== 'identity' ? enc : '';
262
+ if (toGateway && ur.statusCode >= 400) {
263
+ ur.on('data', (c) => { errBytes += c.length; if (!errEnc && errBody.length < 1500) errBody += c.toString('utf8'); });
264
+ }
265
+ ur.pipe(res);
266
+ const ident = toGateway ? ` agent=${ur.headers['x-mnemom-agent'] || '-'} verdict=${ur.headers['x-mnemom-goal-verdict'] || '-'}` : '';
267
+ ur.on('end', () => {
268
+ let bodyNote = '';
269
+ if (toGateway && ur.statusCode >= 400) {
270
+ bodyNote = errEnc ? ` body=${errEnc} ${errBytes} bytes` : (errBody ? ' body=' + errBody.slice(0, 1500).replace(/\s+/g, ' ') : '');
271
+ }
272
+ log(`${tag} -> ${leg} ${ur.statusCode} ${Date.now() - started}ms${ident}${bodyNote}`);
273
+ });
274
+ });
275
+ up.on('error', (e) => {
276
+ log(`${tag} -> ${leg} ERROR ${e.code || e.message}`);
277
+ if (!res.headersSent) res.writeHead(502, { 'content-type': 'application/json' });
278
+ res.end(JSON.stringify({ type: 'error', error: { type: 'proxy_error', message: `mnemom-code-rc-proxy upstream: ${e.code || e.message}` } }));
279
+ });
280
+ res.on('close', () => { if (!up.destroyed) up.destroy(); });
281
+ if (body !== undefined) {
282
+ up.removeHeader('transfer-encoding');
283
+ up.setHeader('content-length', String(body.length));
284
+ up.end(body);
285
+ } else {
286
+ req.pipe(up);
287
+ }
288
+ };
289
+
290
+ if (toGateway && req.method === 'POST') {
291
+ // Buffer the body first: a thread-bearing request is answered here and nothing may be
292
+ // sent to (or left half-open toward) the gateway for it.
293
+ const chunks = [];
294
+ req.on('data', (c) => chunks.push(c));
295
+ req.on('end', () => {
296
+ const { body, thread } = normalizeBody(Buffer.concat(chunks));
297
+ if (thread !== null && THREAD_MODE === 'reject') { rejectThread(res, tag, thread); return; }
298
+ forward(body);
299
+ });
300
+ } else {
301
+ forward();
302
+ }
303
+ }
304
+
305
+ const tlsServer = https.createServer(
306
+ { key: fs.readFileSync(p('leaf.key')), cert: fs.readFileSync(p('leaf.pem')) },
307
+ (req, res) => handle(req, res, false),
308
+ );
309
+ tlsServer.on('tlsClientError', (e) => log(`tls client error: ${e.code || e.message}`));
310
+
311
+ const proxy = http.createServer((req, res) => handle(req, res, true));
312
+ proxy.on('connect', (req, sock, head) => {
313
+ const [host, portStr] = req.url.split(':');
314
+ const port = Number(portStr || 443);
315
+ sock.on('error', () => {});
316
+ if (host === MITM_HOST && port === 443) {
317
+ sock.write('HTTP/1.1 200 Connection Established\r\n\r\n');
318
+ if (head && head.length) sock.unshift(head);
319
+ tlsServer.emit('connection', sock);
320
+ return;
321
+ }
322
+ const up = net.connect(port, host, () => {
323
+ sock.write('HTTP/1.1 200 Connection Established\r\n\r\n');
324
+ if (head && head.length) up.write(head);
325
+ up.pipe(sock); sock.pipe(up);
326
+ });
327
+ up.on('error', (e) => { log(`CONNECT ${req.url} tunnel ERROR ${e.code || e.message}`); sock.destroy(); });
328
+ sock.on('close', () => up.destroy());
329
+ up.on('close', () => sock.destroy());
330
+ log(`CONNECT ${req.url} tunnel`);
331
+ });
332
+ proxy.on('clientError', (e, sock) => { try { sock.destroy(); } catch { /* already gone */ } });
333
+
334
+ proxy.listen(PORT, '127.0.0.1', () => {
335
+ const { port } = proxy.address();
336
+ log(`listening 127.0.0.1:${port} agent=${AGENT} conv=${CONV} door=${GATEWAY.href} contract=${CONTRACT_B64 ? 'yes' : 'no'} provider=${PROVIDER || '-'} thread=${THREAD_MODE}`);
337
+ process.stdout.write(`READY ${port} ${p('ca.pem')}\n`);
338
+ });
339
+
340
+ // Die with the launcher so no orphan keeps the secrets in memory.
341
+ if (PARENT) setInterval(() => { try { process.kill(PARENT, 0); } catch { process.exit(0); } }, 2000).unref();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mnemom/mnemom",
3
- "version": "0.16.2",
3
+ "version": "0.17.0-next.0",
4
4
  "description": "Transparent AI agent tracing",
5
5
  "type": "module",
6
6
  "bin": {
@@ -8,7 +8,8 @@
8
8
  "smoltbot": "./dist/smoltbot-shim.js"
9
9
  },
10
10
  "scripts": {
11
- "build": "npm install && npm install --prefix ../shared/policy-engine && npm run build --prefix ../shared/policy-engine && tsc",
11
+ "build": "tsc && node scripts/copy-assets.mjs",
12
+ "prepublishOnly": "npm run build",
12
13
  "dev": "tsx src/index.ts",
13
14
  "gen:command-tree": "tsx scripts/gen-command-tree.mjs",
14
15
  "check:command-tree": "tsx scripts/gen-command-tree.mjs --check",
@@ -19,15 +20,16 @@
19
20
  "@mnemom/policy-engine": "^0.3.0",
20
21
  "chalk": "^5.3.0",
21
22
  "commander": "^12.0.0",
22
- "js-yaml": "^4.1.1"
23
+ "js-yaml": "^4.3.2",
24
+ "smol-toml": "^1.8.0"
23
25
  },
24
26
  "devDependencies": {
25
27
  "@types/js-yaml": "^4.0.9",
26
28
  "@types/node": "^20.10.0",
27
- "@vitest/coverage-v8": "^1.6.1",
28
- "tsx": "^4.7.0",
29
- "typescript": "^5.3.3",
30
- "vitest": "^1.2.0"
29
+ "@vitest/coverage-v8": "^4.1.10",
30
+ "tsx": "^4.23.1",
31
+ "typescript": "^6.0.3",
32
+ "vitest": "^4.1.10"
31
33
  },
32
34
  "files": [
33
35
  "dist",