@mnemom/mnemom 0.17.23 → 0.17.25

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.
@@ -6,7 +6,8 @@
6
6
  * mnemom agent slack login # store the bot + app-level tokens (0600)
7
7
  * mnemom agent slack logout # remove them
8
8
  * mnemom agent slack status # tokens, bot identity, router, live sessions
9
- * mnemom agent slack post [--channel C] [--thread TS] [--icon-emoji E] <text…|->
9
+ * mnemom agent slack post [--channel C] [--thread TS] [--icon-emoji E] [--] <text…|->
10
+ * # --help prints usage; unknown flags are refused
10
11
  * mnemom agent slack delete [--channel C] <ts> # delete a message THIS session posted
11
12
  * mnemom agent slack inbox [--peek] # unread messages addressed to THIS session
12
13
  * mnemom agent slack listen # this session's channel server (spawned by --slack)
@@ -21,7 +22,15 @@ interface PostArgs {
21
22
  channel?: string;
22
23
  thread?: string;
23
24
  iconEmoji?: string;
25
+ /** `--help` / `-h` was given: print POST_USAGE and post nothing. */
26
+ help?: true;
24
27
  text: string;
25
28
  }
29
+ export declare const POST_USAGE = "Usage: mnemom agent slack post [options] [--] <text\u2026|->\n\nPost a message to Slack as this session.\n\nOptions:\n -c, --channel <C> channel name or id (default: slack_channel from `mnemom agent config`)\n -t, --thread <TS> reply in the thread whose parent message has this ts\n --icon-emoji <E> emoji to show as the poster's icon\n -h, --help show this help and exit; nothing is posted\n\nText:\n The remaining words, joined by spaces. `-` (or no words with a piped stdin) reads stdin.\n Any other word starting with \"-\" before `--` is refused as an unknown flag. A message\n that starts with \"-\" must come after `--`, e.g. mnemom agent slack post -c C1 -- \"-1 on this\"";
30
+ /**
31
+ * Parse `post` arguments. Before a `--` separator, `--help`/`-h` asks for usage and any
32
+ * other dash-word that is not a known flag is refused, so a typo or a guessed flag can
33
+ * never be posted as message text. Everything after `--` is text, verbatim.
34
+ */
26
35
  export declare function parsePostArgs(argv: string[]): PostArgs;
27
36
  export {};
@@ -6,7 +6,8 @@
6
6
  * mnemom agent slack login # store the bot + app-level tokens (0600)
7
7
  * mnemom agent slack logout # remove them
8
8
  * mnemom agent slack status # tokens, bot identity, router, live sessions
9
- * mnemom agent slack post [--channel C] [--thread TS] [--icon-emoji E] <text…|->
9
+ * mnemom agent slack post [--channel C] [--thread TS] [--icon-emoji E] [--] <text…|->
10
+ * # --help prints usage; unknown flags are refused
10
11
  * mnemom agent slack delete [--channel C] <ts> # delete a message THIS session posted
11
12
  * mnemom agent slack inbox [--peek] # unread messages addressed to THIS session
12
13
  * mnemom agent slack listen # this session's channel server (spawned by --slack)
@@ -121,15 +122,39 @@ async function status() {
121
122
  const allow = loadLauncherConfig().slackAllow ?? [];
122
123
  out(`allowed senders: ${allow.length ? allow.join(", ") : "none — no message will reach a session"}`);
123
124
  }
125
+ export const POST_USAGE = `Usage: mnemom agent slack post [options] [--] <text…|->
126
+
127
+ Post a message to Slack as this session.
128
+
129
+ Options:
130
+ -c, --channel <C> channel name or id (default: slack_channel from \`mnemom agent config\`)
131
+ -t, --thread <TS> reply in the thread whose parent message has this ts
132
+ --icon-emoji <E> emoji to show as the poster's icon
133
+ -h, --help show this help and exit; nothing is posted
134
+
135
+ Text:
136
+ The remaining words, joined by spaces. \`-\` (or no words with a piped stdin) reads stdin.
137
+ Any other word starting with "-" before \`--\` is refused as an unknown flag. A message
138
+ that starts with "-" must come after \`--\`, e.g. mnemom agent slack post -c C1 -- "-1 on this"`;
139
+ const POST_SHORT_FLAGS = new Set(["-c", "-t", "-h"]);
140
+ /**
141
+ * Parse `post` arguments. Before a `--` separator, `--help`/`-h` asks for usage and any
142
+ * other dash-word that is not a known flag is refused, so a typo or a guessed flag can
143
+ * never be posted as message text. Everything after `--` is text, verbatim.
144
+ */
124
145
  export function parsePostArgs(argv) {
125
146
  const res = { text: "" };
126
147
  const words = [];
148
+ let unknown;
127
149
  for (let i = 0; i < argv.length; i++) {
128
150
  const a = argv[i];
129
151
  const val = () => {
130
152
  const v = argv[++i];
131
- if (v === undefined)
153
+ // A flag is never a value: `--channel --help` must not eat the --help. (A short
154
+ // dash-word that is not a flag stays a value: `--icon-emoji -1` is Slack's thumbsdown.)
155
+ if (v === undefined || v.startsWith("--") || POST_SHORT_FLAGS.has(v)) {
132
156
  throw new Error(`mnemom agent slack post: ${a} needs a value.`);
157
+ }
133
158
  return v;
134
159
  };
135
160
  if (a === "--channel" || a === "-c")
@@ -138,18 +163,32 @@ export function parsePostArgs(argv) {
138
163
  res.thread = val();
139
164
  else if (a === "--icon-emoji")
140
165
  res.iconEmoji = val();
166
+ else if (a === "--help" || a === "-h")
167
+ res.help = true;
141
168
  else if (a === "--") {
142
169
  words.push(...argv.slice(i + 1));
143
170
  break;
144
171
  }
172
+ else if (a.startsWith("-") && a !== "-")
173
+ unknown ??= a;
145
174
  else
146
175
  words.push(a);
147
176
  }
177
+ // Help wins over an unknown flag: someone asking for usage should get it.
178
+ if (res.help)
179
+ return { help: true, text: "" };
180
+ if (unknown !== undefined) {
181
+ throw new Error(`mnemom agent slack post: unknown flag ${unknown}; put message text after --. See \`mnemom agent slack post --help\`.`);
182
+ }
148
183
  res.text = words.join(" ");
149
184
  return res;
150
185
  }
151
186
  async function post(argv) {
152
187
  const args = parsePostArgs(argv);
188
+ if (args.help) {
189
+ process.stdout.write(POST_USAGE + "\n");
190
+ return;
191
+ }
153
192
  let text = args.text;
154
193
  if (text === "-" || (!text && !process.stdin.isTTY))
155
194
  text = readFileSync(0, "utf8");
@@ -0,0 +1,186 @@
1
+ // Certificate checks and leaf rotation for the Remote Control MITM proxy (rc-proxy.mjs).
2
+ //
3
+ // The proxy mints its CA and leaf by shelling out to whatever `openssl` is first on PATH. On macOS
4
+ // that is often /usr/bin/openssl (LibreSSL), whose defaults differ from OpenSSL 3's: `x509 -req`
5
+ // signs with SHA-1, and EC keys come out with explicit curve parameters. Node (OpenSSL 3) refuses
6
+ // both — a SHA-1 leaf at https.createServer (ERR_SSL_CA_MD_TOO_WEAK), an explicit-parameter key at
7
+ // the client's chain verification. rc-proxy.mjs pins `-sha256` and `ec_param_enc:named_curve`;
8
+ // this module is the fail-closed check behind that pin, so an openssl that still mints something
9
+ // Node cannot use stops the proxy with a message naming the binary, not a raw Node stack trace.
10
+ //
11
+ // Node 22's X509Certificate has no signatureAlgorithm getter, and it reports `namedCurve` even for
12
+ // an explicit-parameter key, so the two properties are read from the certificate's DER directly.
13
+
14
+ import fs from "node:fs";
15
+ import path from "node:path";
16
+ import tls from "node:tls";
17
+ import crypto from "node:crypto";
18
+ import { execFileSync } from "node:child_process";
19
+
20
+ const SIG_ALGS = {
21
+ "2a8648ce3d0401": "ecdsa-with-SHA1",
22
+ "2a8648ce3d040301": "ecdsa-with-SHA224",
23
+ "2a8648ce3d040302": "ecdsa-with-SHA256",
24
+ "2a8648ce3d040303": "ecdsa-with-SHA384",
25
+ "2a8648ce3d040304": "ecdsa-with-SHA512",
26
+ "2a864886f70d010104": "md5WithRSAEncryption",
27
+ "2a864886f70d010105": "sha1WithRSAEncryption",
28
+ "2a864886f70d01010b": "sha256WithRSAEncryption",
29
+ "2a864886f70d01010c": "sha384WithRSAEncryption",
30
+ "2a864886f70d01010d": "sha512WithRSAEncryption",
31
+ };
32
+ const STRONG_SIG_ALGS = new Set([
33
+ "ecdsa-with-SHA256",
34
+ "ecdsa-with-SHA384",
35
+ "ecdsa-with-SHA512",
36
+ "sha256WithRSAEncryption",
37
+ "sha384WithRSAEncryption",
38
+ "sha512WithRSAEncryption",
39
+ ]);
40
+ const OID_EC_PUBLIC_KEY = "2a8648ce3d0201";
41
+
42
+ // One DER TLV at `off`: { tag, start (of the value), end }.
43
+ function tlv(buf, off) {
44
+ if (off + 2 > buf.length) throw new Error("truncated DER");
45
+ const tag = buf[off];
46
+ let len = buf[off + 1];
47
+ let start = off + 2;
48
+ if (len & 0x80) {
49
+ const n = len & 0x7f;
50
+ if (n < 1 || n > 4 || start + n > buf.length) throw new Error("bad DER length");
51
+ len = 0;
52
+ for (let i = 0; i < n; i++) len = len * 256 + buf[start + i];
53
+ start += n;
54
+ }
55
+ const end = start + len;
56
+ if (end > buf.length) throw new Error("truncated DER");
57
+ return { tag, start, end };
58
+ }
59
+
60
+ function children(buf, node) {
61
+ const out = [];
62
+ for (let o = node.start; o < node.end; ) {
63
+ const c = tlv(buf, o);
64
+ out.push(c);
65
+ o = c.end;
66
+ }
67
+ return out;
68
+ }
69
+
70
+ const hex = (buf, node) => buf.subarray(node.start, node.end).toString("hex");
71
+
72
+ /**
73
+ * The signature algorithm and EC parameter encoding of a certificate, read from its DER:
74
+ * { signatureAlgorithm: "ecdsa-with-SHA256" | … | "oid:<hex>", ecParams: "named" | "explicit" | null }
75
+ * (ecParams is null for a non-EC key).
76
+ */
77
+ export function describeCert(pemOrDer) {
78
+ const der =
79
+ Buffer.isBuffer(pemOrDer) && pemOrDer[0] === 0x30
80
+ ? pemOrDer
81
+ : new crypto.X509Certificate(pemOrDer).raw;
82
+ const [tbs, sigAlg] = children(der, tlv(der, 0)); // Certificate ::= SEQ { tbs, sigAlg, sig }
83
+ const sigOid = hex(der, children(der, sigAlg)[0]);
84
+ const t = children(der, tbs);
85
+ const spki = t[t[0].tag === 0xa0 ? 6 : 5]; // [0] version?, serial, sigAlg, issuer, validity, subject, spki
86
+ const [algId] = children(der, spki);
87
+ const [keyOid, params] = children(der, algId);
88
+ let ecParams = null;
89
+ if (hex(der, keyOid) === OID_EC_PUBLIC_KEY) {
90
+ ecParams = params && params.tag === 0x06 ? "named" : "explicit";
91
+ }
92
+ return { signatureAlgorithm: SIG_ALGS[sigOid] || `oid:${sigOid}`, ecParams };
93
+ }
94
+
95
+ /** What makes this certificate unusable by Node's TLS stack, as phrases; empty when none. */
96
+ export function certProblems(pem) {
97
+ const { signatureAlgorithm, ecParams } = describeCert(pem);
98
+ const problems = [];
99
+ if (!STRONG_SIG_ALGS.has(signatureAlgorithm)) {
100
+ problems.push(`signed with ${signatureAlgorithm} (Node needs SHA-256 or stronger)`);
101
+ }
102
+ if (ecParams === "explicit") {
103
+ problems.push("EC key with explicit curve parameters (Node needs a named curve)");
104
+ }
105
+ return problems;
106
+ }
107
+
108
+ /**
109
+ * "/usr/bin/openssl (LibreSSL 3.3.6)" — the `openssl` the proxy runs (the first on env.PATH, as
110
+ * execFileSync resolves it), for error messages. The version comes from running THAT binary.
111
+ */
112
+ export function opensslIdentity(env = process.env) {
113
+ let found = null;
114
+ for (const dir of (env.PATH || "").split(path.delimiter)) {
115
+ if (!dir) continue; // an empty PATH segment ("a::b") is not the cwd here
116
+ const f = path.join(dir, "openssl");
117
+ try {
118
+ fs.accessSync(f, fs.constants.X_OK);
119
+ found = f;
120
+ break;
121
+ } catch {
122
+ /* next */
123
+ }
124
+ }
125
+ if (!found) return "openssl (not found on PATH)";
126
+ let version = "version unknown";
127
+ try {
128
+ version = execFileSync(found, ["version"], {
129
+ encoding: "utf8",
130
+ stdio: ["ignore", "pipe", "ignore"],
131
+ env,
132
+ }).trim();
133
+ } catch {
134
+ /* keep "version unknown" */
135
+ }
136
+ return `${found} (${version})`;
137
+ }
138
+
139
+ /**
140
+ * Throws if `cert` (the CA, when `role` is "CA") cannot be used by Node. For a leaf, also builds
141
+ * the exact secure context https.createServer / setSecureContext will, so anything else Node
142
+ * refuses (a key/cert mismatch, a digest under the security level) fails here, with a message.
143
+ */
144
+ export function assertUsableCert({ role, cert, key }, env = process.env) {
145
+ const problems = certProblems(cert);
146
+ if (!problems.length && key) {
147
+ try {
148
+ tls.createSecureContext({ key, cert });
149
+ } catch (e) {
150
+ problems.push(`Node refuses it: ${e.code || e.message}`);
151
+ }
152
+ }
153
+ if (problems.length) {
154
+ throw new Error(
155
+ `${opensslIdentity(env)} minted a ${role} certificate Node cannot use: ${problems.join("; ")}. ` +
156
+ "Put OpenSSL 3 first on PATH (macOS: `brew install openssl@3`) and relaunch.",
157
+ );
158
+ }
159
+ }
160
+
161
+ /**
162
+ * The leaf in force, and how to replace it. rotate() mints a new leaf and installs it; only when
163
+ * BOTH succeed does the new leaf become current. Before this, `leaf = mint()` ran ahead of the
164
+ * install, so an install that threw left the server serving the old leaf while `notAfter` already
165
+ * held the new leaf's expiry — the CONNECT handler's "expiring" check then never retried, and the
166
+ * session broke with an expired certificate once the original leaf ran out.
167
+ */
168
+ export function makeLeafRotator({ initial, mint, install, log, errText = (e) => e.message }) {
169
+ let leaf = initial;
170
+ return {
171
+ current: () => leaf,
172
+ rotate(why) {
173
+ let next;
174
+ try {
175
+ next = mint();
176
+ install(next);
177
+ } catch (e) {
178
+ log(`leaf rotation FAILED (${why}), keeping the current leaf: ${errText(e)}`);
179
+ return false;
180
+ }
181
+ leaf = next;
182
+ log(`leaf rotated (${why}) notAfter=${new Date(leaf.notAfter).toISOString()}`);
183
+ return true;
184
+ },
185
+ };
186
+ }
package/dist/rc-proxy.mjs CHANGED
@@ -92,6 +92,7 @@ import crypto from "node:crypto";
92
92
  import { execFileSync } from "node:child_process";
93
93
  import { TURN_HEADER, TurnOrigins, turnHeaderEnabled, turnHeaderFor } from "./turn-origin.mjs";
94
94
  import { OWNER_FILE, ensureRcStateRoot, keepPrivateDir, sweepRcDirs } from "./rc-state.mjs";
95
+ import { assertUsableCert, makeLeafRotator } from "./rc-certs.mjs";
95
96
 
96
97
  const MITM_HOST = "api.anthropic.com";
97
98
  const env = process.env;
@@ -180,7 +181,19 @@ if (SUBSCRIPTION) {
180
181
  // session picks it up on its next TLS handshake. (A 2-day CA + leaf with the CA key deleted at
181
182
  // startup broke every session older than 48 h with "SSL certificate has expired".) openssl only
182
183
  // takes the CA key from a file, so each signing writes it 0600 into caDir and deletes it right
183
- // after. Works with macOS's system LibreSSL (3.3+) and OpenSSL 1.1.1+ (`-addext`).
184
+ // after. The openssl flags used (`-addext`, `-pkeyopt`, `-sha256`) exist in OpenSSL 1.1.1+ and 3
185
+ // and in LibreSSL 3.3+ (macOS's /usr/bin/openssl).
186
+ //
187
+ // Until the digest and curve encoding were pinned, this only ever worked with OpenSSL 3 first on
188
+ // PATH: no call passed a digest, and LibreSSL's defaults differ from OpenSSL 3's in two ways that
189
+ // each break Node (OpenSSL 3), whatever the Node version. (1) `x509 -req` signs with
190
+ // ecdsa-with-SHA1, and https.createServer refuses that leaf with ERR_SSL_CA_MD_TOO_WEAK — the proxy
191
+ // died before READY and --remote-control refused to launch. (2) EC keys are written with explicit
192
+ // curve parameters, and a client verifying the chain rejects them ("Certificate public key has
193
+ // explicit ECC parameters"). So every call passes `-sha256` and `ec_param_enc:named_curve`, and
194
+ // every minted CA and leaf is checked before use (assertUsableCert, rc-certs.mjs): an openssl that
195
+ // still produces something Node cannot use stops the proxy with a message naming the binary and
196
+ // its version — or, for a rotation, fails that rotation and keeps the current leaf.
184
197
  //
185
198
  // Nothing may take the CA files mid-session. In the OS temp dir, macOS's dirhelper deleted
186
199
  // ca.pem / leaf.ext from sessions older than ~3-4 days (files not accessed for 3 days; openssl
@@ -240,6 +253,8 @@ try {
240
253
  "ec",
241
254
  "-pkeyopt",
242
255
  "ec_paramgen_curve:prime256v1",
256
+ "-pkeyopt",
257
+ "ec_param_enc:named_curve",
243
258
  "-nodes",
244
259
  "-keyout",
245
260
  p("ca.key"),
@@ -253,6 +268,7 @@ try {
253
268
  "basicConstraints=critical,CA:TRUE",
254
269
  "-addext",
255
270
  "keyUsage=critical,keyCertSign,cRLSign",
271
+ "-sha256",
256
272
  );
257
273
  caKey = fs.readFileSync(p("ca.key"));
258
274
  fs.rmSync(p("ca.key"));
@@ -262,6 +278,11 @@ try {
262
278
  fs.rmSync(p("ca.key"), { force: true });
263
279
  die(`could not mint the ephemeral CA with openssl: ${osslErr(e)}`);
264
280
  }
281
+ try {
282
+ assertUsableCert({ role: "CA", cert: caPem });
283
+ } catch (e) {
284
+ die(`${e.message} Refusing to start.`);
285
+ }
265
286
 
266
287
  // Recreate caDir and its long-lived files from memory if something deleted them, and refresh
267
288
  // every atime/mtime so the cleaner does not try again (see the block comment above).
@@ -299,6 +320,8 @@ function mintLeaf() {
299
320
  "ec",
300
321
  "-pkeyopt",
301
322
  "ec_paramgen_curve:prime256v1",
323
+ "-pkeyopt",
324
+ "ec_param_enc:named_curve",
302
325
  "-nodes",
303
326
  "-keyout",
304
327
  p("leaf.key"),
@@ -306,6 +329,7 @@ function mintLeaf() {
306
329
  p("leaf.csr"),
307
330
  "-subj",
308
331
  `/CN=${MITM_HOST}`,
332
+ "-sha256",
309
333
  );
310
334
  try {
311
335
  fs.writeFileSync(p("ca.key"), caKey, { mode: 0o600, flag: "wx" });
@@ -325,23 +349,27 @@ function mintLeaf() {
325
349
  String(LEAF_DAYS),
326
350
  "-extfile",
327
351
  p("leaf.ext"),
352
+ "-sha256",
328
353
  );
329
354
  } finally {
330
355
  fs.rmSync(p("ca.key"), { force: true });
331
356
  }
332
357
  const cert = fs.readFileSync(p("leaf.pem"));
333
- return {
334
- key: fs.readFileSync(p("leaf.key")),
335
- cert,
336
- notAfter: Date.parse(new crypto.X509Certificate(cert).validTo),
337
- };
358
+ const key = fs.readFileSync(p("leaf.key"));
359
+ assertUsableCert({ role: "leaf", cert, key }); // fail closed: never serve a leaf Node refuses
360
+ return { key, cert, notAfter: Date.parse(new crypto.X509Certificate(cert).validTo) };
338
361
  }
339
362
 
340
- let leaf;
363
+ let firstLeaf;
341
364
  try {
342
- leaf = mintLeaf();
365
+ firstLeaf = mintLeaf();
343
366
  } catch (e) {
344
- die(`could not mint the ephemeral CA with openssl: ${osslErr(e)}`);
367
+ // An openssl failure carries its stderr; an unusable certificate carries assertUsableCert's message.
368
+ const why =
369
+ e.stderr && e.stderr.length
370
+ ? `could not mint the ephemeral CA with openssl: ${osslErr(e)}`
371
+ : null;
372
+ die(why || `${e.message} Refusing to start.`);
345
373
  }
346
374
 
347
375
  // --- request handling ---------------------------------------------------------------------
@@ -613,22 +641,22 @@ function handle(req, res, viaPlain) {
613
641
  }
614
642
  }
615
643
 
616
- const tlsServer = https.createServer({ key: leaf.key, cert: leaf.cert }, (req, res) =>
644
+ const tlsServer = https.createServer({ key: firstLeaf.key, cert: firstLeaf.cert }, (req, res) =>
617
645
  handle(req, res, false),
618
646
  );
619
647
  tlsServer.on("tlsClientError", (e) => log(`tls client error: ${e.code || e.message}`));
620
648
 
621
649
  // Re-mint the leaf and hot-swap it; established TLS connections keep the old one, every new
622
- // handshake gets the new one. A failed re-mint keeps serving the current leaf.
623
- function rotateLeaf(why) {
624
- try {
625
- leaf = mintLeaf();
626
- tlsServer.setSecureContext({ key: leaf.key, cert: leaf.cert });
627
- log(`leaf rotated (${why}) notAfter=${new Date(leaf.notAfter).toISOString()}`);
628
- } catch (e) {
629
- log(`leaf rotation FAILED (${why}), keeping the current leaf: ${osslErr(e)}`);
630
- }
631
- }
650
+ // handshake gets the new one. A failed re-mint OR a failed swap keeps serving the current leaf —
651
+ // and keeps its notAfter, so the CONNECT handler's "expiring" check retries (rc-certs.mjs).
652
+ const leaves = makeLeafRotator({
653
+ initial: firstLeaf,
654
+ mint: mintLeaf,
655
+ install: (next) => tlsServer.setSecureContext({ key: next.key, cert: next.cert }),
656
+ log,
657
+ errText: osslErr,
658
+ });
659
+ const rotateLeaf = (why) => leaves.rotate(why);
632
660
  rotateTimer = setInterval(() => rotateLeaf("interval"), LEAF_ROTATE_MS);
633
661
  rotateTimer.unref();
634
662
  keepAliveTimer = setInterval(() => keepAlive("interval"), CA_KEEPALIVE_MS);
@@ -641,7 +669,7 @@ proxy.on("connect", (req, sock, head) => {
641
669
  sock.on("error", () => {});
642
670
  if (host === MITM_HOST && port === 443) {
643
671
  // A timer can run late (laptop sleep): never hand out a leaf that is about to expire.
644
- if (leaf.notAfter - Date.now() < LEAF_MIN_LEFT_MS) rotateLeaf("expiring");
672
+ if (leaves.current().notAfter - Date.now() < LEAF_MIN_LEFT_MS) rotateLeaf("expiring");
645
673
  sock.write("HTTP/1.1 200 Connection Established\r\n\r\n");
646
674
  if (head && head.length) sock.unshift(head);
647
675
  tlsServer.emit("connection", sock);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mnemom/mnemom",
3
- "version": "0.17.23",
3
+ "version": "0.17.25",
4
4
  "description": "Transparent AI agent tracing",
5
5
  "type": "module",
6
6
  "bin": {