memhouse 0.14.0 → 0.16.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
|
@@ -29,6 +29,8 @@ const {
|
|
|
29
29
|
} = require(path.join(REPO_ROOT, 'memhouse', 'house', 'house'));
|
|
30
30
|
const envfile = require(path.join(REPO_ROOT, 'memhouse', 'envfile'));
|
|
31
31
|
const { consumeSecretChunk } = require(path.join(REPO_ROOT, 'memhouse', 'secret-input'));
|
|
32
|
+
const { capabilitiesFrom } = require(path.join(REPO_ROOT, 'memhouse', 'capabilities'));
|
|
33
|
+
const { unknownFlags, allowedFlags, suggestFlag } = require(path.join(REPO_ROOT, 'memhouse', 'flags'));
|
|
32
34
|
|
|
33
35
|
const HOME_DIR = process.env.MEMHOUSE_HOME || path.join(os.homedir(), '.memhouse');
|
|
34
36
|
const ENV_FILE = path.join(HOME_DIR, 'env');
|
|
@@ -52,6 +54,22 @@ for (let i = 0; i < rest.length; i++) {
|
|
|
52
54
|
}
|
|
53
55
|
const JSON_OUT = flags.json === true;
|
|
54
56
|
|
|
57
|
+
// Unknown flags stop the run — see memhouse/flags.js for why a shrug is not enough.
|
|
58
|
+
(() => {
|
|
59
|
+
const bad = unknownFlags(cmd, flags);
|
|
60
|
+
if (!bad.length) return;
|
|
61
|
+
const allowed = allowedFlags(cmd);
|
|
62
|
+
for (const f of bad) {
|
|
63
|
+
const hint = suggestFlag(f, allowed);
|
|
64
|
+
console.error(`memhouse: unknown option --${f}${hint ? ` (did you mean --${hint}?)` : ''}`);
|
|
65
|
+
}
|
|
66
|
+
const list = [...allowed].sort().map((f) => `--${f}`).join(' ');
|
|
67
|
+
console.error(` ${cmd || 'memhouse'} takes: ${list || '(no options)'}`);
|
|
68
|
+
console.error(' Nothing ran. A flag that is ignored rather than refused is how a --dry-run typo');
|
|
69
|
+
console.error(' becomes a real migration.');
|
|
70
|
+
process.exit(2);
|
|
71
|
+
})();
|
|
72
|
+
|
|
55
73
|
let ONBOARDING = false;
|
|
56
74
|
let _inviteFileToShred = null;
|
|
57
75
|
let _inviteWantsRotate = false;
|
|
@@ -162,6 +180,24 @@ async function ch(cfg, sql, { database = cfg.db, settings = null, timeout = 3000
|
|
|
162
180
|
if (!res.ok) throw new Error(text.trim().split('\n')[0]);
|
|
163
181
|
return text.trim();
|
|
164
182
|
}
|
|
183
|
+
/**
|
|
184
|
+
* The grant lines ClickHouse reports for a user, as strings.
|
|
185
|
+
*
|
|
186
|
+
* Never pass a FORMAT here — chRows appends its own, and a statement carrying two is a
|
|
187
|
+
* syntax error. That mistake once read as "no grants at all", which refused every
|
|
188
|
+
* credential including a genuine superuser, and it was invisible on any machine whose
|
|
189
|
+
* own credential is an ordinary member.
|
|
190
|
+
*
|
|
191
|
+
* Unreadable grants come back as [] rather than throwing: a credential that cannot read
|
|
192
|
+
* its own grants is, for every decision made from this, simply not an administrator.
|
|
193
|
+
*/
|
|
194
|
+
async function readGrants(cfg, user) {
|
|
195
|
+
try {
|
|
196
|
+
const rows = await chRows(cfg, `SHOW GRANTS FOR ${user}`, { database: '' });
|
|
197
|
+
return rows.map((r) => Object.values(r).join(' '));
|
|
198
|
+
} catch { return []; }
|
|
199
|
+
}
|
|
200
|
+
|
|
165
201
|
async function chRows(cfg, sql, opts) {
|
|
166
202
|
const text = await ch(cfg, sql + ' FORMAT JSONEachRow', opts);
|
|
167
203
|
return text ? text.split('\n').map((l) => JSON.parse(l)) : [];
|
|
@@ -363,6 +399,8 @@ Data ship one incremental pass (--full | --loop [sec])
|
|
|
363
399
|
resume <session-id> print the command that reopens that session in its editor
|
|
364
400
|
sessions-query print the session rollup SQL for this credential
|
|
365
401
|
start | stop | shipper loop + dashboard as background daemons
|
|
402
|
+
whoami which credential is in play, and what it may do
|
|
403
|
+
(--json; --admin reads MEMHOUSE_ADMIN_*)
|
|
366
404
|
status daemons, connection, counts, freshness (--json)
|
|
367
405
|
doctor diagnose the whole pipeline
|
|
368
406
|
|
|
@@ -2918,6 +2956,83 @@ async function cmdNightly() {
|
|
|
2918
2956
|
* transfer, and rotation (memhouse passwd) is printed because the inviter knows this
|
|
2919
2957
|
* password until the invitee changes it.
|
|
2920
2958
|
*/
|
|
2959
|
+
/**
|
|
2960
|
+
* `memhouse whoami [--json] [--admin]`
|
|
2961
|
+
*
|
|
2962
|
+
* Which credential is in play here, and what may it actually do. Exists so nothing has
|
|
2963
|
+
* to hand-roll `SHOW GRANTS` and grep the result — a skill that reasons about privileges
|
|
2964
|
+
* in prose gets it subtly wrong, and the wrong answer is either "you cannot" to an
|
|
2965
|
+
* administrator or "go ahead" to a member who is about to hit ACCESS_DENIED.
|
|
2966
|
+
*
|
|
2967
|
+
* `--admin` resolves MEMHOUSE_ADMIN_USER / MEMHOUSE_ADMIN_PASSWORD first, so an agent
|
|
2968
|
+
* can ask "is there an admin credential in this environment?" without inventing a place
|
|
2969
|
+
* to store one. Nothing here prints or persists a password.
|
|
2970
|
+
*/
|
|
2971
|
+
async function cmdWhoami() {
|
|
2972
|
+
const cfg = resolveConfig();
|
|
2973
|
+
const wantAdmin = flags.admin === true;
|
|
2974
|
+
const au = process.env.MEMHOUSE_ADMIN_USER;
|
|
2975
|
+
const ap = process.env.MEMHOUSE_ADMIN_PASSWORD;
|
|
2976
|
+
const usingAdminEnv = wantAdmin && au;
|
|
2977
|
+
// `--admin` with nothing to resolve is worth saying out loud. Silently falling back to
|
|
2978
|
+
// the member credential makes `whoami --admin` and `whoami` print the same thing, and
|
|
2979
|
+
// the reader is left thinking they asked a question the program never heard.
|
|
2980
|
+
const who = usingAdminEnv ? { ...cfg, user: au, password: ap || '' } : cfg;
|
|
2981
|
+
|
|
2982
|
+
if (!who.url || !who.user) {
|
|
2983
|
+
const msg = 'no credential configured';
|
|
2984
|
+
if (JSON_OUT) console.log(JSON.stringify({ ok: false, reason: msg }, null, 2));
|
|
2985
|
+
else {
|
|
2986
|
+
console.log(bad(`${msg} — nothing in ${short(ENV_FILE)} and no MEMHOUSE_URL/MEMHOUSE_USER set.`));
|
|
2987
|
+
if (wantAdmin) console.log(' For --admin, export MEMHOUSE_ADMIN_USER and MEMHOUSE_ADMIN_PASSWORD.');
|
|
2988
|
+
}
|
|
2989
|
+
return 1;
|
|
2990
|
+
}
|
|
2991
|
+
|
|
2992
|
+
let server = null;
|
|
2993
|
+
try { server = (await chRows(who, 'SELECT currentUser() AS u', { database: '' }))[0]?.u; }
|
|
2994
|
+
catch (e) {
|
|
2995
|
+
const reason = netReason(e);
|
|
2996
|
+
if (JSON_OUT) console.log(JSON.stringify({ ok: false, url: who.url, user: who.user, reason }, null, 2));
|
|
2997
|
+
else console.log(bad(`cannot reach ${who.url} as '${who.user}': ${reason}`));
|
|
2998
|
+
return 1;
|
|
2999
|
+
}
|
|
3000
|
+
|
|
3001
|
+
const grants = await readGrants(who, server);
|
|
3002
|
+
const caps = capabilitiesFrom(grants);
|
|
3003
|
+
const source = usingAdminEnv ? 'MEMHOUSE_ADMIN_* environment'
|
|
3004
|
+
: (process.env.MEMHOUSE_USER ? 'MEMHOUSE_* environment' : short(ENV_FILE));
|
|
3005
|
+
const role = caps.isSuperuser ? 'administrator'
|
|
3006
|
+
: caps.canProvision ? 'can provision (users and houses)'
|
|
3007
|
+
: 'member';
|
|
3008
|
+
|
|
3009
|
+
if (JSON_OUT) {
|
|
3010
|
+
console.log(JSON.stringify({
|
|
3011
|
+
ok: true, url: who.url, user: server, db: who.db, source, role, ...caps,
|
|
3012
|
+
grants_readable: grants.length > 0,
|
|
3013
|
+
admin_env_present: Boolean(au),
|
|
3014
|
+
admin_requested: wantAdmin,
|
|
3015
|
+
}, null, 2));
|
|
3016
|
+
return 0;
|
|
3017
|
+
}
|
|
3018
|
+
console.log(ok(`${server} at ${who.url} — ${role}`));
|
|
3019
|
+
if (wantAdmin && !usingAdminEnv) {
|
|
3020
|
+
console.log(warn('--admin asked for MEMHOUSE_ADMIN_USER / MEMHOUSE_ADMIN_PASSWORD; neither is set,'));
|
|
3021
|
+
console.log(' so this is the ordinary configured credential.');
|
|
3022
|
+
}
|
|
3023
|
+
console.log(` credential from: ${source}`);
|
|
3024
|
+
console.log(` house: ${who.db || '(none set)'}`);
|
|
3025
|
+
console.log(` may create users: ${caps.canMintUsers ? 'yes' : 'no'}`);
|
|
3026
|
+
console.log(` may create houses: ${caps.canMintHouses ? 'yes' : 'no'}`);
|
|
3027
|
+
console.log(` may read any house: ${caps.canReadEveryHouse ? 'yes' : 'no'}`);
|
|
3028
|
+
if (!grants.length) console.log(warn('could not read its own grants — treating it as a member'));
|
|
3029
|
+
if (!caps.canProvision) {
|
|
3030
|
+
console.log(' Inviting and server-wide administration need an administrator; this is not one.');
|
|
3031
|
+
console.log(' Supply one for this shell: export MEMHOUSE_ADMIN_USER=… MEMHOUSE_ADMIN_PASSWORD=…');
|
|
3032
|
+
}
|
|
3033
|
+
return 0;
|
|
3034
|
+
}
|
|
3035
|
+
|
|
2921
3036
|
async function cmdInvite() {
|
|
2922
3037
|
const name = positional[0];
|
|
2923
3038
|
if (!name) { console.log('usage: memhouse invite <name> --url <house-url> [--admin-user … [--admin-password …]] [--db <house>] [--out <file>] [--adopt] [--print-sql]'); return 2; }
|
|
@@ -2998,20 +3113,11 @@ async function cmdInvite() {
|
|
|
2998
3113
|
console.log(' before inviting, or use --print-sql and let the invitee be told the address.');
|
|
2999
3114
|
return 1;
|
|
3000
3115
|
}
|
|
3001
|
-
|
|
3002
|
-
|
|
3003
|
-
|
|
3004
|
-
|
|
3005
|
-
|
|
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) {
|
|
3116
|
+
// What this credential may do, judged from its own grants and SCOPE-AWARE — a member
|
|
3117
|
+
// holds CREATE DATABASE inside `ON <their-db>.*`, which mints no new house at all.
|
|
3118
|
+
// Shared with `whoami` so the two can never disagree about who is an administrator.
|
|
3119
|
+
const caps = capabilitiesFrom(await readGrants({ ...c, url }, c.user));
|
|
3120
|
+
if (caps.canProvision) {
|
|
3015
3121
|
adminUser = c.user; adminPass = c.password;
|
|
3016
3122
|
console.log(ok(`inviting as your own credential '${c.user}' (it can create users and houses here)`));
|
|
3017
3123
|
} else {
|
|
@@ -3444,6 +3550,7 @@ async function cmdUninstall() {
|
|
|
3444
3550
|
}
|
|
3445
3551
|
break;
|
|
3446
3552
|
case 'reset': process.exitCode = await cmdReset(); break;
|
|
3553
|
+
case 'whoami': process.exitCode = await cmdWhoami(); break;
|
|
3447
3554
|
case 'invite': process.exitCode = await cmdInvite(); break;
|
|
3448
3555
|
case 'passwd': process.exitCode = await cmdPasswd(); break;
|
|
3449
3556
|
case 'nightly': process.exitCode = await cmdNightly(); break;
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
// What a ClickHouse credential may actually DO, read from its own grants.
|
|
2
|
+
//
|
|
3
|
+
// This exists because asking the wrong question here is expensive and quiet. `invite`
|
|
4
|
+
// used to probe with `SELECT 1 FROM system.users`: since 0.12.6 every member holds
|
|
5
|
+
// SHOW USERS, so every member passed, was told it could manage users, and then died at
|
|
6
|
+
// CREATE DATABASE with a raw ACCESS_DENIED. The replacement must be exact, and it must
|
|
7
|
+
// live in one place — invite and /mem:admin disagreeing about who is an administrator is
|
|
8
|
+
// the same class of bug wearing a different hat.
|
|
9
|
+
//
|
|
10
|
+
// SCOPE IS THE WHOLE POINT. A grant is `GRANT <privileges> ON <scope> TO <user>`, and a
|
|
11
|
+
// privilege only means what its scope allows: an ordinary member holds CREATE DATABASE
|
|
12
|
+
// inside `ON polat.*`, which lets them do nothing whatsoever about creating a NEW
|
|
13
|
+
// database. Matching the privilege name anywhere in the text — which an earlier draft of
|
|
14
|
+
// this did — reads that member as a server administrator. So parse the line, keep the
|
|
15
|
+
// scope, and only count a privilege that is granted server-wide.
|
|
16
|
+
|
|
17
|
+
/** `GRANT a, b, c ON scope TO user [WITH GRANT OPTION]` -> {privs:[…], scope} | null */
|
|
18
|
+
function parseGrantLine(line) {
|
|
19
|
+
const m = /^\s*GRANT\s+(.+?)\s+ON\s+(\S+)\s+TO\s+/i.exec(String(line || ''));
|
|
20
|
+
if (!m) return null;
|
|
21
|
+
const privs = m[1].split(',').map((p) => p.trim().toUpperCase()).filter(Boolean);
|
|
22
|
+
return { privs, scope: m[2] };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Server-wide scope: `*.*` (tables everywhere) or `*` (the access/user namespace). */
|
|
26
|
+
function isServerWide(scope) {
|
|
27
|
+
return scope === '*.*' || scope === '*';
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function hasPriv(privs, name) {
|
|
31
|
+
return privs.includes(name);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Derive capabilities from the lines `SHOW GRANTS FOR <user>` returned.
|
|
36
|
+
*
|
|
37
|
+
* @param {string[]|string} grants one string per grant line, or the whole blob
|
|
38
|
+
* @returns {{canMintUsers:boolean, canMintHouses:boolean, canProvision:boolean,
|
|
39
|
+
* canReadEveryHouse:boolean, canSeeUsers:boolean, isSuperuser:boolean}}
|
|
40
|
+
*/
|
|
41
|
+
function capabilitiesFrom(grants) {
|
|
42
|
+
const lines = Array.isArray(grants) ? grants : String(grants || '').split('\n');
|
|
43
|
+
let canMintUsers = false;
|
|
44
|
+
let canMintHouses = false;
|
|
45
|
+
let canReadEveryHouse = false;
|
|
46
|
+
let canSeeUsers = false;
|
|
47
|
+
|
|
48
|
+
for (const raw of lines) {
|
|
49
|
+
const g = parseGrantLine(raw);
|
|
50
|
+
if (!g) continue;
|
|
51
|
+
const wide = isServerWide(g.scope);
|
|
52
|
+
// SHOW USERS is the one every member has — never evidence of anything.
|
|
53
|
+
if (hasPriv(g.privs, 'SHOW USERS')) canSeeUsers = true;
|
|
54
|
+
if (!wide) continue;
|
|
55
|
+
// Minting accounts. ACCESS MANAGEMENT is the umbrella; CREATE USER the specific.
|
|
56
|
+
if (hasPriv(g.privs, 'ACCESS MANAGEMENT') || hasPriv(g.privs, 'CREATE USER')) canMintUsers = true;
|
|
57
|
+
// Minting houses. `CREATE DATABASE` is the specific; bare `CREATE` is the umbrella
|
|
58
|
+
// ClickHouse hands a superuser (what `deploy --local` grants), and `ALL` covers both.
|
|
59
|
+
if (hasPriv(g.privs, 'CREATE DATABASE') || hasPriv(g.privs, 'CREATE') || hasPriv(g.privs, 'ALL')) canMintHouses = true;
|
|
60
|
+
// Reading anyone's rooms — what separates an administrator's view from a member's.
|
|
61
|
+
if (hasPriv(g.privs, 'SELECT') || hasPriv(g.privs, 'ALL')) canReadEveryHouse = true;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const canProvision = canMintUsers && canMintHouses;
|
|
65
|
+
return {
|
|
66
|
+
canMintUsers,
|
|
67
|
+
canMintHouses,
|
|
68
|
+
canProvision,
|
|
69
|
+
canReadEveryHouse,
|
|
70
|
+
canSeeUsers,
|
|
71
|
+
isSuperuser: canProvision && canReadEveryHouse,
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
module.exports = { capabilitiesFrom, parseGrantLine, isServerWide };
|
|
@@ -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.
|
|
4
|
+
"version": "0.16.0",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "Ramazan Polat"
|
|
7
7
|
},
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
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
|
|
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 administrator credential in the environment; says so plainly, and how to supply one, when there is not.
|
|
4
4
|
user-invocable: true
|
|
5
5
|
argument-hint: "<what you want done>"
|
|
6
6
|
allowed-tools: Bash
|
|
@@ -12,61 +12,61 @@ A member owns one database. An **administrator** owns the ClickHouse: every hous
|
|
|
12
12
|
account, every grant. This skill is the second one, and it is the only skill allowed to
|
|
13
13
|
act outside the caller's own house.
|
|
14
14
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
15
|
+
**An admin password must never enter this conversation.** memhouse ships this transcript
|
|
16
|
+
into the house you are administering, so a password pasted here is a password published
|
|
17
|
+
to the archive — and unlike a file you can delete, the archive is insert-only. Resolve
|
|
18
|
+
the credential from the environment or refuse. Never ask for one in chat, never echo one,
|
|
19
|
+
never write one to a file.
|
|
19
20
|
|
|
20
|
-
## 1.
|
|
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:
|
|
21
|
+
## 1. Ask the CLI what you are holding — do not work it out yourself
|
|
42
22
|
|
|
43
23
|
```bash
|
|
44
|
-
|
|
45
|
-
--data-binary "SHOW GRANTS FOR $ADM_U FORMAT TSV" "$MEMHOUSE_URL/"
|
|
24
|
+
memhouse whoami --admin --json
|
|
46
25
|
```
|
|
47
26
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
>
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
27
|
+
That resolves `MEMHOUSE_ADMIN_USER` / `MEMHOUSE_ADMIN_PASSWORD` when they are set and
|
|
28
|
+
falls back to the configured credential, then reports what it may actually do. Read
|
|
29
|
+
`role` and the capability flags; never infer privileges from a grant string yourself.
|
|
30
|
+
The command prints no password.
|
|
31
|
+
|
|
32
|
+
- **`"canProvision": true`** (or `"role": "administrator"`) — you have an administrator.
|
|
33
|
+
Go to §2.
|
|
34
|
+
- **`"canProvision": false`** — the credential is a member. **Stop.** Do not retry, do
|
|
35
|
+
not look for a stored password, and do not ask for one. Tell the user:
|
|
36
|
+
|
|
37
|
+
> `/mem:admin` needs an administrator credential for `<url>`, and what is configured
|
|
38
|
+
> here is the member account `<user>` — it owns its own house and nothing else. If you
|
|
39
|
+
> administer that ClickHouse, put the credential in this shell's environment and ask
|
|
40
|
+
> again:
|
|
41
|
+
>
|
|
42
|
+
> ```
|
|
43
|
+
> export MEMHOUSE_ADMIN_USER=default
|
|
44
|
+
> read -rs MEMHOUSE_ADMIN_PASSWORD && export MEMHOUSE_ADMIN_PASSWORD
|
|
45
|
+
> ```
|
|
46
|
+
>
|
|
47
|
+
> `read -rs` keeps it off the screen and out of your shell history. It lives only in
|
|
48
|
+
> that shell — nothing is written to disk, and memhouse never stores it.
|
|
49
|
+
>
|
|
50
|
+
> If somebody else administers the server, this is theirs to run, not yours.
|
|
51
|
+
|
|
52
|
+
- **`"ok": false`** — a connection problem, not a privilege one. Report `reason` as
|
|
53
|
+
given; do not translate it into "you are not an admin".
|
|
54
|
+
|
|
55
|
+
**Never suggest storing the credential** — not in `~/.memhouse/env`, not in an
|
|
56
|
+
`admin.env`, not anywhere on disk. An admin credential owns the whole server, and every
|
|
57
|
+
process running as this user can read a file. The environment of one shell is the whole
|
|
58
|
+
supported surface. (A house from `memhouse deploy --local` is the exception that needs
|
|
59
|
+
nothing: its member *is* the superuser, and `whoami` will already say so.)
|
|
60
|
+
|
|
61
|
+
## 2. Do the work
|
|
62
|
+
|
|
63
|
+
Use the same environment for every query. `--fail-with-body` so ClickHouse's own error is
|
|
64
|
+
visible; `&readonly=1` on anything that only reads.
|
|
67
65
|
|
|
68
66
|
```bash
|
|
69
|
-
adm() { curl -sS --fail-with-body
|
|
67
|
+
adm() { curl -sS --fail-with-body \
|
|
68
|
+
--user "$MEMHOUSE_ADMIN_USER:$MEMHOUSE_ADMIN_PASSWORD" \
|
|
69
|
+
--data-binary "$1" "$MEMHOUSE_URL/${2-}"; }
|
|
70
70
|
adm "SELECT … FORMAT PrettyCompact" "?readonly=1"
|
|
71
71
|
```
|
|
72
72
|
|
|
@@ -74,21 +74,19 @@ Reads worth knowing — reach for these before inventing SQL:
|
|
|
74
74
|
|
|
75
75
|
| Question | Query |
|
|
76
76
|
|---|---|
|
|
77
|
-
| who exists | `SELECT name FROM system.users ORDER BY name` |
|
|
78
|
-
| what
|
|
77
|
+
| who exists | `SELECT name, storage FROM system.users ORDER BY name` |
|
|
78
|
+
| what someone may do | `SHOW GRANTS FOR <user>` |
|
|
79
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
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
81
|
| who writes where | `SELECT user_id, host, count() FROM <db>.messages GROUP BY user_id, host` |
|
|
82
82
|
| running queries | `SELECT query_id, user, elapsed, formatReadableSize(memory_usage) AS mem, substring(query,1,120) AS q FROM system.processes` |
|
|
83
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
84
|
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
it does not cover.
|
|
85
|
+
**Prefer a CLI verb over raw SQL wherever one exists.** `memhouse invite <name> --url …`
|
|
86
|
+
provisions a member correctly — right grants, right pin, refuses a house that already
|
|
87
|
+
holds someone's messages — and it takes `--admin-user`/`--admin-password`, so it works
|
|
88
|
+
from this environment. Hand-written `CREATE USER` skips every one of those guards. This
|
|
89
|
+
skill is for what no verb covers.
|
|
92
90
|
|
|
93
91
|
## 3. Destructive work — look first, then confirm
|
|
94
92
|
|
|
@@ -99,30 +97,33 @@ editors that produced them.
|
|
|
99
97
|
|
|
100
98
|
Before any of them:
|
|
101
99
|
|
|
102
|
-
1. **Look at the target and report what is
|
|
100
|
+
1. **Look at the target and report what is really there** — row counts per room, who
|
|
103
101
|
wrote them, the date span, which machines. A house you expected to be empty may not
|
|
104
|
-
be
|
|
102
|
+
be: one `ege` house looked disposable and held 3,733 messages belonging to somebody
|
|
103
|
+
else entirely.
|
|
105
104
|
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
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
105
|
+
it runs on. Revoking a grant closes a share someone may be mid-query on. Rotating a
|
|
106
|
+
password breaks every machine shipping under it — and grants are per-user while the
|
|
107
|
+
fleet is per-`user@host`, so there is no way to revoke one machine.
|
|
108
|
+
3. **Get an explicit yes for that specific object**, quoting the counts you just read. A
|
|
109
|
+
general "yes, do admin things" is not consent to drop a populated house.
|
|
110
|
+
4. **Verify afterwards** and say what is now true, including what was destroyed.
|
|
111
111
|
|
|
112
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.
|
|
114
|
-
outcome plainly, including the row counts destroyed.
|
|
113
|
+
their description, proceed — do not re-litigate a decision they have made.
|
|
115
114
|
|
|
116
115
|
## Never
|
|
117
116
|
|
|
118
117
|
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.
|
|
120
|
-
|
|
118
|
+
a command, not in an echo, not in an error. Commands expand `"$MEMHOUSE_ADMIN_PASSWORD"`;
|
|
119
|
+
the value never appears as literal text.
|
|
120
|
+
|
|
121
|
+
Never recommend writing an admin credential to disk.
|
|
121
122
|
|
|
122
|
-
Never widen a member's grants beyond what `memhouse invite` issues
|
|
123
|
-
|
|
124
|
-
|
|
123
|
+
Never widen a member's grants beyond what `memhouse invite` issues unless the user asks
|
|
124
|
+
for that specific privilege and has been told what it allows. Never grant `ACCESS
|
|
125
|
+
MANAGEMENT`, `CREATE USER`, or anything at `*.*` casually — those mint a second
|
|
125
126
|
administrator.
|
|
126
127
|
|
|
127
|
-
Never touch a house that is not the subject of the request
|
|
128
|
+
Never touch a house that is not the subject of the request, and never run a destructive
|
|
128
129
|
statement whose predicate you have not first run as a `SELECT`.
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
// Which options each command takes, and what to say when one is not among them.
|
|
2
|
+
//
|
|
3
|
+
// Anything `--like-this` used to be accepted and ignored. That is quiet in the good case
|
|
4
|
+
// and dangerous in the bad one: `--dryrun` for `--dry-run` does not warn, it RUNS the
|
|
5
|
+
// migration; `--adopt` on a build that predates the guard is swallowed and the invite
|
|
6
|
+
// proceeds into somebody else's house. A flag the program does not know is a sentence
|
|
7
|
+
// the user believes they said and the program never heard.
|
|
8
|
+
//
|
|
9
|
+
// The tables live here rather than beside the parser so a test can hold them against the
|
|
10
|
+
// flags the CLI actually reads — a flag added to one and not the other fails the suite
|
|
11
|
+
// instead of being discovered by whoever typed it.
|
|
12
|
+
|
|
13
|
+
const GLOBAL_FLAGS = ['json', 'yes', 'help', 'version'];
|
|
14
|
+
|
|
15
|
+
const COMMAND_FLAGS = {
|
|
16
|
+
null: [], help: [], version: [],
|
|
17
|
+
whoami: ['admin'],
|
|
18
|
+
discover: [], stats: [], stop: [], doctor: [], 'sessions-query': [],
|
|
19
|
+
status: [], start: [],
|
|
20
|
+
onboard: ['url', 'user', 'password', 'db', 'port', 'no-ship', 'target', 'house-port', 'tag', 'local'],
|
|
21
|
+
install: ['url', 'user', 'password', 'db', 'port', 'no-ship', 'env', 'print-sql', 'member',
|
|
22
|
+
'member-password', 'admin-user', 'admin-password', 'ensure-schema', 'rotate-password', 'force'],
|
|
23
|
+
setup: ['url', 'user', 'password', 'db', 'port'],
|
|
24
|
+
ship: ['full', 'loop', 'ensure-schema'],
|
|
25
|
+
search: ['limit'],
|
|
26
|
+
resume: [],
|
|
27
|
+
update: ['check', 'migrate', 'no-install'],
|
|
28
|
+
reset: ['all-origins'],
|
|
29
|
+
invite: ['url', 'db', 'out', 'admin-user', 'admin-password', 'adopt', 'print-sql',
|
|
30
|
+
'allow-local', 'member-password'],
|
|
31
|
+
passwd: ['password', 'admin-user', 'admin-password', 'print-sql', 'member'],
|
|
32
|
+
nightly: ['out'],
|
|
33
|
+
migrate: ['dry-run'], 'migrate-rooms': ['dry-run'],
|
|
34
|
+
relocate: ['to', 'to-user', 'to-password', 'to-db', 'from-native-host', 'from-native-port',
|
|
35
|
+
'insecure-native', 'keep-shipper', 'dry-run'],
|
|
36
|
+
deploy: ['local', 'down', 'house-port', 'port', 'tag'],
|
|
37
|
+
service: ['interval'],
|
|
38
|
+
uninstall: ['credentials', 'full-removal'],
|
|
39
|
+
plugins: ['target'],
|
|
40
|
+
prompt: ['install'],
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
/** Levenshtein, small and local — this is the only caller. */
|
|
44
|
+
function editDistance(a, b) {
|
|
45
|
+
const m = Array.from({ length: a.length + 1 }, (_, i) => [i, ...Array(b.length).fill(0)]);
|
|
46
|
+
for (let j = 0; j <= b.length; j++) m[0][j] = j;
|
|
47
|
+
for (let i = 1; i <= a.length; i++) {
|
|
48
|
+
for (let j = 1; j <= b.length; j++) {
|
|
49
|
+
m[i][j] = Math.min(m[i - 1][j] + 1, m[i][j - 1] + 1, m[i - 1][j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1));
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return m[a.length][b.length];
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** The flag a typo most likely meant, or null when nothing is close enough to guess. */
|
|
56
|
+
function suggestFlag(bad, allowed) {
|
|
57
|
+
let best = null; let bd = Infinity;
|
|
58
|
+
for (const c of allowed) { const d = editDistance(bad, c); if (d < bd) { bd = d; best = c; } }
|
|
59
|
+
return bd <= Math.max(2, Math.floor(bad.length / 3)) ? best : null;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Every option this command does not take. Unknown COMMANDS return [] — dispatch owns that. */
|
|
63
|
+
function unknownFlags(cmd, flags) {
|
|
64
|
+
const known = COMMAND_FLAGS[cmd === null || cmd === undefined ? 'null' : cmd];
|
|
65
|
+
if (known === undefined) return [];
|
|
66
|
+
const allowed = allowedFlags(cmd);
|
|
67
|
+
return Object.keys(flags).filter((f) => !allowed.has(f));
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** The full accepted set for a command, globals included. */
|
|
71
|
+
function allowedFlags(cmd) {
|
|
72
|
+
const known = COMMAND_FLAGS[cmd === null || cmd === undefined ? 'null' : cmd] || [];
|
|
73
|
+
return new Set([...GLOBAL_FLAGS, ...known]);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
module.exports = { GLOBAL_FLAGS, COMMAND_FLAGS, unknownFlags, allowedFlags, suggestFlag, editDistance };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "memhouse",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.16.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": {
|