@mnemom/mnemom 0.17.32 → 0.17.34-next.1

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 (88) 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 +34 -4
  14. package/dist/commands/agent.js +116 -20
  15. package/dist/commands/protection.js +1 -1
  16. package/dist/commands/scan.d.ts +97 -0
  17. package/dist/commands/scan.js +238 -0
  18. package/dist/index.js +55 -0
  19. package/dist/lib/api.d.ts +13 -0
  20. package/dist/lib/api.js +25 -1
  21. package/dist/lib/auth.d.ts +6 -0
  22. package/dist/lib/auth.js +161 -14
  23. package/dist/lib/experimental.d.ts +1 -1
  24. package/dist/lib/experimental.js +13 -0
  25. package/dist/lib/oauth.d.ts +7 -0
  26. package/dist/lib/oauth.js +13 -0
  27. package/dist/lib/runner/bundle.d.ts +129 -0
  28. package/dist/lib/runner/bundle.js +319 -0
  29. package/dist/lib/runner/contract-header.d.ts +46 -0
  30. package/dist/lib/runner/contract-header.js +85 -0
  31. package/dist/lib/runner/executor-client.d.ts +71 -0
  32. package/dist/lib/runner/executor-client.js +97 -0
  33. package/dist/lib/runner/job.d.ts +70 -0
  34. package/dist/lib/runner/job.js +463 -0
  35. package/dist/lib/runner/loop.d.ts +45 -0
  36. package/dist/lib/runner/loop.js +104 -0
  37. package/dist/lib/runner/oidc.d.ts +38 -0
  38. package/dist/lib/runner/oidc.js +89 -0
  39. package/dist/lib/runner/options.d.ts +41 -0
  40. package/dist/lib/runner/options.js +47 -0
  41. package/dist/lib/runner/outcome.d.ts +47 -0
  42. package/dist/lib/runner/outcome.js +89 -0
  43. package/dist/lib/runner/phases.d.ts +49 -0
  44. package/dist/lib/runner/phases.js +99 -0
  45. package/dist/lib/runner/proxy.d.ts +45 -0
  46. package/dist/lib/runner/proxy.js +153 -0
  47. package/dist/lib/runner/settings.d.ts +31 -0
  48. package/dist/lib/runner/settings.js +44 -0
  49. package/dist/lib/scan/caps.d.ts +35 -0
  50. package/dist/lib/scan/caps.js +67 -0
  51. package/dist/lib/scan/credential.d.ts +37 -0
  52. package/dist/lib/scan/credential.js +104 -0
  53. package/dist/lib/scan/failure.d.ts +10 -0
  54. package/dist/lib/scan/failure.js +43 -0
  55. package/dist/lib/scan/pr-source.d.ts +57 -0
  56. package/dist/lib/scan/pr-source.js +130 -0
  57. package/dist/lib/scan/record.d.ts +49 -0
  58. package/dist/lib/scan/record.js +85 -0
  59. package/dist/lib/scan/report-email.d.ts +92 -0
  60. package/dist/lib/scan/report-email.js +136 -0
  61. package/dist/lib/scan/report.d.ts +13 -0
  62. package/dist/lib/scan/report.js +30 -0
  63. package/dist/lib/scan/scan.d.ts +64 -0
  64. package/dist/lib/scan/scan.js +182 -0
  65. package/dist/lib/scan/share.d.ts +38 -0
  66. package/dist/lib/scan/share.js +47 -0
  67. package/dist/lib/scan/spend/ai-tool-detect.d.ts +6 -0
  68. package/dist/lib/scan/spend/ai-tool-detect.js +18 -0
  69. package/dist/lib/scan/spend/anthropic-usage.d.ts +57 -0
  70. package/dist/lib/scan/spend/anthropic-usage.js +244 -0
  71. package/dist/lib/scan/spend/cost-corpus.v1.d.ts +56 -0
  72. package/dist/lib/scan/spend/cost-corpus.v1.js +64 -0
  73. package/dist/lib/scan/spend/prices.d.ts +33 -0
  74. package/dist/lib/scan/spend/prices.js +57 -0
  75. package/dist/lib/scan/spend/spend-estimate.d.ts +30 -0
  76. package/dist/lib/scan/spend/spend-estimate.js +70 -0
  77. package/dist/lib/scan/spend/spend-tiers.d.ts +25 -0
  78. package/dist/lib/scan/spend/spend-tiers.js +42 -0
  79. package/dist/lib/scan/spend/token-rate.d.ts +30 -0
  80. package/dist/lib/scan/spend/token-rate.js +54 -0
  81. package/dist/lib/session-key.d.ts +41 -0
  82. package/dist/lib/session-key.js +124 -0
  83. package/dist/lib/statusline-feed.js +10 -11
  84. package/dist/lib/statusline.d.ts +6 -0
  85. package/dist/lib/statusline.js +19 -9
  86. package/dist/rc-key-401.mjs +75 -0
  87. package/dist/rc-proxy.mjs +290 -30
  88. package/package.json +2 -1
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,23 @@
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.
67
+ //
68
+ // SESSION KEY RENEWAL
69
+ // The Mnemom key on line 2 is the launcher's minted session key (MNE-8245): 24h-TTL'd, and a
70
+ // Remote Control seat outlives it. When the launcher passes MNEMOM_AGENT_RC_CONTROL_FD, that fd is
71
+ // a pipe to it carrying newline-delimited lines both ways:
72
+ // launcher -> proxy `key <key id> <expires at> <key>` swap this key in (before the old expires)
73
+ // `key-failed <reason>` a re-mint this proxy asked for failed
74
+ // proxy -> launcher `remint <key id>` the gateway rejected this key
75
+ // The swap is a single assignment, so every request carries either the old key or the new one.
76
+ // On a gateway 401 that names the Mnemom key (`Invalid Mnemom API key`) for a buffered
77
+ // /v1/messages POST, the proxy holds the 401 back, retries once with a newer key when it already
78
+ // has one, else asks the launcher for one re-mint (single flight, bounded wait) and retries once
79
+ // with it; anything else hands the client the original 401. Never a second retry, and never a
80
+ // request whose body was streamed. Only key ids and expiry times are logged, never a key.
57
81
  // The log (MNEMOM_AGENT_RC_PROXY_LOG, else stderr) carries method/path/status/latency and the
58
82
  // gateway's `x-mnemom-agent` / `x-mnemom-goal-verdict` response headers only. Gateway error
59
83
  // bodies (status ≥ 400) are logged truncated so a 4xx is diagnosable (compressed ones as
@@ -80,11 +104,18 @@
80
104
  // is required: it anchors the governed identity.
81
105
  // MNEMOM_AGENT_RC_PROXY_LOG append log lines to this file instead of stderr (optional)
82
106
  // MNEMOM_AGENT_RC_PARENT_PID exit when this pid is gone, so no orphan holds the keys
107
+ // MNEMOM_AGENT_RC_CONTROL_FD the launcher's control pipe (optional; see SESSION KEY RENEWAL)
108
+ // MNEMOM_AGENT_RC_MNEMOM_KEY_ID the line-2 key's id (optional; logged, and named in `remint`)
109
+ // MNEMOM_AGENT_RC_MNEMOM_KEY_EXPIRES_AT the line-2 key's expiry (optional; logged)
83
110
  // MNEMOM_AGENT_RC_PORT listen port (default 0 = ephemeral)
111
+ // MNEMOM_RC_REMINT_WAIT_MS how long a key-rejected request waits for the launcher's re-mint
112
+ // (default 20000; unset or empty means the default, "0" is
113
+ // honoured). The launcher never sets it, only tests do
84
114
  // MNEMOM_AGENT_TURN_HEADER 0/off: no x-mnemom-turn header (default on in implicit mode;
85
115
  // see turn-origin.mjs)
86
116
  //
87
- // stdout: exactly one line, `READY <port> <ca.pem path>`, once listening. Node ≥ 18, no deps.
117
+ // stdout: exactly one line, `READY <port> <ca.pem path>`, once listening (no secret on it). Node ≥ 18,
118
+ // no deps.
88
119
  import http from "node:http";
89
120
  import https from "node:https";
90
121
  import net from "node:net";
@@ -96,6 +127,12 @@ import { execFileSync } from "node:child_process";
96
127
  import { TURN_HEADER, TurnOrigins, turnHeaderEnabled, turnHeaderFor } from "./turn-origin.mjs";
97
128
  import { OWNER_FILE, ensureRcStateRoot, keepPrivateDir, sweepRcDirs } from "./rc-state.mjs";
98
129
  import { assertUsableCert, makeLeafRotator } from "./rc-certs.mjs";
130
+ import {
131
+ KEY_401_BODY_MAX,
132
+ holdBack401,
133
+ isMnemomKeyRejection,
134
+ remintWaitMs,
135
+ } from "./rc-key-401.mjs";
99
136
 
100
137
  const MITM_HOST = "api.anthropic.com";
101
138
  const env = process.env;
@@ -165,7 +202,7 @@ const log = (line, retry = true) => {
165
202
  process.stderr.write(s);
166
203
  };
167
204
 
168
- // --- secrets: three lines on stdin ------------------------------------------------------
205
+ // --- secrets: four lines on stdin -------------------------------------------------------
169
206
  let secrets;
170
207
  try {
171
208
  const lines = fs.readFileSync(0, "utf8").split("\n");
@@ -173,15 +210,141 @@ try {
173
210
  anthropic: (lines[0] || "").trim(),
174
211
  mnemom: (lines[1] || "").trim(),
175
212
  openai: (lines[2] || "").trim(),
213
+ proxy: (lines[3] || "").trim(),
176
214
  };
177
215
  } catch {
178
- die("expected three lines of secrets on stdin (anthropic, mnemom, openai)");
216
+ die("expected four lines on stdin (anthropic, mnemom, openai, proxy credential)");
179
217
  }
180
218
  if (AGENT_ID && !secrets.mnemom)
181
219
  die("a pinned agent (MNEMOM_AGENT_RC_AGENT_ID) needs the Mnemom key (stdin line 2)");
182
220
  if (SUBSCRIPTION) {
183
221
  if (!secrets.mnemom) die("subscription login needs the Mnemom key (stdin line 2)");
184
222
  } else if (!secrets.anthropic) die("the Anthropic key (stdin line 1) is empty");
223
+ // Fail closed: with no credential there is nothing to check a caller against.
224
+ if (!secrets.proxy) die("the proxy credential (stdin line 4) is empty");
225
+
226
+ // --- Mnemom session key renewal (see SESSION KEY RENEWAL) ----------------------------------
227
+ // Key ids and expiry times are the only parts of a key line that may reach the log.
228
+ const KEY_ID_RE = /^[A-Za-z0-9_.:-]{1,128}$/;
229
+ const EXPIRES_RE = /^[0-9A-Za-z:.+-]{1,40}$/;
230
+ const KEY_RE = /^[\x21-\x7e]{1,4096}$/;
231
+ const keyMeta = {
232
+ id: KEY_ID_RE.test(env.MNEMOM_AGENT_RC_MNEMOM_KEY_ID || "")
233
+ ? env.MNEMOM_AGENT_RC_MNEMOM_KEY_ID
234
+ : "-",
235
+ expires: EXPIRES_RE.test(env.MNEMOM_AGENT_RC_MNEMOM_KEY_EXPIRES_AT || "")
236
+ ? env.MNEMOM_AGENT_RC_MNEMOM_KEY_EXPIRES_AT
237
+ : "-",
238
+ };
239
+ // How long a rejected request waits for the launcher's re-mint (rc-key-401.mjs).
240
+ const REMINT_WAIT_MS = remintWaitMs(env);
241
+ const CONTROL_LINE_MAX = 8192;
242
+ let control = null;
243
+ let remint = null; // { promise, resolve, timer } while a re-mint request is outstanding
244
+
245
+ function settleRemint(ok) {
246
+ if (!remint) return;
247
+ const r = remint;
248
+ remint = null;
249
+ clearTimeout(r.timer);
250
+ r.resolve(ok);
251
+ }
252
+
253
+ // Resolves true once a new key has been swapped in, false when none is coming.
254
+ function requestRemint() {
255
+ if (remint) return remint.promise;
256
+ if (!control) return Promise.resolve(false);
257
+ let resolve;
258
+ const promise = new Promise((r) => (resolve = r));
259
+ const timer = setTimeout(() => {
260
+ log(`mnemom key re-mint: no answer from the launcher within ${REMINT_WAIT_MS}ms`);
261
+ settleRemint(false);
262
+ }, REMINT_WAIT_MS);
263
+ timer.unref();
264
+ remint = { promise, resolve, timer };
265
+ control.write(`remint ${keyMeta.id}\n`);
266
+ return promise;
267
+ }
268
+
269
+ function onControlLine(line) {
270
+ const parts = line.split(" ");
271
+ const cmd = parts[0];
272
+ if (
273
+ cmd === "key" &&
274
+ parts.length === 4 &&
275
+ KEY_ID_RE.test(parts[1]) &&
276
+ EXPIRES_RE.test(parts[2]) &&
277
+ KEY_RE.test(parts[3])
278
+ ) {
279
+ const was = keyMeta.id;
280
+ secrets.mnemom = parts[3];
281
+ keyMeta.id = parts[1];
282
+ keyMeta.expires = parts[2];
283
+ log(`mnemom key renewed id=${keyMeta.id} expires=${keyMeta.expires} (was id=${was})`);
284
+ settleRemint(true);
285
+ } else if (cmd === "key-failed") {
286
+ const why = parts
287
+ .slice(1)
288
+ .join(" ")
289
+ .replace(/[^A-Za-z0-9 _.-]/g, "")
290
+ .slice(0, 80);
291
+ log(`mnemom key re-mint failed in the launcher (${why || "-"}); keeping id=${keyMeta.id}`);
292
+ settleRemint(false);
293
+ } else {
294
+ // Never echo the line: a malformed key line still carries a key.
295
+ log(`control: ignored a malformed line (${/^[a-z-]{1,16}$/.test(cmd) ? cmd : "?"})`);
296
+ }
297
+ }
298
+
299
+ const CONTROL_FD = Number(env.MNEMOM_AGENT_RC_CONTROL_FD || 0);
300
+ if (secrets.mnemom && keyMeta.id !== "-")
301
+ log(`mnemom key id=${keyMeta.id} expires=${keyMeta.expires}`);
302
+ if (CONTROL_FD > 2) {
303
+ try {
304
+ const sock = new net.Socket({ fd: CONTROL_FD, readable: true, writable: true });
305
+ control = sock;
306
+ let buf = "";
307
+ sock.on("data", (c) => {
308
+ buf += c.toString("utf8");
309
+ let i;
310
+ while ((i = buf.indexOf("\n")) >= 0) {
311
+ const line = buf.slice(0, i);
312
+ buf = buf.slice(i + 1);
313
+ onControlLine(line);
314
+ }
315
+ if (buf.length > CONTROL_LINE_MAX) {
316
+ buf = "";
317
+ log("control: dropped an oversized line");
318
+ }
319
+ });
320
+ const gone = (why) => {
321
+ if (control !== sock) return;
322
+ control = null;
323
+ log(`control channel closed (${why}): no further session key renewal`);
324
+ settleRemint(false);
325
+ };
326
+ sock.on("error", (e) => gone(e.code || e.message));
327
+ sock.on("close", () => gone("close"));
328
+ } catch (e) {
329
+ log(`control channel unavailable (${e.code || e.message}): no session key renewal`);
330
+ }
331
+ }
332
+
333
+ // --- caller authentication (MNE-9233; see WHAT IT DOES) -------------------------------------
334
+ // `Proxy-Authorization: Basic base64("mnemom:<credential>")`, compared in constant time. The
335
+ // header value is never logged.
336
+ const PROXY_USER = "mnemom";
337
+ const PROXY_AUTH_EXPECTED = Buffer.from(`${PROXY_USER}:${secrets.proxy}`, "utf8");
338
+ function callerAuthorized(header) {
339
+ const m = /^basic\s+([A-Za-z0-9+/]+={0,2})\s*$/i.exec(typeof header === "string" ? header : "");
340
+ if (!m) return false;
341
+ const got = Buffer.from(m[1], "base64");
342
+ return (
343
+ got.length === PROXY_AUTH_EXPECTED.length && crypto.timingSafeEqual(got, PROXY_AUTH_EXPECTED)
344
+ );
345
+ }
346
+ const PROXY_REALM = 'Basic realm="mnemom-code-rc-proxy"';
347
+ const PROXY_407_NOTE = "407 proxy authentication required (no or wrong Proxy-Authorization)";
185
348
 
186
349
  // --- ephemeral CA + rotating leaf for api.anthropic.com ---------------------------------
187
350
  // Private 0700 dir under ~/.mnemom/rc (rc-state.mjs), removed on exit. Claude Code reads NODE_EXTRA_CA_CERTS
@@ -389,6 +552,7 @@ const upstreamAgent = new https.Agent({ keepAlive: true, maxSockets: 32 });
389
552
  const HOP = new Set([
390
553
  "connection",
391
554
  "proxy-connection",
555
+ "proxy-authorization", // this session's proxy credential: never leaves the proxy
392
556
  "keep-alive",
393
557
  "upgrade",
394
558
  "host",
@@ -490,9 +654,34 @@ function rejectThread(res, tag, thread) {
490
654
  );
491
655
  }
492
656
 
657
+ // 407 for an absolute-form request without this session's credential: nothing is parsed,
658
+ // forwarded or injected, and the connection is closed.
659
+ function refusePlain(req, res) {
660
+ req.resume(); // discard any body
661
+ let host = "-";
662
+ try {
663
+ host = new URL(req.url).host || "-";
664
+ } catch {
665
+ /* not absolute-form: logged as "-" */
666
+ }
667
+ res.writeHead(407, {
668
+ "proxy-authenticate": PROXY_REALM,
669
+ "content-type": "text/plain",
670
+ connection: "close",
671
+ });
672
+ res.end("mnemom-code-rc-proxy: proxy authentication required\n");
673
+ log(`${req.method} ${host} -> ${PROXY_407_NOTE}`);
674
+ }
675
+
493
676
  // One handler serves both the MITM'd TLS stream (origin-form URLs, host = api.anthropic.com)
494
677
  // and absolute-form plaintext requests sent straight to the proxy port.
495
678
  function handle(req, res, viaPlain) {
679
+ // A request on the proxy port must carry the credential. One inside the MITM'd TLS stream
680
+ // already did, on the CONNECT that opened the tunnel.
681
+ if (viaPlain && !callerAuthorized(req.headers["proxy-authorization"])) {
682
+ refusePlain(req, res);
683
+ return;
684
+ }
496
685
  let host = MITM_HOST,
497
686
  port = 443,
498
687
  urlPath = req.url,
@@ -534,7 +723,7 @@ function handle(req, res, viaPlain) {
534
723
  delete headers["x-mnemom-agent-id"];
535
724
  if (AGENT_ID) headers["x-mnemom-agent-id"] = AGENT_ID;
536
725
  headers["x-mnemom-conversation-id"] = CONV;
537
- if (secrets.mnemom) headers["x-mnemom-api-key"] = secrets.mnemom;
726
+ // x-mnemom-api-key is set per attempt in forward(): the key can be swapped mid-session.
538
727
  if (PROVIDER) headers["x-mnemom-provider"] = PROVIDER;
539
728
  if (secrets.openai) headers["x-mnemom-egress-key"] = secrets.openai;
540
729
  if (CONTRACT_B64) headers["x-mnemom-contract"] = CONTRACT_B64;
@@ -562,20 +751,96 @@ function handle(req, res, viaPlain) {
562
751
  const tag = `${req.method} ${viaPlain ? host : ""}${urlPath}`;
563
752
  const leg = toGateway ? "gateway" : "upstream";
564
753
 
754
+ // Gateway-leg response fields for the log line.
755
+ const identOf = (ur) => {
756
+ if (!toGateway) return "";
757
+ const goalRev = ur.headers["x-mnemom-goal-revision"];
758
+ const goalThread = ur.headers["x-mnemom-goal-thread"];
759
+ const steer = ur.headers["x-mnemom-goal-steer"];
760
+ return (
761
+ ` agent=${ur.headers["x-mnemom-agent"] || "-"} verdict=${ur.headers["x-mnemom-goal-verdict"] || "-"}` +
762
+ (goalRev ? ` goal-rev=${goalRev}` : "") +
763
+ (goalThread ? ` goal-thread=${goalThread}` : "") +
764
+ (steer ? ` steer=${steer}` : "")
765
+ );
766
+ };
767
+ // Gateway error bodies are logged (truncated) so a 4xx is diagnosable — unless the body is
768
+ // compressed, in which case only its encoding + size is useful (`identity` is not a compression).
769
+ const errEncOf = (ur) => {
770
+ const enc = ur.headers["content-encoding"];
771
+ return enc && enc !== "identity" ? enc : "";
772
+ };
773
+ const bodyNoteOf = (errEnc, errBody, errBytes) =>
774
+ errEnc
775
+ ? ` body=${errEnc} ${errBytes} bytes`
776
+ : errBody
777
+ ? " body=" + errBody.slice(0, 1500).replace(/\s+/g, " ")
778
+ : "";
779
+
565
780
  // Open the upstream request only once we know we are forwarding. `body` is the fully
566
781
  // buffered (normalised) gateway body; undefined means stream the client request through.
567
- const forward = (body) => {
782
+ // `retried` marks the one retry after a rejected Mnemom key (see SESSION KEY RENEWAL).
783
+ const forward = (body, retried = false) => {
784
+ const usedKey = toGateway ? secrets.mnemom : "";
785
+ const usedKeyId = keyMeta.id;
786
+ // Only this proxy states the Mnemom key, and only to the gateway: a client-supplied one is dropped
787
+ // on every leg, and no upstream other than the gateway door ever sees it.
788
+ delete headers["x-mnemom-api-key"];
789
+ if (usedKey) headers["x-mnemom-api-key"] = usedKey;
568
790
  const opts = { ...target, method: req.method, headers };
569
791
  if (!net.isIP(target.hostname)) opts.servername = target.hostname; // SNI is for names, not IPs
570
792
  const up = (toGateway ? https : proto).request(opts, (ur) => {
793
+ // A 401 for a buffered request may be the key expiring under a long session: hold it back
794
+ // (nothing has reached the client yet) and decide once the whole error body is in.
795
+ if (toGateway && ur.statusCode === 401 && usedKey && body !== undefined && !retried) {
796
+ // Over KEY_401_BODY_MAX it is not the key rejection (and a decompression-bomb guard): it
797
+ // streams to the client as is (rc-key-401.mjs).
798
+ const onOverflow = (held) =>
799
+ log(
800
+ `${tag} -> ${leg} ${ur.statusCode} ${Date.now() - started}ms${identOf(ur)} body=${held} bytes (over ${KEY_401_BODY_MAX}: not a key rejection)`,
801
+ );
802
+ holdBack401(ur, res, {
803
+ onOverflow,
804
+ onBody: (raw) => {
805
+ const ms = Date.now() - started;
806
+ const errEnc = errEncOf(ur);
807
+ const answer = () => {
808
+ if (res.destroyed) return; // the client left while the re-mint was pending
809
+ res.writeHead(ur.statusCode, ur.headers);
810
+ res.end(raw);
811
+ const errBody = errEnc ? "" : raw.toString("utf8", 0, Math.min(raw.length, 1500));
812
+ log(
813
+ `${tag} -> ${leg} ${ur.statusCode} ${ms}ms${identOf(ur)}${bodyNoteOf(errEnc, errBody, raw.length)}`,
814
+ );
815
+ };
816
+ if (!isMnemomKeyRejection(raw, ur.headers["content-encoding"])) {
817
+ answer();
818
+ return;
819
+ }
820
+ const newer = secrets.mnemom !== usedKey;
821
+ log(
822
+ `${tag} -> ${leg} 401 ${ms}ms mnemom key id=${usedKeyId} rejected; ${
823
+ newer
824
+ ? `retrying once with id=${keyMeta.id}`
825
+ : "asking the launcher for a fresh key"
826
+ }`,
827
+ );
828
+ (newer ? Promise.resolve(true) : requestRemint()).then((ok) => {
829
+ // A key line never carries an empty key (KEY_RE), so a changed key is a usable one.
830
+ if (ok && secrets.mnemom !== usedKey && !res.destroyed) {
831
+ forward(body, true);
832
+ } else {
833
+ answer();
834
+ }
835
+ });
836
+ },
837
+ });
838
+ return;
839
+ }
571
840
  res.writeHead(ur.statusCode, ur.headers);
572
- // Gateway error bodies are logged (truncated) so a 4xx is diagnosable — unless the
573
- // body is compressed, in which case only its encoding + size is useful
574
- // (`identity` is not a compression).
575
841
  let errBody = "",
576
842
  errBytes = 0;
577
- const enc = ur.headers["content-encoding"];
578
- const errEnc = enc && enc !== "identity" ? enc : "";
843
+ const errEnc = errEncOf(ur);
579
844
  if (toGateway && ur.statusCode >= 400) {
580
845
  ur.on("data", (c) => {
581
846
  errBytes += c.length;
@@ -583,25 +848,12 @@ function handle(req, res, viaPlain) {
583
848
  });
584
849
  }
585
850
  ur.pipe(res);
586
- const goalRev = ur.headers["x-mnemom-goal-revision"];
587
- const goalThread = ur.headers["x-mnemom-goal-thread"];
588
- const steer = ur.headers["x-mnemom-goal-steer"];
589
- const ident = toGateway
590
- ? ` agent=${ur.headers["x-mnemom-agent"] || "-"} verdict=${ur.headers["x-mnemom-goal-verdict"] || "-"}` +
591
- (goalRev ? ` goal-rev=${goalRev}` : "") +
592
- (goalThread ? ` goal-thread=${goalThread}` : "") +
593
- (steer ? ` steer=${steer}` : "")
594
- : "";
595
851
  ur.on("end", () => {
596
- let bodyNote = "";
597
- if (toGateway && ur.statusCode >= 400) {
598
- bodyNote = errEnc
599
- ? ` body=${errEnc} ${errBytes} bytes`
600
- : errBody
601
- ? " body=" + errBody.slice(0, 1500).replace(/\s+/g, " ")
602
- : "";
603
- }
604
- log(`${tag} -> ${leg} ${ur.statusCode} ${Date.now() - started}ms${ident}${bodyNote}`);
852
+ const bodyNote =
853
+ toGateway && ur.statusCode >= 400 ? bodyNoteOf(errEnc, errBody, errBytes) : "";
854
+ log(
855
+ `${tag} -> ${leg} ${ur.statusCode} ${Date.now() - started}ms${identOf(ur)}${bodyNote}${retried ? " (retry)" : ""}`,
856
+ );
605
857
  });
606
858
  });
607
859
  up.on("error", (e) => {
@@ -681,6 +933,14 @@ proxy.on("connect", (req, sock, head) => {
681
933
  const [host, portStr] = req.url.split(":");
682
934
  const port = Number(portStr || 443);
683
935
  sock.on("error", () => {});
936
+ if (!callerAuthorized(req.headers["proxy-authorization"])) {
937
+ // Before the MITM arm and before any upstream socket: an unauthenticated caller gets neither.
938
+ sock.end(
939
+ `HTTP/1.1 407 Proxy Authentication Required\r\nProxy-Authenticate: ${PROXY_REALM}\r\nContent-Length: 0\r\nConnection: close\r\n\r\n`,
940
+ );
941
+ log(`CONNECT ${req.url} -> ${PROXY_407_NOTE}`);
942
+ return;
943
+ }
684
944
  if (host === MITM_HOST && port === 443) {
685
945
  // A timer can run late (laptop sleep): never hand out a leaf that is about to expire.
686
946
  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.34-next.1",
4
4
  "description": "Transparent AI agent tracing",
5
5
  "type": "module",
6
6
  "bin": {
@@ -17,6 +17,7 @@
17
17
  "test": "vitest"
18
18
  },
19
19
  "dependencies": {
20
+ "@mnemom/aip-verifier": "https://github.com/mnemom/aip/releases/download/aip-verifier-v0.6.0/mnemom-aip-verifier-0.6.0.tgz",
20
21
  "@mnemom/policy-engine": "^0.3.0",
21
22
  "chalk": "^5.3.0",
22
23
  "commander": "^12.0.0",