@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.
- package/README.md +1 -0
- package/dist/commands/agents.d.ts +14 -0
- package/dist/commands/agents.js +100 -2
- package/dist/commands/card.d.ts +43 -0
- package/dist/commands/card.js +153 -102
- package/dist/commands/code-config.d.ts +17 -0
- package/dist/commands/code-config.js +147 -0
- package/dist/commands/code-doctor.d.ts +18 -0
- package/dist/commands/code-doctor.js +138 -0
- package/dist/commands/code-setup.d.ts +97 -0
- package/dist/commands/code-setup.js +330 -0
- package/dist/commands/code.d.ts +133 -0
- package/dist/commands/code.js +661 -0
- package/dist/commands/logs.js +11 -1
- package/dist/commands/onboard.d.ts +59 -0
- package/dist/commands/onboard.js +395 -0
- package/dist/commands/org.d.ts +13 -0
- package/dist/commands/org.js +63 -2
- package/dist/commands/protection.d.ts +10 -0
- package/dist/commands/protection.js +109 -0
- package/dist/commands/status.js +5 -0
- package/dist/commands/try-me.js +9 -0
- package/dist/commands/usage.d.ts +35 -0
- package/dist/commands/usage.js +265 -0
- package/dist/commands/wrap.d.ts +28 -0
- package/dist/commands/wrap.js +331 -0
- package/dist/index.js +315 -7
- package/dist/lib/agent-config.d.ts +27 -0
- package/dist/lib/agent-config.js +86 -0
- package/dist/lib/api.d.ts +139 -1
- package/dist/lib/api.js +132 -183
- package/dist/lib/cli-config.d.ts +33 -0
- package/dist/lib/cli-config.js +70 -0
- package/dist/lib/code-config.d.ts +78 -0
- package/dist/lib/code-config.js +281 -0
- package/dist/lib/code.d.ts +154 -0
- package/dist/lib/code.js +252 -0
- package/dist/lib/config.d.ts +10 -0
- package/dist/lib/config.js +39 -3
- package/dist/lib/keyed-identity.d.ts +35 -0
- package/dist/lib/keyed-identity.js +363 -0
- package/dist/lib/protection-drift.d.ts +117 -0
- package/dist/lib/protection-drift.js +180 -0
- package/dist/lib/skills.js +25 -12
- package/dist/lib/version-gate.d.ts +37 -0
- package/dist/lib/version-gate.js +84 -0
- package/dist/rc-proxy.mjs +341 -0
- package/package.json +9 -7
package/dist/lib/skills.js
CHANGED
|
@@ -42,23 +42,36 @@ export const SKILLS = [
|
|
|
42
42
|
},
|
|
43
43
|
{
|
|
44
44
|
name: "onboard",
|
|
45
|
-
status: "
|
|
46
|
-
|
|
47
|
-
|
|
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
|
|
51
|
-
"one command, no manifest.
|
|
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: "
|
|
56
|
-
ref: "MNE-935",
|
|
62
|
+
status: "available",
|
|
57
63
|
summary: "Instrument an existing production agent through the Mnemom gateway.",
|
|
58
|
-
usage: "mnemom wrap"
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
"
|
|
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.
|
|
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": "
|
|
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.
|
|
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.
|
|
28
|
-
"tsx": "^4.
|
|
29
|
-
"typescript": "^
|
|
30
|
-
"vitest": "^1.
|
|
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",
|