create-evda 0.1.8 → 0.1.9
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 +37 -1
- package/README.md +8 -2
- package/bin/evda.mjs +157 -4
- package/create-evda.mjs +6 -1
- package/dist/pairing.mjs +2932 -1684
- package/hooks/evda-day-writer-guard.mjs +7 -0
- package/lib/acknowledge.mjs +187 -0
- package/lib/browse.mjs +354 -0
- package/lib/circles.mjs +5 -1
- package/lib/clients.mjs +18 -2
- package/lib/connect.mjs +8 -1
- package/lib/day-file.mjs +58 -9
- package/lib/day-writer.mjs +103 -0
- package/lib/doctor.mjs +13 -0
- package/lib/every-chat.mjs +132 -0
- package/lib/filechanges.mjs +203 -68
- package/lib/gather-apply.mjs +44 -3
- package/lib/gather.mjs +3 -1
- package/lib/keychain.mjs +144 -0
- package/lib/manifest.mjs +3 -1
- package/lib/mcp-auth.mjs +41 -1
- package/lib/mcp-http.mjs +24 -4
- package/lib/mcp.mjs +893 -73
- package/lib/measurement-log.mjs +246 -0
- package/lib/messages.mjs +5 -2
- package/lib/pairing.mjs +243 -5
- package/lib/parity.mjs +47 -13
- package/lib/qr.mjs +317 -0
- package/lib/record.mjs +20 -0
- package/lib/routines.mjs +42 -3
- 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/tools.mjs +423 -24
- package/lib/watch.mjs +160 -0
- package/package.json +1 -1
- package/skills/evda/SKILL.md +14 -0
- package/templates/AGENTS.md +25 -4
- package/templates/evda-digest.md +3 -1
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
|
|
|
@@ -59,6 +61,7 @@ export function createMcpHttpServer({ dir, version, publicUrl, env = process.env
|
|
|
59
61
|
const base = String(publicUrl).replace(/\/+$/, "");
|
|
60
62
|
const baseUrl = new URL(base);
|
|
61
63
|
if (baseUrl.protocol !== "https:" && !["127.0.0.1", "localhost"].includes(baseUrl.hostname)) throw new Error(`--public-url must be https (got ${base})`);
|
|
64
|
+
auth.refuseFalsifyOnPublicServer(base, env); // HQ-2082: no test switch on an address the world can reach
|
|
62
65
|
const resource = `${base}/mcp`;
|
|
63
66
|
const prmUrl = `${base}/.well-known/oauth-protected-resource/mcp`;
|
|
64
67
|
const evda = openEvda(dir); // refuse to start on something that is not a record
|
|
@@ -75,14 +78,17 @@ export function createMcpHttpServer({ dir, version, publicUrl, env = process.env
|
|
|
75
78
|
const url = new URL(req.url, base);
|
|
76
79
|
const path = url.pathname;
|
|
77
80
|
const ip = clientIp(req);
|
|
78
|
-
const note = { who: null };
|
|
81
|
+
const note = { who: null, method: null, tool: null, reason: null };
|
|
79
82
|
const send = (status, body, headers = {}) => {
|
|
80
83
|
const isJson = body !== null && typeof body === "object";
|
|
81
84
|
res.writeHead(status, { "cache-control": "no-store", "x-content-type-options": "nosniff", ...(isJson ? { "content-type": "application/json" } : {}), ...headers });
|
|
82
85
|
res.end(body === null || body === undefined ? undefined : isJson ? JSON.stringify(body) : body);
|
|
83
86
|
// Path only: a query string on /authorize carries a state and a challenge, and on a bad
|
|
84
87
|
// request could carry anything.
|
|
85
|
-
|
|
88
|
+
// A refusal names why (HQ-2235): the error text this server wrote and the JSON-RPC method when
|
|
89
|
+
// the body parsed, never the body itself, so four different 400s can be told apart afterwards.
|
|
90
|
+
const why = status >= 400 && status < 500 ? { reason: note.reason ?? reasonOf(body), ...(note.method ? { method: note.method } : {}), ...(note.tool ? { tool: note.tool } : {}) } : {};
|
|
91
|
+
record({ http: req.method, path: knownPath(path), status, ms: clock() - started, ...(note.who ? { conn: note.who } : {}), ...why });
|
|
86
92
|
};
|
|
87
93
|
try {
|
|
88
94
|
const now = clock();
|
|
@@ -143,6 +149,7 @@ export function createMcpHttpServer({ dir, version, publicUrl, env = process.env
|
|
|
143
149
|
if (req.method === "DELETE") {
|
|
144
150
|
const s = sessions.get(sid);
|
|
145
151
|
if (s && s.family === grant.family) sessions.delete(sid);
|
|
152
|
+
if (!s) note.reason = "session not found";
|
|
146
153
|
return send(s ? 204 : 404, null);
|
|
147
154
|
}
|
|
148
155
|
if (req.method !== "POST") return send(405, { error: "method_not_allowed" }, { allow: "POST, DELETE" });
|
|
@@ -151,6 +158,8 @@ export function createMcpHttpServer({ dir, version, publicUrl, env = process.env
|
|
|
151
158
|
const raw = await readBody(req, MCP_BODY_LIMIT);
|
|
152
159
|
try { msg = JSON.parse(raw); } catch { return send(400, { jsonrpc: "2.0", id: null, error: { code: -32700, message: "parse error" } }); }
|
|
153
160
|
const batch = Array.isArray(msg) ? msg : [msg];
|
|
161
|
+
note.method = batch.map((x) => (typeof x?.method !== "string" ? "none" : METHODS.has(x.method) ? x.method : "unknown_method")).join(",");
|
|
162
|
+
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
163
|
const isInit = batch.some((x) => x?.method === "initialize");
|
|
155
164
|
let session;
|
|
156
165
|
let newSid = null;
|
|
@@ -158,7 +167,7 @@ export function createMcpHttpServer({ dir, version, publicUrl, env = process.env
|
|
|
158
167
|
if (batch.length !== 1) return send(400, { jsonrpc: "2.0", id: null, error: { code: -32600, message: "initialize must be sent alone" } });
|
|
159
168
|
evictSessions(now);
|
|
160
169
|
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 }) } };
|
|
170
|
+
session = { family: grant.family, last: now, ctx: { version, log: (s) => log(s), clock, env: readerEnv, evda: () => openEvda(dir), session: newSession(), caller: callerFrom({ env: readerEnv }) } };
|
|
162
171
|
sessions.set(newSid, session);
|
|
163
172
|
} else {
|
|
164
173
|
if (!sid) return send(400, { jsonrpc: "2.0", id: null, error: { code: -32600, message: "Mcp-Session-Id header required; initialize first" } });
|
|
@@ -177,6 +186,8 @@ export function createMcpHttpServer({ dir, version, publicUrl, env = process.env
|
|
|
177
186
|
if (method && one?.id !== undefined && one?.id !== null) {
|
|
178
187
|
const st = r?.error ? "error" : r?.result?.isError ? "refused" : "ok";
|
|
179
188
|
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 };
|
|
189
|
+
// Which app connected (HQ-2207): initialize's clientInfo name and version, nothing else of it.
|
|
190
|
+
if (method === "initialize") { const ci = clientInfoOf(one.params); line.client_name = ci.name; line.client_version = ci.version; }
|
|
180
191
|
if (auth.FALSIFY("log-content", env)) line.args = one.params?.arguments;
|
|
181
192
|
record(line);
|
|
182
193
|
}
|
|
@@ -268,6 +279,15 @@ function page(title, body) {
|
|
|
268
279
|
</head><body><h1>${esc(title)}</h1>${body}</body></html>`;
|
|
269
280
|
}
|
|
270
281
|
|
|
282
|
+
// The error text of a refusal this server wrote: a JSON-RPC error message, an OAuth error_description
|
|
283
|
+
// or error code, or an HTML page's title. Server-authored strings only, capped.
|
|
284
|
+
function reasonOf(body) {
|
|
285
|
+
let r = null;
|
|
286
|
+
if (typeof body === "string") r = /<title>([^<]*)<\/title>/.exec(body)?.[1] ?? null;
|
|
287
|
+
else if (body && typeof body === "object") r = typeof body.error === "object" ? body.error?.message : body.error_description ?? body.error;
|
|
288
|
+
return typeof r === "string" ? r.slice(0, 200) : null;
|
|
289
|
+
}
|
|
290
|
+
|
|
271
291
|
function withParams(uri, params) {
|
|
272
292
|
const u = new URL(uri);
|
|
273
293
|
for (const [k, v] of Object.entries(params)) if (v !== undefined && v !== null) u.searchParams.set(k, v);
|