@totalreclaw/totalreclaw 3.1.0 → 3.2.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.
@@ -0,0 +1,546 @@
1
+ /**
2
+ * 3.2.0 secure-onboarding CLI wizard.
3
+ *
4
+ * Runs as `openclaw totalreclaw onboard` via OpenClaw's `api.registerCli`
5
+ * plugin hook. Lives on a pure TTY surface — stdout/stderr/stdin — and
6
+ * NEVER routes any of its I/O through the LLM provider, the gateway
7
+ * transcript, or any session-persisted channel. This is the load-bearing
8
+ * property of the 3.2.0 threat model: the recovery phrase is generated,
9
+ * displayed, entered, and persisted ENTIRELY on the user's local machine.
10
+ *
11
+ * Dispatch (registered from `index.ts`):
12
+ * openclaw totalreclaw onboard — interactive generate / import / skip
13
+ * openclaw totalreclaw status — show current onboarding state
14
+ *
15
+ * Scope (per user ratification 2026-04-19):
16
+ * - LOCAL USERS ONLY in 3.2.0. Import on a remote OpenClaw gateway is
17
+ * not supported; QR-pairing is deferred to 3.3.0.
18
+ * - Two paths: generate a new 12-word BIP-39 mnemonic, or import an
19
+ * existing one. Skip exits without side-effects.
20
+ *
21
+ * Non-goals:
22
+ * - This file does NOT touch the gateway's `register()` flow. It neither
23
+ * calls `initialize()` nor drives key derivation. Users re-enter their
24
+ * chat session after running the wizard; `initialize()` reads the new
25
+ * credentials.json on the next `ensureInitialized()` call. Forcing a
26
+ * re-init here would require importing the full plugin key-derivation
27
+ * stack, which this module intentionally avoids.
28
+ *
29
+ * Architectural constraints:
30
+ * - No network. No `process.env` reads. No outbound markers in comments
31
+ * — see skill/scripts/check-scanner.mjs. The module imports nothing
32
+ * that contains a network surface.
33
+ * - Minimal deps. Uses `@scure/bip39` (already a transitive dep via
34
+ * `@totalreclaw/core`) for mnemonic generation + validation. Uses
35
+ * `node:readline/promises` for interactive prompts. No `inquirer`,
36
+ * no `readline-sync`.
37
+ *
38
+ * See docs/plans/2026-04-20-plugin-320-secure-onboarding.md (internal repo,
39
+ * commit dc6bddd) for the full design rationale.
40
+ */
41
+
42
+ import fs from 'node:fs';
43
+ import path from 'node:path';
44
+ import readline from 'node:readline/promises';
45
+ import { generateMnemonic, validateMnemonic } from '@scure/bip39';
46
+ import { wordlist } from '@scure/bip39/wordlists/english.js';
47
+
48
+ import {
49
+ writeCredentialsJson,
50
+ loadCredentialsJson,
51
+ type CredentialsFile,
52
+ writeOnboardingState,
53
+ loadOnboardingState,
54
+ type OnboardingState,
55
+ } from './fs-helpers.js';
56
+
57
+ // ---------------------------------------------------------------------------
58
+ // User-facing strings (centralised so tests can assert on them + localisation
59
+ // has one place to grow into later).
60
+ // ---------------------------------------------------------------------------
61
+
62
+ export const COPY = {
63
+ welcome:
64
+ '\nTotalReclaw — Secure onboarding\n\n' +
65
+ 'TotalReclaw is an end-to-end encrypted memory vault for AI agents.\n' +
66
+ 'Your memories are encrypted with a key only you control, stored on-chain\n' +
67
+ 'so they persist across sessions and clients.\n',
68
+ menu:
69
+ 'How would you like to set up TotalReclaw?\n\n' +
70
+ ' [1] Generate a new recovery phrase (first-time users)\n' +
71
+ ' [2] Import an existing TotalReclaw recovery phrase (returning users)\n' +
72
+ ' [3] Skip for now — memory features stay disabled\n',
73
+ menuPrompt: 'Enter 1, 2, or 3: ',
74
+ alreadyActive:
75
+ '\nTotalReclaw is already set up and active on this machine.\n' +
76
+ 'Run `openclaw totalreclaw status` for details, or delete\n' +
77
+ '~/.totalreclaw/credentials.json to start over.\n',
78
+ generateWarning:
79
+ '\nABOUT YOUR RECOVERY PHRASE\n\n' +
80
+ ' - It is the ONLY key to your encrypted memories. TotalReclaw servers\n' +
81
+ ' cannot recover it. If you lose it, your memories are gone forever.\n' +
82
+ ' - You can import it into other TotalReclaw clients (Hermes, MCP) to\n' +
83
+ ' recall the same memories everywhere.\n' +
84
+ ' - You can restore your account on a new machine at any time using\n' +
85
+ ' this phrase.\n' +
86
+ ' - It is NOT a blockchain wallet. Do NOT fund it with crypto, and do\n' +
87
+ ' NOT reuse an existing wallet\'s phrase here.\n',
88
+ importWarning:
89
+ '\nBEFORE YOU PASTE AN EXISTING PHRASE\n\n' +
90
+ ' Your TotalReclaw recovery phrase must be DEDICATED to TotalReclaw.\n\n' +
91
+ ' - NEVER import a phrase that controls a crypto wallet with funds.\n' +
92
+ ' - NEVER import a phrase used with another service (Metamask, Ledger,\n' +
93
+ ' Trust, seed backups, etc.).\n' +
94
+ ' - If your only copy of a TotalReclaw phrase is on a shared wallet,\n' +
95
+ ' STOP NOW, move your funds off the wallet, and pick a new dedicated\n' +
96
+ ' phrase.\n',
97
+ importRemoteLimitation:
98
+ '\nNote: in 3.2.0, import only works when you run OpenClaw locally on the\n' +
99
+ 'machine that will hold the credentials. Importing an existing vault on a\n' +
100
+ 'remote OpenClaw gateway is not yet supported — that arrives in 3.3.0 via\n' +
101
+ 'QR-pairing.\n',
102
+ importPrompt: 'Paste your 12-word recovery phrase (input hidden): ',
103
+ clipboardHint:
104
+ '\nTip: prefer typing the phrase into a password manager over copy-paste.\n' +
105
+ 'OS clipboards can be captured by other apps.\n',
106
+ ackPromptTemplate: 'Type word #%N% from your phrase: ',
107
+ postSuccessGenerate:
108
+ '\nDone. Your recovery phrase is saved at ~/.totalreclaw/credentials.json\n' +
109
+ '(mode 0600). Memory tools are now active.\n\n' +
110
+ ' Next: run `openclaw chat` to start. I will automatically remember\n' +
111
+ ' important things and recall relevant context across sessions.\n\n' +
112
+ ' To view your phrase again later on this machine, open credentials.json\n' +
113
+ ' directly. Keep it safe — on a new machine, run this wizard again and\n' +
114
+ ' choose "import" with this phrase.\n',
115
+ postSuccessImport:
116
+ '\nDone. Your phrase is saved at ~/.totalreclaw/credentials.json (mode 0600).\n' +
117
+ 'Memory tools are now active.\n\n' +
118
+ ' Next: run `openclaw chat` to start. Existing memories tied to this\n' +
119
+ ' phrase will be available via totalreclaw_recall.\n',
120
+ skipped:
121
+ '\nSkipped. Run `openclaw totalreclaw onboard` anytime to resume.\n' +
122
+ 'Memory tools remain disabled until you do.\n',
123
+ ackFailed:
124
+ '\nWord mismatch. Please write the phrase down carefully and run this\n' +
125
+ 'wizard again. No credentials have been written.\n',
126
+ importInvalid:
127
+ '\nInvalid BIP-39 phrase (12 words required, checksum must match).\n' +
128
+ 'No credentials have been written. Run the wizard again with the\n' +
129
+ 'correct phrase.\n',
130
+ existingPhraseHint:
131
+ '\nA recovery phrase already exists at ~/.totalreclaw/credentials.json.\n' +
132
+ 'Delete that file first to replace it, or re-import with the same phrase.\n',
133
+ statusHeader: 'TotalReclaw status\n',
134
+ statusFresh:
135
+ ' onboarding: not complete\n' +
136
+ ' next step: run `openclaw totalreclaw onboard` on this machine\n' +
137
+ ' note: your recovery phrase will be shown on the terminal, never in chat.\n',
138
+ statusActive:
139
+ ' onboarding: complete\n' +
140
+ ' credentials: ~/.totalreclaw/credentials.json (mode 0600)\n' +
141
+ ' state: ~/.totalreclaw/state.json\n',
142
+ };
143
+
144
+ // ---------------------------------------------------------------------------
145
+ // Types
146
+ // ---------------------------------------------------------------------------
147
+
148
+ export type WizardChoice = 'generate' | 'import' | 'skip';
149
+
150
+ export interface WizardIo {
151
+ stdout: NodeJS.WritableStream;
152
+ stderr: NodeJS.WritableStream;
153
+ /** Prompt and return the user's input. Single-line. */
154
+ ask(question: string): Promise<string>;
155
+ /** Prompt and return the user's input with no echo to stdout. */
156
+ askHidden(question: string): Promise<string>;
157
+ close(): void;
158
+ }
159
+
160
+ export interface WizardResult {
161
+ choice: WizardChoice;
162
+ /**
163
+ * On 'skip' or failure, undefined. On successful 'generate' or 'import',
164
+ * the final state persisted to disk (useful for tests).
165
+ */
166
+ state?: OnboardingState;
167
+ /** Non-null only on error outcomes; stdout has the human copy. */
168
+ error?: string;
169
+ }
170
+
171
+ export interface WizardDeps {
172
+ credentialsPath: string;
173
+ statePath: string;
174
+ io: WizardIo;
175
+ /** Override for tests — defaults to @scure/bip39 generateMnemonic(128). */
176
+ generateMnemonic?: () => string;
177
+ /** Override for tests — defaults to @scure/bip39 validateMnemonic. */
178
+ validateMnemonic?: (phrase: string) => boolean;
179
+ /** Override for tests to force deterministic probe indices. */
180
+ randomProbeIndices?: () => number[];
181
+ }
182
+
183
+ // ---------------------------------------------------------------------------
184
+ // IO factory — default builds a readline-backed prompter over process.stdin/out.
185
+ // ---------------------------------------------------------------------------
186
+
187
+ /**
188
+ * Build a WizardIo backed by `process.stdin` / `process.stdout`. Hidden
189
+ * input (for the import path) is implemented via direct raw-mode manipulation
190
+ * of the TTY, which is the standard node technique when no prompter library
191
+ * is available. Falls back to non-hidden input if the process is not
192
+ * attached to a TTY (e.g. CI / piped stdin) — the caller's responsibility
193
+ * to decide whether that is OK.
194
+ */
195
+ export function buildDefaultIo(): WizardIo {
196
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
197
+ const stdout = process.stdout;
198
+ const stderr = process.stderr;
199
+
200
+ async function ask(question: string): Promise<string> {
201
+ return (await rl.question(question)).trim();
202
+ }
203
+
204
+ async function askHidden(question: string): Promise<string> {
205
+ const stdin = process.stdin;
206
+ const isTTY = (stdin as NodeJS.ReadStream & { isTTY?: boolean }).isTTY === true;
207
+ if (!isTTY || typeof stdin.setRawMode !== 'function') {
208
+ // No TTY — fall back to visible input with a note. Safer than refusing.
209
+ stdout.write('(input will be visible because stdin is not a TTY)\n');
210
+ return ask(question);
211
+ }
212
+
213
+ stdout.write(question);
214
+
215
+ return new Promise<string>((resolve) => {
216
+ const chars: string[] = [];
217
+ const onData = (chunk: Buffer) => {
218
+ const str = chunk.toString('utf-8');
219
+ for (const ch of str) {
220
+ const code = ch.charCodeAt(0);
221
+ if (code === 13 || code === 10) {
222
+ // Enter / newline → finalise
223
+ stdout.write('\n');
224
+ stdin.setRawMode(false);
225
+ stdin.pause();
226
+ stdin.off('data', onData);
227
+ resolve(chars.join('').trim());
228
+ return;
229
+ }
230
+ if (code === 3) {
231
+ // Ctrl-C → propagate SIGINT so the CLI exits cleanly
232
+ stdin.setRawMode(false);
233
+ stdin.pause();
234
+ stdin.off('data', onData);
235
+ process.kill(process.pid, 'SIGINT');
236
+ return;
237
+ }
238
+ if (code === 127 || code === 8) {
239
+ // Backspace / delete
240
+ if (chars.length > 0) chars.pop();
241
+ continue;
242
+ }
243
+ chars.push(ch);
244
+ }
245
+ };
246
+ stdin.setRawMode(true);
247
+ stdin.resume();
248
+ stdin.on('data', onData);
249
+ });
250
+ }
251
+
252
+ return {
253
+ stdout,
254
+ stderr,
255
+ ask,
256
+ askHidden,
257
+ close: () => rl.close(),
258
+ };
259
+ }
260
+
261
+ // ---------------------------------------------------------------------------
262
+ // Internals
263
+ // ---------------------------------------------------------------------------
264
+
265
+ function printMnemonicGrid(mnemonic: string, out: NodeJS.WritableStream): void {
266
+ const words = mnemonic.trim().split(/\s+/);
267
+ const cols = 4;
268
+ const pad = 14;
269
+ const lines: string[] = [];
270
+ for (let row = 0; row * cols < words.length; row++) {
271
+ const parts: string[] = [];
272
+ for (let col = 0; col < cols; col++) {
273
+ const idx = row * cols + col;
274
+ if (idx >= words.length) break;
275
+ const label = `${String(idx + 1).padStart(2, ' ')}. ${words[idx]}`;
276
+ parts.push(label.padEnd(pad, ' '));
277
+ }
278
+ lines.push(' ' + parts.join(''));
279
+ }
280
+ out.write(lines.join('\n') + '\n');
281
+ }
282
+
283
+ /**
284
+ * Pick three distinct random word indices (0..11) for the retype-ack challenge.
285
+ * Overridable for tests.
286
+ */
287
+ function defaultRandomProbeIndices(): number[] {
288
+ const pool = [...Array(12).keys()];
289
+ const out: number[] = [];
290
+ for (let i = 0; i < 3; i++) {
291
+ const pick = Math.floor(Math.random() * pool.length);
292
+ out.push(pool[pick]);
293
+ pool.splice(pick, 1);
294
+ }
295
+ return out.sort((a, b) => a - b);
296
+ }
297
+
298
+ async function runAckChallenge(
299
+ mnemonic: string,
300
+ indices: number[],
301
+ io: WizardIo,
302
+ ): Promise<boolean> {
303
+ const words = mnemonic.trim().split(/\s+/);
304
+ for (const idx of indices) {
305
+ const ans = await io.ask(COPY.ackPromptTemplate.replace('%N%', String(idx + 1)));
306
+ if (ans.trim().toLowerCase() !== words[idx]) {
307
+ return false;
308
+ }
309
+ }
310
+ return true;
311
+ }
312
+
313
+ function normaliseMnemonic(input: string): string {
314
+ // Collapse whitespace, strip zero-width, lowercase. BIP-39 wordlist is
315
+ // entirely lowercase ASCII; any uppercase the user paste-in gets normalised.
316
+ return input
317
+ .normalize('NFKC')
318
+ .replace(/[\u200B-\u200F\uFEFF]/g, '')
319
+ .toLowerCase()
320
+ .trim()
321
+ .split(/\s+/)
322
+ .join(' ');
323
+ }
324
+
325
+ function writeCredsAndState(
326
+ credentialsPath: string,
327
+ statePath: string,
328
+ mnemonic: string,
329
+ createdBy: 'generate' | 'import',
330
+ ): OnboardingState {
331
+ const creds: CredentialsFile = { mnemonic };
332
+ if (!writeCredentialsJson(credentialsPath, creds)) {
333
+ throw new Error(
334
+ `Could not write credentials.json at ${credentialsPath}. Check that the parent directory is writable.`,
335
+ );
336
+ }
337
+ const state: OnboardingState = {
338
+ onboardingState: 'active',
339
+ createdBy,
340
+ credentialsCreatedAt: new Date().toISOString(),
341
+ version: '3.2.0',
342
+ };
343
+ if (!writeOnboardingState(statePath, state)) {
344
+ throw new Error(
345
+ `Could not write state.json at ${statePath}. Check that the parent directory is writable.`,
346
+ );
347
+ }
348
+ return state;
349
+ }
350
+
351
+ // ---------------------------------------------------------------------------
352
+ // Public entry points
353
+ // ---------------------------------------------------------------------------
354
+
355
+ /**
356
+ * Interactive onboarding wizard. Single shot — caller builds a WizardDeps and
357
+ * awaits. All user-visible output goes to `io.stdout` / `io.stderr`; all
358
+ * input comes from `io.ask` / `io.askHidden`. No console.log calls.
359
+ *
360
+ * Returns a `WizardResult`. Tests can assert on both the result and the
361
+ * stdout buffer.
362
+ */
363
+ export async function runOnboardingWizard(deps: WizardDeps): Promise<WizardResult> {
364
+ const { credentialsPath, statePath, io } = deps;
365
+ const genMnemonic = deps.generateMnemonic ?? (() => generateMnemonic(wordlist, 128));
366
+ const validate = deps.validateMnemonic ?? ((p: string) => validateMnemonic(p, wordlist));
367
+ const probe = deps.randomProbeIndices ?? defaultRandomProbeIndices;
368
+
369
+ // If onboarding is already complete on disk, short-circuit instead of
370
+ // letting the user overwrite an existing phrase by accident.
371
+ const existingCreds = loadCredentialsJson(credentialsPath);
372
+ if (existingCreds?.mnemonic && typeof existingCreds.mnemonic === 'string' && existingCreds.mnemonic.trim()) {
373
+ io.stdout.write(COPY.alreadyActive);
374
+ const persisted = loadOnboardingState(statePath);
375
+ return {
376
+ choice: 'skip',
377
+ state: persisted ?? {
378
+ onboardingState: 'active',
379
+ version: '3.2.0',
380
+ },
381
+ };
382
+ }
383
+
384
+ io.stdout.write(COPY.welcome);
385
+ io.stdout.write(COPY.menu);
386
+
387
+ const choiceRaw = await io.ask(COPY.menuPrompt);
388
+ let choice: WizardChoice;
389
+ if (choiceRaw === '1' || choiceRaw.toLowerCase() === 'generate') {
390
+ choice = 'generate';
391
+ } else if (choiceRaw === '2' || choiceRaw.toLowerCase() === 'import') {
392
+ choice = 'import';
393
+ } else if (choiceRaw === '3' || choiceRaw.toLowerCase() === 'skip') {
394
+ choice = 'skip';
395
+ } else {
396
+ io.stderr.write(`\nUnrecognised choice "${choiceRaw}". Aborting.\n`);
397
+ return { choice: 'skip', error: `invalid-choice:${choiceRaw}` };
398
+ }
399
+
400
+ if (choice === 'skip') {
401
+ io.stdout.write(COPY.skipped);
402
+ return { choice: 'skip' };
403
+ }
404
+
405
+ if (choice === 'generate') {
406
+ io.stdout.write(COPY.generateWarning);
407
+ io.stdout.write(COPY.importRemoteLimitation);
408
+ const mnemonic = genMnemonic();
409
+ if (typeof mnemonic !== 'string' || mnemonic.trim().split(/\s+/).length !== 12) {
410
+ io.stderr.write('\nInternal error: mnemonic generator returned an invalid phrase.\n');
411
+ return { choice, error: 'generator-invalid' };
412
+ }
413
+
414
+ io.stdout.write('\nYour recovery phrase (WRITE THIS DOWN):\n\n');
415
+ printMnemonicGrid(mnemonic, io.stdout);
416
+ io.stdout.write(COPY.clipboardHint);
417
+ io.stdout.write('\n');
418
+
419
+ const indices = probe();
420
+ const ok = await runAckChallenge(mnemonic, indices, io);
421
+ if (!ok) {
422
+ io.stderr.write(COPY.ackFailed);
423
+ return { choice, error: 'ack-failed' };
424
+ }
425
+
426
+ try {
427
+ const state = writeCredsAndState(credentialsPath, statePath, mnemonic, 'generate');
428
+ io.stdout.write(COPY.postSuccessGenerate);
429
+ return { choice, state };
430
+ } catch (err) {
431
+ const msg = err instanceof Error ? err.message : String(err);
432
+ io.stderr.write(`\n${msg}\n`);
433
+ return { choice, error: `write-failed:${msg}` };
434
+ }
435
+ }
436
+
437
+ // choice === 'import'
438
+ io.stdout.write(COPY.importWarning);
439
+ io.stdout.write(COPY.importRemoteLimitation);
440
+ const raw = await io.askHidden(COPY.importPrompt);
441
+ const normalised = normaliseMnemonic(raw);
442
+ const words = normalised.split(/\s+/).filter(Boolean);
443
+ if (words.length !== 12 || !validate(normalised)) {
444
+ io.stderr.write(COPY.importInvalid);
445
+ return { choice, error: 'invalid-phrase' };
446
+ }
447
+
448
+ try {
449
+ const state = writeCredsAndState(credentialsPath, statePath, normalised, 'import');
450
+ io.stdout.write(COPY.postSuccessImport);
451
+ return { choice, state };
452
+ } catch (err) {
453
+ const msg = err instanceof Error ? err.message : String(err);
454
+ io.stderr.write(`\n${msg}\n`);
455
+ return { choice, error: `write-failed:${msg}` };
456
+ }
457
+ }
458
+
459
+ /**
460
+ * Print the current onboarding status to the given stdout. Safe — never
461
+ * displays the mnemonic; only the state + file paths. Called by both the
462
+ * `openclaw totalreclaw status` CLI subcommand and (if desired) the
463
+ * `totalreclaw_status` tool.
464
+ */
465
+ export function printStatus(
466
+ credentialsPath: string,
467
+ statePath: string,
468
+ out: NodeJS.WritableStream,
469
+ ): void {
470
+ out.write(COPY.statusHeader);
471
+ const credExists = fs.existsSync(credentialsPath);
472
+ const stateFileExists = fs.existsSync(statePath);
473
+ if (credExists) {
474
+ const creds = loadCredentialsJson(credentialsPath);
475
+ const hasMnemonic = typeof creds?.mnemonic === 'string' && creds.mnemonic.trim().length > 0;
476
+ if (hasMnemonic) {
477
+ out.write(COPY.statusActive);
478
+ if (stateFileExists) {
479
+ const st = loadOnboardingState(statePath);
480
+ if (st?.credentialsCreatedAt) out.write(` created: ${st.credentialsCreatedAt}\n`);
481
+ if (st?.createdBy) out.write(` method: ${st.createdBy}\n`);
482
+ }
483
+ return;
484
+ }
485
+ }
486
+ out.write(COPY.statusFresh);
487
+ }
488
+
489
+ /**
490
+ * Helper the `index.ts` registerCli hook calls to wire both subcommands
491
+ * onto the OpenClaw program. Kept here so the wiring + the wizard live in
492
+ * the same module — index.ts just forwards.
493
+ *
494
+ * `program` is the OpenClaw-provided commander Command. We attach a
495
+ * top-level `totalreclaw` command with `onboard` + `status` subcommands.
496
+ */
497
+ export function registerOnboardingCli(
498
+ program: import('commander').Command,
499
+ opts: { credentialsPath: string; statePath: string; logger: { info(msg: string): void; warn(msg: string): void; error(msg: string): void } },
500
+ ): void {
501
+ const tr = program
502
+ .command('totalreclaw')
503
+ .description('TotalReclaw encrypted memory — secure onboarding + status');
504
+
505
+ tr.command('onboard')
506
+ .description('Interactive onboarding: generate or import a recovery phrase (runs locally, no LLM)')
507
+ .action(async () => {
508
+ const io = buildDefaultIo();
509
+ try {
510
+ const result = await runOnboardingWizard({
511
+ credentialsPath: opts.credentialsPath,
512
+ statePath: opts.statePath,
513
+ io,
514
+ });
515
+ if (result.error) {
516
+ opts.logger.warn(`onboarding wizard exited with error: ${result.error}`);
517
+ io.close();
518
+ process.exit(1);
519
+ }
520
+ if (result.choice === 'generate' || result.choice === 'import') {
521
+ opts.logger.info(`onboarding: state=active createdBy=${result.state?.createdBy}`);
522
+ }
523
+ } catch (err) {
524
+ const msg = err instanceof Error ? err.message : String(err);
525
+ opts.logger.error(`onboarding wizard crashed: ${msg}`);
526
+ io.close();
527
+ process.exit(2);
528
+ }
529
+ io.close();
530
+ });
531
+
532
+ tr.command('status')
533
+ .description('Show TotalReclaw onboarding state — never displays the recovery phrase')
534
+ .action(() => {
535
+ printStatus(opts.credentialsPath, opts.statePath, process.stdout);
536
+ });
537
+ }
538
+
539
+ // Ensure this module is reachable for import resolution in ESM tests.
540
+ export const __modulePath = (() => {
541
+ try {
542
+ return path.resolve(path.dirname(new URL(import.meta.url).pathname));
543
+ } catch {
544
+ return __dirname;
545
+ }
546
+ })();
package/package.json CHANGED
@@ -1,19 +1,26 @@
1
1
  {
2
2
  "name": "@totalreclaw/totalreclaw",
3
- "version": "3.1.0",
4
- "description": "End-to-end encrypted memory for AI agents portable, yours forever. Automatic extraction, semantic search, and on-chain storage",
3
+ "version": "3.2.0",
4
+ "description": "End-to-end encrypted, agent-portable memory for OpenClaw and any LLM-agent runtime. XChaCha20-Poly1305 with protobuf v4 + on-chain Memory Taxonomy v1 (claim / preference / directive / commitment / episode / summary).",
5
5
  "type": "module",
6
6
  "keywords": [
7
7
  "totalreclaw",
8
8
  "openclaw",
9
9
  "ai-memory",
10
10
  "ai-agent",
11
+ "agent-memory",
12
+ "portable-memory",
11
13
  "e2e-encryption",
12
14
  "encryption",
13
15
  "e2ee",
16
+ "xchacha20-poly1305",
17
+ "bip39",
18
+ "erc-4337",
14
19
  "lsh",
15
20
  "semantic-search",
16
- "memory"
21
+ "memory",
22
+ "memory-taxonomy",
23
+ "v1"
17
24
  ],
18
25
  "homepage": "https://totalreclaw.xyz",
19
26
  "repository": {
package/tool-gating.ts ADDED
@@ -0,0 +1,69 @@
1
+ /**
2
+ * Tool gating predicate for 3.2.0 — the `before_tool_call` hook in index.ts
3
+ * delegates to this module so the logic is testable without standing up a
4
+ * full OpenClaw plugin host.
5
+ *
6
+ * Scope: the 3.2.0 state machine has two states (`fresh`, `active`). Memory
7
+ * tools are blocked when state is anything other than `active`. Billing +
8
+ * setup-adjacent tools remain usable — users need to be able to upgrade,
9
+ * migrate, and start onboarding before their vault is active.
10
+ *
11
+ * This module imports ONLY types + the state resolver. No I/O beyond what
12
+ * `resolveOnboardingState` already does; no network; no env reads.
13
+ */
14
+
15
+ import type { OnboardingState } from './fs-helpers.js';
16
+
17
+ /**
18
+ * Tool names gated on `state=active`. Keep in sync with the actual
19
+ * `registerTool` calls in `index.ts`. Anything NOT in this set is always
20
+ * callable (e.g. totalreclaw_upgrade, totalreclaw_migrate,
21
+ * totalreclaw_onboarding_start, totalreclaw_setup).
22
+ */
23
+ export const GATED_TOOL_NAMES: readonly string[] = Object.freeze([
24
+ 'totalreclaw_remember',
25
+ 'totalreclaw_recall',
26
+ 'totalreclaw_forget',
27
+ 'totalreclaw_export',
28
+ 'totalreclaw_status',
29
+ 'totalreclaw_consolidate',
30
+ 'totalreclaw_pin',
31
+ 'totalreclaw_unpin',
32
+ 'totalreclaw_import_from',
33
+ 'totalreclaw_import_batch',
34
+ ]);
35
+
36
+ export interface GateDecision {
37
+ /** True when the tool call must be blocked. */
38
+ block: boolean;
39
+ /** Non-secret message surfaced to the LLM when `block === true`. */
40
+ blockReason?: string;
41
+ }
42
+
43
+ /**
44
+ * Decide whether a specific tool call should be blocked given the current
45
+ * onboarding state. Does not read any files — caller resolves state first
46
+ * (that lets tests stub state without touching disk).
47
+ */
48
+ export function decideToolGate(
49
+ toolName: string | undefined,
50
+ state: OnboardingState | null | undefined,
51
+ ): GateDecision {
52
+ if (!toolName) return { block: false };
53
+ if (!GATED_TOOL_NAMES.includes(toolName)) return { block: false };
54
+ if (state?.onboardingState === 'active') return { block: false };
55
+ return {
56
+ block: true,
57
+ blockReason:
58
+ 'TotalReclaw onboarding required. Run `openclaw totalreclaw onboard` ' +
59
+ 'in a terminal (or call the `totalreclaw_onboarding_start` tool for ' +
60
+ 'details). Memory tools are gated until the user completes setup.',
61
+ };
62
+ }
63
+
64
+ /**
65
+ * Convenience predicate — useful for tests + documentation.
66
+ */
67
+ export function isGatedToolName(toolName: string): boolean {
68
+ return GATED_TOOL_NAMES.includes(toolName);
69
+ }