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 +87 -0
- package/dist/auth-KB2ZRMS3.js +14 -0
- package/dist/chunk-N6PPKOPK.js +105 -0
- package/dist/cli.js +10 -6
- package/dist/{server-6AOI7NQP.js → server-KUG7U6SG.js} +122 -28
- package/package.json +1 -1
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,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-
|
|
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
|
|
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 ${
|
|
3627
|
+
console.log(chalk2.dim(`engram server already running \u2014 opening ${publicUrl}`));
|
|
3628
3628
|
} else {
|
|
3629
|
-
console.log(chalk2.dim(`Starting engram server on ${
|
|
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
|
-
|
|
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, [
|
|
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
|
|
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
|
-
|
|
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
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
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(
|
|
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
|
-
|
|
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">◆</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 === "/
|
|
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">◆</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
|
-
|
|
1364
|
-
|
|
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.
|
|
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",
|