memgineering 0.4.0 → 0.4.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 +53 -0
- package/dist/index.js +120 -19
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -11,6 +11,59 @@ language the reader wants. The bilingual rule the monorepo applies to
|
|
|
11
11
|
|
|
12
12
|
## [Unreleased]
|
|
13
13
|
|
|
14
|
+
## [0.4.2] — 2026-08-10
|
|
15
|
+
|
|
16
|
+
### Fixed
|
|
17
|
+
|
|
18
|
+
- **`setup --web` no longer prints the install key where it will be kept.** The
|
|
19
|
+
URL carries the key that authorizes reading your folders and writing into
|
|
20
|
+
`~/.claude`, and it was printed on every run — including under `--json`, so a
|
|
21
|
+
copy landed in the transcript of the agent that ran the command, which may be
|
|
22
|
+
stored, synced, or sent somewhere. It is now shown only when it is the way
|
|
23
|
+
in: the browser did not open, or you asked for it with `--print-url`.
|
|
24
|
+
|
|
25
|
+
**If you parse `--json`:** `url` is now present only when `opened` is
|
|
26
|
+
`false`. `port` and `opened` are always there. This is a smaller promise than
|
|
27
|
+
before and it is deliberate — the alternative was to keep handing out the key
|
|
28
|
+
to avoid changing a field.
|
|
29
|
+
|
|
30
|
+
- **The link is no longer described as working "only once".** It never did: the
|
|
31
|
+
key is single-use for applying the setup and reusable for reading, so a
|
|
32
|
+
person told "only once" and then able to reload had been told something
|
|
33
|
+
untrue about the thing protecting their machine. It now says what actually
|
|
34
|
+
ends it — applying, closing the tab, or an hour.
|
|
35
|
+
|
|
36
|
+
## [0.4.1] — 2026-08-10
|
|
37
|
+
|
|
38
|
+
Security fixes in `setup --web`, from the 2026-08-09 audit
|
|
39
|
+
(`docs/security-audit-2026-08-09.md`). Every one was reproduced against
|
|
40
|
+
0.3.0/0.4.0 rather than reasoned about. No command or flag changed.
|
|
41
|
+
|
|
42
|
+
### Fixed
|
|
43
|
+
|
|
44
|
+
- **The setup screen no longer stays up for whoever is knocking.** Its idle
|
|
45
|
+
timer was reset before any request was checked, so anything that could reach
|
|
46
|
+
the port kept a writable install endpoint alive by pinging it — without ever
|
|
47
|
+
presenting the key. The timer now moves only for requests that passed the
|
|
48
|
+
origin and key checks, and there is a one-hour ceiling above it that nothing
|
|
49
|
+
can push back.
|
|
50
|
+
- **`--web` no longer reads a folder you did not mean.** Pointed at a system
|
|
51
|
+
directory it walked one and returned the title and first paragraph of every
|
|
52
|
+
markdown file under it. System folders are refused, the scan stops after
|
|
53
|
+
5,000 notes rather than running for minutes on a large folder with no way to
|
|
54
|
+
cancel, and the folder step stops answering once the install is done.
|
|
55
|
+
- **A second submit no longer reports a failed install that succeeded.** Two
|
|
56
|
+
applies — the ordinary case of opening the setup link in a second tab —
|
|
57
|
+
both got past the already-applied guard, and the loser was told "Nothing
|
|
58
|
+
further was written" while everything had been written.
|
|
59
|
+
- **The screen cannot be embedded in another page**, and a request that changes
|
|
60
|
+
something must say where it came from.
|
|
61
|
+
- **`--web` no longer claims it opened a browser when it did not.** On a machine
|
|
62
|
+
with no default browser — or a sandbox, or `xdg-open` with nothing to hand it
|
|
63
|
+
— the opener exits non-zero and this reported success anyway, so an agent told
|
|
64
|
+
its user the screen was open while the desktop was unchanged and the printed
|
|
65
|
+
URL was the only way forward.
|
|
66
|
+
|
|
14
67
|
## [0.4.0] — 2026-08-10
|
|
15
68
|
|
|
16
69
|
A template is not an answer, a revision that revises nothing is not written,
|
package/dist/index.js
CHANGED
|
@@ -4122,6 +4122,7 @@ var init_setup_web_page = __esm({
|
|
|
4122
4122
|
var setup_web_exports = {};
|
|
4123
4123
|
__export(setup_web_exports, {
|
|
4124
4124
|
browserOpener: () => browserOpener,
|
|
4125
|
+
disclosure: () => disclosure,
|
|
4125
4126
|
runSetupWeb: () => runSetupWeb,
|
|
4126
4127
|
startSetupWebServer: () => startSetupWebServer
|
|
4127
4128
|
});
|
|
@@ -4130,10 +4131,11 @@ import { randomBytes as randomBytes2, timingSafeEqual } from "crypto";
|
|
|
4130
4131
|
import { readdir as readdir6, stat as stat3 } from "fs/promises";
|
|
4131
4132
|
import { createServer } from "http";
|
|
4132
4133
|
import { homedir as homedir3 } from "os";
|
|
4133
|
-
import { basename, isAbsolute as isAbsolute2, join as join14, resolve as resolve7 } from "path";
|
|
4134
|
+
import { basename, isAbsolute as isAbsolute2, join as join14, relative as relative2, resolve as resolve7 } from "path";
|
|
4134
4135
|
async function startSetupWebServer(opts) {
|
|
4135
4136
|
const token = randomBytes2(32).toString("hex");
|
|
4136
4137
|
const idleMs = opts.idleMs ?? IDLE_TIMEOUT_MS;
|
|
4138
|
+
const maxMs = opts.maxMs ?? MAX_LIFETIME_MS;
|
|
4137
4139
|
const lingerMs = opts.lingerMs ?? APPLY_LINGER_MS;
|
|
4138
4140
|
const sockets = /* @__PURE__ */ new Set();
|
|
4139
4141
|
const server = createServer();
|
|
@@ -4162,6 +4164,7 @@ async function startSetupWebServer(opts) {
|
|
|
4162
4164
|
if (stopped) return;
|
|
4163
4165
|
stopped = true;
|
|
4164
4166
|
if (idleTimer) clearTimeout(idleTimer);
|
|
4167
|
+
clearTimeout(maxTimer);
|
|
4165
4168
|
process.off("SIGINT", onInterrupt);
|
|
4166
4169
|
await new Promise((resolve10) => {
|
|
4167
4170
|
server.close(() => resolve10());
|
|
@@ -4175,14 +4178,15 @@ async function startSetupWebServer(opts) {
|
|
|
4175
4178
|
idleTimer = setTimeout(() => void stop("idle"), idleMs);
|
|
4176
4179
|
idleTimer.unref();
|
|
4177
4180
|
};
|
|
4181
|
+
const maxTimer = setTimeout(() => void stop("idle"), maxMs);
|
|
4182
|
+
maxTimer.unref();
|
|
4178
4183
|
function onInterrupt() {
|
|
4179
4184
|
void stop("interrupted");
|
|
4180
4185
|
}
|
|
4181
4186
|
process.once("SIGINT", onInterrupt);
|
|
4182
4187
|
const state = { detected: opts.detected, token, ownOrigins, applied: false };
|
|
4183
4188
|
server.on("request", (req, res) => {
|
|
4184
|
-
touch()
|
|
4185
|
-
void handle(req, res, state, origin).then((outcome) => {
|
|
4189
|
+
void handle(req, res, state, origin, touch).then((outcome) => {
|
|
4186
4190
|
if (outcome === "applied") setTimeout(() => void stop("applied"), lingerMs).unref();
|
|
4187
4191
|
if (outcome === "cancelled") setTimeout(() => void stop("cancelled"), 50).unref();
|
|
4188
4192
|
}).catch((err) => {
|
|
@@ -4209,18 +4213,32 @@ async function runSetupWeb(opts) {
|
|
|
4209
4213
|
const server = await startSetupWebServer({ detected: opts.detected });
|
|
4210
4214
|
const opened = opts.printUrl ? false : await openBrowser(server.url);
|
|
4211
4215
|
printDual({
|
|
4212
|
-
|
|
4216
|
+
// The URL carries the key that authorizes reading folders and writing into
|
|
4217
|
+
// `~/.claude`, and it was printed on every run — in `--json` too, so it
|
|
4218
|
+
// landed in the transcript of the agent that ran the command, which may be
|
|
4219
|
+
// stored, synced or sent somewhere. That is a different exposure from the
|
|
4220
|
+
// one everybody thinks of with a local token: not a process on this machine
|
|
4221
|
+
// that could read those files anyway, but a copy of the key leaving it.
|
|
4222
|
+
//
|
|
4223
|
+
// So it is emitted only when it is the way in — the browser did not open,
|
|
4224
|
+
// or `--print-url` asked for it. When the browser DID open, the person is
|
|
4225
|
+
// already looking at the screen and nobody needs the string.
|
|
4226
|
+
json: disclosure(server.url, server.port, opened),
|
|
4213
4227
|
human: () => {
|
|
4214
4228
|
if (opened) {
|
|
4215
4229
|
printHuman("Opened setup in your browser.\n");
|
|
4216
|
-
printHuman(
|
|
4230
|
+
printHuman(
|
|
4231
|
+
c.gray(
|
|
4232
|
+
" Nothing appeared? Run `memgineering setup --web --print-url` and open\n the link yourself \u2014 it is not printed here on purpose, because it\n carries the key that authorizes the install."
|
|
4233
|
+
)
|
|
4234
|
+
);
|
|
4217
4235
|
} else {
|
|
4218
4236
|
printHuman("Open this in a browser to finish setting up:\n");
|
|
4219
4237
|
printHuman(` ${server.url}`);
|
|
4220
4238
|
}
|
|
4221
4239
|
printHuman(
|
|
4222
4240
|
c.gray(
|
|
4223
|
-
"\n The link only
|
|
4241
|
+
"\n The link works only on this machine, and only until this screen\n closes \u2014 applying the setup, closing the tab, or an hour, whichever\n comes first. Ctrl-C to stop waiting."
|
|
4224
4242
|
)
|
|
4225
4243
|
);
|
|
4226
4244
|
}
|
|
@@ -4237,9 +4255,23 @@ async function runSetupWeb(opts) {
|
|
|
4237
4255
|
else printHuman(`
|
|
4238
4256
|
${reason === "applied" ? c.green(ending[reason]) : c.gray(ending[reason])}`);
|
|
4239
4257
|
}
|
|
4240
|
-
|
|
4258
|
+
function disclosure(url, port, opened) {
|
|
4259
|
+
return opened ? { port, opened } : { port, opened, url };
|
|
4260
|
+
}
|
|
4261
|
+
async function handle(req, res, state, origin, touch = () => {
|
|
4262
|
+
}) {
|
|
4241
4263
|
const url = new URL(req.url ?? "/", origin);
|
|
4242
4264
|
const sender = req.headers.origin;
|
|
4265
|
+
if (sender === void 0 && req.method !== "GET" && req.method !== "HEAD") {
|
|
4266
|
+
sendJson(res, 403, {
|
|
4267
|
+
error: {
|
|
4268
|
+
code: "forbidden_origin",
|
|
4269
|
+
message: "a request that changes something must say where it came from",
|
|
4270
|
+
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."
|
|
4271
|
+
}
|
|
4272
|
+
});
|
|
4273
|
+
return "served";
|
|
4274
|
+
}
|
|
4243
4275
|
if (sender !== void 0 && !state.ownOrigins.has(sender)) {
|
|
4244
4276
|
sendJson(res, 403, {
|
|
4245
4277
|
error: {
|
|
@@ -4260,6 +4292,7 @@ async function handle(req, res, state, origin) {
|
|
|
4260
4292
|
});
|
|
4261
4293
|
return "served";
|
|
4262
4294
|
}
|
|
4295
|
+
touch();
|
|
4263
4296
|
if (req.method === "GET" && url.pathname === "/") {
|
|
4264
4297
|
sendHtml(res, renderSetupPage());
|
|
4265
4298
|
return "served";
|
|
@@ -4277,7 +4310,7 @@ async function handle(req, res, state, origin) {
|
|
|
4277
4310
|
return "served";
|
|
4278
4311
|
}
|
|
4279
4312
|
if (req.method === "POST" && url.pathname === "/api/inspect") {
|
|
4280
|
-
return inspectFromScreen(req, res);
|
|
4313
|
+
return inspectFromScreen(req, res, state);
|
|
4281
4314
|
}
|
|
4282
4315
|
if (req.method === "POST" && url.pathname === "/api/apply") {
|
|
4283
4316
|
return applyFromScreen(req, res, state);
|
|
@@ -4354,7 +4387,24 @@ async function holdsNotes(dir) {
|
|
|
4354
4387
|
}
|
|
4355
4388
|
return false;
|
|
4356
4389
|
}
|
|
4357
|
-
|
|
4390
|
+
function isSystemPath(path) {
|
|
4391
|
+
return SYSTEM_ROOTS.some((root) => {
|
|
4392
|
+
if (root === "/") return path === "/";
|
|
4393
|
+
const rel = relative2(root, path);
|
|
4394
|
+
return rel === "" || !rel.startsWith("..") && !isAbsolute2(rel);
|
|
4395
|
+
});
|
|
4396
|
+
}
|
|
4397
|
+
async function inspectFromScreen(req, res, state) {
|
|
4398
|
+
if (state.applied) {
|
|
4399
|
+
sendJson(res, 409, {
|
|
4400
|
+
error: {
|
|
4401
|
+
code: "already_applied",
|
|
4402
|
+
message: "setup is finished \u2014 this screen no longer reads folders",
|
|
4403
|
+
hint: "Run `memgineering link <folder>` to connect another set of notes, or `memgineering setup --web` again."
|
|
4404
|
+
}
|
|
4405
|
+
});
|
|
4406
|
+
return "served";
|
|
4407
|
+
}
|
|
4358
4408
|
const parsed = await readJsonBody(req, res);
|
|
4359
4409
|
if (parsed === void 0) return "served";
|
|
4360
4410
|
const asked = parsed.path;
|
|
@@ -4368,7 +4418,17 @@ async function inspectFromScreen(req, res) {
|
|
|
4368
4418
|
});
|
|
4369
4419
|
return "served";
|
|
4370
4420
|
}
|
|
4371
|
-
const path = expandHome(asked.trim());
|
|
4421
|
+
const path = resolve7(expandHome(asked.trim()));
|
|
4422
|
+
if (isSystemPath(path)) {
|
|
4423
|
+
sendJson(res, 400, {
|
|
4424
|
+
error: {
|
|
4425
|
+
code: "invalid_input",
|
|
4426
|
+
message: `that is a system folder, not a notes folder: ${path}`,
|
|
4427
|
+
hint: "Point this at the FOLDER holding your notes \u2014 somewhere you write, like `~/Documents/Notes`."
|
|
4428
|
+
}
|
|
4429
|
+
});
|
|
4430
|
+
return "served";
|
|
4431
|
+
}
|
|
4372
4432
|
const info = await stat3(path).catch(() => null);
|
|
4373
4433
|
if (info === null) {
|
|
4374
4434
|
sendJson(res, 200, {
|
|
@@ -4390,7 +4450,8 @@ async function inspectFromScreen(req, res) {
|
|
|
4390
4450
|
}
|
|
4391
4451
|
try {
|
|
4392
4452
|
const target = await openLinkTarget(path, { quiet: true });
|
|
4393
|
-
const rows = await target.preview(
|
|
4453
|
+
const rows = await target.preview(PREVIEW_CAP);
|
|
4454
|
+
const capped = target.notes.length > PREVIEW_CAP;
|
|
4394
4455
|
const cfg = await loadConfig();
|
|
4395
4456
|
sendJson(res, 200, {
|
|
4396
4457
|
path,
|
|
@@ -4401,6 +4462,10 @@ async function inspectFromScreen(req, res) {
|
|
|
4401
4462
|
// number is the one a person is agreeing to; the first is why it moved.
|
|
4402
4463
|
scanned: target.notes.length,
|
|
4403
4464
|
would_index: rows.length,
|
|
4465
|
+
// Named, so a number that stopped early is never read as a total. The
|
|
4466
|
+
// screen shows what a person is agreeing to, and "5,000" presented as the
|
|
4467
|
+
// whole of a 24,000-note folder is a worse answer than a slow one.
|
|
4468
|
+
...capped ? { would_index_capped: PREVIEW_CAP } : {},
|
|
4404
4469
|
excluded: target.scan.excluded.length,
|
|
4405
4470
|
skipped_symlinks: target.scan.skippedSymlinks.length,
|
|
4406
4471
|
refused: [
|
|
@@ -4430,14 +4495,18 @@ async function applyFromScreen(req, res, state) {
|
|
|
4430
4495
|
});
|
|
4431
4496
|
return "served";
|
|
4432
4497
|
}
|
|
4498
|
+
state.applied = true;
|
|
4433
4499
|
const parsed = await readJsonBody(req, res);
|
|
4434
|
-
if (parsed === void 0)
|
|
4500
|
+
if (parsed === void 0) {
|
|
4501
|
+
state.applied = false;
|
|
4502
|
+
return "served";
|
|
4503
|
+
}
|
|
4435
4504
|
const choice = readChoice(parsed, state.detected);
|
|
4436
4505
|
if ("error" in choice) {
|
|
4506
|
+
state.applied = false;
|
|
4437
4507
|
sendJson(res, 400, { error: choice.error });
|
|
4438
4508
|
return "served";
|
|
4439
4509
|
}
|
|
4440
|
-
state.applied = true;
|
|
4441
4510
|
const changes = await applySetup(choice.value, false);
|
|
4442
4511
|
const brain = await connectBrain(choice.brain);
|
|
4443
4512
|
const cfg = await loadConfig();
|
|
@@ -4656,17 +4725,34 @@ function sendHtml(res, html) {
|
|
|
4656
4725
|
// The page is one self-contained file by design; say so, so that a future
|
|
4657
4726
|
// edit that reaches for a CDN font fails visibly here instead of quietly
|
|
4658
4727
|
// making a local setup screen talk to the internet.
|
|
4659
|
-
|
|
4728
|
+
// `frame-ancestors 'none'` because a hostile page could and did load this
|
|
4729
|
+
// screen in an iframe. Nothing is exploitable through it while the token
|
|
4730
|
+
// stands between an attacker and the API — but the consent screen for
|
|
4731
|
+
// installing into somebody's agent should not be embeddable, and the day
|
|
4732
|
+
// the token moves out of the URL into a cookie it would become the whole
|
|
4733
|
+
// attack rather than a curiosity.
|
|
4734
|
+
"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'",
|
|
4735
|
+
// The header the older browsers read. Same statement, twice.
|
|
4736
|
+
"x-frame-options": "DENY"
|
|
4660
4737
|
});
|
|
4661
4738
|
res.end(html);
|
|
4662
4739
|
}
|
|
4663
4740
|
async function openBrowser(url) {
|
|
4664
4741
|
const { file, args } = browserOpener(url);
|
|
4665
4742
|
return new Promise((resolve10) => {
|
|
4743
|
+
let settled = false;
|
|
4744
|
+
const finish = (opened) => {
|
|
4745
|
+
if (settled) return;
|
|
4746
|
+
settled = true;
|
|
4747
|
+
resolve10(opened);
|
|
4748
|
+
};
|
|
4666
4749
|
const child = spawn(file, args, { detached: true, stdio: "ignore" });
|
|
4667
|
-
child.on("error", () =>
|
|
4750
|
+
child.on("error", () => finish(false));
|
|
4751
|
+
child.on("exit", (code) => {
|
|
4752
|
+
if (code !== null && code !== 0) finish(false);
|
|
4753
|
+
});
|
|
4668
4754
|
child.unref();
|
|
4669
|
-
setTimeout(() =>
|
|
4755
|
+
setTimeout(() => finish(true), 300).unref();
|
|
4670
4756
|
});
|
|
4671
4757
|
}
|
|
4672
4758
|
function browserOpener(url) {
|
|
@@ -4674,7 +4760,7 @@ function browserOpener(url) {
|
|
|
4674
4760
|
if (process.platform === "win32") return { file: "cmd", args: ["/c", "start", "", url] };
|
|
4675
4761
|
return { file: "xdg-open", args: [url] };
|
|
4676
4762
|
}
|
|
4677
|
-
var IDLE_TIMEOUT_MS, APPLY_LINGER_MS, MAX_BODY_BYTES, MAX_CANDIDATES;
|
|
4763
|
+
var IDLE_TIMEOUT_MS, MAX_LIFETIME_MS, APPLY_LINGER_MS, MAX_BODY_BYTES, MAX_CANDIDATES, PREVIEW_CAP, SYSTEM_ROOTS;
|
|
4678
4764
|
var init_setup_web = __esm({
|
|
4679
4765
|
"src/commands/setup-web.ts"() {
|
|
4680
4766
|
"use strict";
|
|
@@ -4686,9 +4772,24 @@ var init_setup_web = __esm({
|
|
|
4686
4772
|
init_setup();
|
|
4687
4773
|
init_setup_web_page();
|
|
4688
4774
|
IDLE_TIMEOUT_MS = 10 * 60 * 1e3;
|
|
4775
|
+
MAX_LIFETIME_MS = 60 * 60 * 1e3;
|
|
4689
4776
|
APPLY_LINGER_MS = 1e3;
|
|
4690
4777
|
MAX_BODY_BYTES = 64 * 1024;
|
|
4691
4778
|
MAX_CANDIDATES = 8;
|
|
4779
|
+
PREVIEW_CAP = 5e3;
|
|
4780
|
+
SYSTEM_ROOTS = [
|
|
4781
|
+
"/",
|
|
4782
|
+
"/usr",
|
|
4783
|
+
"/bin",
|
|
4784
|
+
"/sbin",
|
|
4785
|
+
"/etc",
|
|
4786
|
+
"/dev",
|
|
4787
|
+
"/proc",
|
|
4788
|
+
"/sys",
|
|
4789
|
+
"/System",
|
|
4790
|
+
"/Library",
|
|
4791
|
+
"/opt"
|
|
4792
|
+
];
|
|
4692
4793
|
}
|
|
4693
4794
|
});
|
|
4694
4795
|
|
|
@@ -8251,7 +8352,7 @@ init_errors();
|
|
|
8251
8352
|
init_config();
|
|
8252
8353
|
init_vault();
|
|
8253
8354
|
init_ui();
|
|
8254
|
-
import { isAbsolute as isAbsolute3, relative as
|
|
8355
|
+
import { isAbsolute as isAbsolute3, relative as relative3, resolve as resolve9 } from "path";
|
|
8255
8356
|
import { Command as Command17 } from "commander";
|
|
8256
8357
|
var VIA_MEANS = {
|
|
8257
8358
|
flag: "because --vault named it",
|
|
@@ -8328,7 +8429,7 @@ A pointer to an unlinked folder would fail on every command instead of at this o
|
|
|
8328
8429
|
});
|
|
8329
8430
|
return;
|
|
8330
8431
|
}
|
|
8331
|
-
const value = isInside(root, where) ?
|
|
8432
|
+
const value = isInside(root, where) ? relative3(where, root) || "." : root;
|
|
8332
8433
|
const file = resolve9(where, BRAND.pointerFileName);
|
|
8333
8434
|
await writeThenRename(file, `${value}
|
|
8334
8435
|
`);
|
package/package.json
CHANGED