create-evda 0.1.8 → 0.1.10
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 +109 -1
- package/README.md +20 -7
- package/bin/evda.mjs +277 -7
- package/create-evda.mjs +17 -4
- package/dist/pairing.mjs +2932 -1684
- package/hooks/evda-day-writer-guard.mjs +7 -0
- package/lib/acknowledge.mjs +187 -0
- package/lib/adopt.mjs +488 -0
- package/lib/agents.mjs +1 -1
- package/lib/attested-host.mjs +269 -0
- package/lib/browse.mjs +354 -0
- package/lib/circles.mjs +5 -1
- package/lib/clients.mjs +29 -2
- package/lib/connect.mjs +9 -2
- package/lib/connecting-apps.mjs +56 -0
- package/lib/connector-tools.mjs +85 -0
- package/lib/corrections.mjs +34 -0
- package/lib/credentials-migrate.mjs +120 -0
- package/lib/day-file.mjs +173 -15
- package/lib/day-writer.mjs +110 -0
- package/lib/doctor.mjs +45 -3
- package/lib/every-chat.mjs +128 -0
- package/lib/filechanges.mjs +203 -68
- package/lib/gather-apply.mjs +176 -26
- package/lib/gather.mjs +119 -17
- package/lib/html-notes.mjs +124 -0
- package/lib/keep-hook.mjs +61 -8
- package/lib/keychain.mjs +181 -0
- package/lib/manifest.mjs +5 -1
- package/lib/mcp-auth.mjs +41 -1
- package/lib/mcp-http.mjs +42 -4
- package/lib/mcp.mjs +1198 -76
- package/lib/measurement-log.mjs +249 -0
- package/lib/messages.mjs +5 -2
- package/lib/migrate-people.mjs +54 -20
- package/lib/pairing.mjs +243 -5
- package/lib/parity.mjs +47 -13
- package/lib/person-folders.mjs +89 -0
- package/lib/plugin-hook.mjs +109 -0
- package/lib/plugin.mjs +208 -0
- package/lib/presets.mjs +26 -5
- package/lib/publish.mjs +55 -10
- package/lib/qr.mjs +317 -0
- package/lib/record.mjs +74 -0
- package/lib/routines.mjs +54 -4
- package/lib/sigstore-trust.json +20 -0
- package/lib/skills.mjs +1 -1
- package/lib/sync-lock.mjs +143 -0
- package/lib/sync-rules.mjs +154 -1
- package/lib/takeout.mjs +169 -0
- package/lib/tdx-attestation.mjs +412 -0
- package/lib/tdx-trust.json +10 -0
- package/lib/tools.mjs +1040 -31
- package/lib/touches.mjs +427 -0
- package/lib/update-check.mjs +30 -0
- package/lib/watch.mjs +160 -0
- package/lib/yaml.mjs +8 -4
- package/package.json +2 -1
- package/skills/evda/SKILL.md +14 -0
- package/templates/AGENTS.md +81 -10
- package/templates/evda-digest.md +5 -1
package/lib/keychain.mjs
ADDED
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
// macOS Keychain storage for the credential lines gather finds (HQ-1916, builds on HQ-1368).
|
|
2
|
+
// First version, his own words: "it just needs to be super initial versioning" -- the smallest thing
|
|
3
|
+
// that takes the password off disk.
|
|
4
|
+
//
|
|
5
|
+
// At `evda gather apply`, a note gather caught as holding a credential (lib/gather.mjs CRED_LINE, or
|
|
6
|
+
// a bare token line shaped like a secret) is still sealed into Credentials/ as it is today, but each
|
|
7
|
+
// value found in it is written into the login Keychain (service EVÐA) instead of staying in the
|
|
8
|
+
// file. The line keeps its own label; only the value becomes a plain pointer:
|
|
9
|
+
// Password: in your Keychain (EVÐA, wifi.md, password)
|
|
10
|
+
// Not macOS, or the Keychain write fails: the value stays in the file, unchanged, as it did before
|
|
11
|
+
// this ticket, and gather-apply says so in one line. Nothing here calls any MCP tool, and nothing
|
|
12
|
+
// here is registered as one: the owner reads a secret with `evda secret <file>`, on their own Mac,
|
|
13
|
+
// printed to their own terminal only (Spec 07, the sealed folder's own guarantee).
|
|
14
|
+
//
|
|
15
|
+
// EVDA_KEYCHAIN_FALSIFY=<mode,...>, for the tests:
|
|
16
|
+
// no-keychain keychainAvailable() always says no, so a credential file is never redacted
|
|
17
|
+
// write-fails every "security add-generic-password" fails, so the value stays in the file
|
|
18
|
+
//
|
|
19
|
+
// HQ-2051: under node's test runner, nothing here may reach the default (login) keychain. A test run
|
|
20
|
+
// that names no keychain, or names one that does not exist (`security` then falls back to the
|
|
21
|
+
// default instead of failing), throws before `security` runs. On Hestia's CI runner the default
|
|
22
|
+
// keychain does not exist under the throwaway HOME, so such a call raised a "Keychain Not Found"
|
|
23
|
+
// dialog that sat on the screen for hours and failed every Mac journey. A test that truly means the
|
|
24
|
+
// login keychain sets EVDA_KEYCHAIN_ALLOW_DEFAULT=1; none does.
|
|
25
|
+
|
|
26
|
+
import { execFileSync } from "node:child_process";
|
|
27
|
+
import { existsSync } from "node:fs";
|
|
28
|
+
import { CRED_LINE } from "./gather.mjs";
|
|
29
|
+
import { secretShape } from "./references.mjs";
|
|
30
|
+
|
|
31
|
+
export const SERVICE = "EVÐA";
|
|
32
|
+
export const KEYCHAIN_FALSIFY_MODES = ["no-keychain", "write-fails"];
|
|
33
|
+
const falsified = (mode) => (process.env.EVDA_KEYCHAIN_FALSIFY ?? "").split(",").map((s) => s.trim()).includes(mode);
|
|
34
|
+
|
|
35
|
+
// The account a credential's Keychain item is filed under: the record file it came from, and its
|
|
36
|
+
// label, so `evda secret <file>` can find every value a file's pointers name.
|
|
37
|
+
export const accountFor = (fileName, label) => `${fileName} :: ${label}`;
|
|
38
|
+
|
|
39
|
+
// Whether this machine can hold a secret in the Keychain at all. Never throws.
|
|
40
|
+
export function keychainAvailable() {
|
|
41
|
+
if (falsified("no-keychain")) return false;
|
|
42
|
+
if (process.platform !== "darwin") return false;
|
|
43
|
+
try {
|
|
44
|
+
execFileSync("security", ["list-keychains"], { stdio: ["ignore", "ignore", "ignore"] });
|
|
45
|
+
return true;
|
|
46
|
+
} catch {
|
|
47
|
+
return false;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// True inside a `node --test` run: its children carry NODE_TEST_CONTEXT, and so does every CLI a
|
|
52
|
+
// test spawns with the inherited env; --test-isolation=none runs in-process with --test in execArgv.
|
|
53
|
+
export function underTestRunner(env = process.env, execArgv = process.execArgv) {
|
|
54
|
+
return Boolean(env.NODE_TEST_CONTEXT) || execArgv.some((a) => a === "--test" || a.startsWith("--test-"));
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Throws when a test run would reach the default keychain. Every Keychain call below goes through it.
|
|
58
|
+
export function assertKeychainAllowed(keychainPath, env = process.env) {
|
|
59
|
+
if (!underTestRunner(env) || env.EVDA_KEYCHAIN_ALLOW_DEFAULT === "1") return;
|
|
60
|
+
if (!keychainPath) throw new Error("evda refuses the default keychain under a test run: pass a throwaway keychainPath or set EVDA_KEYCHAIN_FALSIFY (HQ-2051)");
|
|
61
|
+
if (!existsSync(keychainPath)) throw new Error(`evda refuses a keychain that does not exist under a test run (${keychainPath}): \`security\` would fall back to the default keychain (HQ-2051)`);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const keychainArgs = (keychainPath) => (keychainPath ? [keychainPath] : []);
|
|
65
|
+
|
|
66
|
+
// `security add-generic-password`, overwriting an item already at this account (a second gather of
|
|
67
|
+
// the same file lands on the same value). Throws on failure; the caller decides what that means.
|
|
68
|
+
export function addSecret({ account, value, keychainPath = null } = {}) {
|
|
69
|
+
if (falsified("write-fails")) throw new Error("EVDA_KEYCHAIN_FALSIFY=write-fails");
|
|
70
|
+
assertKeychainAllowed(keychainPath);
|
|
71
|
+
execFileSync("security", ["add-generic-password", "-a", account, "-s", SERVICE, "-w", value, "-U", ...keychainArgs(keychainPath)], { stdio: ["ignore", "ignore", "pipe"] });
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Never throws on a missing item: revert removes what it can and says what it removed. Under a test
|
|
75
|
+
// run it does throw when it would reach the default keychain (HQ-2051).
|
|
76
|
+
export function removeSecret({ account, keychainPath = null } = {}) {
|
|
77
|
+
assertKeychainAllowed(keychainPath);
|
|
78
|
+
try {
|
|
79
|
+
execFileSync("security", ["delete-generic-password", "-a", account, "-s", SERVICE, ...keychainArgs(keychainPath)], { stdio: ["ignore", "ignore", "ignore"] });
|
|
80
|
+
return true;
|
|
81
|
+
} catch {
|
|
82
|
+
return false;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// The value at an account, or throws (no item, or the Keychain refused to read it).
|
|
87
|
+
export function readSecret({ account, keychainPath = null } = {}) {
|
|
88
|
+
assertKeychainAllowed(keychainPath);
|
|
89
|
+
return execFileSync("security", ["find-generic-password", "-a", account, "-s", SERVICE, "-w", ...keychainArgs(keychainPath)], { stdio: ["ignore", "pipe", "ignore"] }).toString("utf8").replace(/\n$/, "");
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Every credential value on its own line in `text`: a labeled line (CRED_LINE, the same shape that
|
|
93
|
+
// typed the note in the first place) keeps its label and the text before the value, so only the
|
|
94
|
+
// value itself is later replaced; a bare line shaped like a secret (a token with no "label:") has no
|
|
95
|
+
// label of its own, so it is numbered "secret", "secret 2", ... in the order it appears. A labeled
|
|
96
|
+
// line whose value is empty (the code sits on the next line instead) is left alone: multi-line values
|
|
97
|
+
// are out of this first version's scope.
|
|
98
|
+
// A line already holding a pointer is not a value, so a file sealed once has nothing left to move
|
|
99
|
+
// (HQ-2622: the migration's second run is a no-op because of this). Two lines with the same label
|
|
100
|
+
// ("password" twice, or a "secret:" line beside a bare token) get "<label> 2", "<label> 3", ... so
|
|
101
|
+
// each lands on its own Keychain account and neither overwrites the other.
|
|
102
|
+
export function extractSecrets(text) {
|
|
103
|
+
const lines = text.split("\n");
|
|
104
|
+
const out = [];
|
|
105
|
+
let bare = 0;
|
|
106
|
+
for (let i = 0; i < lines.length; i++) {
|
|
107
|
+
const line = lines[i];
|
|
108
|
+
if (POINTER.test(line)) continue;
|
|
109
|
+
const m = CRED_LINE.exec(line);
|
|
110
|
+
if (m) {
|
|
111
|
+
const label = (m[1] ?? m[2]).toLowerCase().replace(/[\s_-]+/g, " ");
|
|
112
|
+
const value = m[3].replace(/[*_\x60]+$/g, "").trim();
|
|
113
|
+
if (!value) continue;
|
|
114
|
+
out.push({ index: i, label, prefix: line.slice(0, line.length - m[3].length), value, whole: false });
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
if (secretShape(line.trim())) {
|
|
118
|
+
bare += 1;
|
|
119
|
+
out.push({ index: i, label: bare === 1 ? "secret" : `secret ${bare}`, prefix: "", value: line, whole: true });
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
const used = new Set();
|
|
123
|
+
for (const s of out) {
|
|
124
|
+
let label = s.label;
|
|
125
|
+
for (let n = 2; used.has(label); n++) label = `${s.label} ${n}`;
|
|
126
|
+
used.add(label);
|
|
127
|
+
s.label = label;
|
|
128
|
+
}
|
|
129
|
+
return out;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// A pointer line reads back its account: "in your Keychain (EVÐA, wifi.md, password)".
|
|
133
|
+
const POINTER = /in your Keychain \(([^,]+), ([^,]+), ([^)]+)\)/;
|
|
134
|
+
export const pointerFor = (fileName, label) => `in your Keychain (${SERVICE}, ${fileName}, ${label})`;
|
|
135
|
+
|
|
136
|
+
// Move every credential value in `text` into the Keychain, one item per line, and return the text
|
|
137
|
+
// with each replaced by its pointer. All-or-nothing per file: if any value fails to write, every
|
|
138
|
+
// item this call added is removed again and the original text comes back unchanged, so a file is
|
|
139
|
+
// never left half in the Keychain and half redacted.
|
|
140
|
+
// Returns { ok: true, text, accounts } or { ok: false, why }.
|
|
141
|
+
export function sealForKeychain(text, fileName, { keychainPath = null } = {}) {
|
|
142
|
+
if (!keychainAvailable()) return { ok: false, why: "not on macOS, or the Keychain is unavailable" };
|
|
143
|
+
const secrets = extractSecrets(text);
|
|
144
|
+
if (!secrets.length) return { ok: false, why: "no credential value was found on its own line" };
|
|
145
|
+
const added = [];
|
|
146
|
+
try {
|
|
147
|
+
for (const s of secrets) {
|
|
148
|
+
const account = accountFor(fileName, s.label);
|
|
149
|
+
addSecret({ account, value: s.value, keychainPath });
|
|
150
|
+
added.push(account);
|
|
151
|
+
}
|
|
152
|
+
} catch (e) {
|
|
153
|
+
for (const account of added) removeSecret({ account, keychainPath });
|
|
154
|
+
return { ok: false, why: `the Keychain write failed (${String(e.message ?? e).split("\n")[0]})` };
|
|
155
|
+
}
|
|
156
|
+
const lines = text.split("\n");
|
|
157
|
+
for (const s of secrets) lines[s.index] = s.whole ? pointerFor(fileName, s.label) : `${s.prefix}${pointerFor(fileName, s.label)}`;
|
|
158
|
+
return { ok: true, text: lines.join("\n"), accounts: added };
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// The owner's own read: every pointer in `text` resolved back to its value, for `evda secret <file>`
|
|
162
|
+
// only. No MCP tool calls this.
|
|
163
|
+
export function resolvePointers(text, { keychainPath = null } = {}) {
|
|
164
|
+
const lines = text.split("\n");
|
|
165
|
+
let resolved = 0;
|
|
166
|
+
let failed = 0;
|
|
167
|
+
for (let i = 0; i < lines.length; i++) {
|
|
168
|
+
const m = POINTER.exec(lines[i]);
|
|
169
|
+
if (!m) continue;
|
|
170
|
+
const account = accountFor(m[2], m[3]);
|
|
171
|
+
try {
|
|
172
|
+
const value = readSecret({ account, keychainPath });
|
|
173
|
+
lines[i] = lines[i].slice(0, m.index) + value + lines[i].slice(m.index + m[0].length);
|
|
174
|
+
resolved += 1;
|
|
175
|
+
} catch {
|
|
176
|
+
lines[i] = `${lines[i]} (could not read this from the Keychain)`;
|
|
177
|
+
failed += 1;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
return { text: lines.join("\n"), resolved, failed };
|
|
181
|
+
}
|
package/lib/manifest.mjs
CHANGED
|
@@ -16,7 +16,11 @@ export const SCHEMA_VERSION = 1;
|
|
|
16
16
|
export const KNOWN_LAYERS = ["record", "ledger", "circles"];
|
|
17
17
|
// `filing` (HQ-1385) is the owner's answer to the one setup question, "everything" or "asked";
|
|
18
18
|
// lib/day-file.mjs filingOf reads it for every agent and hook.
|
|
19
|
-
|
|
19
|
+
// `every_chat` (HQ-2205) is, per chat app, whether the owner put the every-chat line in that app's
|
|
20
|
+
// settings ("set") or does not want it ("declined"); lib/every-chat.mjs reads it, evda_every_chat writes it.
|
|
21
|
+
// `corrections` (HQ-2325) is the file a correction is appended to, when the owner keeps them in
|
|
22
|
+
// their own file rather than the operating layer's "What I have been taught"; lib/corrections.mjs reads it.
|
|
23
|
+
export const KNOWN_KEYS = ["evda", "id", "name", "owner", "created", "layers", "mapping", "subrecords", "read_by", "filing", "every_chat", "sync", "corrections"];
|
|
20
24
|
|
|
21
25
|
// Whether a record's circles stop at its sub-records (HQ-1371). A sub-record is a directory below
|
|
22
26
|
// the root holding its own evda.json. "bounded": the parent's circles, coverage, scan and release
|
package/lib/mcp-auth.mjs
CHANGED
|
@@ -21,6 +21,9 @@
|
|
|
21
21
|
// EVDA_MCP_FALSIFY=skip-redirect-check the redirect URI is not compared, at /authorize or /token
|
|
22
22
|
// EVDA_MCP_FALSIFY=log-content the call log carries the tool arguments (lib/mcp-http.mjs)
|
|
23
23
|
// EVDA_MCP_FALSIFY=log-args every tool call logs its arguments, stdio and HTTP (lib/mcp.mjs)
|
|
24
|
+
//
|
|
25
|
+
// None of them may be set on a server the internet can reach: a --public-url outside loopback and the
|
|
26
|
+
// reserved test names refuses to start while any switch is set (HQ-2082, refuseFalsifyOnPublicServer).
|
|
24
27
|
|
|
25
28
|
import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
|
|
26
29
|
import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
@@ -28,7 +31,11 @@ import { homedir } from "node:os";
|
|
|
28
31
|
import { join } from "node:path";
|
|
29
32
|
import { EvdaError } from "./errors.mjs";
|
|
30
33
|
|
|
31
|
-
|
|
34
|
+
// Every switch, so the startup guard can name the ones that are set rather than ask about each.
|
|
35
|
+
export const MCP_FALSIFY_MODES = ["no-token-check", "accept-expired", "accept-revoked", "skip-pkce", "skip-redirect-check", "log-content", "log-args"];
|
|
36
|
+
|
|
37
|
+
export const falsifyModes = (env = process.env) => String(env.EVDA_MCP_FALSIFY ?? "").split(",").map((m) => m.trim()).filter(Boolean);
|
|
38
|
+
export const FALSIFY = (mode, env = process.env) => falsifyModes(env).includes(mode);
|
|
32
39
|
|
|
33
40
|
export const ACCESS_TTL_MS = 60 * 60 * 1000; // one hour; Claude refreshes on a 401
|
|
34
41
|
export const REFRESH_TTL_MS = 30 * 24 * 60 * 60 * 1000; // thirty days, renewed on every rotation
|
|
@@ -44,6 +51,39 @@ export const SCOPE = "evda";
|
|
|
44
51
|
export const HOSTED_REDIRECTS = ["https://claude.ai/api/mcp/auth_callback", "https://claude.com/api/mcp/auth_callback"];
|
|
45
52
|
const LOOPBACK = new Set(["localhost", "127.0.0.1", "[::1]"]);
|
|
46
53
|
|
|
54
|
+
// Can a stranger on the internet reach this address? Loopback is this machine, and the reserved names
|
|
55
|
+
// (RFC 6761 .test .invalid .example .localhost, RFC 2606 example.com and its siblings) resolve nowhere,
|
|
56
|
+
// which is what the tests serve on. Anything else counts as reachable, including an address nobody can
|
|
57
|
+
// parse: a value this cannot read is the one most likely to be a real address typed wrong.
|
|
58
|
+
const RESERVED_TLDS = new Set(["test", "invalid", "example", "localhost"]);
|
|
59
|
+
const RESERVED_NAMES = new Set(["example.com", "example.net", "example.org"]);
|
|
60
|
+
export function isPublicUrl(url) {
|
|
61
|
+
let host;
|
|
62
|
+
try { host = new URL(String(url)).hostname.toLowerCase(); } catch { return true; }
|
|
63
|
+
if (LOOPBACK.has(host) || host === "::1" || /^127\./.test(host)) return false;
|
|
64
|
+
return !(RESERVED_TLDS.has(host.split(".").pop()) || RESERVED_NAMES.has(host));
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* HQ-2082: each test hook above turns off one of this server's refusals, and no-token-check turns off
|
|
69
|
+
* authentication altogether, so a switch set on the machine that serves the public address would open
|
|
70
|
+
* the record to anyone who finds it. One can arrive without anybody choosing it: exported from a shell
|
|
71
|
+
* profile, or carried in a run script copied from a test. So the server refuses to start instead.
|
|
72
|
+
*
|
|
73
|
+
* Config time, once, before anything listens. A per-request check would already have served the first
|
|
74
|
+
* request of a process that should never have started, and would leave the switches half working.
|
|
75
|
+
*
|
|
76
|
+
* Returns the switches that are set, which on loopback is where they go on working.
|
|
77
|
+
*/
|
|
78
|
+
export function refuseFalsifyOnPublicServer(publicUrl, env = process.env) {
|
|
79
|
+
const set = falsifyModes(env);
|
|
80
|
+
if (!set.length || !isPublicUrl(publicUrl)) return set;
|
|
81
|
+
throw new EvdaError(
|
|
82
|
+
`REFUSED: EVDA_MCP_FALSIFY is set to ${set.join(", ")}, and --public-url ${publicUrl} is an address the internet can reach. Every one of those switches turns off a refusal this server makes, so serving that address with one set would hand the record to anyone who finds it. Unset EVDA_MCP_FALSIFY, or serve on loopback (--public-url http://127.0.0.1:<port>) to use them.`,
|
|
83
|
+
{ code: "FALSIFY_ON_PUBLIC_URL", exit: 2 }
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
|
|
47
87
|
export const sha256 = (s) => createHash("sha256").update(s).digest("hex");
|
|
48
88
|
const b64url = (buf) => buf.toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
49
89
|
export const newSecret = (bytes = 32) => b64url(randomBytes(bytes));
|
package/lib/mcp-http.mjs
CHANGED
|
@@ -7,6 +7,8 @@
|
|
|
7
7
|
// It binds to 127.0.0.1 by default and speaks plain HTTP: TLS and the public name belong to the
|
|
8
8
|
// tunnel in front of it (Tailscale Funnel on Hestia). --public-url is the address the world uses,
|
|
9
9
|
// and every URL in the metadata is built from it rather than from a Host header anyone can send.
|
|
10
|
+
// When that address is one the internet can reach, no EVDA_MCP_FALSIFY switch may be set: the server
|
|
11
|
+
// refuses to start rather than serve the world with a refusal turned off (HQ-2082).
|
|
10
12
|
//
|
|
11
13
|
// The caller is always Claude. A remote client cannot be a model on the owner's own devices, and its
|
|
12
14
|
// clientInfo is whatever it says, so read_by (HQ-1374) applies as it does to Claude locally.
|
|
@@ -21,7 +23,7 @@ import { createServer } from "node:http";
|
|
|
21
23
|
import { appendFileSync } from "node:fs";
|
|
22
24
|
import { randomBytes } from "node:crypto";
|
|
23
25
|
import { openEvda } from "./manifest.mjs";
|
|
24
|
-
import { handle, newSession, TOOLS } from "./mcp.mjs";
|
|
26
|
+
import { handle, newSession, TOOLS, clientInfoOf } from "./mcp.mjs";
|
|
25
27
|
import { callerFrom } from "./readers.mjs";
|
|
26
28
|
import * as auth from "./mcp-auth.mjs";
|
|
27
29
|
|
|
@@ -52,6 +54,23 @@ function limiter() {
|
|
|
52
54
|
};
|
|
53
55
|
}
|
|
54
56
|
|
|
57
|
+
// HQ-2330: what a request carries beyond the protocol, by NAME only, so the log can show whether an
|
|
58
|
+
// app sends something that names the conversation (claude.ai reuses one Mcp-Session-Id across chats).
|
|
59
|
+
// Values are never logged: a header or a _meta value can carry anything.
|
|
60
|
+
const PLAIN_HEADERS = new Set(["host", "content-type", "content-length", "authorization", "accept", "accept-encoding", "accept-language", "user-agent", "connection", "origin", "mcp-session-id", "mcp-protocol-version", "last-event-id", "via", "forwarded", "x-real-ip", "cache-control", "pragma", "te", "keep-alive"]);
|
|
61
|
+
const clipName = (k) => String(k).replace(/[^\x21-\x7e]/g, "").slice(0, 64);
|
|
62
|
+
export function extraNames(headers, params) {
|
|
63
|
+
const out = {};
|
|
64
|
+
const h = Object.keys(headers ?? {}).map((k) => k.toLowerCase()).filter((k) => !PLAIN_HEADERS.has(k) && !k.startsWith("x-forwarded-") && !k.startsWith("tailscale-") && !k.startsWith("sec-fetch-")).map(clipName).slice(0, 20);
|
|
65
|
+
if (h.length) out.header_names = h;
|
|
66
|
+
const meta = params?._meta;
|
|
67
|
+
if (meta && typeof meta === "object" && !Array.isArray(meta)) {
|
|
68
|
+
const m = Object.keys(meta).map(clipName).slice(0, 20);
|
|
69
|
+
if (m.length) out.meta_keys = m;
|
|
70
|
+
}
|
|
71
|
+
return out;
|
|
72
|
+
}
|
|
73
|
+
|
|
55
74
|
const esc = (s) => String(s ?? "").replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c]);
|
|
56
75
|
const shortId = (s) => auth.sha256(String(s)).slice(0, 8);
|
|
57
76
|
|
|
@@ -59,6 +78,7 @@ export function createMcpHttpServer({ dir, version, publicUrl, env = process.env
|
|
|
59
78
|
const base = String(publicUrl).replace(/\/+$/, "");
|
|
60
79
|
const baseUrl = new URL(base);
|
|
61
80
|
if (baseUrl.protocol !== "https:" && !["127.0.0.1", "localhost"].includes(baseUrl.hostname)) throw new Error(`--public-url must be https (got ${base})`);
|
|
81
|
+
auth.refuseFalsifyOnPublicServer(base, env); // HQ-2082: no test switch on an address the world can reach
|
|
62
82
|
const resource = `${base}/mcp`;
|
|
63
83
|
const prmUrl = `${base}/.well-known/oauth-protected-resource/mcp`;
|
|
64
84
|
const evda = openEvda(dir); // refuse to start on something that is not a record
|
|
@@ -75,14 +95,17 @@ export function createMcpHttpServer({ dir, version, publicUrl, env = process.env
|
|
|
75
95
|
const url = new URL(req.url, base);
|
|
76
96
|
const path = url.pathname;
|
|
77
97
|
const ip = clientIp(req);
|
|
78
|
-
const note = { who: null };
|
|
98
|
+
const note = { who: null, method: null, tool: null, reason: null };
|
|
79
99
|
const send = (status, body, headers = {}) => {
|
|
80
100
|
const isJson = body !== null && typeof body === "object";
|
|
81
101
|
res.writeHead(status, { "cache-control": "no-store", "x-content-type-options": "nosniff", ...(isJson ? { "content-type": "application/json" } : {}), ...headers });
|
|
82
102
|
res.end(body === null || body === undefined ? undefined : isJson ? JSON.stringify(body) : body);
|
|
83
103
|
// Path only: a query string on /authorize carries a state and a challenge, and on a bad
|
|
84
104
|
// request could carry anything.
|
|
85
|
-
|
|
105
|
+
// A refusal names why (HQ-2235): the error text this server wrote and the JSON-RPC method when
|
|
106
|
+
// the body parsed, never the body itself, so four different 400s can be told apart afterwards.
|
|
107
|
+
const why = status >= 400 && status < 500 ? { reason: note.reason ?? reasonOf(body), ...(note.method ? { method: note.method } : {}), ...(note.tool ? { tool: note.tool } : {}) } : {};
|
|
108
|
+
record({ http: req.method, path: knownPath(path), status, ms: clock() - started, ...(note.who ? { conn: note.who } : {}), ...why });
|
|
86
109
|
};
|
|
87
110
|
try {
|
|
88
111
|
const now = clock();
|
|
@@ -143,6 +166,7 @@ export function createMcpHttpServer({ dir, version, publicUrl, env = process.env
|
|
|
143
166
|
if (req.method === "DELETE") {
|
|
144
167
|
const s = sessions.get(sid);
|
|
145
168
|
if (s && s.family === grant.family) sessions.delete(sid);
|
|
169
|
+
if (!s) note.reason = "session not found";
|
|
146
170
|
return send(s ? 204 : 404, null);
|
|
147
171
|
}
|
|
148
172
|
if (req.method !== "POST") return send(405, { error: "method_not_allowed" }, { allow: "POST, DELETE" });
|
|
@@ -151,6 +175,8 @@ export function createMcpHttpServer({ dir, version, publicUrl, env = process.env
|
|
|
151
175
|
const raw = await readBody(req, MCP_BODY_LIMIT);
|
|
152
176
|
try { msg = JSON.parse(raw); } catch { return send(400, { jsonrpc: "2.0", id: null, error: { code: -32700, message: "parse error" } }); }
|
|
153
177
|
const batch = Array.isArray(msg) ? msg : [msg];
|
|
178
|
+
note.method = batch.map((x) => (typeof x?.method !== "string" ? "none" : METHODS.has(x.method) ? x.method : "unknown_method")).join(",");
|
|
179
|
+
if (batch.length === 1 && batch[0]?.method === "tools/call") note.tool = TOOL_NAMES.has(batch[0].params?.name) ? batch[0].params.name : "unknown_tool";
|
|
154
180
|
const isInit = batch.some((x) => x?.method === "initialize");
|
|
155
181
|
let session;
|
|
156
182
|
let newSid = null;
|
|
@@ -158,7 +184,7 @@ export function createMcpHttpServer({ dir, version, publicUrl, env = process.env
|
|
|
158
184
|
if (batch.length !== 1) return send(400, { jsonrpc: "2.0", id: null, error: { code: -32600, message: "initialize must be sent alone" } });
|
|
159
185
|
evictSessions(now);
|
|
160
186
|
newSid = randomBytes(24).toString("hex");
|
|
161
|
-
session = { family: grant.family, last: now, ctx: { version, log: (s) => log(s), env: readerEnv, evda: () => openEvda(dir), session: newSession(), caller: callerFrom({ env: readerEnv }) } };
|
|
187
|
+
session = { family: grant.family, last: now, ctx: { version, log: (s) => log(s), clock, env: readerEnv, evda: () => openEvda(dir), session: newSession(), caller: callerFrom({ env: readerEnv }), keepsToolList: true } };
|
|
162
188
|
sessions.set(newSid, session);
|
|
163
189
|
} else {
|
|
164
190
|
if (!sid) return send(400, { jsonrpc: "2.0", id: null, error: { code: -32600, message: "Mcp-Session-Id header required; initialize first" } });
|
|
@@ -177,6 +203,9 @@ export function createMcpHttpServer({ dir, version, publicUrl, env = process.env
|
|
|
177
203
|
if (method && one?.id !== undefined && one?.id !== null) {
|
|
178
204
|
const st = r?.error ? "error" : r?.result?.isError ? "refused" : "ok";
|
|
179
205
|
const line = { call: method === "tools/call" ? (TOOL_NAMES.has(tool) ? tool : "unknown_tool") : METHODS.has(method) ? method : "unknown_method", status: st, ms: clock() - t0, session: shortId(sid ?? newSid), conn: note.who };
|
|
206
|
+
// Which app connected (HQ-2207): initialize's clientInfo name and version, nothing else of it.
|
|
207
|
+
if (method === "initialize") { const ci = clientInfoOf(one.params); line.client_name = ci.name; line.client_version = ci.version; }
|
|
208
|
+
Object.assign(line, extraNames(req.headers, one.params));
|
|
180
209
|
if (auth.FALSIFY("log-content", env)) line.args = one.params?.arguments;
|
|
181
210
|
record(line);
|
|
182
211
|
}
|
|
@@ -268,6 +297,15 @@ function page(title, body) {
|
|
|
268
297
|
</head><body><h1>${esc(title)}</h1>${body}</body></html>`;
|
|
269
298
|
}
|
|
270
299
|
|
|
300
|
+
// The error text of a refusal this server wrote: a JSON-RPC error message, an OAuth error_description
|
|
301
|
+
// or error code, or an HTML page's title. Server-authored strings only, capped.
|
|
302
|
+
function reasonOf(body) {
|
|
303
|
+
let r = null;
|
|
304
|
+
if (typeof body === "string") r = /<title>([^<]*)<\/title>/.exec(body)?.[1] ?? null;
|
|
305
|
+
else if (body && typeof body === "object") r = typeof body.error === "object" ? body.error?.message : body.error_description ?? body.error;
|
|
306
|
+
return typeof r === "string" ? r.slice(0, 200) : null;
|
|
307
|
+
}
|
|
308
|
+
|
|
271
309
|
function withParams(uri, params) {
|
|
272
310
|
const u = new URL(uri);
|
|
273
311
|
for (const [k, v] of Object.entries(params)) if (v !== undefined && v !== null) u.searchParams.set(k, v);
|