engramx 2.0.1 → 2.0.2

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/CHANGELOG.md CHANGED
@@ -4,6 +4,93 @@ All notable changes to engram are documented here. Format based on
4
4
  [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versioning follows
5
5
  [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
+ ## [2.0.2] — 2026-04-18 — Security hotfix: HTTP server auth & CORS
8
+
9
+ **This is a security release. Upgrade immediately if you run `engram server`
10
+ or `engram ui`.** Credit: [@gabiudrescu](https://github.com/gabiudrescu) for
11
+ responsible disclosure ([#7](https://github.com/NickCirv/engram/issues/7)).
12
+
13
+ ### Security — fixed
14
+
15
+ - **Graph exfiltration + persistent prompt injection via cross-origin browser
16
+ tabs.** The HTTP server previously shipped with `Access-Control-Allow-Origin: *`
17
+ on every response and defaulted to no authentication. A malicious page the
18
+ developer visited could `fetch('http://127.0.0.1:7337/query')` to steal the
19
+ local graph, then `POST /learn` (with `Content-Type: text/plain`, a
20
+ CORS-safelisted content type) to persist `bug:` / `fix:` patterns that the
21
+ v2 Sentinel handlers later re-injected into the user's coding agent on
22
+ SessionStart and on every Edit/Write of the named file. Severity: High —
23
+ confidentiality + persistent indirect prompt injection.
24
+
25
+ **Fix (four stacked defenses):**
26
+ 1. **Fail-closed auth.** Every route except `/health` and `/favicon.ico`
27
+ now requires `Authorization: Bearer <token>` or an HttpOnly
28
+ `engram_token` cookie. A random 64-character token is auto-generated
29
+ on first server start and persisted to `~/.engram/http-server.token`
30
+ with mode `0600`. `ENGRAM_API_TOKEN` env var still overrides.
31
+ 2. **No wildcard CORS.** `Access-Control-Allow-Origin: *` has been removed
32
+ from every response. By default no CORS headers are emitted — the
33
+ dashboard is same-origin. Additional origins opt in via
34
+ `ENGRAM_ALLOWED_ORIGINS=a.com,b.com`.
35
+ 3. **Host + Origin validation** (DNS-rebinding defense). Requests with a
36
+ `Host` header other than `127.0.0.1|localhost|::1` on the bound port
37
+ return 400. Requests with an `Origin` not in the same-origin or env
38
+ allowlist return 403.
39
+ 4. **`Content-Type: application/json` enforced on mutations.** POST / PUT /
40
+ DELETE without `application/json` return 415. This blocks the
41
+ `text/plain` CSRF vector from the PoC and forces CORS preflight for
42
+ any cross-origin writer.
43
+
44
+ - **Timing side-channel on token comparison.** The previous
45
+ `header === \`Bearer ${token}\`` comparison was not constant-time.
46
+ Replaced with a length-first, constant-time `safeEqual()`.
47
+
48
+ ### Added
49
+
50
+ - `src/server/auth.ts` — token management (get-or-create, safeEqual, cookie
51
+ parsing, Host/Origin validators).
52
+ - `tests/server/security.test.ts` — PoC-style tests covering fail-closed
53
+ auth (including empty Bearer / empty cookie guards), env-downgrade
54
+ rejection (token is snapshot at start), cookie auth, wildcard-CORS
55
+ absence, same-origin echo, foreign-origin 403, Host header validation
56
+ (including no-port rejection + case-insensitive hostname), `text/plain`
57
+ rejection on `/learn`, the `/ui?token=` cross-site oracle defence via
58
+ `Sec-Fetch-Site` gating, and the end-to-end exploit chain from #7.
59
+ - `SECURITY.md` at repo root with disclosure policy and scope.
60
+ - `GET /ui?token=<t>` bootstrap path for the browser dashboard. The CLI
61
+ passes the token once; the server exchanges it for an HttpOnly cookie via
62
+ a 302 redirect and strips the token from the URL. Dashboard JS never sees
63
+ the raw token.
64
+
65
+ ### Changed
66
+
67
+ - `createHttpServer(projectRoot, port)` now resolves to `Promise<TokenInfo>`
68
+ (previously `Promise<void>`). The returned object exposes the token source
69
+ (env / file / generated) and the token file path. The CLI uses this to
70
+ print a one-time banner pointing users at `~/.engram/http-server.token`
71
+ when a fresh token is minted.
72
+ - `checkAuth` rewritten as fail-closed, accepts Bearer header OR
73
+ `engram_token` cookie, uses constant-time comparison.
74
+ - Server-Sent Events endpoint (`/api/sse`) no longer emits wildcard CORS and
75
+ inherits the same origin-allowlist behavior as every other route.
76
+
77
+ ### Breaking
78
+
79
+ - **External callers (curl, scripts, CI probes) must now send the token.**
80
+ Fix the one-liner on each caller:
81
+ ```bash
82
+ curl -H "Authorization: Bearer $(cat ~/.engram/http-server.token)" \
83
+ http://127.0.0.1:7337/stats
84
+ ```
85
+ - Requests with `Host: something-else.com` are rejected 400 even if they
86
+ resolve to 127.0.0.1 locally. DNS rebinding defense — intended behavior.
87
+ - Cross-origin requests (`Origin: https://example.com`) are rejected 403
88
+ unless the origin is in `ENGRAM_ALLOWED_ORIGINS`. No legitimate caller
89
+ should be affected.
90
+ - `/ui` navigation from the browser now requires `?token=<t>` on first visit
91
+ (set automatically when you run `engram ui`) or a pre-existing
92
+ `engram_token` cookie.
93
+
7
94
  ## [2.0.1] — 2026-04-17 — Windows CI + favicon route
8
95
 
9
96
  Patch release fixing two issues caught immediately after v2.0.0 shipped.
@@ -0,0 +1,14 @@
1
+ import {
2
+ getOrCreateToken,
3
+ isHostValid,
4
+ isOriginAllowed,
5
+ parseCookies,
6
+ safeEqual
7
+ } from "./chunk-N6PPKOPK.js";
8
+ export {
9
+ getOrCreateToken,
10
+ isHostValid,
11
+ isOriginAllowed,
12
+ parseCookies,
13
+ safeEqual
14
+ };
@@ -0,0 +1,105 @@
1
+ // src/server/auth.ts
2
+ import { randomBytes } from "crypto";
3
+ import {
4
+ chmodSync,
5
+ existsSync,
6
+ mkdirSync,
7
+ readFileSync,
8
+ writeFileSync
9
+ } from "fs";
10
+ import { homedir } from "os";
11
+ import { join } from "path";
12
+ var TOKEN_MIN_LEN = 32;
13
+ var TOKEN_BYTES = 32;
14
+ function tokenDir() {
15
+ return join(homedir(), ".engram");
16
+ }
17
+ function tokenPath() {
18
+ return join(tokenDir(), "http-server.token");
19
+ }
20
+ function getOrCreateToken() {
21
+ const envToken = process.env.ENGRAM_API_TOKEN;
22
+ if (envToken && envToken.length >= TOKEN_MIN_LEN) {
23
+ return { token: envToken, source: "env", path: null };
24
+ }
25
+ const path = tokenPath();
26
+ if (existsSync(path)) {
27
+ try {
28
+ const cached = readFileSync(path, "utf8").trim();
29
+ if (cached.length >= TOKEN_MIN_LEN) {
30
+ return { token: cached, source: "file", path };
31
+ }
32
+ } catch {
33
+ }
34
+ }
35
+ const fresh = randomBytes(TOKEN_BYTES).toString("hex");
36
+ const dir = tokenDir();
37
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
38
+ writeFileSync(path, fresh + "\n", { mode: 384 });
39
+ try {
40
+ chmodSync(path, 384);
41
+ } catch {
42
+ }
43
+ return { token: fresh, source: "generated", path };
44
+ }
45
+ function safeEqual(a, b) {
46
+ if (a.length === 0 || b.length === 0) return false;
47
+ if (a.length !== b.length) return false;
48
+ let diff = 0;
49
+ for (let i = 0; i < a.length; i++) {
50
+ diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
51
+ }
52
+ return diff === 0;
53
+ }
54
+ function parseCookies(header) {
55
+ const out = {};
56
+ if (!header || typeof header !== "string") return out;
57
+ for (const pair of header.split(/;\s*/)) {
58
+ const eq = pair.indexOf("=");
59
+ if (eq < 0) continue;
60
+ const key = pair.slice(0, eq).trim();
61
+ const value = pair.slice(eq + 1).trim();
62
+ if (key) out[key] = value;
63
+ }
64
+ return out;
65
+ }
66
+ function isHostValid(hostHeader, port) {
67
+ if (!hostHeader) return false;
68
+ let hostname;
69
+ let portStr;
70
+ if (hostHeader.startsWith("[")) {
71
+ const close = hostHeader.indexOf("]");
72
+ if (close < 0) return false;
73
+ hostname = hostHeader.slice(1, close);
74
+ portStr = hostHeader.slice(close + 2);
75
+ } else {
76
+ const colon = hostHeader.lastIndexOf(":");
77
+ if (colon < 0) {
78
+ hostname = hostHeader;
79
+ portStr = "";
80
+ } else {
81
+ hostname = hostHeader.slice(0, colon);
82
+ portStr = hostHeader.slice(colon + 1);
83
+ }
84
+ }
85
+ const h = hostname.toLowerCase();
86
+ if (h !== "127.0.0.1" && h !== "localhost" && h !== "::1") return false;
87
+ if (portStr !== String(port)) return false;
88
+ return true;
89
+ }
90
+ function isOriginAllowed(origin, port) {
91
+ if (origin === `http://127.0.0.1:${port}`) return true;
92
+ if (origin === `http://localhost:${port}`) return true;
93
+ const env = process.env.ENGRAM_ALLOWED_ORIGINS;
94
+ if (!env) return false;
95
+ const list = env.split(",").map((s) => s.trim()).filter(Boolean);
96
+ return list.includes(origin);
97
+ }
98
+
99
+ export {
100
+ getOrCreateToken,
101
+ safeEqual,
102
+ parseCookies,
103
+ isHostValid,
104
+ isOriginAllowed
105
+ };
package/dist/cli.js CHANGED
@@ -3605,12 +3605,12 @@ program.command("stress-test").description("Run stress tests: memory, concurrenc
3605
3605
  }
3606
3606
  });
3607
3607
  program.command("server").description("Start engram HTTP REST server (binds to 127.0.0.1 only)").option("--http", "Enable HTTP server (default)").option("--port <port>", "HTTP port", "7337").option("-p, --project <path>", "Project directory", ".").action(async (opts) => {
3608
- const { startHttpServer } = await import("./server-6AOI7NQP.js");
3608
+ const { startHttpServer } = await import("./server-KUG7U6SG.js");
3609
3609
  await startHttpServer(pathResolve(opts.project), parseInt(opts.port, 10));
3610
3610
  });
3611
3611
  program.command("ui").description("Open the web dashboard (auto-starts HTTP server if needed)").option("--port <port>", "HTTP port", "7337").option("-p, --project <path>", "Project directory", ".").option("--no-open", "Don't launch browser, just print the URL").action(async (opts) => {
3612
3612
  const port = parseInt(opts.port, 10);
3613
- const url = `http://127.0.0.1:${port}/ui`;
3613
+ const publicUrl = `http://127.0.0.1:${port}/ui`;
3614
3614
  const projectRoot = pathResolve(opts.project);
3615
3615
  const { existsSync: existsSync10, readFileSync: readFileSync6 } = await import("fs");
3616
3616
  const pidPath = join9(projectRoot, ".engram", "http-server.pid");
@@ -3624,9 +3624,9 @@ program.command("ui").description("Open the web dashboard (auto-starts HTTP serv
3624
3624
  }
3625
3625
  }
3626
3626
  if (alreadyRunning) {
3627
- console.log(chalk2.dim(`engram server already running \u2014 opening ${url}`));
3627
+ console.log(chalk2.dim(`engram server already running \u2014 opening ${publicUrl}`));
3628
3628
  } else {
3629
- console.log(chalk2.dim(`Starting engram server on ${url}...`));
3629
+ console.log(chalk2.dim(`Starting engram server on ${publicUrl}...`));
3630
3630
  const { spawn } = await import("child_process");
3631
3631
  const child = spawn(
3632
3632
  process.argv[0],
@@ -3636,15 +3636,19 @@ program.command("ui").description("Open the web dashboard (auto-starts HTTP serv
3636
3636
  child.unref();
3637
3637
  await new Promise((r) => setTimeout(r, 500));
3638
3638
  }
3639
- console.log(chalk2.green(`\u2713 Dashboard: ${url}`));
3639
+ const { getOrCreateToken } = await import("./auth-KB2ZRMS3.js");
3640
+ const { token } = getOrCreateToken();
3641
+ const bootUrl = `${publicUrl}?token=${encodeURIComponent(token)}`;
3642
+ console.log(chalk2.green(`\u2713 Dashboard: ${publicUrl}`));
3640
3643
  if (opts.open !== false) {
3641
3644
  const { platform } = process;
3642
3645
  const opener = platform === "darwin" ? "open" : platform === "win32" ? "start" : "xdg-open";
3643
3646
  try {
3644
3647
  const { execFile: execFile4 } = await import("child_process");
3645
- execFile4(opener, [url], { shell: platform === "win32" }, () => {
3648
+ execFile4(opener, [bootUrl], { shell: platform === "win32" }, () => {
3646
3649
  });
3647
3650
  } catch {
3651
+ console.log(chalk2.dim(` Open manually: ${bootUrl}`));
3648
3652
  }
3649
3653
  }
3650
3654
  });
@@ -2,6 +2,13 @@ import {
2
2
  ContextCache,
3
3
  getContextCache
4
4
  } from "./chunk-CIQQ5Y3S.js";
5
+ import {
6
+ getOrCreateToken,
7
+ isHostValid,
8
+ isOriginAllowed,
9
+ parseCookies,
10
+ safeEqual
11
+ } from "./chunk-N6PPKOPK.js";
5
12
  import {
6
13
  getComponentStatus,
7
14
  summarizeHookLog
@@ -1006,6 +1013,14 @@ var PROVIDERS = [
1006
1013
  "context7",
1007
1014
  "obsidian"
1008
1015
  ];
1016
+ var serverToken = "";
1017
+ var serverPort = 0;
1018
+ function currentToken() {
1019
+ return serverToken;
1020
+ }
1021
+ function authCookie(token) {
1022
+ return `engram_token=${token}; HttpOnly; SameSite=Strict; Path=/`;
1023
+ }
1009
1024
  function parseUrl(req) {
1010
1025
  return new URL(req.url ?? "/", `http://${req.headers.host ?? "localhost"}`);
1011
1026
  }
@@ -1014,23 +1029,42 @@ async function readBody(req) {
1014
1029
  for await (const chunk of req) chunks.push(chunk);
1015
1030
  return Buffer.concat(chunks).toString("utf-8");
1016
1031
  }
1017
- function json(res, status, data) {
1032
+ function corsHeaders(req) {
1033
+ const origin = req.headers.origin;
1034
+ if (!origin || !isOriginAllowed(origin, serverPort)) return {};
1035
+ return {
1036
+ "Access-Control-Allow-Origin": origin,
1037
+ "Access-Control-Allow-Credentials": "true",
1038
+ "Vary": "Origin"
1039
+ };
1040
+ }
1041
+ function json(res, status, data, extraHeaders = {}) {
1018
1042
  res.writeHead(status, {
1019
1043
  "Content-Type": "application/json",
1020
- "Access-Control-Allow-Origin": "*",
1021
- "Access-Control-Allow-Methods": "GET, POST, OPTIONS",
1022
- "Access-Control-Allow-Headers": "Authorization, Content-Type"
1044
+ ...extraHeaders
1023
1045
  });
1024
1046
  res.end(JSON.stringify(data));
1025
1047
  }
1026
1048
  function checkAuth(req, res) {
1027
- const token = process.env.ENGRAM_API_TOKEN;
1028
- if (!token) return true;
1029
- const header = req.headers.authorization ?? "";
1030
- if (header === `Bearer ${token}`) return true;
1049
+ const expected = currentToken();
1050
+ const auth = req.headers.authorization ?? "";
1051
+ if (auth.startsWith("Bearer ")) {
1052
+ const presented = auth.slice(7).trim();
1053
+ if (safeEqual(presented, expected)) return true;
1054
+ }
1055
+ const cookies = parseCookies(req.headers.cookie);
1056
+ if (cookies.engram_token && safeEqual(cookies.engram_token, expected)) {
1057
+ return true;
1058
+ }
1031
1059
  json(res, 401, { error: "Unauthorized" });
1032
1060
  return false;
1033
1061
  }
1062
+ function requireJsonContentType(req, res) {
1063
+ const ct = (req.headers["content-type"] ?? "").toLowerCase();
1064
+ if (ct.startsWith("application/json")) return true;
1065
+ json(res, 415, { error: "Content-Type must be application/json" });
1066
+ return false;
1067
+ }
1034
1068
  function handleHealth(_req, res, startedAt) {
1035
1069
  json(res, 200, {
1036
1070
  ok: true,
@@ -1219,12 +1253,12 @@ async function handleGraphGodNodes(_req, res, projectRoot) {
1219
1253
  }
1220
1254
  var sseClients = /* @__PURE__ */ new Set();
1221
1255
  var hookLogWatcher = null;
1222
- function handleSSE(_req, res, projectRoot) {
1256
+ function handleSSE(req, res, projectRoot) {
1223
1257
  res.writeHead(200, {
1224
1258
  "Content-Type": "text/event-stream",
1225
1259
  "Cache-Control": "no-cache",
1226
1260
  "Connection": "keep-alive",
1227
- "Access-Control-Allow-Origin": "*"
1261
+ ...corsHeaders(req)
1228
1262
  });
1229
1263
  res.write('data: {"type":"connected"}\n\n');
1230
1264
  sseClients.add(res);
@@ -1277,23 +1311,73 @@ function removePid(projectRoot) {
1277
1311
  function createHttpServer(projectRoot, port) {
1278
1312
  return new Promise((resolve, reject) => {
1279
1313
  const startedAt = Date.now();
1314
+ const tokenInfo = getOrCreateToken();
1315
+ serverToken = tokenInfo.token;
1316
+ serverPort = port;
1280
1317
  const server = createServer(async (req, res) => {
1318
+ if (!isHostValid(req.headers.host, port)) {
1319
+ res.writeHead(400);
1320
+ res.end();
1321
+ return;
1322
+ }
1323
+ const origin = req.headers.origin;
1324
+ if (origin && !isOriginAllowed(origin, port)) {
1325
+ res.writeHead(403);
1326
+ res.end();
1327
+ return;
1328
+ }
1329
+ if (origin && isOriginAllowed(origin, port)) {
1330
+ res.setHeader("Access-Control-Allow-Origin", origin);
1331
+ res.setHeader("Access-Control-Allow-Credentials", "true");
1332
+ res.setHeader("Vary", "Origin");
1333
+ }
1281
1334
  if (req.method === "OPTIONS") {
1282
1335
  res.writeHead(204, {
1283
- "Access-Control-Allow-Origin": "*",
1284
1336
  "Access-Control-Allow-Methods": "GET, POST, OPTIONS",
1285
1337
  "Access-Control-Allow-Headers": "Authorization, Content-Type"
1286
1338
  });
1287
1339
  res.end();
1288
1340
  return;
1289
1341
  }
1290
- if (!checkAuth(req, res)) return;
1291
1342
  const url = parseUrl(req);
1292
1343
  const path = url.pathname;
1344
+ if (req.method === "GET" && path === "/health") {
1345
+ handleHealth(req, res, startedAt);
1346
+ return;
1347
+ }
1348
+ if (req.method === "GET" && path === "/favicon.ico") {
1349
+ const svg = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><rect width="100" height="100" rx="20" fill="#0a0a0b"/><text x="50" y="62" font-size="56" text-anchor="middle" fill="#10b981" font-family="Menlo,monospace">&#9670;</text></svg>';
1350
+ res.writeHead(200, {
1351
+ "Content-Type": "image/svg+xml",
1352
+ "Cache-Control": "public, max-age=86400"
1353
+ });
1354
+ res.end(svg);
1355
+ return;
1356
+ }
1357
+ if (req.method === "GET" && (path === "/ui" || path === "/ui/")) {
1358
+ const queryToken = url.searchParams.get("token");
1359
+ if (queryToken) {
1360
+ const fetchSite = req.headers["sec-fetch-site"];
1361
+ const siteOk = fetchSite === void 0 || fetchSite === "none" || fetchSite === "same-origin";
1362
+ if (siteOk && safeEqual(queryToken, currentToken())) {
1363
+ res.writeHead(302, {
1364
+ Location: "/ui",
1365
+ "Set-Cookie": authCookie(currentToken()),
1366
+ "Referrer-Policy": "no-referrer",
1367
+ "Cache-Control": "no-store",
1368
+ "X-Content-Type-Options": "nosniff"
1369
+ });
1370
+ res.end();
1371
+ return;
1372
+ }
1373
+ }
1374
+ }
1375
+ if (!checkAuth(req, res)) return;
1376
+ if (req.method === "POST" || req.method === "PUT" || req.method === "DELETE") {
1377
+ if (!requireJsonContentType(req, res)) return;
1378
+ }
1293
1379
  try {
1294
- if (req.method === "GET" && path === "/health") {
1295
- handleHealth(req, res, startedAt);
1296
- } else if (req.method === "GET" && path === "/query") {
1380
+ if (req.method === "GET" && path === "/query") {
1297
1381
  await handleQuery(req, res, projectRoot);
1298
1382
  } else if (req.method === "GET" && path === "/stats") {
1299
1383
  await handleStats(req, res, projectRoot);
@@ -1322,16 +1406,11 @@ function createHttpServer(projectRoot, port) {
1322
1406
  } else if (req.method === "GET" && (path === "/ui" || path === "/ui/")) {
1323
1407
  res.writeHead(200, {
1324
1408
  "Content-Type": "text/html; charset=utf-8",
1325
- "Cache-Control": "no-cache"
1409
+ "Cache-Control": "no-cache",
1410
+ "Set-Cookie": authCookie(currentToken()),
1411
+ "X-Content-Type-Options": "nosniff"
1326
1412
  });
1327
1413
  res.end(buildDashboardHtml());
1328
- } else if (req.method === "GET" && path === "/favicon.ico") {
1329
- const svg = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><rect width="100" height="100" rx="20" fill="#0a0a0b"/><text x="50" y="62" font-size="56" text-anchor="middle" fill="#10b981" font-family="Menlo,monospace">&#9670;</text></svg>';
1330
- res.writeHead(200, {
1331
- "Content-Type": "image/svg+xml",
1332
- "Cache-Control": "public, max-age=86400"
1333
- });
1334
- res.end(svg);
1335
1414
  } else {
1336
1415
  json(res, 404, { error: "Not found" });
1337
1416
  }
@@ -1351,7 +1430,7 @@ function createHttpServer(projectRoot, port) {
1351
1430
  };
1352
1431
  process.on("SIGINT", cleanup);
1353
1432
  process.on("SIGTERM", cleanup);
1354
- resolve();
1433
+ resolve(tokenInfo);
1355
1434
  });
1356
1435
  });
1357
1436
  }
@@ -1359,11 +1438,26 @@ function createHttpServer(projectRoot, port) {
1359
1438
  // src/server/index.ts
1360
1439
  var DEFAULT_PORT = 7337;
1361
1440
  async function startHttpServer(projectRoot, port = DEFAULT_PORT) {
1362
- await createHttpServer(projectRoot, port);
1363
- process.stdout.write(
1364
- `engram HTTP server listening on http://127.0.0.1:${port}
1441
+ const tokenInfo = await createHttpServer(projectRoot, port);
1442
+ const url = `http://127.0.0.1:${port}`;
1443
+ process.stdout.write(`engram HTTP server listening on ${url}
1444
+ `);
1445
+ if (tokenInfo.source === "env") {
1446
+ process.stderr.write(
1447
+ "engram: auth token from ENGRAM_API_TOKEN env var\n"
1448
+ );
1449
+ } else if (tokenInfo.source === "file") {
1450
+ process.stderr.write(
1451
+ `engram: auth token at ${tokenInfo.path}
1452
+ `
1453
+ );
1454
+ } else {
1455
+ process.stderr.write(
1456
+ `engram: auth token generated at ${tokenInfo.path} (0600)
1457
+ curl -H "Authorization: Bearer $(cat ${tokenInfo.path})" ${url}/stats
1365
1458
  `
1366
- );
1459
+ );
1460
+ }
1367
1461
  }
1368
1462
  export {
1369
1463
  startHttpServer
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "engramx",
3
- "version": "2.0.1",
3
+ "version": "2.0.2",
4
4
  "description": "The context spine for AI coding agents. 8 providers + pluggable context sources, 3-layer memory cache, web dashboard, multi-IDE support (Claude Code, Cursor, Continue, Zed, Aider, Windsurf, Neovim, Emacs). 88.1% measured session-level token savings. Local SQLite, zero native deps, zero cloud.",
5
5
  "repository": {
6
6
  "type": "git",