memhouse 0.13.0 → 0.14.0

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/bin/memhouse.js CHANGED
@@ -28,6 +28,7 @@ const {
28
28
  SCHEMA_VERSION, MIN_WRITER_SCHEMA, MIGRATIONS, META_TYPES, createStatement,
29
29
  } = require(path.join(REPO_ROOT, 'memhouse', 'house', 'house'));
30
30
  const envfile = require(path.join(REPO_ROOT, 'memhouse', 'envfile'));
31
+ const { consumeSecretChunk } = require(path.join(REPO_ROOT, 'memhouse', 'secret-input'));
31
32
 
32
33
  const HOME_DIR = process.env.MEMHOUSE_HOME || path.join(os.homedir(), '.memhouse');
33
34
  const ENV_FILE = path.join(HOME_DIR, 'env');
@@ -270,16 +271,20 @@ function askSecret(label, dflt = '') {
270
271
  const wasRaw = stdin.isRaw;
271
272
  stdin.setRawMode(true); stdin.resume(); stdin.setEncoding('utf-8');
272
273
  let buf = '';
273
- const onData = (ch) => {
274
- if (ch === '\r' || ch === '\n' || ch === '\u0004') {
275
- stdin.removeListener('data', onData);
276
- stdin.setRawMode(!!wasRaw); stdin.pause();
277
- process.stdout.write('\n');
278
- return resolve(buf || dflt);
279
- }
280
- if (ch === '\u0003') { process.stdout.write('\n'); process.exit(130); }
281
- if (ch === '\u007f' || ch === '\b') { buf = buf.slice(0, -1); return; }
282
- buf += ch;
274
+ // One 'data' event carries a CHUNK, not a keystroke — see memhouse/secret-input.js
275
+ // for why that distinction is load-bearing and what it broke.
276
+ let done = false;
277
+ const onData = (chunk) => {
278
+ if (done) return;
279
+ const r = consumeSecretChunk(buf, chunk);
280
+ buf = r.buf;
281
+ if (r.interrupted) { process.stdout.write('\n'); process.exit(130); }
282
+ if (!r.done) return;
283
+ done = true;
284
+ stdin.removeListener('data', onData);
285
+ stdin.setRawMode(!!wasRaw); stdin.pause();
286
+ process.stdout.write('\n');
287
+ resolve(buf || dflt);
283
288
  };
284
289
  stdin.on('data', onData);
285
290
  });
@@ -325,9 +330,11 @@ Setup onboard interactive wizard: discover → configure →
325
330
  --env FILE installs from an invite file (see: invite)
326
331
  invite <name> mint a member + house on the server and write the env
327
332
  file their install needs (--url --admin-user
328
- --admin-password [--db NAME] [--out FILE]); local
329
- machine untouched. Refuses a house that already holds
330
- someone's messages — --adopt if sharing it is intended
333
+ [--admin-password, else prompted] [--db NAME]
334
+ [--out FILE]); local machine untouched. Refuses a house
335
+ that already holds someone's messages — --adopt if
336
+ sharing it is intended. Not an admin? --print-sql gives
337
+ the statements to hand to whoever is
331
338
  passwd rotate this member's password + rewrite the env file
332
339
  (admin-assisted: --admin-user --admin-password)
333
340
  --print-sql print the SQL, run it yourself
@@ -2913,7 +2920,7 @@ async function cmdNightly() {
2913
2920
  */
2914
2921
  async function cmdInvite() {
2915
2922
  const name = positional[0];
2916
- if (!name) { console.log('usage: memhouse invite <name> --url <house-url> --admin-user … --admin-password … [--db <house>] [--out <file>] [--adopt]'); return 2; }
2923
+ if (!name) { console.log('usage: memhouse invite <name> --url <house-url> [--admin-user … [--admin-password …]] [--db <house>] [--out <file>] [--adopt] [--print-sql]'); return 2; }
2917
2924
  try { require(path.join(REPO_ROOT, 'memhouse', 'house', 'house')).assertUsableName(name, 'member'); }
2918
2925
  catch (e) { console.log(bad(e.message)); return 1; }
2919
2926
  const cfg = resolveConfig();
@@ -2936,27 +2943,106 @@ async function cmdInvite() {
2936
2943
  try { require(path.join(REPO_ROOT, 'memhouse', 'house', 'house')).assertUsableName(db, 'house'); }
2937
2944
  catch (e) { console.log(bad(e.message)); return 1; }
2938
2945
 
2946
+ // The way out for someone who is NOT the administrator. Being unable to invite is not a
2947
+ // bug to be worked around — a member holds no CREATE USER by design — but "ask your
2948
+ // admin" is only useful if you can hand them something exact. This prints the same
2949
+ // statements adminBootstrap would run, so the person with the credential runs four
2950
+ // lines instead of installing anything. Nothing is contacted and nothing is written.
2951
+ if (flags['print-sql'] === true) {
2952
+ const password = flags['member-password'] && flags['member-password'] !== true
2953
+ ? String(flags['member-password']) : generatePassword();
2954
+ console.log(`-- memhouse: give '${name}' an account and their own house on this ClickHouse.`);
2955
+ console.log(`-- Run as a user with ACCESS MANAGEMENT (a stock 'default' will do).`);
2956
+ console.log(memberSql(db, name, password));
2957
+ console.log('-- Then send them these four lines — they are a credential, so use a channel');
2958
+ console.log('-- you trust (croc, a password manager), not chat:');
2959
+ console.log(`-- MEMHOUSE_URL='${url}'`);
2960
+ console.log(`-- MEMHOUSE_USER='${name}'`);
2961
+ console.log(`-- MEMHOUSE_PASSWORD='${password}'`);
2962
+ console.log(`-- MEMHOUSE_DB='${db}'`);
2963
+ console.log(`-- They install with: memhouse install --url ${url} --user ${name} --password '…' --db ${db}`);
2964
+ return 0;
2965
+ }
2966
+
2939
2967
  // Whose credential mints the member. Explicit --admin-* wins; otherwise TRY THE
2940
- // CONFIGURED ONE — an install made as an admin-capable ClickHouse user (access
2941
- // management) can invite with no extra flags, which is the common case for the person
2942
- // who set the house up. Fall back to asking only when the stored credential cannot
2943
- // read system.users (the proxy for "can it provision").
2968
+ // CONFIGURED ONE — a `deploy --local` house makes its member the superuser
2969
+ // (CLICKHOUSE_DEFAULT_ACCESS_MANAGEMENT=1), so the person who stood the house up
2970
+ // invites with no extra flags.
2944
2971
  let adminUser = flags['admin-user'];
2945
2972
  let adminPass = flags['admin-password'];
2946
2973
  if (!adminUser) {
2947
2974
  const c = requireConfig(cfg, 'invite');
2948
2975
  if (!c.user) return 1;
2976
+ // Ask what this credential may actually DO, not whether it can look at a table.
2977
+ // The old probe was `SELECT 1 FROM system.users` — but since 0.12.6 every member is
2978
+ // granted SHOW USERS, so every member passed it, was told "it can manage users on
2979
+ // this house", and then died at CREATE DATABASE with a raw ACCESS_DENIED. A probe
2980
+ // that says yes to everyone is worse than no probe: it turns "you are not an admin"
2981
+ // into a confusing failure three steps later.
2982
+ //
2983
+ // chRows appends FORMAT itself, so DO NOT pass one — a statement carrying FORMAT
2984
+ // twice is a syntax error, which the catch below turned into "no grants" and refused
2985
+ // EVERY credential, a real superuser included. Only a clean machine could show that:
2986
+ // this machine's credential is a member either way, so the bug was invisible here.
2987
+ // Reachability FIRST, and reported as itself. Folding it into the grants read makes an
2988
+ // unreachable host — a typo, a loopback-bound house, a tunnel that is down — come back
2989
+ // as "you are only a member", which sends the reader after a privilege they already
2990
+ // have. Caught on the testbed, where a wrong --url produced exactly that.
2949
2991
  try {
2950
- await ch({ ...c, url }, 'SELECT 1 FROM system.users LIMIT 1', { database: '' });
2992
+ await ch({ ...c, url }, 'SELECT 1', { database: '' });
2993
+ } catch (e) {
2994
+ console.log(bad(`cannot reach ${url} as '${c.user}': ${netReason(e)}`));
2995
+ console.log(' --url is the address the INVITEE will use, and invite checks it from here first.');
2996
+ console.log(' A `deploy --local` house is bound to loopback on purpose, so no LAN address');
2997
+ console.log(' reaches it and no invitee could either — publish it (tunnel, reverse proxy)');
2998
+ console.log(' before inviting, or use --print-sql and let the invitee be told the address.');
2999
+ return 1;
3000
+ }
3001
+ let grants = '';
3002
+ try {
3003
+ const g = await chRows({ ...c, url }, `SHOW GRANTS FOR ${c.user}`, { database: '' });
3004
+ grants = g.map((r) => Object.values(r).join(' ')).join('\n');
3005
+ } catch { /* reachable but grants unreadable — treated as "not an admin" below */ }
3006
+ // Two capabilities, and a superuser states them in more than one shape: CREATE USER
3007
+ // may arrive as itself or under ACCESS MANAGEMENT, and the database half may be an
3008
+ // explicit CREATE DATABASE or the umbrella CREATE on *.* — which is what
3009
+ // `deploy --local` grants. Match the meaning, not one spelling.
3010
+ const canMintUsers = /\bACCESS MANAGEMENT\b|\bCREATE USER\b/.test(grants);
3011
+ const canMintHouses = /\bCREATE DATABASE\b/.test(grants)
3012
+ || grants.split('\n').some((l) => /\bCREATE\b/.test(l) && /\bON \*\.\*/.test(l));
3013
+ const canProvision = canMintUsers && canMintHouses;
3014
+ if (canProvision) {
2951
3015
  adminUser = c.user; adminPass = c.password;
2952
- console.log(ok(`inviting as your own credential '${c.user}' (it can manage users on this house)`));
2953
- } catch {
2954
- console.log(bad('your configured credential cannot create users on this house.'));
2955
- console.log(' Pass an admin that can: --admin-user <a> --admin-password <p>');
3016
+ console.log(ok(`inviting as your own credential '${c.user}' (it can create users and houses here)`));
3017
+ } else {
3018
+ console.log(bad(`'${c.user}' is a MEMBER of this ClickHouse, not an administrator — it cannot create accounts.`));
3019
+ console.log(' Inviting mints a ClickHouse user and a database, which needs CREATE USER and');
3020
+ console.log(' CREATE DATABASE. A member deliberately holds neither: that is the boundary that');
3021
+ console.log(' keeps one housemate out of another house.');
3022
+ console.log('');
3023
+ console.log(' If you RUN this ClickHouse — use the admin credential you created it with');
3024
+ console.log(' (for a stock server that is `default`); memhouse never stores it, so pass it');
3025
+ console.log(' per invite and it is used for one connection and discarded:');
3026
+ console.log(` memhouse invite ${name} --url ${url} --admin-user default`);
3027
+ console.log(' (leave --admin-password off and it is prompted for, so it stays out of');
3028
+ console.log(' your shell history and the process list)');
3029
+ console.log('');
3030
+ console.log(' If SOMEONE ELSE runs it — you cannot invite, and no flag changes that.');
3031
+ console.log(' Print the statements and send them to whoever administers the server:');
3032
+ console.log(` memhouse invite ${name} --url ${url} --print-sql`);
2956
3033
  return 1;
2957
3034
  }
2958
3035
  } else if (adminPass === undefined) {
2959
- console.log(bad('--admin-user given without --admin-password')); return 1;
3036
+ // Prompt rather than refuse. A password given as --admin-password lands in the
3037
+ // process list for the life of the request and in shell history unless the caller
3038
+ // remembered a leading space; asking for it keeps it in this process only.
3039
+ // Non-TTY (an agent, CI) still gets the old refusal — there is nobody to ask.
3040
+ if (!process.stdin.isTTY) {
3041
+ console.log(bad('--admin-user given without --admin-password (no TTY to prompt on)'));
3042
+ return 1;
3043
+ }
3044
+ adminPass = await askSecret(` password for '${adminUser}'`);
3045
+ if (!adminPass) { console.log(bad('no password given')); return 1; }
2960
3046
  }
2961
3047
 
2962
3048
  // WHERE to connect to provision. --url is the address the INVITEE will use — it may be
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "mem",
3
3
  "description": "Conversation memory for AI agents over the memhouse typed ClickHouse store: ask, search, browse, analyze, check, share, and invite across all 17 supported editors and machines, plus administration of the ClickHouse behind it. Skills install as /mem:hello, /mem:ask, /mem:search, /mem:sessions, /mem:share, /mem:invite, /mem:sql, /mem:status, /mem:users, and /mem:admin.",
4
- "version": "0.13.0",
4
+ "version": "0.14.0",
5
5
  "author": {
6
6
  "name": "Ramazan Polat"
7
7
  },
@@ -42,14 +42,42 @@ substituted into a command — that burns the credential into the transcript on
42
42
  If it prints `invite written: …`, go to step 4 — you are done, and no admin secret
43
43
  ever touched this conversation.
44
44
 
45
- ONLY if it fails with "your configured credential cannot create users" does
46
- provisioning need a real admin. Do NOT take the admin password in chat — memhouse
47
- archives this transcript. Hand the user the line to run THEMSELVES (a leading space
48
- keeps it out of shell history):
49
-
50
- ```
51
- memhouse invite <name> --url <url> [--db <shared-house>] --admin-user <admin> --admin-password 'PASTE_IT_HERE'
52
- ```
45
+ Otherwise read WHICH refusal came back; they need different answers.
46
+
47
+ **"cannot reach `<url>` …"** — nothing to do with privileges. The address is wrong,
48
+ or the house is bound to loopback (`memhouse deploy --local` binds to 127.0.0.1 on
49
+ purpose, so no LAN address reaches it and no invitee could either). Ask the user for
50
+ the address the invitee will actually use; do not retry with a guess.
51
+
52
+ **"`<user>` is a MEMBER of this ClickHouse, not an administrator"** — inviting needs
53
+ CREATE USER and CREATE DATABASE, which a member deliberately does not hold. Two cases,
54
+ and you must ask which one applies rather than assuming:
55
+
56
+ - **They run the ClickHouse.** They have the admin credential; memhouse never stores
57
+ it. Do NOT take that password in chat — memhouse archives this transcript. Hand them
58
+ the line to run THEMSELVES, and leave `--admin-password` OFF so it is prompted for,
59
+ keeping it out of both the process list and shell history:
60
+
61
+ ```
62
+ memhouse invite <name> --url <url> [--db <shared-house>] --admin-user <admin>
63
+ ```
64
+
65
+ **You cannot run that line yourself.** The prompt needs a TTY and your Bash tool has
66
+ none, so it refuses with "no TTY to prompt on" — and the fix is NOT to add
67
+ `--admin-password`, which would put the credential in this transcript. The user runs
68
+ it in their own terminal; you wait for them to say it is done.
69
+
70
+ - **Somebody else runs it.** They cannot invite, and no flag changes that. Print the
71
+ statements for whoever administers the server — this contacts nothing and needs no
72
+ credential:
73
+
74
+ ```
75
+ memhouse invite <name> --url <url> [--db <shared-house>] --print-sql
76
+ ```
77
+
78
+ The output ends with the four `MEMHOUSE_*` lines the invitee needs. Those lines are
79
+ a credential: tell the user to send them over a trusted channel, and **never print
80
+ them into the conversation yourself**.
53
81
 
54
82
  **If it refuses with "house `<db>` already exists and holds N messages", STOP.** That
55
83
  house is somebody's memory, and inviting into it would hand the invitee someone else's
@@ -0,0 +1,39 @@
1
+ // Consuming a hidden-input chunk, as pure logic so it can be tested without a terminal.
2
+ //
3
+ // The subtlety this exists for: a raw-mode stdin 'data' event carries a CHUNK, not a
4
+ // keystroke. While someone types, each chunk happens to be one character, so a handler
5
+ // that compares the whole chunk against '\r' looks correct for as long as anyone tests it
6
+ // by hand. The moment a password is PASTED — which is how a credential out of a password
7
+ // manager actually arrives — the chunk is 'hunter2\r', equal to no terminator, and the
8
+ // prompt hangs forever with the secret sitting in the buffer. Anything piping input hits
9
+ // the same wall.
10
+ //
11
+ // So: walk the chunk. Callers own the terminal; this owns the decision.
12
+
13
+ const ENTER = ['\r', '\n', '\u0004']; // CR, LF, EOT
14
+ const INTERRUPT = '\u0003'; // ctrl-C
15
+ const ERASE = ['\u007f', '\b']; // DEL, BS
16
+
17
+ /**
18
+ * Fold one chunk into the accumulated buffer.
19
+ *
20
+ * @param {string} buf what has been read so far
21
+ * @param {string} chunk the raw 'data' payload (may hold any number of characters)
22
+ * @returns {{buf: string, done: boolean, interrupted: boolean}}
23
+ * `done` — a terminator was seen; `buf` is the final value and anything after the
24
+ * terminator in the same chunk is deliberately discarded (it belongs to whatever
25
+ * prompt comes next, not this one).
26
+ * `interrupted` — ctrl-C was seen; the caller decides how to exit.
27
+ */
28
+ function consumeSecretChunk(buf, chunk) {
29
+ let out = String(buf == null ? '' : buf);
30
+ for (const ch of String(chunk == null ? '' : chunk)) {
31
+ if (ch === INTERRUPT) return { buf: out, done: false, interrupted: true };
32
+ if (ENTER.includes(ch)) return { buf: out, done: true, interrupted: false };
33
+ if (ERASE.includes(ch)) { out = out.slice(0, -1); continue; }
34
+ out += ch;
35
+ }
36
+ return { buf: out, done: false, interrupted: false };
37
+ }
38
+
39
+ module.exports = { consumeSecretChunk };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "memhouse",
3
- "version": "0.13.0",
3
+ "version": "0.14.0",
4
4
  "description": "memhouse \u2014 agent conversation memory across 17 editors: parse-on-client shippers, a typed ClickHouse store with one set of rooms per member, kernel-agency install, and the agentlytics dashboard",
5
5
  "main": "bin/memhouse.js",
6
6
  "bin": {