memhouse 0.12.7 → 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/README.md +12 -2
- package/bin/memhouse.js +168 -24
- package/memhouse/delivery/plugin/.claude-plugin/plugin.json +2 -2
- package/memhouse/delivery/plugin/skills/admin/SKILL.md +128 -0
- package/memhouse/delivery/plugin/skills/invite/SKILL.md +82 -17
- package/memhouse/secret-input.js +39 -0
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -76,12 +76,22 @@ memhouse update [--check] upgrade, restart daemons, check the s
|
|
|
76
76
|
memhouse start | stop dashboard + shipper as daemons
|
|
77
77
|
memhouse service install | uninstall survive a reboot
|
|
78
78
|
memhouse deploy --local | --down stand up (or remove) a local house
|
|
79
|
-
memhouse
|
|
80
|
-
|
|
79
|
+
memhouse invite <name> mint a member + house, hand them one env file
|
|
80
|
+
memhouse passwd rotate this member's password
|
|
81
|
+
memhouse migrate | migrate-rooms [--dry-run] [--yes]
|
|
82
|
+
run whatever this house still needs
|
|
83
|
+
memhouse relocate --to <url> copy this house to a new ClickHouse, then repoint
|
|
84
|
+
memhouse nightly [--out DIR] build an installable tarball from this checkout
|
|
85
|
+
memhouse plugins install claude 9 skills — ask, hello, invite, search,
|
|
86
|
+
sessions, share, sql, status, users — into
|
|
87
|
+
every Claude Code config dir found
|
|
81
88
|
memhouse prompt memory snippet for an agent's system prompt
|
|
82
89
|
memhouse prompt --install an install prompt, rendered for this machine
|
|
83
90
|
```
|
|
84
91
|
|
|
92
|
+
Any command's own usage: `memhouse <command> --help` (or `-h`) — always prints usage,
|
|
93
|
+
never runs the command.
|
|
94
|
+
|
|
85
95
|
Every command works both ways: interactive for you, `--yes` / flags / `--json` for
|
|
86
96
|
an agent — so an agent can install its own memory unattended.
|
|
87
97
|
|
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
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
if (
|
|
281
|
-
if (
|
|
282
|
-
|
|
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,8 +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
|
|
329
|
-
machine untouched
|
|
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
|
|
330
338
|
passwd rotate this member's password + rewrite the env file
|
|
331
339
|
(admin-assisted: --admin-user --admin-password)
|
|
332
340
|
--print-sql print the SQL, run it yourself
|
|
@@ -690,6 +698,12 @@ GRANT ALTER USER ON ${member} TO ${member};
|
|
|
690
698
|
-- It grants no read of anyone's rows; reading a housemate's memory still needs /mem:share.
|
|
691
699
|
GRANT SHOW USERS ON *.* TO ${member};
|
|
692
700
|
|
|
701
|
+
-- \`memhouse relocate\` runs FROM the destination, pulling the source over remoteSecure() —
|
|
702
|
+
-- a table function ClickHouse gates behind its own access type, separate from GRANT ALL on
|
|
703
|
+
-- a database. Without this, relocate fails at the native-reachability probe with
|
|
704
|
+
-- ACCESS_DENIED (source untouched, nothing lost — just unusable until granted).
|
|
705
|
+
GRANT REMOTE ON *.* TO ${member};
|
|
706
|
+
|
|
693
707
|
-- Pin async_insert on the user (ADD SETTING merges; a bare SETTINGS clause would replace
|
|
694
708
|
-- the user's whole list). The pin keeps the user_id stamp honest: a MATERIALIZED
|
|
695
709
|
-- currentUser() is computed during the INSERT, and an async flush stores it as the empty
|
|
@@ -805,8 +819,16 @@ async function adminBootstrap(cfg, admin) {
|
|
|
805
819
|
// Best-effort like ALTER USER above — an admin without access-management just skips it.
|
|
806
820
|
try { await q(`GRANT SHOW USERS ON *.* TO ${admin.member}`, { database: '' }); }
|
|
807
821
|
catch { /* no access-management: /mem:users section 4 stays admin-only for this member */ }
|
|
822
|
+
// REMOTE: `memhouse relocate` runs FROM the destination, pulling the source over
|
|
823
|
+
// remoteSecure() — a table function ClickHouse gates behind its own access type,
|
|
824
|
+
// separate from any GRANT ALL on a database. Without it every relocate a member runs
|
|
825
|
+
// fails at the native-reachability probe with ACCESS_DENIED, source untouched, no
|
|
826
|
+
// data lost — just relocate unusable until an admin grants this by hand. Best-effort
|
|
827
|
+
// like the two grants above.
|
|
828
|
+
try { await q(`GRANT REMOTE ON *.* TO ${admin.member}`, { database: '' }); }
|
|
829
|
+
catch { /* no access-management: relocate stays admin-assisted for this member */ }
|
|
808
830
|
await q(`ALTER USER ${admin.member} ADD SETTING ${MEMBER_PIN}`, { database: '' });
|
|
809
|
-
console.log(ok(`granted the house: ALL ON ${cfg.db}.* to '${admin.member}', SHOW USERS, async_insert pinned`));
|
|
831
|
+
console.log(ok(`granted the house: ALL ON ${cfg.db}.* to '${admin.member}', SHOW USERS, REMOTE, async_insert pinned`));
|
|
810
832
|
} catch (e) {
|
|
811
833
|
console.log(bad(`could not grant the house to '${admin.member}': ${e.message}`));
|
|
812
834
|
if (createdUser) {
|
|
@@ -2898,7 +2920,7 @@ async function cmdNightly() {
|
|
|
2898
2920
|
*/
|
|
2899
2921
|
async function cmdInvite() {
|
|
2900
2922
|
const name = positional[0];
|
|
2901
|
-
if (!name) { console.log('usage: memhouse invite <name> --url <house-url> --admin-user … --admin-password … [--db <house>] [--out <file>]'); 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; }
|
|
2902
2924
|
try { require(path.join(REPO_ROOT, 'memhouse', 'house', 'house')).assertUsableName(name, 'member'); }
|
|
2903
2925
|
catch (e) { console.log(bad(e.message)); return 1; }
|
|
2904
2926
|
const cfg = resolveConfig();
|
|
@@ -2921,27 +2943,106 @@ async function cmdInvite() {
|
|
|
2921
2943
|
try { require(path.join(REPO_ROOT, 'memhouse', 'house', 'house')).assertUsableName(db, 'house'); }
|
|
2922
2944
|
catch (e) { console.log(bad(e.message)); return 1; }
|
|
2923
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
|
+
|
|
2924
2967
|
// Whose credential mints the member. Explicit --admin-* wins; otherwise TRY THE
|
|
2925
|
-
// CONFIGURED ONE —
|
|
2926
|
-
//
|
|
2927
|
-
//
|
|
2928
|
-
// 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.
|
|
2929
2971
|
let adminUser = flags['admin-user'];
|
|
2930
2972
|
let adminPass = flags['admin-password'];
|
|
2931
2973
|
if (!adminUser) {
|
|
2932
2974
|
const c = requireConfig(cfg, 'invite');
|
|
2933
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.
|
|
2934
2991
|
try {
|
|
2935
|
-
await ch({ ...c, url }, 'SELECT 1
|
|
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) {
|
|
2936
3015
|
adminUser = c.user; adminPass = c.password;
|
|
2937
|
-
console.log(ok(`inviting as your own credential '${c.user}' (it can
|
|
2938
|
-
}
|
|
2939
|
-
console.log(bad('
|
|
2940
|
-
console.log('
|
|
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`);
|
|
2941
3033
|
return 1;
|
|
2942
3034
|
}
|
|
2943
3035
|
} else if (adminPass === undefined) {
|
|
2944
|
-
|
|
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; }
|
|
2945
3046
|
}
|
|
2946
3047
|
|
|
2947
3048
|
// WHERE to connect to provision. --url is the address the INVITEE will use — it may be
|
|
@@ -2951,6 +3052,42 @@ async function cmdInvite() {
|
|
|
2951
3052
|
// EXPLICIT --admin-* (where the admin typed the target themselves) provisions at --url.
|
|
2952
3053
|
// Either way the invite FILE carries --url, the invitee's path.
|
|
2953
3054
|
const provisionUrl = flags['admin-user'] ? url : cfg.url;
|
|
3055
|
+
|
|
3056
|
+
// Is there already a house here, with somebody's memory in it? The house is created
|
|
3057
|
+
// with CREATE DATABASE IF NOT EXISTS, so inviting a name whose database already exists
|
|
3058
|
+
// ADOPTS it — same output as a fresh one, and the invitee lands on top of rows that are
|
|
3059
|
+
// not theirs. (The user half is safe: adminBootstrap refuses an existing ClickHouse
|
|
3060
|
+
// user, so no sitting member's password is ever rotated out from under them.)
|
|
3061
|
+
//
|
|
3062
|
+
// Found the hard way: an invite meant for one person was sent to another, who shipped
|
|
3063
|
+
// 3,733 messages under it. Re-inviting the intended person reported success and handed
|
|
3064
|
+
// them the first person's memory, with nothing on any surface saying so.
|
|
3065
|
+
//
|
|
3066
|
+
// Read-only, best-effort: an admin that cannot count rows should not lose the ability
|
|
3067
|
+
// to invite, and a house that does not exist yet is the ordinary case.
|
|
3068
|
+
const adminCfg = { ...cfg, url: provisionUrl, user: adminUser, password: adminPass || '', db, stated: true };
|
|
3069
|
+
let occupied = null;
|
|
3070
|
+
try {
|
|
3071
|
+
const exists = await chRows(adminCfg, `SELECT count() AS n FROM system.tables WHERE database = '${db.replace(/'/g, "\\'")}' AND name = 'messages'`, { database: '' });
|
|
3072
|
+
if (Number(exists[0] && exists[0].n) > 0) {
|
|
3073
|
+
const r = await chRows(adminCfg, 'SELECT count() AS msgs, uniqExact(user_id) AS writers FROM messages', { database: db });
|
|
3074
|
+
const msgs = Number(r[0] && r[0].msgs) || 0;
|
|
3075
|
+
if (msgs > 0) occupied = { msgs, writers: Number(r[0].writers) || 0 };
|
|
3076
|
+
}
|
|
3077
|
+
} catch { /* cannot tell — provisioning below will surface any real access problem */ }
|
|
3078
|
+
if (occupied && flags.adopt !== true) {
|
|
3079
|
+
console.log(bad(`house '${db}' already exists and holds ${occupied.msgs} messages from ${occupied.writers} writer(s) — NOT inviting.`));
|
|
3080
|
+
console.log(` Inviting '${name}' here would hand them somebody else's memory, and their`);
|
|
3081
|
+
console.log(' first ship would land on top of it. Nothing has been changed.');
|
|
3082
|
+
console.log(' Pick a different handle: memhouse invite <other-name> --url …');
|
|
3083
|
+
console.log(` Or a different house: memhouse invite ${name} --url … --db <house>`);
|
|
3084
|
+
console.log(` If sharing this house IS the intent, say so: --adopt`);
|
|
3085
|
+
return 1;
|
|
3086
|
+
}
|
|
3087
|
+
if (occupied) {
|
|
3088
|
+
console.log(warn(`adopting existing house '${db}' — ${occupied.msgs} messages already here (--adopt)`));
|
|
3089
|
+
}
|
|
3090
|
+
|
|
2954
3091
|
const built = await adminBootstrap({ ...cfg, url: provisionUrl, db, stated: true }, {
|
|
2955
3092
|
user: adminUser, password: adminPass || '', member: name, quiet: true,
|
|
2956
3093
|
});
|
|
@@ -3202,6 +3339,13 @@ async function cmdUninstall() {
|
|
|
3202
3339
|
if (flags.version === true || process.argv.slice(2).some((a) => a === '-v' || a === '-V')) {
|
|
3203
3340
|
console.log(PKG.version); return;
|
|
3204
3341
|
}
|
|
3342
|
+
// `memhouse <cmd> --help` (or `-h`) must never reach a command's own logic — passwd,
|
|
3343
|
+
// invite, uninstall and friends act on first call with no separate confirm step, so a
|
|
3344
|
+
// --help that fell through to the default branch would DO the thing instead of
|
|
3345
|
+
// describing it. One check ahead of the switch, for every command, is the whole fix.
|
|
3346
|
+
if (cmd && (flags.help === true || process.argv.slice(2).some((a) => a === '-h' || a === '--help'))) {
|
|
3347
|
+
console.log(HELP); return;
|
|
3348
|
+
}
|
|
3205
3349
|
const cfg = resolveConfig();
|
|
3206
3350
|
|
|
3207
3351
|
switch (cmd) {
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mem",
|
|
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. Skills install as /mem:hello, /mem:ask, /mem:search, /mem:sessions, /mem:share, /mem:invite, /mem:sql, /mem:status, and /mem:
|
|
4
|
-
"version": "0.
|
|
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.14.0",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "Ramazan Polat"
|
|
7
7
|
},
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: admin
|
|
3
|
+
description: Operate the memhouse ClickHouse as its administrator — list and inspect every user and house, provision or remove members, grant and revoke access, and read server-wide health (disk, parts, mutations, running queries). Use when the user asks to "do X as admin", "manage users", "drop a house", "see disk usage", "who is on this server", or when a member-scoped skill refused for want of privileges. Requires an admin credential; says so plainly when there is not one.
|
|
4
|
+
user-invocable: true
|
|
5
|
+
argument-hint: "<what you want done>"
|
|
6
|
+
allowed-tools: Bash
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
# /mem:admin — operate the house as its administrator
|
|
10
|
+
|
|
11
|
+
A member owns one database. An **administrator** owns the ClickHouse: every house, every
|
|
12
|
+
account, every grant. This skill is the second one, and it is the only skill allowed to
|
|
13
|
+
act outside the caller's own house.
|
|
14
|
+
|
|
15
|
+
Everything here runs through the same rule as `/mem:invite`: **an admin password must
|
|
16
|
+
never enter this conversation.** memhouse ships this transcript into the house you are
|
|
17
|
+
administering, so a password pasted in chat is a password published to the archive.
|
|
18
|
+
Resolve it from the environment or a file, never from a prompt, and never echo it.
|
|
19
|
+
|
|
20
|
+
## 1. Find an admin credential — never ask for one in chat
|
|
21
|
+
|
|
22
|
+
Try these in order and stop at the first that works. **Never print any of these files or
|
|
23
|
+
the variables in them** — no `cat`, no `env | grep`, no `set -x`.
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
MH_HOME="${MEMHOUSE_HOME:-$HOME/.memhouse}"
|
|
27
|
+
# a) an explicit admin credential in the environment
|
|
28
|
+
ADM_U=${MEMHOUSE_ADMIN_USER-}; ADM_P=${MEMHOUSE_ADMIN_PASSWORD-}
|
|
29
|
+
# b) an admin env file kept beside the member config
|
|
30
|
+
if [ -z "$ADM_U" ] && [ -f "$MH_HOME/admin.env" ]; then
|
|
31
|
+
set -a; . "$MH_HOME/admin.env"; set +a
|
|
32
|
+
ADM_U=${MEMHOUSE_ADMIN_USER:-${MEMHOUSE_USER-}}; ADM_P=${MEMHOUSE_ADMIN_PASSWORD:-${MEMHOUSE_PASSWORD-}}
|
|
33
|
+
fi
|
|
34
|
+
# c) the ordinary member credential — on a house the pilot stood up themselves
|
|
35
|
+
# (memhouse deploy --local) the member IS the superuser
|
|
36
|
+
set -a; [ -f "$MH_HOME/env" ] && . "$MH_HOME/env"; set +a
|
|
37
|
+
: "${ADM_U:=${MEMHOUSE_USER-}}"; : "${ADM_P:=${MEMHOUSE_PASSWORD-}}"
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
Then **prove** it is actually an admin rather than assuming — a member can often read
|
|
41
|
+
`system.users` while holding nothing else:
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
curl -sS --fail-with-body --user "$ADM_U:$ADM_P" \
|
|
45
|
+
--data-binary "SHOW GRANTS FOR $ADM_U FORMAT TSV" "$MEMHOUSE_URL/"
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
Admin means the grants carry `ACCESS MANAGEMENT`, or `CREATE USER`/`CREATE DATABASE` at
|
|
49
|
+
`*.*` — not merely `SHOW USERS`, which every member has. If none of a/b/c qualifies,
|
|
50
|
+
**stop and say so**:
|
|
51
|
+
|
|
52
|
+
> This needs administrator access to the ClickHouse at `<url>`, and the credential
|
|
53
|
+
> configured here is the member account `<user>` — it owns its own house and nothing
|
|
54
|
+
> else. To use `/mem:admin`, put an admin credential in `~/.memhouse/admin.env`
|
|
55
|
+
> (`MEMHOUSE_ADMIN_USER=` / `MEMHOUSE_ADMIN_PASSWORD=`, mode 600) or export
|
|
56
|
+
> `MEMHOUSE_ADMIN_USER` / `MEMHOUSE_ADMIN_PASSWORD`, then ask again. Whoever operates
|
|
57
|
+
> the server has it; on a house from `memhouse deploy --local` it is the account already
|
|
58
|
+
> in `~/.memhouse/env`.
|
|
59
|
+
|
|
60
|
+
Do not offer to carry on with reduced powers, do not suggest the user paste a password
|
|
61
|
+
into the chat, and do not improvise around the refusal. Say which of a/b/c you tried.
|
|
62
|
+
|
|
63
|
+
## 2. Run the request
|
|
64
|
+
|
|
65
|
+
Use the resolved credential for every query. `--fail-with-body` so ClickHouse's own error
|
|
66
|
+
is visible; add `&readonly=1` on anything that only reads.
|
|
67
|
+
|
|
68
|
+
```bash
|
|
69
|
+
adm() { curl -sS --fail-with-body --user "$ADM_U:$ADM_P" --data-binary "$1" "$MEMHOUSE_URL/${2-}"; }
|
|
70
|
+
adm "SELECT … FORMAT PrettyCompact" "?readonly=1"
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
Reads worth knowing — reach for these before inventing SQL:
|
|
74
|
+
|
|
75
|
+
| Question | Query |
|
|
76
|
+
|---|---|
|
|
77
|
+
| who exists | `SELECT name FROM system.users ORDER BY name` |
|
|
78
|
+
| what can someone do | `SHOW GRANTS FOR <user>` |
|
|
79
|
+
| every house and its size | `SELECT database, formatReadableSize(sum(bytes_on_disk)) AS disk, sum(rows) AS rows FROM system.parts WHERE active GROUP BY database ORDER BY sum(bytes_on_disk) DESC` |
|
|
80
|
+
| a house's rooms | `SELECT table, formatReadableSize(sum(bytes_on_disk)) AS disk, sum(rows) AS rows FROM system.parts WHERE active AND database = '<db>' GROUP BY table` |
|
|
81
|
+
| who writes where | `SELECT user_id, host, count() FROM <db>.messages GROUP BY user_id, host` |
|
|
82
|
+
| running queries | `SELECT query_id, user, elapsed, formatReadableSize(memory_usage) AS mem, substring(query,1,120) AS q FROM system.processes` |
|
|
83
|
+
| unfinished mutations | `SELECT database, table, mutation_id, command, parts_to_do, latest_fail_reason FROM system.mutations WHERE NOT is_done` |
|
|
84
|
+
| merge backlog | `SELECT database, table, elapsed, progress FROM system.merges` |
|
|
85
|
+
|
|
86
|
+
Writes follow the shapes memhouse itself uses, so an admin-made member is identical to an
|
|
87
|
+
invited one — a member gets `GRANT ALL ON <db>.* … WITH GRANT OPTION`, plus `SHOW USERS`,
|
|
88
|
+
self-scoped `ALTER USER`, `REMOTE`, and the async-insert pin. **Prefer the CLI over raw
|
|
89
|
+
SQL when one exists**: `memhouse invite <name> --url …` provisions a member correctly
|
|
90
|
+
(and refuses a house that already holds someone's messages); this skill is for the things
|
|
91
|
+
it does not cover.
|
|
92
|
+
|
|
93
|
+
## 3. Destructive work — look first, then confirm
|
|
94
|
+
|
|
95
|
+
`DROP DATABASE`, `DROP USER`, `REVOKE`, `ALTER … DELETE`, `TRUNCATE`, `KILL QUERY` and
|
|
96
|
+
password rotation all change something a person depends on. There are no backups and no
|
|
97
|
+
undo: a dropped house is gone, and the transcripts behind it may have aged out of the
|
|
98
|
+
editors that produced them.
|
|
99
|
+
|
|
100
|
+
Before any of them:
|
|
101
|
+
|
|
102
|
+
1. **Look at the target and report what is actually there** — row counts per room, who
|
|
103
|
+
wrote them, the date span, which machines. A house you expected to be empty may not
|
|
104
|
+
be.
|
|
105
|
+
2. **Name the collateral.** Dropping a user stops that account's shipper on every machine
|
|
106
|
+
it runs on. Revoking a grant closes a share the other person may be mid-query on.
|
|
107
|
+
Rotating a password breaks every machine shipping under it.
|
|
108
|
+
3. **Get an explicit yes for that specific object**, quoting the counts you just read.
|
|
109
|
+
A general "yes do admin things" is not consent to drop a populated house.
|
|
110
|
+
4. **Verify afterwards** and say what is now true.
|
|
111
|
+
|
|
112
|
+
If the user has already told you exactly what to destroy and why, and the counts match
|
|
113
|
+
their description, proceed — do not re-litigate a decision they have made. Report the
|
|
114
|
+
outcome plainly, including the row counts destroyed.
|
|
115
|
+
|
|
116
|
+
## Never
|
|
117
|
+
|
|
118
|
+
Never accept an admin password typed into the conversation, and never print one — not in
|
|
119
|
+
a command, not in an echo, not in an error. If a command needs it, expand a shell
|
|
120
|
+
variable (`--admin-password "$ADM_P"`), so the value never appears in the transcript.
|
|
121
|
+
|
|
122
|
+
Never widen a member's grants beyond what `memhouse invite` issues without the user
|
|
123
|
+
asking for that specific privilege and being told what it allows. Never grant
|
|
124
|
+
`ACCESS MANAGEMENT`, `CREATE USER`, or anything at `*.*` casually — those make a second
|
|
125
|
+
administrator.
|
|
126
|
+
|
|
127
|
+
Never touch a house that is not the subject of the request. Never run a destructive
|
|
128
|
+
statement whose predicate you have not first run as a `SELECT`.
|
|
@@ -30,9 +30,10 @@ substituted into a command — that burns the credential into the transcript on
|
|
|
30
30
|
Only ask about a **shared house** if the user brings one up; default is the invitee's
|
|
31
31
|
own house (omit --db; never pass `--db default` for "the default house").
|
|
32
32
|
|
|
33
|
-
3. TRY IT BARE FIRST — run this via the Bash tool
|
|
34
|
-
|
|
35
|
-
whoever set the house up, so no
|
|
33
|
+
3. TRY IT BARE FIRST — run this via the Bash tool, from the directory the file should
|
|
34
|
+
land in (step 4). `memhouse invite` uses the caller's OWN configured credential when
|
|
35
|
+
it can manage users, which is the common case for whoever set the house up, so no
|
|
36
|
+
admin is needed:
|
|
36
37
|
|
|
37
38
|
```
|
|
38
39
|
memhouse invite <name> --url <url> [--db <shared-house>]
|
|
@@ -41,25 +42,89 @@ substituted into a command — that burns the credential into the transcript on
|
|
|
41
42
|
If it prints `invite written: …`, go to step 4 — you are done, and no admin secret
|
|
42
43
|
ever touched this conversation.
|
|
43
44
|
|
|
44
|
-
|
|
45
|
-
provisioning need a real admin. Do NOT take the admin password in chat — memhouse
|
|
46
|
-
archives this transcript. Hand the user the line to run THEMSELVES (a leading space
|
|
47
|
-
keeps it out of shell history):
|
|
45
|
+
Otherwise read WHICH refusal came back; they need different answers.
|
|
48
46
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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
|
+
```
|
|
52
77
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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**.
|
|
81
|
+
|
|
82
|
+
**If it refuses with "house `<db>` already exists and holds N messages", STOP.** That
|
|
83
|
+
house is somebody's memory, and inviting into it would hand the invitee someone else's
|
|
84
|
+
sessions. Do not pass `--adopt` on your own initiative — report the row count and the
|
|
85
|
+
writer count to the user and let them choose: a different handle, a different `--db`,
|
|
86
|
+
or `--adopt` if sharing that house is genuinely what they want.
|
|
87
|
+
|
|
88
|
+
4. **Put the file where the user can attach it.** The CLI writes `invite-<name>.env`
|
|
89
|
+
into the CURRENT directory, and the user has to send it by hand — so run the command
|
|
90
|
+
from their downloads directory (on macOS and most Linux desktops `~/Downloads`), or
|
|
91
|
+
move it there afterwards. Never leave it in a git repo: it is a password, and
|
|
92
|
+
`invite-*.env` is only gitignored in this repo, not in whatever checkout the user
|
|
93
|
+
happened to be standing in.
|
|
94
|
+
|
|
95
|
+
Confirm it is there and still mode `600`, and say the full path. **Never print the
|
|
96
|
+
file's contents** — the password inside would land in a transcript memhouse ships.
|
|
97
|
+
|
|
98
|
+
5. **Say whether the house was fresh.** Run
|
|
99
|
+
`memhouse stats` or query `SELECT count() FROM <db>.messages` and tell the user
|
|
100
|
+
plainly: a brand-new house reads 0. This is a second line of defence behind step 3's
|
|
101
|
+
refusal, and it is how the user learns the invitee is starting clean.
|
|
102
|
+
|
|
103
|
+
6. **Write the message the user will send.** They are about to hand over a file plus an
|
|
104
|
+
explanation, so compose it for them rather than making them write it. Put it in a
|
|
105
|
+
fenced block they can copy whole, and cover:
|
|
106
|
+
- what memhouse is, in a sentence or two — the invitee may never have heard of it
|
|
107
|
+
- that the attached `invite-<name>.env` IS a password: don't forward it, don't paste
|
|
108
|
+
it into chat
|
|
109
|
+
- `npm install -g memhouse` (Node 24+, no flags, nothing compiles), then
|
|
110
|
+
`memhouse install --env invite-<name>.env`
|
|
111
|
+
- that install OFFERS to change the password to one only they know, then deletes the
|
|
112
|
+
file — so they should say yes, because the inviter knows the current one
|
|
113
|
+
- `memhouse onboard` to start shipping, `memhouse status` to check it worked
|
|
114
|
+
- that their house is theirs alone; the inviter cannot read it unless they run
|
|
115
|
+
`/mem:share <inviter>`
|
|
116
|
+
|
|
117
|
+
If the invitee is REPLACING an existing setup (they were shipping under another
|
|
118
|
+
credential), add `memhouse ship --full` after the install — a plain incremental pass
|
|
119
|
+
skips sessions the new house has no record of, so their history would not follow them.
|
|
120
|
+
|
|
121
|
+
Then: once installed, sharing works both ways — `/mem:share <name>`.
|
|
60
122
|
|
|
61
123
|
## Never
|
|
62
124
|
|
|
63
125
|
Never create a ClickHouse user by running raw SQL with an admin password you were given
|
|
64
126
|
in chat. Never invent an admin credential. Never suggest granting beyond what `memhouse
|
|
65
127
|
invite` does. If the binary is absent, stop and say so — do not improvise the SQL.
|
|
128
|
+
|
|
129
|
+
Never print the invite file's contents, and never pass `--adopt` unless the user asked
|
|
130
|
+
for it after being told what is already in that house.
|
|
@@ -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,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "memhouse",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "memhouse
|
|
3
|
+
"version": "0.14.0",
|
|
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": {
|
|
7
7
|
"memhouse": "./bin/memhouse.js"
|