memhouse 0.18.8 → 0.18.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.
Files changed (45) hide show
  1. package/AGENTS.md +194 -0
  2. package/CHANGELOG.md +970 -0
  3. package/README.md +54 -423
  4. package/SECURITY.md +68 -0
  5. package/bin/memhouse.js +202 -92
  6. package/docs/README.md +46 -0
  7. package/docs/guides/admin-password.md +95 -0
  8. package/docs/guides/configuration.md +109 -0
  9. package/docs/guides/daemons.md +53 -0
  10. package/docs/guides/install.md +128 -0
  11. package/docs/guides/invite.md +87 -0
  12. package/docs/guides/operate.md +112 -0
  13. package/docs/guides/search-and-resume.md +95 -0
  14. package/docs/guides/share.md +102 -0
  15. package/docs/guides/storage.md +90 -0
  16. package/docs/guides/troubleshooting.md +78 -0
  17. package/docs/guides/uninstall.md +35 -0
  18. package/docs/guides/update.md +68 -0
  19. package/docs/releases/README.md +10 -0
  20. package/docs/releases/v0.18.10.md +226 -0
  21. package/docs/tutorials/01-first-house.md +132 -0
  22. package/docs/tutorials/02-join-a-house.md +91 -0
  23. package/docs/tutorials/03-share-your-memory.md +96 -0
  24. package/examples/01-local-one-machine/README.md +99 -0
  25. package/examples/01-local-one-machine/run.sh +61 -0
  26. package/examples/02-existing-clickhouse/README.md +121 -0
  27. package/examples/02-existing-clickhouse/run.sh +71 -0
  28. package/examples/03-team-house/README.md +129 -0
  29. package/examples/03-team-house/run.sh +124 -0
  30. package/examples/04-self-hosted-team/.env.example +22 -0
  31. package/examples/04-self-hosted-team/README.md +187 -0
  32. package/examples/04-self-hosted-team/compose.yml +158 -0
  33. package/examples/04-self-hosted-team/smoke.sh +113 -0
  34. package/examples/README.md +42 -0
  35. package/examples/fixtures/README.md +24 -0
  36. package/examples/fixtures/make-sessions.js +63 -0
  37. package/memhouse/admin-secret.js +81 -0
  38. package/memhouse/delivery/plugin/.claude-plugin/plugin.json +1 -1
  39. package/memhouse/deploy.js +4 -2
  40. package/memhouse/flags.js +4 -4
  41. package/memhouse/provision.js +12 -3
  42. package/memhouse/server/queries.js +18 -4
  43. package/memhouse/server/server.js +9 -1
  44. package/memhouse/service.js +5 -0
  45. package/package.json +10 -2
package/bin/memhouse.js CHANGED
@@ -147,7 +147,7 @@ function requireConfig(cfg, what) {
147
147
  }
148
148
 
149
149
  function childEnv(cfg, override = {}) {
150
- return {
150
+ const env = {
151
151
  ...process.env,
152
152
  // Set only by the install path, which prints its own refusal for this case.
153
153
  ...(cfg._quietDenied ? { MEMHOUSE_QUIET_DENIED: '1' } : {}),
@@ -159,6 +159,11 @@ function childEnv(cfg, override = {}) {
159
159
  // environment would write into another member's rooms.
160
160
  ...override,
161
161
  };
162
+ // A daemon never needs the admin credential. Inherited from an exported shell variable
163
+ // it would sit in the shipper's and dashboard's environment for their whole lifetime.
164
+ delete env.MEMHOUSE_ADMIN_USER;
165
+ delete env.MEMHOUSE_ADMIN_PASSWORD;
166
+ return env;
162
167
  }
163
168
 
164
169
  function writeEnvFile(cfg) {
@@ -349,6 +354,34 @@ function netReason(e) {
349
354
  // Read without echoing. `onboard` and `setup` both prompt for the house password and
350
355
  // printed it back on the terminal — in a wizard whose whole audience is someone typing a
351
356
  // credential in front of whoever is in the room.
357
+ // The admin password for `adminUser`: --admin-password-file (or - for stdin), then
358
+ // --admin-password (warned), then MEMHOUSE_ADMIN_PASSWORD, then the stored credential
359
+ // of that SAME admin, then a TTY prompt. null = none, and the caller has been told how
360
+ // to give one without putting it on the command line. See memhouse/admin-secret.js.
361
+ async function adminPasswordFor(adminUser) {
362
+ const as = require(path.join(REPO_ROOT, 'memhouse', 'admin-secret'));
363
+ const file = readEnvFile();
364
+ let r;
365
+ try {
366
+ r = as.resolveAdminPassword({
367
+ adminUser, flags, stored: { user: file.MEMHOUSE_ADMIN_USER, password: file.MEMHOUSE_ADMIN_PASSWORD },
368
+ });
369
+ } catch (e) { console.log(bad(e.message)); return null; }
370
+ // stderr: `members --json` and friends must emit nothing but JSON on stdout.
371
+ if (r.source === 'flag') console.error(warn(as.ARGV_WARNING));
372
+ if (r.password !== undefined) return r.password;
373
+ if (process.stdin.isTTY) {
374
+ const p = await askSecret(` password for '${adminUser}'`);
375
+ if (p) return p;
376
+ console.log(bad('no password given'));
377
+ return null;
378
+ }
379
+ const lines = as.missingAdminPasswordHelp(adminUser);
380
+ console.log(bad(lines[0]));
381
+ for (const l of lines.slice(1)) console.log(l);
382
+ return null;
383
+ }
384
+
352
385
  function askSecret(label, dflt = '') {
353
386
  if (!process.stdin.isTTY) return ask(label, dflt);
354
387
  return new Promise((resolve) => {
@@ -414,22 +447,23 @@ memhouse ${PKG.version} — agent conversation memory across 17 editors
414
447
  Setup onboard interactive wizard: discover → configure → ship → start
415
448
  install scriptable setup (--url --user --password --db [--yes] [--no-ship])
416
449
  --print-sql print the SQL, run it yourself
417
- with admin: --admin-user --admin-password [--member NAME]
418
- builds house + user + rooms + grants, then verifies as
419
- the member. The admin credential IS written to the env
420
- file (mode 600) so invite/members need no flags; delete
421
- those two lines if you would rather pass it each time.
450
+ with admin: --admin-user NAME [--member NAME] builds the
451
+ member + grants, then verifies as the member. The admin
452
+ password comes from MEMHOUSE_ADMIN_PASSWORD (with-secret),
453
+ --admin-password-file FILE|- (stdin), or a prompt; plain
454
+ --admin-password works but shows in ps/history. It is NOT
455
+ saved unless --keep-admin (then invite needs no flags).
422
456
  --env FILE installs from an invite file (see: invite)
423
457
  invite <name> mint a member and write the env file their install needs
424
- (--url [--db NAME] [--out FILE]; --admin-user and
425
- --admin-password only if this install keeps no admin
458
+ (--url [--db NAME] [--out FILE]; --admin-user — password
459
+ as for install — only if this install keeps no admin
426
460
  credential). They get rooms named for them, <db>.<name>_*,
427
461
  and one grant on exactly those. Refuses a name whose rooms
428
462
  already hold messages; --adopt takes them over (same
429
463
  person, new credential). Not an admin? --print-sql prints
430
464
  the plan to hand to whoever is.
431
465
  passwd rotate this member's password + rewrite the env file
432
- (admin-assisted: --admin-user --admin-password)
466
+ (admin-assisted: --admin-user, password as for install)
433
467
  setup (re)write the connection config only (--yes = no prompts)
434
468
  discover read-only preflight: editors, sessions, reachable ClickHouses
435
469
  uninstall stop daemons + service, clear runtime state (asks; --yes).
@@ -752,10 +786,33 @@ function generatePassword() {
752
786
  * hand produces exactly what `--admin-user` would have produced. It is rendered from the
753
787
  * same templates provision.js applies, so the two cannot drift into different houses.
754
788
  */
755
- function memberSql(db, member, password) {
756
- // The SAME plan adminBootstrap executes, rendered. Not a second description of it.
789
+ function memberSql(db, member) {
790
+ // The SAME plan adminBootstrap executes, rendered. Not a second description of it — except
791
+ // the password: a printed plan carries a placeholder, never a real one (G23). Printed SQL
792
+ // lands in terminals, scrollback and agent transcripts, and memhouse ships transcripts.
757
793
  const { plan, render } = require(path.join(REPO_ROOT, 'memhouse', 'provision'));
758
- return render(plan({ db, member, password }), { db, member });
794
+ return render(plan({ db, member, placeholder: true }), { db, member });
795
+ }
796
+
797
+ // The handoff lines that follow a printed plan: an env file for the member, with the
798
+ // password left as the placeholder the plan used. `invite` marks it MEMHOUSE_INVITE=1, so
799
+ // the member's `install --env` offers to rotate it to one only they know.
800
+ function handoffLines({ url, db, member, invite }) {
801
+ const { PASSWORD_PLACEHOLDER } = require(path.join(REPO_ROOT, 'memhouse', 'provision'));
802
+ const lines = [
803
+ `-- Then put these lines in a file (mode 600), with the same password where ${PASSWORD_PLACEHOLDER}`,
804
+ '-- stands, and hand it over a channel you trust (croc, a password manager) — not chat:',
805
+ `-- MEMHOUSE_URL='${url}'`,
806
+ `-- MEMHOUSE_USER='${member}'`,
807
+ `-- MEMHOUSE_PASSWORD='${PASSWORD_PLACEHOLDER}'`,
808
+ `-- MEMHOUSE_DB='${db}'`,
809
+ ];
810
+ if (invite) lines.push('-- MEMHOUSE_INVITE=1');
811
+ lines.push('-- They install with: memhouse install --env <that file> (it deletes the file afterwards)');
812
+ if (flags['member-password'] !== undefined) {
813
+ lines.push('-- The --member-password you gave is not printed: use it where the placeholder stands.');
814
+ }
815
+ return lines.join('\n');
759
816
  }
760
817
 
761
818
  /**
@@ -813,17 +870,13 @@ async function adminBootstrap(cfg, admin) {
813
870
  } else {
814
871
  if (!password) {
815
872
  password = generatePassword();
816
- // For a local install the password must be SHOWN — it is the user's only copy. For
817
- // an invite it must NOT: the caller writes it to the credential file, and printing
818
- // it here would land it in the terminal and, via /mem:access, in a transcript
819
- // memhouse itself ships. admin.quiet is the invite path.
820
- if (!admin.quiet) {
821
- console.log('');
822
- console.log(` password for '${admin.member}': ${password}`);
823
- console.log(` Shown once. It goes into ${ENV_FILE}; to change it later:`);
824
- console.log(` ALTER USER ${admin.member} IDENTIFIED BY '…' then: memhouse setup --password …`);
825
- console.log('');
826
- }
873
+ // Never printed, on any path (G23). It used to be shown once on an install, "the
874
+ // user's only copy" — but install writes it to the env file, and an invite to the
875
+ // credential file, so the terminal never needed it. What the terminal gets ends up in
876
+ // scrollback and, via /mem:access and /mem:admin, in a transcript memhouse itself
877
+ // ships into the house. A run that fails before the password is written down drops
878
+ // the user it created (below), so no account is left behind with a password nobody
879
+ // holds.
827
880
  }
828
881
  try { await q(`CREATE USER ${admin.member} IDENTIFIED BY '${password.replace(/'/g, "\\'")}'`, { database: '' }); }
829
882
  catch (e) {
@@ -869,9 +922,15 @@ async function adminBootstrap(cfg, admin) {
869
922
  } catch (e) {
870
923
  console.log(bad(`could not grant the house to '${admin.member}': ${e.message}`));
871
924
  if (createdUser) {
872
- console.log(` ClickHouse user '${createdUser}' WAS created before this failed.`);
873
- console.log(` Continue once fixed: memhouse install --member ${createdUser} --member-password '<the password above>' …`);
874
- console.log(` Or undo it as the admin: DROP USER ${createdUser}`);
925
+ // This run made the user and nobody holds its password (it is never printed), so the
926
+ // only useful state is "not there": drop it, and the same command can simply be re-run.
927
+ try {
928
+ await q(`DROP USER IF EXISTS ${createdUser}`, { database: '' });
929
+ console.log(` the ClickHouse user '${createdUser}' this run created was dropped — fix the cause and re-run`);
930
+ } catch (e2) {
931
+ console.log(` ClickHouse user '${createdUser}' WAS created and could not be dropped (${String(e2.message).split('\n')[0]}).`);
932
+ console.log(` Undo it as the admin: DROP USER ${createdUser} — then re-run.`);
933
+ }
875
934
  } else {
876
935
  console.log(' nothing was written');
877
936
  }
@@ -1029,6 +1088,7 @@ async function cmdInstall({ interactive }) {
1029
1088
  }
1030
1089
  let cfg = resolveConfig();
1031
1090
  const adminUser = flags['admin-user'];
1091
+ if (adminUser === true) { console.log(bad('--admin-user needs a name, e.g. --admin-user default')); return 1; }
1032
1092
 
1033
1093
  // The house name is spliced unquoted into CREATE DATABASE and GRANT ALL ON <db>.* —
1034
1094
  // validate it HERE, on every install mode, not only where deploy happens to choose it.
@@ -1046,17 +1106,11 @@ async function cmdInstall({ interactive }) {
1046
1106
  if (flags['print-sql'] === true) {
1047
1107
  const member = resolveMemberHandle();
1048
1108
  if (!member) return 1;
1049
- const password = flags['member-password'] || generatePassword();
1050
- console.log(memberSql(cfg.db, member, password));
1051
- console.log(`-- Then, once that has run:`);
1109
+ console.log(memberSql(cfg.db, member));
1052
1110
  // Only echo a URL that was actually STATED. --print-sql is the path where the house is
1053
1111
  // typically someone else's, run by someone else, and cfg.url falls back to
1054
1112
  // http://localhost:8123 — the one address the rest of the product refuses to guess.
1055
- // Handing it back as a copy-paste command carrying a live credential is worse than
1056
- // leaving a placeholder.
1057
- console.log(cfg.stated
1058
- ? `-- memhouse install --url ${cfg.url} --db ${cfg.db} --user ${member} --password '${password}'`
1059
- : `-- memhouse install --url <the house this SQL was run on> --db ${cfg.db} --user ${member} --password '${password}'`);
1113
+ console.log(handoffLines({ url: cfg.stated ? cfg.url : '<the house this SQL was run on>', db: cfg.db, member, invite: false }));
1060
1114
  return 0;
1061
1115
  }
1062
1116
 
@@ -1064,11 +1118,22 @@ async function cmdInstall({ interactive }) {
1064
1118
  if (adminUser) {
1065
1119
  const member = resolveMemberHandle();
1066
1120
  if (!member) return 1;
1121
+ // From a file, stdin, the environment or a prompt — never REQUIRED on argv.
1122
+ const adminPassword = await adminPasswordFor(String(adminUser));
1123
+ if (adminPassword === null) return 1;
1067
1124
  const built = await adminBootstrap(cfg, {
1068
- user: adminUser, password: flags['admin-password'] || '', member,
1125
+ user: adminUser, password: adminPassword, member,
1069
1126
  });
1070
1127
  if (!built) return 1;
1071
- cfg = built;
1128
+ // The admin credential is used for this install and NOT saved, unless asked
1129
+ // (--keep-admin) or this env file already keeps that same admin (an operator seat).
1130
+ // It used to be saved every time, which the help said and every agent-facing text
1131
+ // (the install prompt, invite's own advice, /mem:admin) contradicted — and a saved
1132
+ // admin password ends up wherever the env file goes.
1133
+ const keepAdmin = flags['keep-admin'] === true || readEnvFile().MEMHOUSE_ADMIN_USER === String(adminUser);
1134
+ cfg = keepAdmin
1135
+ ? { ...built, adminUser: String(adminUser), adminPassword }
1136
+ : { ...built, adminUser: '', adminPassword: '' };
1072
1137
  // BEFORE the env file and BEFORE the ship. INSTALL.md's contract is that the config is
1073
1138
  // written last, "after everything above has proved out", because a config left by a
1074
1139
  // failed install is read as truth by the next command. Placed after the write, a
@@ -1099,8 +1164,13 @@ async function cmdInstall({ interactive }) {
1099
1164
  console.log(` every machine you install as '${cfg.user}' writes into the same rooms; this is what tells them apart`);
1100
1165
  }
1101
1166
  console.log(ok('installed'));
1102
- console.log(' adding a housemate later — the admin credential is in your env file, so no flags:');
1103
- console.log(` memhouse invite <name> --url ${cfg.url}`);
1167
+ if (cfg.adminUser) {
1168
+ console.log(' adding a housemate later — the admin credential is kept in your env file, so no flags:');
1169
+ console.log(` memhouse invite <name> --url ${cfg.url}`);
1170
+ } else {
1171
+ console.log(` the admin credential was used for this install and not saved. To invite later:`);
1172
+ console.log(` with-secret MEMHOUSE_ADMIN_PASSWORD=<reference> -- memhouse invite <name> --url ${cfg.url} --admin-user ${adminUser}`);
1173
+ }
1104
1174
  printGettingStarted(cfg);
1105
1175
  await finishInvite(cfg);
1106
1176
  // The first ship loads the whole backlog; do it in the background so install returns
@@ -1114,7 +1184,9 @@ async function cmdInstall({ interactive }) {
1114
1184
  // authenticating as memhouse_root instead: the three flags did nothing, --yes satisfied
1115
1185
  // haveAll so no prompt asked who you were, and resolveConfig's default was used AS A
1116
1186
  // CREDENTIAL. Refuse rather than do something else silently.
1117
- const adminOnly = ['member', 'member-password'].filter((k) => flags[k] !== undefined);
1187
+ // Every flag that only means something to the admin bootstrap. Left out, it was silently
1188
+ // ignored on a member install (`--keep-admin` was — Codex P2 on #14).
1189
+ const adminOnly = ['member', 'member-password', 'keep-admin', 'admin-password-file', 'admin-password'].filter((k) => flags[k] !== undefined);
1118
1190
  if (!adminUser && adminOnly.length) {
1119
1191
  console.log(bad(`--${adminOnly.join(', --')} ${adminOnly.length > 1 ? 'are' : 'is'} only read with --admin-user`));
1120
1192
  console.log(' Those flags provision a member, which needs house admin. Without them this');
@@ -3168,9 +3240,16 @@ async function cmdShare() {
3168
3240
  // today (the member has no grant option on the database) but with a raw ACCESS_DENIED
3169
3241
  // naming a privilege nobody can explain. Grant the rooms this member actually owns.
3170
3242
  const mine = roomNames(cfg.user);
3171
- // One grant on the member's own pattern: the shape the member holds, one level down
3172
- // (SELECT only). It reaches nothing a housemate wrote.
3173
- const shareTargets = [`${db}.${mine.pattern}`];
3243
+ // A FULL share is one grant on the member's own pattern: the shape the member holds, one
3244
+ // level down (SELECT only). It reaches nothing a housemate wrote — and, by design,
3245
+ // everything of this member's, including the stat tables and any room added later.
3246
+ const wildcard = `${db}.${mine.pattern}`;
3247
+ // A SCOPED share is granted table by table: exactly the rooms its row policies filter,
3248
+ // and nothing else. It used to take the wildcard too, and the policies covered only
3249
+ // these three, so a grantee scoped to one project read every other project's opening
3250
+ // prompt, folder and token totals straight out of <member>_session_stats, plus meta,
3251
+ // events and whatever table the pattern would match next.
3252
+ const scopedTargets = ROOM_TYPES.map((room) => `${db}.${mine.physical[room]}`);
3174
3253
 
3175
3254
  // Statements, not reads: this is the one command in the read family that writes, so it
3176
3255
  // does NOT go through the readonly connection the skills use.
@@ -3196,13 +3275,19 @@ async function cmdShare() {
3196
3275
  // re-share silently inherits the old scope. Measured — a re-granted user saw 2,121 rows
3197
3276
  // instead of the whole house, with nothing on any surface explaining the filter.
3198
3277
  if (flags.revoke === true) {
3278
+ // Withdraw the grant FIRST, then the filters. The other order failed open: a REVOKE
3279
+ // refused after the policies were dropped left the grantee with an unfiltered share.
3280
+ try { for (const tgt of [wildcard, ...scopedTargets]) await run(`REVOKE SELECT ON ${tgt} FROM ${user}`); }
3281
+ catch (e) {
3282
+ console.log(bad(`could not revoke: ${netReason(e)}`));
3283
+ console.log(` Nothing else was changed: any row policies for '${user}' are still in place.`);
3284
+ return 1;
3285
+ }
3199
3286
  let dropped = 0;
3200
3287
  for (const room of ROOM_TYPES) {
3201
3288
  try { await run(`DROP ROW POLICY IF EXISTS ${share.policyName(user, room)} ON ${db}.${mine.physical[room]}`); dropped++; }
3202
3289
  catch (e) { console.log(warn(`could not drop the policy on ${room}: ${e.message.split('\n')[0]}`)); }
3203
3290
  }
3204
- try { for (const tgt of shareTargets) await run(`REVOKE SELECT ON ${tgt} FROM ${user}`); }
3205
- catch (e) { console.log(bad(`could not revoke: ${netReason(e)}`)); return 1; }
3206
3291
  await houseMeta(cfg, `share:${user}`, `revoked ${new Date().toISOString().slice(0, 10)}`);
3207
3292
  console.log(ok(`revoked '${user}' — SELECT withdrawn and ${dropped} row polic${dropped === 1 ? 'y' : 'ies'} dropped`));
3208
3293
  console.log(' Dropping the grant alone would have left the policies, and a later re-share');
@@ -3211,9 +3296,12 @@ async function cmdShare() {
3211
3296
  }
3212
3297
 
3213
3298
  // ── grant ─────────────────────────────────────────────────────────────────────
3214
- const scopeRaw = flags.only !== undefined && flags.only !== true ? String(flags.only) : null;
3299
+ const scopeRaw = flags.only !== undefined && flags.only !== true ? String(flags.only).trim() : null;
3215
3300
  let scope = null;
3216
- if (flags.only === true) { console.log(bad('--only needs a scope, e.g. --only project=memhouse or --only session=<id>')); return 1; }
3301
+ // An EMPTY scope is a mistake, not a request for everything. `--only "$SCOPE"` with an
3302
+ // unset variable used to fall through to a full share — the widest grant this command
3303
+ // makes, from the flag whose whole purpose is to narrow it.
3304
+ if (flags.only === true || scopeRaw === '') { console.log(bad('--only needs a scope, e.g. --only project=memhouse or --only session=<id>')); return 1; }
3217
3305
  if (scopeRaw) {
3218
3306
  try { scope = share.parseScope(scopeRaw); } catch (e) { console.log(bad(e.message)); return 1; }
3219
3307
  }
@@ -3251,24 +3339,47 @@ async function cmdShare() {
3251
3339
  made.push(room);
3252
3340
  } catch (e) {
3253
3341
  console.log(bad(`could not scope ${room}: ${e.message.split('\n')[0]}`));
3254
- for (const r of made) {
3255
- try { await run(`DROP ROW POLICY IF EXISTS ${share.policyName(user, r)} ON ${db}.${mine.physical[r]}`); } catch { /* best effort */ }
3256
- }
3257
- console.log(` NOTHING WAS GRANTED — '${user}' can read no more than before, and any`);
3258
- console.log(' filters this attempt created have been removed.');
3342
+ // Do NOT drop what this attempt wrote. `OR REPLACE` may have just overwritten the
3343
+ // filter of an EXISTING scoped share, and dropping it would leave that grantee's
3344
+ // table grant unfiltered — failing open. A filter without a grant is inert, and the
3345
+ // full-share and --revoke paths both clear leftovers.
3346
+ console.log(` NOTHING WAS GRANTED — '${user}' can read no more than before.`);
3347
+ if (made.length) console.log(` The filters written for ${made.join(', ')} are left in place; \`memhouse share ${user} --revoke\` clears them.`);
3259
3348
  return 1;
3260
3349
  }
3261
3350
  }
3262
3351
  }
3263
3352
 
3264
- try { for (const tgt of shareTargets) await run(`GRANT SELECT ON ${tgt} TO ${user}`); }
3265
- catch (e) {
3353
+ // Full: the wildcard. Scoped: first take back any wildcard an earlier share left — it
3354
+ // would reach straight past the filters — then grant the filtered rooms one by one.
3355
+ // A failure part-way leaves the grantee with LESS than before, never more.
3356
+ try {
3357
+ if (scope) {
3358
+ await run(`REVOKE SELECT ON ${wildcard} FROM ${user}`);
3359
+ for (const tgt of scopedTargets) await run(`GRANT SELECT ON ${tgt} TO ${user}`);
3360
+ } else {
3361
+ await run(`GRANT SELECT ON ${wildcard} TO ${user}`);
3362
+ }
3363
+ } catch (e) {
3266
3364
  console.log(bad(`could not grant: ${netReason(e)}`));
3267
- // Filters without a grant are inert, but leaving them behind would make a later full
3268
- // share silently scoped — the case the widening path below exists to catch.
3269
3365
  if (scope) {
3270
- for (const room of ROOM_TYPES) {
3271
- try { await run(`DROP ROW POLICY IF EXISTS ${share.policyName(user, room)} ON ${db}.${mine.physical[room]}`); } catch { /* best effort */ }
3366
+ // Withdraw the grant, and drop the filters ONLY once that is confirmed. If any REVOKE
3367
+ // is refused — the owner lost the grant option, a transient error — the grantee may
3368
+ // still hold a table-level SELECT from an earlier scoped share, and dropping its
3369
+ // filter would expose every row. Leaving a filter behind costs nothing: without a
3370
+ // grant it is inert, and the full-share and --revoke paths both clear it.
3371
+ let withdrawn = true;
3372
+ for (const tgt of [wildcard, ...scopedTargets]) {
3373
+ try { await run(`REVOKE SELECT ON ${tgt} FROM ${user}`); } catch { withdrawn = false; }
3374
+ }
3375
+ if (withdrawn) {
3376
+ for (const room of ROOM_TYPES) {
3377
+ try { await run(`DROP ROW POLICY IF EXISTS ${share.policyName(user, room)} ON ${db}.${mine.physical[room]}`); } catch { /* inert without a grant */ }
3378
+ }
3379
+ console.log(` '${user}' was left with no access to your rooms — nothing wider than before.`);
3380
+ } else {
3381
+ console.log(` Could not confirm '${user}' lost every grant, so the filters stay in place — whatever`);
3382
+ console.log(` '${user}' can still reach is filtered. Retry, or: memhouse share ${user} --revoke`);
3272
3383
  }
3273
3384
  }
3274
3385
  return 1;
@@ -3306,6 +3417,8 @@ async function cmdShare() {
3306
3417
  console.log(` ${room.padEnd(11)} ${r[0].n} of ${all[0].n} rows`);
3307
3418
  } catch { /* counting is a courtesy */ }
3308
3419
  }
3420
+ console.log(' Not shared under a scope: the stat tables, meta and events — they carry no');
3421
+ console.log(' per-session filter, so a scoped reader gets none of them.');
3309
3422
  console.log(` Widen to everything: memhouse share ${user}`);
3310
3423
  console.log(` Withdraw: memhouse share ${user} --revoke`);
3311
3424
  return 0;
@@ -3321,7 +3434,10 @@ async function cmdShare() {
3321
3434
  * true = permissive, false = restrictive, null = could not tell.
3322
3435
  */
3323
3436
  async function probePermissive(cfg, db) {
3324
- const t = `_mh_probe_${process.pid}`;
3437
+ // Inside the member's own pattern: the one place the one-layout grant lets them create a
3438
+ // table and put a policy on it. A bare `_mh_probe_<pid>` sat outside it, was refused on
3439
+ // every server, and turned this check into a permanent "could not verify".
3440
+ const t = `${cfg.user}__mh_probe_${process.pid}`;
3325
3441
  const run = (sql) => ch(cfg, sql, { database: db });
3326
3442
  try {
3327
3443
  await run(`CREATE TABLE IF NOT EXISTS ${t} (x UInt8) ENGINE = MergeTree ORDER BY x`);
@@ -3576,19 +3692,11 @@ async function cmdInvite() {
3576
3692
  // statements adminBootstrap would run, so the person with the credential runs four
3577
3693
  // lines instead of installing anything. Nothing is contacted and nothing is written.
3578
3694
  if (flags['print-sql'] === true) {
3579
- const password = flags['member-password'] && flags['member-password'] !== true
3580
- ? String(flags['member-password']) : generatePassword();
3581
3695
  // memberSql prints its own header naming the house and the credential it needs; a
3582
3696
  // second one above it said the same thing twice, and in a shared house said the wrong
3583
3697
  // thing ("their own house" — they get rooms in a shared one).
3584
- console.log(memberSql(db, name, password));
3585
- console.log('-- Then send them these four lines — they are a credential, so use a channel');
3586
- console.log('-- you trust (croc, a password manager), not chat:');
3587
- console.log(`-- MEMHOUSE_URL='${url}'`);
3588
- console.log(`-- MEMHOUSE_USER='${name}'`);
3589
- console.log(`-- MEMHOUSE_PASSWORD='${password}'`);
3590
- console.log(`-- MEMHOUSE_DB='${db}'`);
3591
- console.log(`-- They install with: memhouse install --url ${url} --user ${name} --password '…' --db ${db}`);
3698
+ console.log(memberSql(db, name));
3699
+ console.log(handoffLines({ url, db, member: name, invite: true }));
3592
3700
  return 0;
3593
3701
  }
3594
3702
 
@@ -3599,8 +3707,16 @@ async function cmdInvite() {
3599
3707
  // --admin-* wins. Otherwise the admin credential this install keeps beside its member
3600
3708
  // credential (a house you deployed or administer). Failing both, the member credential
3601
3709
  // itself is tried below — which works for whoever set the house up as its superuser.
3602
- let adminUser = flags['admin-user'] || cfg.adminUser || undefined;
3603
- let adminPass = flags['admin-password'] || (!flags['admin-user'] && cfg.adminPassword) || undefined;
3710
+ if (flags['admin-user'] === true) { console.log(bad('--admin-user needs a name, e.g. --admin-user default')); return 1; }
3711
+ let adminUser = (flags['admin-user'] ? String(flags['admin-user']) : '') || cfg.adminUser || undefined;
3712
+ let adminPass;
3713
+ if (adminUser) {
3714
+ // --admin-password-file / --admin-password / MEMHOUSE_ADMIN_PASSWORD / the stored one
3715
+ // for this same admin / a prompt. `--admin-user` as a flag used to switch the
3716
+ // environment off, so the only scriptable path put the password on argv.
3717
+ adminPass = await adminPasswordFor(adminUser);
3718
+ if (adminPass === null) return 1;
3719
+ }
3604
3720
  if (!adminUser) {
3605
3721
  const c = requireConfig(cfg, 'invite');
3606
3722
  if (!c.user) return 1;
@@ -3643,28 +3759,16 @@ async function cmdInvite() {
3643
3759
  console.log(' keeps one housemate out of another house.');
3644
3760
  console.log('');
3645
3761
  console.log(' If you RUN this ClickHouse — use the admin credential you created it with');
3646
- console.log(' (for a stock server that is `default`); memhouse never stores it, so pass it');
3647
- console.log(' per invite and it is used for one connection and discarded:');
3648
- console.log(` memhouse invite ${name} --url ${url} --admin-user default`);
3649
- console.log(' (leave --admin-password off and it is prompted for, so it stays out of');
3650
- console.log(' your shell history and the process list)');
3762
+ console.log(' (for a stock server that is `default`); memhouse does not store it, so give it');
3763
+ console.log(' per invite — used for one connection and discarded, never on the command line:');
3764
+ console.log(` with-secret MEMHOUSE_ADMIN_PASSWORD=<reference> -- memhouse invite ${name} --url ${url} --admin-user default`);
3765
+ console.log(` memhouse invite ${name} --url ${url} --admin-user default (prompts on a terminal)`);
3651
3766
  console.log('');
3652
3767
  console.log(' If SOMEONE ELSE runs it — you cannot invite, and no flag changes that.');
3653
3768
  console.log(' Print the statements and send them to whoever administers the server:');
3654
3769
  console.log(` memhouse invite ${name} --url ${url} --print-sql`);
3655
3770
  return 1;
3656
3771
  }
3657
- } else if (adminPass === undefined) {
3658
- // Prompt rather than refuse. A password given as --admin-password lands in the
3659
- // process list for the life of the request and in shell history unless the caller
3660
- // remembered a leading space; asking for it keeps it in this process only.
3661
- // Non-TTY (an agent, CI) still gets the old refusal — there is nobody to ask.
3662
- if (!process.stdin.isTTY) {
3663
- console.log(bad('--admin-user given without --admin-password (no TTY to prompt on)'));
3664
- return 1;
3665
- }
3666
- adminPass = await askSecret(` password for '${adminUser}'`);
3667
- if (!adminPass) { console.log(bad('no password given')); return 1; }
3668
3772
  }
3669
3773
 
3670
3774
  // WHERE to connect to provision. --url is the address the INVITEE will use — it may be
@@ -3844,12 +3948,15 @@ async function cmdPasswd({ quiet = false, forNext = null } = {}) {
3844
3948
  }
3845
3949
  }
3846
3950
  }
3847
- if (!adminUser || flags['admin-password'] === undefined) {
3951
+ if (!adminUser || adminUser === true) {
3848
3952
  console.log(bad(`'${cfg.user}' cannot change its own password on this house (pre-0.11 member, or no access management).`));
3849
- console.log(' Rotate with an admin credential: memhouse passwd --admin-user <a> --admin-password <p>');
3953
+ console.log(' Rotate with an admin credential (password from MEMHOUSE_ADMIN_PASSWORD, --admin-password-file, or a prompt):');
3954
+ console.log(' memhouse passwd --admin-user <a>');
3850
3955
  return 1;
3851
3956
  }
3852
- const via = { ...cfg, user: adminUser, password: flags['admin-password'] || '' };
3957
+ const adminPassword = await adminPasswordFor(String(adminUser));
3958
+ if (adminPassword === null) return 1;
3959
+ const via = { ...cfg, user: adminUser, password: adminPassword };
3853
3960
  try { await ch(via, `ALTER USER \`${cfg.user}\` IDENTIFIED BY '${escPw}'`, { database: '' }); }
3854
3961
  catch (e) { console.log(bad(`could not rotate: ${e.message}`)); return 1; }
3855
3962
  }
@@ -3866,7 +3973,8 @@ async function cmdPasswd({ quiet = false, forNext = null } = {}) {
3866
3973
  console.log(ok(`password rotated for '${cfg.user}' and ${ENV_FILE.replace(os.homedir(), '~')} updated`));
3867
3974
  console.log(' restart anything that inlines the credential here: memhouse update --no-install');
3868
3975
  console.log(warn('this rotated the ONE server credential — every OTHER machine you ship as'));
3869
- console.log(` '${cfg.user}' now fails auth until it gets the new password (memhouse setup --password …).`);
3976
+ console.log(` '${cfg.user}' now fails auth until its env file gets the new password: copy the`);
3977
+ console.log(` MEMHOUSE_PASSWORD line from ${ENV_FILE.replace(os.homedir(), '~')} over a channel you trust.`);
3870
3978
  return 0;
3871
3979
  }
3872
3980
 
@@ -4100,7 +4208,8 @@ async function cmdUninstall() {
4100
4208
  const convert = require(path.join(REPO_ROOT, 'memhouse', 'convert'));
4101
4209
  const db = flags.db && flags.db !== true ? String(flags.db) : 'mem';
4102
4210
  const au = flags['admin-user'] && flags['admin-user'] !== true ? String(flags['admin-user']) : (cfg.adminUser || null);
4103
- const ap = flags['admin-password'] && flags['admin-password'] !== true ? String(flags['admin-password']) : (cfg.adminPassword || '');
4211
+ const ap = au ? await adminPasswordFor(au) : '';
4212
+ if (ap === null) { process.exitCode = 1; break; }
4104
4213
  if (!au) { console.log(bad('convert needs an admin credential: --admin-user/--admin-password (or the one this instance keeps)')); process.exitCode = 1; break; }
4105
4214
  const who = { ...cfg, user: au, password: ap };
4106
4215
  const only = flags.member && flags.member !== true ? String(flags.member) : null;
@@ -4188,7 +4297,8 @@ async function cmdUninstall() {
4188
4297
  // --admin-* wins; otherwise the admin credential this install keeps (a house you
4189
4298
  // deployed or administer). A member alone cannot read other accounts' grants.
4190
4299
  const au = flags['admin-user'] && flags['admin-user'] !== true ? String(flags['admin-user']) : (cfg.adminUser || null);
4191
- const ap = flags['admin-password'] && flags['admin-password'] !== true ? String(flags['admin-password']) : (cfg.adminPassword || '');
4300
+ const ap = au ? await adminPasswordFor(au) : '';
4301
+ if (ap === null) { process.exitCode = 1; break; }
4192
4302
  // Reading ANOTHER account's grants needs privilege; without it ClickHouse simply
4193
4303
  // returns fewer rows. Say so rather than presenting a short list as the whole truth.
4194
4304
  const who = au ? { ...cfg, user: au, password: ap } : cfg;
package/docs/README.md ADDED
@@ -0,0 +1,46 @@
1
+ # memhouse documentation
2
+
3
+ Start with a tutorial if memhouse is new to you. Use a guide when you know what you want
4
+ to do. [`examples/`](../examples/README.md) holds runnable setups.
5
+
6
+ ## Tutorials
7
+
8
+ Each one starts from nothing and ends with something working, in about ten minutes.
9
+
10
+ | | |
11
+ |---|---|
12
+ | [1. Your first house](tutorials/01-first-house.md) | one machine: a local ClickHouse, your sessions shipped, the dashboard, search, `/mem:recall` |
13
+ | [2. Join a team's house](tutorials/02-join-a-house.md) | someone invited you: install from the invite file, make the password yours, ship |
14
+ | [3. Share your memory](tutorials/03-share-your-memory.md) | let a teammate read your house, all of it or one project, and take it back |
15
+
16
+ ## Guides
17
+
18
+ One common operation each.
19
+
20
+ | Guide | When you want to |
21
+ |---|---|
22
+ | [Install and onboard](guides/install.md) | install against a local, existing, or invited house; fix `EACCES` |
23
+ | [Configuration](guides/configuration.md) | choose which editors and directories ship; run several instances; follow a channel |
24
+ | [Update](guides/update.md) | stay current, and upgrade every machine of a house |
25
+ | [Daemons and services](guides/daemons.md) | keep the shipper and dashboard running, across reboots |
26
+ | [Invite members](guides/invite.md) | bring a teammate into a house you administer |
27
+ | [Share and revoke](guides/share.md) | let someone read your rooms, scoped or whole |
28
+ | [Admin password](guides/admin-password.md) | run an admin command without the password reaching `ps`, history, or a transcript |
29
+ | [Search, resume and skills](guides/search-and-resume.md) | find an old session, reopen it, let an agent answer from history |
30
+ | [How memory is stored](guides/storage.md) | houses, rooms, epochs, machine identity, what is never deleted |
31
+ | [Operate a house](guides/operate.md) | members, migrations, moving a house, backups |
32
+ | [Troubleshooting](guides/troubleshooting.md) | an editor ships nothing, daemons stop, a dashboard looks empty |
33
+ | [Uninstall](guides/uninstall.md) | stop, forget the house, or remove everything |
34
+
35
+ ## Reference
36
+
37
+ - `memhouse <command> --help`: every command's own usage. It always prints usage and
38
+ never runs the command.
39
+ - [Release notes](releases/): what changed in each version, and what to do about it.
40
+ - [`../memhouse/house/HOUSE.md`](../memhouse/house/HOUSE.md): the layout and the schema,
41
+ column by column.
42
+ - [`../memhouse/DESIGN.md`](../memhouse/DESIGN.md): the design, and why it is shaped this
43
+ way.
44
+ - [`../SECURITY.md`](../SECURITY.md): what holds, and what does not.
45
+ - [`docs/design/`](https://github.com/agent-realm/memhouse/tree/main/docs/design) (in the
46
+ repository): designs captured before they are built.