@mnemom/mnemom 0.17.32 → 0.17.33

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 (49) hide show
  1. package/dist/commands/agent-fuel.d.ts +9 -0
  2. package/dist/commands/agent-fuel.js +36 -2
  3. package/dist/commands/agent-run.d.ts +50 -0
  4. package/dist/commands/agent-run.js +221 -0
  5. package/dist/commands/agent-setup.d.ts +8 -3
  6. package/dist/commands/agent-setup.js +32 -9
  7. package/dist/commands/agent-share-fixture.d.ts +3 -1
  8. package/dist/commands/agent-share-fixture.js +11 -7
  9. package/dist/commands/agent-share-render.d.ts +23 -0
  10. package/dist/commands/agent-share-render.js +72 -0
  11. package/dist/commands/agent-share.d.ts +15 -2
  12. package/dist/commands/agent-share.js +30 -9
  13. package/dist/commands/agent.d.ts +19 -4
  14. package/dist/commands/agent.js +33 -13
  15. package/dist/index.js +20 -0
  16. package/dist/lib/api.js +17 -1
  17. package/dist/lib/auth.d.ts +6 -0
  18. package/dist/lib/auth.js +161 -14
  19. package/dist/lib/experimental.d.ts +1 -1
  20. package/dist/lib/experimental.js +13 -0
  21. package/dist/lib/oauth.d.ts +7 -0
  22. package/dist/lib/oauth.js +13 -0
  23. package/dist/lib/runner/bundle.d.ts +129 -0
  24. package/dist/lib/runner/bundle.js +319 -0
  25. package/dist/lib/runner/contract-header.d.ts +46 -0
  26. package/dist/lib/runner/contract-header.js +85 -0
  27. package/dist/lib/runner/executor-client.d.ts +71 -0
  28. package/dist/lib/runner/executor-client.js +97 -0
  29. package/dist/lib/runner/job.d.ts +70 -0
  30. package/dist/lib/runner/job.js +463 -0
  31. package/dist/lib/runner/loop.d.ts +45 -0
  32. package/dist/lib/runner/loop.js +104 -0
  33. package/dist/lib/runner/oidc.d.ts +38 -0
  34. package/dist/lib/runner/oidc.js +89 -0
  35. package/dist/lib/runner/options.d.ts +41 -0
  36. package/dist/lib/runner/options.js +47 -0
  37. package/dist/lib/runner/outcome.d.ts +47 -0
  38. package/dist/lib/runner/outcome.js +89 -0
  39. package/dist/lib/runner/phases.d.ts +49 -0
  40. package/dist/lib/runner/phases.js +99 -0
  41. package/dist/lib/runner/proxy.d.ts +45 -0
  42. package/dist/lib/runner/proxy.js +153 -0
  43. package/dist/lib/runner/settings.d.ts +31 -0
  44. package/dist/lib/runner/settings.js +44 -0
  45. package/dist/lib/statusline-feed.js +10 -11
  46. package/dist/lib/statusline.d.ts +6 -0
  47. package/dist/lib/statusline.js +19 -9
  48. package/dist/rc-proxy.mjs +69 -5
  49. package/package.json +2 -1
@@ -0,0 +1,153 @@
1
+ /**
2
+ * The runner's own model proxy (ruling 34.11, 34.12 (7); Doors W3.11).
3
+ *
4
+ * A served phase's Claude Code (and a code phase's child) points ANTHROPIC_BASE_URL here, on
5
+ * loopback. The proxy forwards each request to the gateway and decides every Mnemom header itself:
6
+ * any `x-mnemom-*` the phase sent is dropped, the lease key goes in X-Mnemom-Api-Key, and the job's
7
+ * sealed contract goes in `x-mnemom-contract` (exactly one, the sealed version). The phase never
8
+ * sees the lease key. A 409 `contract_mismatch` from the gateway (34.11) is reported to the job,
9
+ * which refetches and continues only on a higher sealed version, else fails closed.
10
+ */
11
+ import { createServer, request as httpRequest, } from "node:http";
12
+ import { request as httpsRequest } from "node:https";
13
+ import { applySealedContract, CONTRACT_HEADER } from "./contract-header.js";
14
+ export const LEASE_KEY_HEADER = "x-mnemom-api-key";
15
+ /** Hop-by-hop headers (RFC 9110 7.6.1) and the ones the proxy owns. */
16
+ const DROP = new Set([
17
+ "connection",
18
+ "keep-alive",
19
+ "proxy-connection",
20
+ "transfer-encoding",
21
+ "upgrade",
22
+ "te",
23
+ "trailer",
24
+ "host",
25
+ "content-length",
26
+ ]);
27
+ /** Outbound headers for one request: the phase's own, minus anything the proxy decides. */
28
+ export function outboundHeaders(incoming, state) {
29
+ const kept = {};
30
+ for (const [name, value] of Object.entries(incoming)) {
31
+ const lower = name.toLowerCase();
32
+ if (DROP.has(lower) || lower.startsWith("x-mnemom-"))
33
+ continue;
34
+ kept[lower] = value;
35
+ }
36
+ kept[LEASE_KEY_HEADER] = state.leaseKey;
37
+ return applySealedContract(kept, state.contractValue);
38
+ }
39
+ /** The largest 409 body the proxy reads for a contract mismatch. */
40
+ export const MISMATCH_BODY_MAX = 64 * 1024;
41
+ function mismatchVersion(body) {
42
+ try {
43
+ const b = JSON.parse(body);
44
+ const code = typeof b.error === "object" && b.error ? b.error.code : (b.error ?? b.code);
45
+ if (code !== "contract_mismatch")
46
+ return undefined;
47
+ const v = (typeof b.error === "object" && b.error ? b.error.details?.expected_version : undefined) ??
48
+ b.expected_version;
49
+ return Number.isSafeInteger(v) ? v : null;
50
+ }
51
+ catch {
52
+ return undefined;
53
+ }
54
+ }
55
+ /** The client for the gateway's scheme: TLS for https, plain only for a local http gateway. */
56
+ export function requestFor(protocol) {
57
+ return protocol === "https:" ? httpsRequest : httpRequest;
58
+ }
59
+ /**
60
+ * The phase's answer when the gateway fails: a 502 if nothing was sent yet, else the connection is
61
+ * cut, so a truncated body is never mistaken for a whole one.
62
+ */
63
+ export function upstreamFailed(res, e) {
64
+ if (res.headersSent) {
65
+ res.destroy(e);
66
+ return;
67
+ }
68
+ res.writeHead(502, { "content-type": "application/json" });
69
+ res.end(JSON.stringify({ error: { code: "runner_proxy_upstream", message: e.message } }));
70
+ }
71
+ export async function startModelProxy(gatewayUrl, state) {
72
+ const upstream = new URL(gatewayUrl);
73
+ const send = requestFor(upstream.protocol);
74
+ const basePath = upstream.pathname.replace(/\/+$/, "");
75
+ const forwarded = [];
76
+ const proxy = {
77
+ url: "",
78
+ forwarded,
79
+ contractMismatch: null,
80
+ close: async () => { },
81
+ };
82
+ const server = createServer((req, res) => {
83
+ const headers = outboundHeaders(req.headers, state);
84
+ const sent = headers[CONTRACT_HEADER];
85
+ const out = send({
86
+ protocol: upstream.protocol,
87
+ hostname: upstream.hostname,
88
+ port: upstream.port || undefined,
89
+ method: req.method,
90
+ path: `${basePath}${req.url ?? "/"}`,
91
+ headers,
92
+ }, (up) => {
93
+ const status = up.statusCode ?? 502;
94
+ forwarded.push({
95
+ method: req.method ?? "GET",
96
+ path: req.url ?? "/",
97
+ contract: sent,
98
+ status,
99
+ });
100
+ if (status === 409) {
101
+ // Buffered to read the mismatch, up to a cap; a larger body is relayed unread.
102
+ const chunks = [];
103
+ let size = 0;
104
+ up.on("error", (e) => upstreamFailed(res, e));
105
+ const onData = (c) => {
106
+ chunks.push(c);
107
+ size += c.length;
108
+ if (size <= MISMATCH_BODY_MAX)
109
+ return;
110
+ up.off("data", onData);
111
+ up.off("end", onEnd);
112
+ res.writeHead(status, up.headers);
113
+ for (const b of chunks)
114
+ res.write(b);
115
+ up.pipe(res);
116
+ };
117
+ const onEnd = () => {
118
+ const body = Buffer.concat(chunks);
119
+ const v = mismatchVersion(body.toString("utf8"));
120
+ if (v !== undefined)
121
+ proxy.contractMismatch = { expected_version: v };
122
+ res.writeHead(status, up.headers);
123
+ res.end(body);
124
+ };
125
+ up.on("data", onData);
126
+ up.on("end", onEnd);
127
+ return;
128
+ }
129
+ res.writeHead(status, up.headers);
130
+ // A gateway that drops mid-body errors the response, not the request.
131
+ up.on("error", (e) => upstreamFailed(res, e));
132
+ up.pipe(res);
133
+ });
134
+ out.on("error", (e) => {
135
+ forwarded.push({
136
+ method: req.method ?? "GET",
137
+ path: req.url ?? "/",
138
+ contract: sent,
139
+ status: 502,
140
+ });
141
+ upstreamFailed(res, e);
142
+ });
143
+ req.pipe(out);
144
+ });
145
+ await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
146
+ const { port } = server.address();
147
+ proxy.url = `http://127.0.0.1:${port}`;
148
+ proxy.close = () => new Promise((resolve) => {
149
+ server.closeAllConnections?.();
150
+ server.close(() => resolve());
151
+ });
152
+ return proxy;
153
+ }
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Thin runner settings (Doors W-L13, W4.4): what the runner does on a Mnemom error, and how a job
3
+ * lands once its PR is open.
4
+ *
5
+ * Fail mode (decision 20, OD-2): served-phase jobs fail CLOSED by default. A Mnemom error (claim,
6
+ * bundle fetch, bundle verification, the job read, the gateway) stops the job and leaves it for
7
+ * the lease to lapse back to ready; nothing runs unverified or ungoverned. `open` lets a job that
8
+ * already holds a verified bundle carry on through a transient error on a later call. It never
9
+ * lets an unverified bundle run: verification failures stop the job in every mode. An unknown
10
+ * value reads as the default, closed, with a warning, so a typo can never weaken the runner.
11
+ *
12
+ * Merge strategy (W4.4, T15): the bundle may name `merge_on_green` (our fleet) or `external`
13
+ * (a person merges). A bundle that names none resolves to `external`: the job opens the PR and
14
+ * stops.
15
+ */
16
+ import type { MergeStrategy, ServedPhaseBundle } from "./bundle.js";
17
+ export type RunnerFailMode = "closed" | "open";
18
+ export declare const RUNNER_FAIL_MODE_ENV = "MNEMOM_RUNNER_FAIL_MODE";
19
+ export declare const DEFAULT_RUNNER_FAIL_MODE: RunnerFailMode;
20
+ export interface ResolvedFailMode {
21
+ mode: RunnerFailMode;
22
+ /** Set when a value was given but not recognised (the default applied). */
23
+ warning?: string;
24
+ }
25
+ /**
26
+ * The fail mode for one runner. Precedence: the `--fail-mode` flag, then MNEMOM_RUNNER_FAIL_MODE,
27
+ * then closed. Matching is case-insensitive and trims whitespace; an empty value counts as unset.
28
+ */
29
+ export declare function resolveFailMode(flag: string | undefined, env: string | undefined): ResolvedFailMode;
30
+ /** The bundle's merge strategy, `external` when it names none. */
31
+ export declare function resolveMergeStrategy(bundle: Pick<ServedPhaseBundle, "merge_strategy">): MergeStrategy;
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Thin runner settings (Doors W-L13, W4.4): what the runner does on a Mnemom error, and how a job
3
+ * lands once its PR is open.
4
+ *
5
+ * Fail mode (decision 20, OD-2): served-phase jobs fail CLOSED by default. A Mnemom error (claim,
6
+ * bundle fetch, bundle verification, the job read, the gateway) stops the job and leaves it for
7
+ * the lease to lapse back to ready; nothing runs unverified or ungoverned. `open` lets a job that
8
+ * already holds a verified bundle carry on through a transient error on a later call. It never
9
+ * lets an unverified bundle run: verification failures stop the job in every mode. An unknown
10
+ * value reads as the default, closed, with a warning, so a typo can never weaken the runner.
11
+ *
12
+ * Merge strategy (W4.4, T15): the bundle may name `merge_on_green` (our fleet) or `external`
13
+ * (a person merges). A bundle that names none resolves to `external`: the job opens the PR and
14
+ * stops.
15
+ */
16
+ export const RUNNER_FAIL_MODE_ENV = "MNEMOM_RUNNER_FAIL_MODE";
17
+ export const DEFAULT_RUNNER_FAIL_MODE = "closed";
18
+ /**
19
+ * The fail mode for one runner. Precedence: the `--fail-mode` flag, then MNEMOM_RUNNER_FAIL_MODE,
20
+ * then closed. Matching is case-insensitive and trims whitespace; an empty value counts as unset.
21
+ */
22
+ export function resolveFailMode(flag, env) {
23
+ for (const [source, raw] of [
24
+ ["--fail-mode", flag],
25
+ [RUNNER_FAIL_MODE_ENV, env],
26
+ ]) {
27
+ if (raw === undefined)
28
+ continue;
29
+ const v = raw.trim().toLowerCase();
30
+ if (v === "")
31
+ continue;
32
+ if (v === "closed" || v === "open")
33
+ return { mode: v };
34
+ return {
35
+ mode: DEFAULT_RUNNER_FAIL_MODE,
36
+ warning: `${source}=${JSON.stringify(raw)} is not closed or open; failing closed`,
37
+ };
38
+ }
39
+ return { mode: DEFAULT_RUNNER_FAIL_MODE };
40
+ }
41
+ /** The bundle's merge strategy, `external` when it names none. */
42
+ export function resolveMergeStrategy(bundle) {
43
+ return bundle.merge_strategy ?? "external";
44
+ }
@@ -12,7 +12,7 @@
12
12
  import { renameSync, writeFileSync } from "node:fs";
13
13
  import { getRecordTotals } from "./api.js";
14
14
  import { readRecordState } from "./conduct-receipt.js";
15
- import { LINEAGE_KEY_RE, MMU_RE } from "./statusline.js";
15
+ import { LINEAGE_KEY_RE, toFedTotals } from "./statusline.js";
16
16
  /** How often the launcher re-reads the totals. */
17
17
  export const FEED_INTERVAL_MS = 10_000;
18
18
  /**
@@ -32,18 +32,17 @@ export async function feedOnce(o) {
32
32
  const totals = await (o.fetchTotals ?? getRecordTotals)(o.orgId, key);
33
33
  if (!totals || totals.lineage_key !== key)
34
34
  return false;
35
- // Persist only amounts the bin would render: a malformed reply never replaces a good feed.
36
- const mu = totals.mu_charged_mmu;
37
- if (typeof totals.model_cost_mmu !== "string" || !MMU_RE.test(totals.model_cost_mmu)) {
38
- return false;
39
- }
40
- if (mu !== null && (typeof mu !== "string" || !MMU_RE.test(mu)))
41
- return false;
42
- const tmp = `${o.feedFile}.tmp`;
43
- writeFileSync(tmp, JSON.stringify({
35
+ // Persist only the amounts the bin would render, rebuilt from the checked values (never
36
+ // the reply itself): a malformed reply never replaces a good feed. The path is the
37
+ // launcher's own feedFile; nothing in the reply names where it is written.
38
+ const fed = toFedTotals({
44
39
  model_cost_mmu: totals.model_cost_mmu,
45
40
  mu_charged_mmu: totals.mu_charged_mmu,
46
- }));
41
+ });
42
+ if (!fed)
43
+ return false;
44
+ const tmp = `${o.feedFile}.tmp`;
45
+ writeFileSync(tmp, JSON.stringify(fed));
47
46
  renameSync(tmp, o.feedFile);
48
47
  return true;
49
48
  }
@@ -38,6 +38,12 @@ export interface FedTotals {
38
38
  /** null: no billing row yet, shown "μ not charged" (OD-9). */
39
39
  mu_charged_mmu: string | null;
40
40
  }
41
+ /**
42
+ * The totals the segment renders, rebuilt from only the two checked amounts, or null when
43
+ * either is off. The one shape check for both sides of the feed: the launcher writes only
44
+ * what this returns (lib/statusline-feed.ts) and the bin reads only what this accepts.
45
+ */
46
+ export declare function toFedTotals(d: Record<string, unknown>): FedTotals | null;
41
47
  /** The feed file's totals, or null when it is absent or anything in it is off. */
42
48
  export declare function readFeed(path: string): FedTotals | null;
43
49
  /** Model cost in dollars: 1000 mmu = 1 μ = 1¢. Under half a cent reads "<$0.01", never "$0.00". */
@@ -37,19 +37,29 @@ export const FEED_ENV = "MNEMOM_STATUSLINE_FEED";
37
37
  export const LINEAGE_KEY_RE = /^(cid|cr):[0-9a-f]{16}$/;
38
38
  /** A millimu amount as the record route returns it: a decimal string, no sign, no leading 0. */
39
39
  export const MMU_RE = /^(0|[1-9][0-9]{0,18})$/;
40
+ /** `v` when it is a millimu decimal string (MMU_RE), else undefined. */
41
+ function mmu(v) {
42
+ return typeof v === "string" && MMU_RE.test(v) ? v : undefined;
43
+ }
44
+ /**
45
+ * The totals the segment renders, rebuilt from only the two checked amounts, or null when
46
+ * either is off. The one shape check for both sides of the feed: the launcher writes only
47
+ * what this returns (lib/statusline-feed.ts) and the bin reads only what this accepts.
48
+ */
49
+ export function toFedTotals(d) {
50
+ const cost = mmu(d.model_cost_mmu);
51
+ if (cost === undefined)
52
+ return null;
53
+ if (d.mu_charged_mmu === null)
54
+ return { model_cost_mmu: cost, mu_charged_mmu: null };
55
+ const mu = mmu(d.mu_charged_mmu);
56
+ return mu === undefined ? null : { model_cost_mmu: cost, mu_charged_mmu: mu };
57
+ }
40
58
  /** The feed file's totals, or null when it is absent or anything in it is off. */
41
59
  export function readFeed(path) {
42
60
  try {
43
61
  const d = parseJson(readFileSync(path, "utf8"));
44
- if (!d)
45
- return null;
46
- const cost = d.model_cost_mmu;
47
- const mu = d.mu_charged_mmu;
48
- if (typeof cost !== "string" || !MMU_RE.test(cost))
49
- return null;
50
- if (mu !== null && (typeof mu !== "string" || !MMU_RE.test(mu)))
51
- return null;
52
- return { model_cost_mmu: cost, mu_charged_mmu: mu };
62
+ return d ? toFedTotals(d) : null;
53
63
  }
54
64
  catch {
55
65
  return null;
package/dist/rc-proxy.mjs CHANGED
@@ -13,6 +13,15 @@
13
13
  //
14
14
  // WHAT IT DOES
15
15
  // * Listens on 127.0.0.1 (ephemeral port) as an HTTP CONNECT proxy.
16
+ // * Serves only the caller that holds this session's proxy credential (MNE-9233). Loopback is not
17
+ // a boundary: every local process, any other user and every other seat's session can reach the
18
+ // port, and this proxy injects the owner's keys. The launcher mints the credential per session,
19
+ // hands it here on stdin and to Claude Code as the userinfo of HTTPS_PROXY
20
+ // (`http://mnemom:<credential>@127.0.0.1:<port>`), which Claude Code sends as
21
+ // `Proxy-Authorization: Basic` on CONNECT (measured on Claude Code 2.1.312) and on absolute-form
22
+ // requests (its bundled axios builds the header from the proxy URL's userinfo). Any other caller
23
+ // gets 407 before anything is tunnelled, terminated or injected. `Proxy-Authorization` is
24
+ // hop-by-hop: it is never forwarded upstream or to the gateway.
16
25
  // * Terminates TLS for api.anthropic.com ONLY, with an ephemeral CA (397 days, key held in
17
26
  // memory only) minted at startup into a private 0700 dir under ~/.mnemom/rc (removed on
18
27
  // exit; see rc-state.mjs for why not the OS temp dir), and a 7-day leaf re-minted every 24 h
@@ -52,8 +61,9 @@
52
61
  // (diagnostic only: it forwards continue-deltas the API rejects).
53
62
  //
54
63
  // SECRETS
55
- // Arrive on stdin as three newline-terminated lines: Anthropic key, Mnemom key (may be
56
- // empty), OpenAI egress key (may be empty). Never argv, never env, never disk, never logged.
64
+ // Arrive on stdin as four newline-terminated lines: Anthropic key, Mnemom key (may be
65
+ // empty), OpenAI egress key (may be empty), the per-session proxy credential (required).
66
+ // Never argv, never env, never disk, never logged.
57
67
  // The log (MNEMOM_AGENT_RC_PROXY_LOG, else stderr) carries method/path/status/latency and the
58
68
  // gateway's `x-mnemom-agent` / `x-mnemom-goal-verdict` response headers only. Gateway error
59
69
  // bodies (status ≥ 400) are logged truncated so a 4xx is diagnosable (compressed ones as
@@ -84,7 +94,8 @@
84
94
  // MNEMOM_AGENT_TURN_HEADER 0/off: no x-mnemom-turn header (default on in implicit mode;
85
95
  // see turn-origin.mjs)
86
96
  //
87
- // stdout: exactly one line, `READY <port> <ca.pem path>`, once listening. Node ≥ 18, no deps.
97
+ // stdout: exactly one line, `READY <port> <ca.pem path>`, once listening (no secret on it). Node ≥ 18,
98
+ // no deps.
88
99
  import http from "node:http";
89
100
  import https from "node:https";
90
101
  import net from "node:net";
@@ -165,7 +176,7 @@ const log = (line, retry = true) => {
165
176
  process.stderr.write(s);
166
177
  };
167
178
 
168
- // --- secrets: three lines on stdin ------------------------------------------------------
179
+ // --- secrets: four lines on stdin -------------------------------------------------------
169
180
  let secrets;
170
181
  try {
171
182
  const lines = fs.readFileSync(0, "utf8").split("\n");
@@ -173,15 +184,34 @@ try {
173
184
  anthropic: (lines[0] || "").trim(),
174
185
  mnemom: (lines[1] || "").trim(),
175
186
  openai: (lines[2] || "").trim(),
187
+ proxy: (lines[3] || "").trim(),
176
188
  };
177
189
  } catch {
178
- die("expected three lines of secrets on stdin (anthropic, mnemom, openai)");
190
+ die("expected four lines on stdin (anthropic, mnemom, openai, proxy credential)");
179
191
  }
180
192
  if (AGENT_ID && !secrets.mnemom)
181
193
  die("a pinned agent (MNEMOM_AGENT_RC_AGENT_ID) needs the Mnemom key (stdin line 2)");
182
194
  if (SUBSCRIPTION) {
183
195
  if (!secrets.mnemom) die("subscription login needs the Mnemom key (stdin line 2)");
184
196
  } else if (!secrets.anthropic) die("the Anthropic key (stdin line 1) is empty");
197
+ // Fail closed: with no credential there is nothing to check a caller against.
198
+ if (!secrets.proxy) die("the proxy credential (stdin line 4) is empty");
199
+
200
+ // --- caller authentication (MNE-9233; see WHAT IT DOES) -------------------------------------
201
+ // `Proxy-Authorization: Basic base64("mnemom:<credential>")`, compared in constant time. The
202
+ // header value is never logged.
203
+ const PROXY_USER = "mnemom";
204
+ const PROXY_AUTH_EXPECTED = Buffer.from(`${PROXY_USER}:${secrets.proxy}`, "utf8");
205
+ function callerAuthorized(header) {
206
+ const m = /^basic\s+([A-Za-z0-9+/]+={0,2})\s*$/i.exec(typeof header === "string" ? header : "");
207
+ if (!m) return false;
208
+ const got = Buffer.from(m[1], "base64");
209
+ return (
210
+ got.length === PROXY_AUTH_EXPECTED.length && crypto.timingSafeEqual(got, PROXY_AUTH_EXPECTED)
211
+ );
212
+ }
213
+ const PROXY_REALM = 'Basic realm="mnemom-code-rc-proxy"';
214
+ const PROXY_407_NOTE = "407 proxy authentication required (no or wrong Proxy-Authorization)";
185
215
 
186
216
  // --- ephemeral CA + rotating leaf for api.anthropic.com ---------------------------------
187
217
  // Private 0700 dir under ~/.mnemom/rc (rc-state.mjs), removed on exit. Claude Code reads NODE_EXTRA_CA_CERTS
@@ -389,6 +419,7 @@ const upstreamAgent = new https.Agent({ keepAlive: true, maxSockets: 32 });
389
419
  const HOP = new Set([
390
420
  "connection",
391
421
  "proxy-connection",
422
+ "proxy-authorization", // this session's proxy credential: never leaves the proxy
392
423
  "keep-alive",
393
424
  "upgrade",
394
425
  "host",
@@ -490,9 +521,34 @@ function rejectThread(res, tag, thread) {
490
521
  );
491
522
  }
492
523
 
524
+ // 407 for an absolute-form request without this session's credential: nothing is parsed,
525
+ // forwarded or injected, and the connection is closed.
526
+ function refusePlain(req, res) {
527
+ req.resume(); // discard any body
528
+ let host = "-";
529
+ try {
530
+ host = new URL(req.url).host || "-";
531
+ } catch {
532
+ /* not absolute-form: logged as "-" */
533
+ }
534
+ res.writeHead(407, {
535
+ "proxy-authenticate": PROXY_REALM,
536
+ "content-type": "text/plain",
537
+ connection: "close",
538
+ });
539
+ res.end("mnemom-code-rc-proxy: proxy authentication required\n");
540
+ log(`${req.method} ${host} -> ${PROXY_407_NOTE}`);
541
+ }
542
+
493
543
  // One handler serves both the MITM'd TLS stream (origin-form URLs, host = api.anthropic.com)
494
544
  // and absolute-form plaintext requests sent straight to the proxy port.
495
545
  function handle(req, res, viaPlain) {
546
+ // A request on the proxy port must carry the credential. One inside the MITM'd TLS stream
547
+ // already did, on the CONNECT that opened the tunnel.
548
+ if (viaPlain && !callerAuthorized(req.headers["proxy-authorization"])) {
549
+ refusePlain(req, res);
550
+ return;
551
+ }
496
552
  let host = MITM_HOST,
497
553
  port = 443,
498
554
  urlPath = req.url,
@@ -681,6 +737,14 @@ proxy.on("connect", (req, sock, head) => {
681
737
  const [host, portStr] = req.url.split(":");
682
738
  const port = Number(portStr || 443);
683
739
  sock.on("error", () => {});
740
+ if (!callerAuthorized(req.headers["proxy-authorization"])) {
741
+ // Before the MITM arm and before any upstream socket: an unauthenticated caller gets neither.
742
+ sock.end(
743
+ `HTTP/1.1 407 Proxy Authentication Required\r\nProxy-Authenticate: ${PROXY_REALM}\r\nContent-Length: 0\r\nConnection: close\r\n\r\n`,
744
+ );
745
+ log(`CONNECT ${req.url} -> ${PROXY_407_NOTE}`);
746
+ return;
747
+ }
684
748
  if (host === MITM_HOST && port === 443) {
685
749
  // A timer can run late (laptop sleep): never hand out a leaf that is about to expire.
686
750
  if (leaves.current().notAfter - Date.now() < LEAF_MIN_LEFT_MS) rotateLeaf("expiring");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mnemom/mnemom",
3
- "version": "0.17.32",
3
+ "version": "0.17.33",
4
4
  "description": "Transparent AI agent tracing",
5
5
  "type": "module",
6
6
  "bin": {
@@ -24,6 +24,7 @@
24
24
  "smol-toml": "^1.9.0"
25
25
  },
26
26
  "devDependencies": {
27
+ "@mnemom/aip-verifier": "https://github.com/mnemom/aip/releases/download/aip-verifier-v0.6.0/mnemom-aip-verifier-0.6.0.tgz",
27
28
  "@types/js-yaml": "^4.0.9",
28
29
  "@types/node": "^20.10.0",
29
30
  "@vitest/coverage-v8": "^4.1.12",