memgineering 0.4.0 → 0.4.1
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 +31 -0
- package/dist/index.js +99 -16
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -11,6 +11,37 @@ language the reader wants. The bilingual rule the monorepo applies to
|
|
|
11
11
|
|
|
12
12
|
## [Unreleased]
|
|
13
13
|
|
|
14
|
+
## [0.4.1] — 2026-08-10
|
|
15
|
+
|
|
16
|
+
Security fixes in `setup --web`, from the 2026-08-09 audit
|
|
17
|
+
(`docs/security-audit-2026-08-09.md`). Every one was reproduced against
|
|
18
|
+
0.3.0/0.4.0 rather than reasoned about. No command or flag changed.
|
|
19
|
+
|
|
20
|
+
### Fixed
|
|
21
|
+
|
|
22
|
+
- **The setup screen no longer stays up for whoever is knocking.** Its idle
|
|
23
|
+
timer was reset before any request was checked, so anything that could reach
|
|
24
|
+
the port kept a writable install endpoint alive by pinging it — without ever
|
|
25
|
+
presenting the key. The timer now moves only for requests that passed the
|
|
26
|
+
origin and key checks, and there is a one-hour ceiling above it that nothing
|
|
27
|
+
can push back.
|
|
28
|
+
- **`--web` no longer reads a folder you did not mean.** Pointed at a system
|
|
29
|
+
directory it walked one and returned the title and first paragraph of every
|
|
30
|
+
markdown file under it. System folders are refused, the scan stops after
|
|
31
|
+
5,000 notes rather than running for minutes on a large folder with no way to
|
|
32
|
+
cancel, and the folder step stops answering once the install is done.
|
|
33
|
+
- **A second submit no longer reports a failed install that succeeded.** Two
|
|
34
|
+
applies — the ordinary case of opening the setup link in a second tab —
|
|
35
|
+
both got past the already-applied guard, and the loser was told "Nothing
|
|
36
|
+
further was written" while everything had been written.
|
|
37
|
+
- **The screen cannot be embedded in another page**, and a request that changes
|
|
38
|
+
something must say where it came from.
|
|
39
|
+
- **`--web` no longer claims it opened a browser when it did not.** On a machine
|
|
40
|
+
with no default browser — or a sandbox, or `xdg-open` with nothing to hand it
|
|
41
|
+
— the opener exits non-zero and this reported success anyway, so an agent told
|
|
42
|
+
its user the screen was open while the desktop was unchanged and the printed
|
|
43
|
+
URL was the only way forward.
|
|
44
|
+
|
|
14
45
|
## [0.4.0] — 2026-08-10
|
|
15
46
|
|
|
16
47
|
A template is not an answer, a revision that revises nothing is not written,
|
package/dist/index.js
CHANGED
|
@@ -4130,10 +4130,11 @@ import { randomBytes as randomBytes2, timingSafeEqual } from "crypto";
|
|
|
4130
4130
|
import { readdir as readdir6, stat as stat3 } from "fs/promises";
|
|
4131
4131
|
import { createServer } from "http";
|
|
4132
4132
|
import { homedir as homedir3 } from "os";
|
|
4133
|
-
import { basename, isAbsolute as isAbsolute2, join as join14, resolve as resolve7 } from "path";
|
|
4133
|
+
import { basename, isAbsolute as isAbsolute2, join as join14, relative as relative2, resolve as resolve7 } from "path";
|
|
4134
4134
|
async function startSetupWebServer(opts) {
|
|
4135
4135
|
const token = randomBytes2(32).toString("hex");
|
|
4136
4136
|
const idleMs = opts.idleMs ?? IDLE_TIMEOUT_MS;
|
|
4137
|
+
const maxMs = opts.maxMs ?? MAX_LIFETIME_MS;
|
|
4137
4138
|
const lingerMs = opts.lingerMs ?? APPLY_LINGER_MS;
|
|
4138
4139
|
const sockets = /* @__PURE__ */ new Set();
|
|
4139
4140
|
const server = createServer();
|
|
@@ -4162,6 +4163,7 @@ async function startSetupWebServer(opts) {
|
|
|
4162
4163
|
if (stopped) return;
|
|
4163
4164
|
stopped = true;
|
|
4164
4165
|
if (idleTimer) clearTimeout(idleTimer);
|
|
4166
|
+
clearTimeout(maxTimer);
|
|
4165
4167
|
process.off("SIGINT", onInterrupt);
|
|
4166
4168
|
await new Promise((resolve10) => {
|
|
4167
4169
|
server.close(() => resolve10());
|
|
@@ -4175,14 +4177,15 @@ async function startSetupWebServer(opts) {
|
|
|
4175
4177
|
idleTimer = setTimeout(() => void stop("idle"), idleMs);
|
|
4176
4178
|
idleTimer.unref();
|
|
4177
4179
|
};
|
|
4180
|
+
const maxTimer = setTimeout(() => void stop("idle"), maxMs);
|
|
4181
|
+
maxTimer.unref();
|
|
4178
4182
|
function onInterrupt() {
|
|
4179
4183
|
void stop("interrupted");
|
|
4180
4184
|
}
|
|
4181
4185
|
process.once("SIGINT", onInterrupt);
|
|
4182
4186
|
const state = { detected: opts.detected, token, ownOrigins, applied: false };
|
|
4183
4187
|
server.on("request", (req, res) => {
|
|
4184
|
-
touch()
|
|
4185
|
-
void handle(req, res, state, origin).then((outcome) => {
|
|
4188
|
+
void handle(req, res, state, origin, touch).then((outcome) => {
|
|
4186
4189
|
if (outcome === "applied") setTimeout(() => void stop("applied"), lingerMs).unref();
|
|
4187
4190
|
if (outcome === "cancelled") setTimeout(() => void stop("cancelled"), 50).unref();
|
|
4188
4191
|
}).catch((err) => {
|
|
@@ -4237,9 +4240,20 @@ async function runSetupWeb(opts) {
|
|
|
4237
4240
|
else printHuman(`
|
|
4238
4241
|
${reason === "applied" ? c.green(ending[reason]) : c.gray(ending[reason])}`);
|
|
4239
4242
|
}
|
|
4240
|
-
async function handle(req, res, state, origin) {
|
|
4243
|
+
async function handle(req, res, state, origin, touch = () => {
|
|
4244
|
+
}) {
|
|
4241
4245
|
const url = new URL(req.url ?? "/", origin);
|
|
4242
4246
|
const sender = req.headers.origin;
|
|
4247
|
+
if (sender === void 0 && req.method !== "GET" && req.method !== "HEAD") {
|
|
4248
|
+
sendJson(res, 403, {
|
|
4249
|
+
error: {
|
|
4250
|
+
code: "forbidden_origin",
|
|
4251
|
+
message: "a request that changes something must say where it came from",
|
|
4252
|
+
hint: "This screen answers the page it opened on this machine. If you are driving it directly, send `Origin` matching the URL `memgineering setup --web` printed."
|
|
4253
|
+
}
|
|
4254
|
+
});
|
|
4255
|
+
return "served";
|
|
4256
|
+
}
|
|
4243
4257
|
if (sender !== void 0 && !state.ownOrigins.has(sender)) {
|
|
4244
4258
|
sendJson(res, 403, {
|
|
4245
4259
|
error: {
|
|
@@ -4260,6 +4274,7 @@ async function handle(req, res, state, origin) {
|
|
|
4260
4274
|
});
|
|
4261
4275
|
return "served";
|
|
4262
4276
|
}
|
|
4277
|
+
touch();
|
|
4263
4278
|
if (req.method === "GET" && url.pathname === "/") {
|
|
4264
4279
|
sendHtml(res, renderSetupPage());
|
|
4265
4280
|
return "served";
|
|
@@ -4277,7 +4292,7 @@ async function handle(req, res, state, origin) {
|
|
|
4277
4292
|
return "served";
|
|
4278
4293
|
}
|
|
4279
4294
|
if (req.method === "POST" && url.pathname === "/api/inspect") {
|
|
4280
|
-
return inspectFromScreen(req, res);
|
|
4295
|
+
return inspectFromScreen(req, res, state);
|
|
4281
4296
|
}
|
|
4282
4297
|
if (req.method === "POST" && url.pathname === "/api/apply") {
|
|
4283
4298
|
return applyFromScreen(req, res, state);
|
|
@@ -4354,7 +4369,24 @@ async function holdsNotes(dir) {
|
|
|
4354
4369
|
}
|
|
4355
4370
|
return false;
|
|
4356
4371
|
}
|
|
4357
|
-
|
|
4372
|
+
function isSystemPath(path) {
|
|
4373
|
+
return SYSTEM_ROOTS.some((root) => {
|
|
4374
|
+
if (root === "/") return path === "/";
|
|
4375
|
+
const rel = relative2(root, path);
|
|
4376
|
+
return rel === "" || !rel.startsWith("..") && !isAbsolute2(rel);
|
|
4377
|
+
});
|
|
4378
|
+
}
|
|
4379
|
+
async function inspectFromScreen(req, res, state) {
|
|
4380
|
+
if (state.applied) {
|
|
4381
|
+
sendJson(res, 409, {
|
|
4382
|
+
error: {
|
|
4383
|
+
code: "already_applied",
|
|
4384
|
+
message: "setup is finished \u2014 this screen no longer reads folders",
|
|
4385
|
+
hint: "Run `memgineering link <folder>` to connect another set of notes, or `memgineering setup --web` again."
|
|
4386
|
+
}
|
|
4387
|
+
});
|
|
4388
|
+
return "served";
|
|
4389
|
+
}
|
|
4358
4390
|
const parsed = await readJsonBody(req, res);
|
|
4359
4391
|
if (parsed === void 0) return "served";
|
|
4360
4392
|
const asked = parsed.path;
|
|
@@ -4368,7 +4400,17 @@ async function inspectFromScreen(req, res) {
|
|
|
4368
4400
|
});
|
|
4369
4401
|
return "served";
|
|
4370
4402
|
}
|
|
4371
|
-
const path = expandHome(asked.trim());
|
|
4403
|
+
const path = resolve7(expandHome(asked.trim()));
|
|
4404
|
+
if (isSystemPath(path)) {
|
|
4405
|
+
sendJson(res, 400, {
|
|
4406
|
+
error: {
|
|
4407
|
+
code: "invalid_input",
|
|
4408
|
+
message: `that is a system folder, not a notes folder: ${path}`,
|
|
4409
|
+
hint: "Point this at the FOLDER holding your notes \u2014 somewhere you write, like `~/Documents/Notes`."
|
|
4410
|
+
}
|
|
4411
|
+
});
|
|
4412
|
+
return "served";
|
|
4413
|
+
}
|
|
4372
4414
|
const info = await stat3(path).catch(() => null);
|
|
4373
4415
|
if (info === null) {
|
|
4374
4416
|
sendJson(res, 200, {
|
|
@@ -4390,7 +4432,8 @@ async function inspectFromScreen(req, res) {
|
|
|
4390
4432
|
}
|
|
4391
4433
|
try {
|
|
4392
4434
|
const target = await openLinkTarget(path, { quiet: true });
|
|
4393
|
-
const rows = await target.preview(
|
|
4435
|
+
const rows = await target.preview(PREVIEW_CAP);
|
|
4436
|
+
const capped = target.notes.length > PREVIEW_CAP;
|
|
4394
4437
|
const cfg = await loadConfig();
|
|
4395
4438
|
sendJson(res, 200, {
|
|
4396
4439
|
path,
|
|
@@ -4401,6 +4444,10 @@ async function inspectFromScreen(req, res) {
|
|
|
4401
4444
|
// number is the one a person is agreeing to; the first is why it moved.
|
|
4402
4445
|
scanned: target.notes.length,
|
|
4403
4446
|
would_index: rows.length,
|
|
4447
|
+
// Named, so a number that stopped early is never read as a total. The
|
|
4448
|
+
// screen shows what a person is agreeing to, and "5,000" presented as the
|
|
4449
|
+
// whole of a 24,000-note folder is a worse answer than a slow one.
|
|
4450
|
+
...capped ? { would_index_capped: PREVIEW_CAP } : {},
|
|
4404
4451
|
excluded: target.scan.excluded.length,
|
|
4405
4452
|
skipped_symlinks: target.scan.skippedSymlinks.length,
|
|
4406
4453
|
refused: [
|
|
@@ -4430,14 +4477,18 @@ async function applyFromScreen(req, res, state) {
|
|
|
4430
4477
|
});
|
|
4431
4478
|
return "served";
|
|
4432
4479
|
}
|
|
4480
|
+
state.applied = true;
|
|
4433
4481
|
const parsed = await readJsonBody(req, res);
|
|
4434
|
-
if (parsed === void 0)
|
|
4482
|
+
if (parsed === void 0) {
|
|
4483
|
+
state.applied = false;
|
|
4484
|
+
return "served";
|
|
4485
|
+
}
|
|
4435
4486
|
const choice = readChoice(parsed, state.detected);
|
|
4436
4487
|
if ("error" in choice) {
|
|
4488
|
+
state.applied = false;
|
|
4437
4489
|
sendJson(res, 400, { error: choice.error });
|
|
4438
4490
|
return "served";
|
|
4439
4491
|
}
|
|
4440
|
-
state.applied = true;
|
|
4441
4492
|
const changes = await applySetup(choice.value, false);
|
|
4442
4493
|
const brain = await connectBrain(choice.brain);
|
|
4443
4494
|
const cfg = await loadConfig();
|
|
@@ -4656,17 +4707,34 @@ function sendHtml(res, html) {
|
|
|
4656
4707
|
// The page is one self-contained file by design; say so, so that a future
|
|
4657
4708
|
// edit that reaches for a CDN font fails visibly here instead of quietly
|
|
4658
4709
|
// making a local setup screen talk to the internet.
|
|
4659
|
-
|
|
4710
|
+
// `frame-ancestors 'none'` because a hostile page could and did load this
|
|
4711
|
+
// screen in an iframe. Nothing is exploitable through it while the token
|
|
4712
|
+
// stands between an attacker and the API — but the consent screen for
|
|
4713
|
+
// installing into somebody's agent should not be embeddable, and the day
|
|
4714
|
+
// the token moves out of the URL into a cookie it would become the whole
|
|
4715
|
+
// attack rather than a curiosity.
|
|
4716
|
+
"content-security-policy": "default-src 'none'; style-src 'unsafe-inline'; script-src 'unsafe-inline'; connect-src 'self'; form-action 'none'; base-uri 'none'; frame-ancestors 'none'",
|
|
4717
|
+
// The header the older browsers read. Same statement, twice.
|
|
4718
|
+
"x-frame-options": "DENY"
|
|
4660
4719
|
});
|
|
4661
4720
|
res.end(html);
|
|
4662
4721
|
}
|
|
4663
4722
|
async function openBrowser(url) {
|
|
4664
4723
|
const { file, args } = browserOpener(url);
|
|
4665
4724
|
return new Promise((resolve10) => {
|
|
4725
|
+
let settled = false;
|
|
4726
|
+
const finish = (opened) => {
|
|
4727
|
+
if (settled) return;
|
|
4728
|
+
settled = true;
|
|
4729
|
+
resolve10(opened);
|
|
4730
|
+
};
|
|
4666
4731
|
const child = spawn(file, args, { detached: true, stdio: "ignore" });
|
|
4667
|
-
child.on("error", () =>
|
|
4732
|
+
child.on("error", () => finish(false));
|
|
4733
|
+
child.on("exit", (code) => {
|
|
4734
|
+
if (code !== null && code !== 0) finish(false);
|
|
4735
|
+
});
|
|
4668
4736
|
child.unref();
|
|
4669
|
-
setTimeout(() =>
|
|
4737
|
+
setTimeout(() => finish(true), 300).unref();
|
|
4670
4738
|
});
|
|
4671
4739
|
}
|
|
4672
4740
|
function browserOpener(url) {
|
|
@@ -4674,7 +4742,7 @@ function browserOpener(url) {
|
|
|
4674
4742
|
if (process.platform === "win32") return { file: "cmd", args: ["/c", "start", "", url] };
|
|
4675
4743
|
return { file: "xdg-open", args: [url] };
|
|
4676
4744
|
}
|
|
4677
|
-
var IDLE_TIMEOUT_MS, APPLY_LINGER_MS, MAX_BODY_BYTES, MAX_CANDIDATES;
|
|
4745
|
+
var IDLE_TIMEOUT_MS, MAX_LIFETIME_MS, APPLY_LINGER_MS, MAX_BODY_BYTES, MAX_CANDIDATES, PREVIEW_CAP, SYSTEM_ROOTS;
|
|
4678
4746
|
var init_setup_web = __esm({
|
|
4679
4747
|
"src/commands/setup-web.ts"() {
|
|
4680
4748
|
"use strict";
|
|
@@ -4686,9 +4754,24 @@ var init_setup_web = __esm({
|
|
|
4686
4754
|
init_setup();
|
|
4687
4755
|
init_setup_web_page();
|
|
4688
4756
|
IDLE_TIMEOUT_MS = 10 * 60 * 1e3;
|
|
4757
|
+
MAX_LIFETIME_MS = 60 * 60 * 1e3;
|
|
4689
4758
|
APPLY_LINGER_MS = 1e3;
|
|
4690
4759
|
MAX_BODY_BYTES = 64 * 1024;
|
|
4691
4760
|
MAX_CANDIDATES = 8;
|
|
4761
|
+
PREVIEW_CAP = 5e3;
|
|
4762
|
+
SYSTEM_ROOTS = [
|
|
4763
|
+
"/",
|
|
4764
|
+
"/usr",
|
|
4765
|
+
"/bin",
|
|
4766
|
+
"/sbin",
|
|
4767
|
+
"/etc",
|
|
4768
|
+
"/dev",
|
|
4769
|
+
"/proc",
|
|
4770
|
+
"/sys",
|
|
4771
|
+
"/System",
|
|
4772
|
+
"/Library",
|
|
4773
|
+
"/opt"
|
|
4774
|
+
];
|
|
4692
4775
|
}
|
|
4693
4776
|
});
|
|
4694
4777
|
|
|
@@ -8251,7 +8334,7 @@ init_errors();
|
|
|
8251
8334
|
init_config();
|
|
8252
8335
|
init_vault();
|
|
8253
8336
|
init_ui();
|
|
8254
|
-
import { isAbsolute as isAbsolute3, relative as
|
|
8337
|
+
import { isAbsolute as isAbsolute3, relative as relative3, resolve as resolve9 } from "path";
|
|
8255
8338
|
import { Command as Command17 } from "commander";
|
|
8256
8339
|
var VIA_MEANS = {
|
|
8257
8340
|
flag: "because --vault named it",
|
|
@@ -8328,7 +8411,7 @@ A pointer to an unlinked folder would fail on every command instead of at this o
|
|
|
8328
8411
|
});
|
|
8329
8412
|
return;
|
|
8330
8413
|
}
|
|
8331
|
-
const value = isInside(root, where) ?
|
|
8414
|
+
const value = isInside(root, where) ? relative3(where, root) || "." : root;
|
|
8332
8415
|
const file = resolve9(where, BRAND.pointerFileName);
|
|
8333
8416
|
await writeThenRename(file, `${value}
|
|
8334
8417
|
`);
|
package/package.json
CHANGED