@totalreclaw/totalreclaw 3.3.1-rc.9 → 3.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (81) hide show
  1. package/CHANGELOG.md +249 -1
  2. package/SKILL.md +29 -23
  3. package/api-client.ts +18 -11
  4. package/claims-helper.ts +47 -1
  5. package/config.ts +108 -4
  6. package/confirm-indexed.ts +191 -0
  7. package/crypto.ts +10 -2
  8. package/dist/api-client.js +226 -0
  9. package/dist/billing-cache.js +100 -0
  10. package/dist/claims-helper.js +624 -0
  11. package/dist/config.js +297 -0
  12. package/dist/confirm-indexed.js +127 -0
  13. package/dist/consolidation.js +258 -0
  14. package/dist/contradiction-sync.js +1034 -0
  15. package/dist/crypto.js +138 -0
  16. package/dist/digest-sync.js +361 -0
  17. package/dist/download-ux.js +63 -0
  18. package/dist/embedder-cache.js +185 -0
  19. package/dist/embedder-loader.js +121 -0
  20. package/dist/embedder-network.js +301 -0
  21. package/dist/embedding.js +141 -0
  22. package/dist/extractor.js +1225 -0
  23. package/dist/first-run.js +103 -0
  24. package/dist/fs-helpers.js +725 -0
  25. package/dist/gateway-url.js +197 -0
  26. package/dist/generate-mnemonic.js +13 -0
  27. package/dist/hot-cache-wrapper.js +101 -0
  28. package/dist/import-adapters/base-adapter.js +64 -0
  29. package/dist/import-adapters/chatgpt-adapter.js +238 -0
  30. package/dist/import-adapters/claude-adapter.js +114 -0
  31. package/dist/import-adapters/gemini-adapter.js +201 -0
  32. package/dist/import-adapters/index.js +26 -0
  33. package/dist/import-adapters/mcp-memory-adapter.js +219 -0
  34. package/dist/import-adapters/mem0-adapter.js +158 -0
  35. package/dist/import-adapters/types.js +1 -0
  36. package/dist/index.js +5388 -0
  37. package/dist/llm-client.js +687 -0
  38. package/dist/llm-profile-reader.js +346 -0
  39. package/dist/lsh.js +62 -0
  40. package/dist/onboarding-cli.js +750 -0
  41. package/dist/pair-cli.js +344 -0
  42. package/dist/pair-crypto.js +359 -0
  43. package/dist/pair-http.js +404 -0
  44. package/dist/pair-page.js +826 -0
  45. package/dist/pair-qr.js +107 -0
  46. package/dist/pair-remote-client.js +410 -0
  47. package/dist/pair-session-store.js +566 -0
  48. package/dist/pin.js +556 -0
  49. package/dist/qa-bug-report.js +301 -0
  50. package/dist/relay-headers.js +44 -0
  51. package/dist/reranker.js +409 -0
  52. package/dist/retype-setscope.js +368 -0
  53. package/dist/semantic-dedup.js +75 -0
  54. package/dist/subgraph-search.js +289 -0
  55. package/dist/subgraph-store.js +694 -0
  56. package/dist/tool-gating.js +58 -0
  57. package/download-ux.ts +91 -0
  58. package/embedder-cache.ts +230 -0
  59. package/embedder-loader.ts +189 -0
  60. package/embedder-network.ts +350 -0
  61. package/embedding.ts +118 -27
  62. package/fs-helpers.ts +277 -0
  63. package/gateway-url.ts +57 -9
  64. package/index.ts +469 -250
  65. package/llm-client.ts +4 -3
  66. package/lsh.ts +7 -2
  67. package/onboarding-cli.ts +114 -1
  68. package/package.json +24 -5
  69. package/pair-cli.ts +76 -8
  70. package/pair-crypto.ts +34 -24
  71. package/pair-page.ts +28 -17
  72. package/pair-qr.ts +152 -0
  73. package/pair-remote-client.ts +540 -0
  74. package/pin.ts +31 -0
  75. package/qa-bug-report.ts +84 -2
  76. package/relay-headers.ts +50 -0
  77. package/reranker.ts +40 -0
  78. package/retype-setscope.ts +69 -8
  79. package/skill.json +1 -1
  80. package/subgraph-search.ts +4 -3
  81. package/subgraph-store.ts +15 -10
@@ -0,0 +1,750 @@
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
+ import fs from 'node:fs';
42
+ import path from 'node:path';
43
+ import readline from 'node:readline/promises';
44
+ import { generateMnemonic, validateMnemonic } from '@scure/bip39';
45
+ import { wordlist } from '@scure/bip39/wordlists/english.js';
46
+ import { writeCredentialsJson, loadCredentialsJson, writeOnboardingState, loadOnboardingState, } from './fs-helpers.js';
47
+ // ---------------------------------------------------------------------------
48
+ // User-facing strings (centralised so tests can assert on them + localisation
49
+ // has one place to grow into later).
50
+ // ---------------------------------------------------------------------------
51
+ /**
52
+ * 3.3.1-rc.18 (issue #95) — deprecation warning for the interactive
53
+ * phrase-print branch. Emitted to STDERR (never stdout) so it is visible
54
+ * to humans but does not pollute any pipe consuming the wizard's output.
55
+ *
56
+ * The phrase-print branch will be REMOVED in the next RC after rc.18.
57
+ * Users running on a TTY can still complete the flow in rc.18; agents
58
+ * MUST use `--pair-only` or the `totalreclaw_pair` tool today.
59
+ */
60
+ export const PHRASE_PRINT_DEPRECATION_WARNING = '\nDEPRECATION (issue #95): the interactive `openclaw totalreclaw onboard` flow\n' +
61
+ ' prints your recovery phrase to this terminal. This is being removed in the\n' +
62
+ ' next release candidate. For agent / scripted invocation, use:\n' +
63
+ ' openclaw totalreclaw onboard --pair-only\n' +
64
+ ' which emits ONLY {pair_url, pin} JSON and routes the phrase through the\n' +
65
+ ' browser flow (never on stdout).\n\n';
66
+ export const COPY = {
67
+ welcome: '\nTotalReclaw — Secure onboarding\n\n' +
68
+ 'TotalReclaw is an end-to-end encrypted memory vault for AI agents.\n' +
69
+ 'Your memories are encrypted with a key only you control, stored on-chain\n' +
70
+ 'so they persist across sessions and clients.\n',
71
+ menu: 'How would you like to set up TotalReclaw?\n\n' +
72
+ ' [1] Generate a new recovery phrase (first-time users)\n' +
73
+ ' [2] Import an existing TotalReclaw recovery phrase (returning users)\n' +
74
+ ' [3] Skip for now — memory features stay disabled\n',
75
+ menuPrompt: 'Enter 1, 2, or 3: ',
76
+ alreadyActive: '\nTotalReclaw is already set up and active on this machine.\n' +
77
+ 'Run `openclaw totalreclaw status` for details, or delete\n' +
78
+ '~/.totalreclaw/credentials.json to start over.\n',
79
+ generateWarning: '\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: '\nBEFORE YOU PASTE AN EXISTING PHRASE\n\n' +
89
+ ' Your TotalReclaw recovery phrase must be DEDICATED to TotalReclaw.\n\n' +
90
+ ' - NEVER import a phrase that controls a crypto wallet with funds.\n' +
91
+ ' - NEVER import a phrase used with another service (Metamask, Ledger,\n' +
92
+ ' Trust, seed backups, etc.).\n' +
93
+ ' - If your only copy of a TotalReclaw phrase is on a shared wallet,\n' +
94
+ ' STOP NOW, move your funds off the wallet, and pick a new dedicated\n' +
95
+ ' phrase.\n',
96
+ importRemoteLimitation: '\nNote: in 3.2.0, import only works when you run OpenClaw locally on the\n' +
97
+ 'machine that will hold the credentials. Importing an existing vault on a\n' +
98
+ 'remote OpenClaw gateway is not yet supported — that arrives in 3.3.0 via\n' +
99
+ 'QR-pairing.\n',
100
+ importPrompt: 'Paste your 12-word recovery phrase (input hidden): ',
101
+ clipboardHint: '\nTip: prefer typing the phrase into a password manager over copy-paste.\n' +
102
+ 'OS clipboards can be captured by other apps.\n',
103
+ ackPromptTemplate: 'Type word #%N% from your phrase: ',
104
+ postSuccessGenerate: '\nDone. Your recovery phrase is saved at ~/.totalreclaw/credentials.json\n' +
105
+ '(mode 0600). Memory tools are now active.\n\n' +
106
+ ' Next: run `openclaw chat` to start. I will automatically remember\n' +
107
+ ' important things and recall relevant context across sessions.\n\n' +
108
+ ' To view your phrase again later on this machine, open credentials.json\n' +
109
+ ' directly. Keep it safe — on a new machine, run this wizard again and\n' +
110
+ ' choose "import" with this phrase.\n',
111
+ postSuccessImport: '\nDone. Your phrase is saved at ~/.totalreclaw/credentials.json (mode 0600).\n' +
112
+ 'Memory tools are now active.\n\n' +
113
+ ' Next: run `openclaw chat` to start. Existing memories tied to this\n' +
114
+ ' phrase will be available via totalreclaw_recall.\n',
115
+ skipped: '\nSkipped. Run `openclaw totalreclaw onboard` anytime to resume.\n' +
116
+ 'Memory tools remain disabled until you do.\n',
117
+ ackFailed: '\nWord mismatch. Please write the phrase down carefully and run this\n' +
118
+ 'wizard again. No credentials have been written.\n',
119
+ importInvalid: '\nInvalid recovery phrase (12 words required, checksum must match).\n' +
120
+ 'No credentials have been written. Run the wizard again with the\n' +
121
+ 'correct phrase.\n',
122
+ existingPhraseHint: '\nA recovery phrase already exists at ~/.totalreclaw/credentials.json.\n' +
123
+ 'Delete that file first to replace it, or re-import with the same phrase.\n',
124
+ statusHeader: 'TotalReclaw status\n',
125
+ statusFresh: ' onboarding: not complete\n' +
126
+ ' next step: run `openclaw totalreclaw onboard` on this machine\n' +
127
+ ' note: your recovery phrase will be shown on the terminal, never in chat.\n',
128
+ statusActive: ' onboarding: complete\n' +
129
+ ' credentials: ~/.totalreclaw/credentials.json (mode 0600)\n' +
130
+ ' state: ~/.totalreclaw/state.json\n',
131
+ };
132
+ // ---------------------------------------------------------------------------
133
+ // IO factory — default builds a readline-backed prompter over process.stdin/out.
134
+ // ---------------------------------------------------------------------------
135
+ /**
136
+ * Build a WizardIo backed by `process.stdin` / `process.stdout`. Hidden
137
+ * input (for the import path) is implemented via direct raw-mode manipulation
138
+ * of the TTY, which is the standard node technique when no prompter library
139
+ * is available. Falls back to non-hidden input if the process is not
140
+ * attached to a TTY (e.g. CI / piped stdin) — the caller's responsibility
141
+ * to decide whether that is OK.
142
+ */
143
+ export function buildDefaultIo() {
144
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
145
+ const stdout = process.stdout;
146
+ const stderr = process.stderr;
147
+ async function ask(question) {
148
+ return (await rl.question(question)).trim();
149
+ }
150
+ async function askHidden(question) {
151
+ const stdin = process.stdin;
152
+ const isTTY = stdin.isTTY === true;
153
+ if (!isTTY || typeof stdin.setRawMode !== 'function') {
154
+ // No TTY — fall back to visible input with a note. Safer than refusing.
155
+ stdout.write('(input will be visible because stdin is not a TTY)\n');
156
+ return ask(question);
157
+ }
158
+ stdout.write(question);
159
+ return new Promise((resolve) => {
160
+ const chars = [];
161
+ const onData = (chunk) => {
162
+ const str = chunk.toString('utf-8');
163
+ for (const ch of str) {
164
+ const code = ch.charCodeAt(0);
165
+ if (code === 13 || code === 10) {
166
+ // Enter / newline → finalise
167
+ stdout.write('\n');
168
+ stdin.setRawMode(false);
169
+ stdin.pause();
170
+ stdin.off('data', onData);
171
+ resolve(chars.join('').trim());
172
+ return;
173
+ }
174
+ if (code === 3) {
175
+ // Ctrl-C → propagate SIGINT so the CLI exits cleanly
176
+ stdin.setRawMode(false);
177
+ stdin.pause();
178
+ stdin.off('data', onData);
179
+ process.kill(process.pid, 'SIGINT');
180
+ return;
181
+ }
182
+ if (code === 127 || code === 8) {
183
+ // Backspace / delete
184
+ if (chars.length > 0)
185
+ chars.pop();
186
+ continue;
187
+ }
188
+ chars.push(ch);
189
+ }
190
+ };
191
+ stdin.setRawMode(true);
192
+ stdin.resume();
193
+ stdin.on('data', onData);
194
+ });
195
+ }
196
+ return {
197
+ stdout,
198
+ stderr,
199
+ ask,
200
+ askHidden,
201
+ close: () => rl.close(),
202
+ };
203
+ }
204
+ // ---------------------------------------------------------------------------
205
+ // Internals
206
+ // ---------------------------------------------------------------------------
207
+ function printMnemonicGrid(mnemonic, out) {
208
+ const words = mnemonic.trim().split(/\s+/);
209
+ const cols = 4;
210
+ const pad = 14;
211
+ const lines = [];
212
+ for (let row = 0; row * cols < words.length; row++) {
213
+ const parts = [];
214
+ for (let col = 0; col < cols; col++) {
215
+ const idx = row * cols + col;
216
+ if (idx >= words.length)
217
+ break;
218
+ const label = `${String(idx + 1).padStart(2, ' ')}. ${words[idx]}`;
219
+ parts.push(label.padEnd(pad, ' '));
220
+ }
221
+ lines.push(' ' + parts.join(''));
222
+ }
223
+ out.write(lines.join('\n') + '\n');
224
+ }
225
+ /**
226
+ * Pick three distinct random word indices (0..11) for the retype-ack challenge.
227
+ * Overridable for tests.
228
+ */
229
+ function defaultRandomProbeIndices() {
230
+ const pool = [...Array(12).keys()];
231
+ const out = [];
232
+ for (let i = 0; i < 3; i++) {
233
+ const pick = Math.floor(Math.random() * pool.length);
234
+ out.push(pool[pick]);
235
+ pool.splice(pick, 1);
236
+ }
237
+ return out.sort((a, b) => a - b);
238
+ }
239
+ async function runAckChallenge(mnemonic, indices, io) {
240
+ const words = mnemonic.trim().split(/\s+/);
241
+ for (const idx of indices) {
242
+ const ans = await io.ask(COPY.ackPromptTemplate.replace('%N%', String(idx + 1)));
243
+ if (ans.trim().toLowerCase() !== words[idx]) {
244
+ return false;
245
+ }
246
+ }
247
+ return true;
248
+ }
249
+ function normaliseMnemonic(input) {
250
+ // Collapse whitespace, strip zero-width, lowercase. BIP-39 wordlist is
251
+ // entirely lowercase ASCII; any uppercase the user paste-in gets normalised.
252
+ return input
253
+ .normalize('NFKC')
254
+ .replace(/[\u200B-\u200F\uFEFF]/g, '')
255
+ .toLowerCase()
256
+ .trim()
257
+ .split(/\s+/)
258
+ .join(' ');
259
+ }
260
+ function writeCredsAndState(credentialsPath, statePath, mnemonic, createdBy) {
261
+ const creds = { mnemonic };
262
+ if (!writeCredentialsJson(credentialsPath, creds)) {
263
+ throw new Error(`Could not write credentials.json at ${credentialsPath}. Check that the parent directory is writable.`);
264
+ }
265
+ const state = {
266
+ onboardingState: 'active',
267
+ createdBy,
268
+ credentialsCreatedAt: new Date().toISOString(),
269
+ version: '3.2.0',
270
+ };
271
+ if (!writeOnboardingState(statePath, state)) {
272
+ throw new Error(`Could not write state.json at ${statePath}. Check that the parent directory is writable.`);
273
+ }
274
+ return state;
275
+ }
276
+ // ---------------------------------------------------------------------------
277
+ // Public entry points
278
+ // ---------------------------------------------------------------------------
279
+ /**
280
+ * Interactive onboarding wizard. Single shot — caller builds a WizardDeps and
281
+ * awaits. All user-visible output goes to `io.stdout` / `io.stderr`; all
282
+ * input comes from `io.ask` / `io.askHidden`. No console.log calls.
283
+ *
284
+ * Returns a `WizardResult`. Tests can assert on both the result and the
285
+ * stdout buffer.
286
+ */
287
+ export async function runOnboardingWizard(deps) {
288
+ const { credentialsPath, statePath, io } = deps;
289
+ const genMnemonic = deps.generateMnemonic ?? (() => generateMnemonic(wordlist, 128));
290
+ const validate = deps.validateMnemonic ?? ((p) => validateMnemonic(p, wordlist));
291
+ const probe = deps.randomProbeIndices ?? defaultRandomProbeIndices;
292
+ // If onboarding is already complete on disk, short-circuit instead of
293
+ // letting the user overwrite an existing phrase by accident.
294
+ const existingCreds = loadCredentialsJson(credentialsPath);
295
+ if (existingCreds?.mnemonic && typeof existingCreds.mnemonic === 'string' && existingCreds.mnemonic.trim()) {
296
+ io.stdout.write(COPY.alreadyActive);
297
+ const persisted = loadOnboardingState(statePath);
298
+ return {
299
+ choice: 'skip',
300
+ state: persisted ?? {
301
+ onboardingState: 'active',
302
+ version: '3.2.0',
303
+ },
304
+ };
305
+ }
306
+ io.stdout.write(COPY.welcome);
307
+ io.stdout.write(COPY.menu);
308
+ const choiceRaw = await io.ask(COPY.menuPrompt);
309
+ let choice;
310
+ if (choiceRaw === '1' || choiceRaw.toLowerCase() === 'generate') {
311
+ choice = 'generate';
312
+ }
313
+ else if (choiceRaw === '2' || choiceRaw.toLowerCase() === 'import') {
314
+ choice = 'import';
315
+ }
316
+ else if (choiceRaw === '3' || choiceRaw.toLowerCase() === 'skip') {
317
+ choice = 'skip';
318
+ }
319
+ else {
320
+ io.stderr.write(`\nUnrecognised choice "${choiceRaw}". Aborting.\n`);
321
+ return { choice: 'skip', error: `invalid-choice:${choiceRaw}` };
322
+ }
323
+ if (choice === 'skip') {
324
+ io.stdout.write(COPY.skipped);
325
+ return { choice: 'skip' };
326
+ }
327
+ if (choice === 'generate') {
328
+ // 3.3.1-rc.18 (issue #95) — deprecation banner on stderr ONLY.
329
+ // The phrase-print branch is scheduled for removal in the RC after
330
+ // rc.18; we keep it functional in rc.18 for back-compat with users
331
+ // running the wizard on a real TTY today.
332
+ io.stderr.write(PHRASE_PRINT_DEPRECATION_WARNING);
333
+ io.stdout.write(COPY.generateWarning);
334
+ io.stdout.write(COPY.importRemoteLimitation);
335
+ const mnemonic = genMnemonic();
336
+ if (typeof mnemonic !== 'string' || mnemonic.trim().split(/\s+/).length !== 12) {
337
+ io.stderr.write('\nInternal error: recovery phrase generator returned an invalid phrase.\n');
338
+ return { choice, error: 'generator-invalid' };
339
+ }
340
+ io.stdout.write('\nYour recovery phrase (WRITE THIS DOWN):\n\n');
341
+ printMnemonicGrid(mnemonic, io.stdout);
342
+ // 3.3.0-rc.2: storage guidance canonical copy — emitted verbatim so
343
+ // the CLI, the browser page, and any future surface share identical
344
+ // wording. See first-run.ts COPY.STORAGE_GUIDANCE.
345
+ io.stdout.write('\n' +
346
+ 'Your recovery phrase is 12 words. Store it somewhere safe — a password manager works well. Use it only for TotalReclaw. Don\'t reuse it anywhere else. Don\'t put funds on it.\n');
347
+ io.stdout.write(COPY.clipboardHint);
348
+ io.stdout.write('\n');
349
+ const indices = probe();
350
+ const ok = await runAckChallenge(mnemonic, indices, io);
351
+ if (!ok) {
352
+ io.stderr.write(COPY.ackFailed);
353
+ return { choice, error: 'ack-failed' };
354
+ }
355
+ try {
356
+ const state = writeCredsAndState(credentialsPath, statePath, mnemonic, 'generate');
357
+ io.stdout.write(COPY.postSuccessGenerate);
358
+ return { choice, state };
359
+ }
360
+ catch (err) {
361
+ const msg = err instanceof Error ? err.message : String(err);
362
+ io.stderr.write(`\n${msg}\n`);
363
+ return { choice, error: `write-failed:${msg}` };
364
+ }
365
+ }
366
+ // choice === 'import'
367
+ io.stdout.write(COPY.importWarning);
368
+ io.stdout.write(COPY.importRemoteLimitation);
369
+ const raw = await io.askHidden(COPY.importPrompt);
370
+ const normalised = normaliseMnemonic(raw);
371
+ const words = normalised.split(/\s+/).filter(Boolean);
372
+ if (words.length !== 12 || !validate(normalised)) {
373
+ io.stderr.write(COPY.importInvalid);
374
+ return { choice, error: 'invalid-phrase' };
375
+ }
376
+ try {
377
+ const state = writeCredsAndState(credentialsPath, statePath, normalised, 'import');
378
+ io.stdout.write(COPY.postSuccessImport);
379
+ return { choice, state };
380
+ }
381
+ catch (err) {
382
+ const msg = err instanceof Error ? err.message : String(err);
383
+ io.stderr.write(`\n${msg}\n`);
384
+ return { choice, error: `write-failed:${msg}` };
385
+ }
386
+ }
387
+ /**
388
+ * Print the current onboarding status to the given stdout. Safe — never
389
+ * displays the mnemonic; only the state + file paths. Called by both the
390
+ * `openclaw totalreclaw status` CLI subcommand and (if desired) the
391
+ * `totalreclaw_status` tool.
392
+ */
393
+ export function printStatus(credentialsPath, statePath, out) {
394
+ out.write(COPY.statusHeader);
395
+ const credExists = fs.existsSync(credentialsPath);
396
+ const stateFileExists = fs.existsSync(statePath);
397
+ if (credExists) {
398
+ const creds = loadCredentialsJson(credentialsPath);
399
+ // Accept both canonical `mnemonic` and legacy `recovery_phrase` — same
400
+ // back-compat pattern used by fs-helpers.ts::extractBootstrapMnemonic.
401
+ const hasMnemonic = (typeof creds?.mnemonic === 'string' && creds.mnemonic.trim().length > 0) ||
402
+ (typeof creds?.recovery_phrase === 'string' && creds.recovery_phrase.trim().length > 0);
403
+ if (hasMnemonic) {
404
+ out.write(COPY.statusActive);
405
+ if (stateFileExists) {
406
+ const st = loadOnboardingState(statePath);
407
+ if (st?.credentialsCreatedAt)
408
+ out.write(` created: ${st.credentialsCreatedAt}\n`);
409
+ if (st?.createdBy)
410
+ out.write(` method: ${st.createdBy}\n`);
411
+ }
412
+ return;
413
+ }
414
+ }
415
+ out.write(COPY.statusFresh);
416
+ }
417
+ /**
418
+ * Run the onboarding flow without any TTY prompts. Never throws on
419
+ * invalid input — always returns a NonInteractiveOnboardResult so the
420
+ * CLI can render a clear JSON error.
421
+ *
422
+ * Security:
423
+ * - Never writes the phrase to stdout unless `emitPhrase === true`.
424
+ * - Writes credentials.json with mode 0600 via `writeCredentialsJson`.
425
+ * - Does NOT prompt the user. Does NOT call readline.
426
+ */
427
+ export async function runNonInteractiveOnboard(inputs) {
428
+ const action = inputs.mode;
429
+ const generateFn = inputs.generateMnemonic ?? (() => generateMnemonic(wordlist, 128));
430
+ const validateFn = inputs.validateMnemonic ?? ((p) => validateMnemonic(p, wordlist));
431
+ // Refuse to overwrite an existing active onboarding — matches the
432
+ // interactive wizard's shortcut. Agents can still delete the file
433
+ // themselves if they truly want to re-onboard.
434
+ const existing = loadCredentialsJson(inputs.credentialsPath);
435
+ if (existing?.mnemonic &&
436
+ typeof existing.mnemonic === 'string' &&
437
+ existing.mnemonic.trim().length > 0) {
438
+ return {
439
+ ok: false,
440
+ action,
441
+ error: 'already-active',
442
+ error_detail: `A recovery phrase already exists at ${inputs.credentialsPath}. Delete it first to re-onboard.`,
443
+ };
444
+ }
445
+ let mnemonic;
446
+ if (action === 'generate') {
447
+ mnemonic = generateFn();
448
+ if (typeof mnemonic !== 'string' || mnemonic.trim().split(/\s+/).length !== 12) {
449
+ return {
450
+ ok: false,
451
+ action,
452
+ error: 'generator-invalid',
453
+ error_detail: 'generateMnemonic produced an invalid phrase',
454
+ };
455
+ }
456
+ }
457
+ else {
458
+ const raw = inputs.phrase ?? '';
459
+ if (!raw.trim()) {
460
+ return {
461
+ ok: false,
462
+ action,
463
+ error: 'missing-phrase',
464
+ error_detail: 'mode=restore requires --phrase <12-or-24-words> or --phrase - (read from stdin).',
465
+ };
466
+ }
467
+ const normalised = normaliseMnemonic(raw);
468
+ const words = normalised.split(/\s+/).filter(Boolean);
469
+ if ((words.length !== 12 && words.length !== 24) || !validateFn(normalised)) {
470
+ return {
471
+ ok: false,
472
+ action,
473
+ error: 'invalid-phrase',
474
+ error_detail: 'phrase must be 12 or 24 BIP-39 words with a valid checksum.',
475
+ };
476
+ }
477
+ mnemonic = normalised;
478
+ }
479
+ let state;
480
+ try {
481
+ state = writeCredsAndState(inputs.credentialsPath, inputs.statePath, mnemonic, action === 'generate' ? 'generate' : 'import');
482
+ }
483
+ catch (err) {
484
+ return {
485
+ ok: false,
486
+ action,
487
+ error: 'write-failed',
488
+ error_detail: err instanceof Error ? err.message : String(err),
489
+ };
490
+ }
491
+ // Derive scope address if possible — best-effort, never block success.
492
+ let scopeAddress;
493
+ if (inputs.deriveScopeAddress) {
494
+ try {
495
+ scopeAddress = await inputs.deriveScopeAddress(mnemonic);
496
+ }
497
+ catch {
498
+ scopeAddress = undefined;
499
+ }
500
+ }
501
+ const result = {
502
+ ok: true,
503
+ action,
504
+ credentials_path: inputs.credentialsPath,
505
+ };
506
+ if (scopeAddress)
507
+ result.scope_address = scopeAddress;
508
+ if (inputs.emitPhrase)
509
+ result.mnemonic = mnemonic;
510
+ void state; // Touch for clarity — state is persisted via writeCredsAndState.
511
+ return result;
512
+ }
513
+ // ---------------------------------------------------------------------------
514
+ // CLI registration
515
+ // ---------------------------------------------------------------------------
516
+ /**
517
+ * Helper the `index.ts` registerCli hook calls to wire both subcommands
518
+ * onto the OpenClaw program. Kept here so the wiring + the wizard live in
519
+ * the same module — index.ts just forwards.
520
+ *
521
+ * `program` is the OpenClaw-provided commander Command. We attach a
522
+ * top-level `totalreclaw` command with `onboard` + `status` subcommands.
523
+ *
524
+ * 3.3.1 — `onboard` now accepts:
525
+ * --non-interactive Fail fast if any input would be prompted for.
526
+ * --json Emit the result as structured JSON.
527
+ * --mode <generate|restore>
528
+ * Skip the menu prompt.
529
+ * --phrase <12-or-24> Required for --mode restore. `-` reads stdin.
530
+ * --emit-phrase Include the plaintext phrase in the JSON payload
531
+ * (NOT recommended — the phrase lives in
532
+ * credentials.json; prefer reading it there).
533
+ *
534
+ * 3.3.1-rc.18 — `onboard` accepts:
535
+ * --pair-only Phrase-safe agent-shell flag (issue #95).
536
+ * Delegates to the pair flow and emits a single
537
+ * line of JSON `{v, pair_url, pin, expires_at_ms}`
538
+ * to stdout. Phrase NEVER touches stdout, stderr,
539
+ * or the logger in this mode. Use this for any
540
+ * agent-driven setup; it is the recommended path
541
+ * when a container-based agent does not have the
542
+ * `totalreclaw_pair` tool injected.
543
+ *
544
+ * Requires `pairSessionsPath` + `renderPairingUrl`
545
+ * to be supplied to `registerOnboardingCli`. If
546
+ * absent, `--pair-only` exits non-zero with a
547
+ * clear message instead of falling through to the
548
+ * phrase-print branch.
549
+ */
550
+ export function registerOnboardingCli(program, opts) {
551
+ const tr = program
552
+ .command('totalreclaw')
553
+ .description('TotalReclaw encrypted memory — secure onboarding + status');
554
+ tr.command('onboard')
555
+ .description('Interactive onboarding: generate or import a recovery phrase (runs locally, no LLM). For agent-driven setup prefer --pair-only.')
556
+ .option('--non-interactive', 'Exit non-zero if any input would be prompted for (agent-driven use)')
557
+ .option('--json', 'Emit the result as a structured JSON payload. Only valid with --non-interactive.')
558
+ .option('--mode <mode>', 'generate | restore — skip the menu prompt')
559
+ .option('--phrase <phrase>', 'Recovery phrase for --mode restore. `-` reads from stdin.')
560
+ .option('--emit-phrase', 'Include the plaintext phrase in the JSON payload (not recommended). Default: false.')
561
+ .option('--pair-only', 'Phrase-safe agent-invocation mode (issue #95). Emits ONLY {v,pair_url,pin,expires_at_ms} JSON to stdout via the pair flow. Phrase never touches stdout/stderr/logger. RECOMMENDED for any agent or scripted invocation.')
562
+ .action(async (...actionArgs) => {
563
+ // commander: (options, cmd)
564
+ const cliOpts = (actionArgs[0] ?? {});
565
+ // ---------------------------------------------------------------
566
+ // 3.3.1-rc.18 — `--pair-only` (issue #95)
567
+ //
568
+ // Phrase-safe agent-shell flag. Delegates to the pair flow and
569
+ // emits a single line of JSON `{v, pair_url, pin, expires_at_ms}`
570
+ // to stdout. By construction:
571
+ // - The pair flow is x25519-only — pair-crypto.ts does NOT
572
+ // import @scure/bip39 and never touches a recovery phrase.
573
+ // - No interactive prompts, no readline, no @scure/bip39 import
574
+ // in this code path. Phrase never enters stdout/stderr/logger.
575
+ // - Stays silent on status transitions (the runPairCli
576
+ // `pair-only` output mode suppresses banners, spinners, and
577
+ // all human-readable copy).
578
+ //
579
+ // This MUST be the path agents take when they need to set up
580
+ // TotalReclaw via a shell. The interactive phrase-print branch
581
+ // below is deprecated for that use case and emits a warning when
582
+ // the user falls through to it.
583
+ // ---------------------------------------------------------------
584
+ if (cliOpts.pairOnly) {
585
+ if (!opts.pairSessionsPath || !opts.renderPairingUrl) {
586
+ process.stderr.write('--pair-only is unavailable: this OpenClaw build did not wire the pair flow into the onboard CLI. ' +
587
+ 'Use `openclaw totalreclaw pair generate --url-pin-only` instead.\n');
588
+ process.exit(1);
589
+ }
590
+ // Resolve mode. --mode restore is incompatible with --pair-only
591
+ // since pair flow's "import" mode runs in the browser, not in
592
+ // the CLI. Default to 'generate' silently.
593
+ const pairMode = cliOpts.mode === 'restore' || cliOpts.mode === 'import' ? 'import' : 'generate';
594
+ // Lazy import — keeps pair-cli + qrcode-terminal off the
595
+ // onboarding hot path when --pair-only is not used.
596
+ const { runPairCli, defaultRenderQr, buildDefaultPairCliIo } = await import('./pair-cli.js');
597
+ const io = buildDefaultPairCliIo();
598
+ try {
599
+ const outcome = await runPairCli(pairMode, {
600
+ sessionsPath: opts.pairSessionsPath,
601
+ renderPairingUrl: opts.renderPairingUrl,
602
+ renderQr: defaultRenderQr,
603
+ io,
604
+ outputMode: 'pair-only',
605
+ });
606
+ if (outcome.status !== 'completed') {
607
+ process.exit(outcome.status === 'canceled' ? 130 : 1);
608
+ }
609
+ process.exit(0);
610
+ }
611
+ catch (err) {
612
+ // CRITICAL: this catch MUST NOT include the phrase, the
613
+ // mnemonic, or any user secret in the message. The pair flow
614
+ // does not produce phrase material, so this is structurally
615
+ // safe — but defense-in-depth: emit a fixed error string.
616
+ opts.logger.error(`pair-only delegation crashed: ${err instanceof Error ? err.message : String(err)}`);
617
+ process.stderr.write('--pair-only failed (see logs).\n');
618
+ process.exit(2);
619
+ }
620
+ }
621
+ if (cliOpts.nonInteractive) {
622
+ // Non-interactive path — no readline, no prompts.
623
+ const mode = cliOpts.mode === 'generate'
624
+ ? 'generate'
625
+ : cliOpts.mode === 'restore' || cliOpts.mode === 'import'
626
+ ? 'restore'
627
+ : null;
628
+ if (!mode) {
629
+ const msg = '--non-interactive requires --mode <generate|restore>. ' +
630
+ 'Example: openclaw totalreclaw onboard --non-interactive --json --mode generate';
631
+ if (cliOpts.json) {
632
+ process.stdout.write(JSON.stringify({ ok: false, error: 'missing-mode', error_detail: msg }) + '\n');
633
+ }
634
+ else {
635
+ process.stderr.write(msg + '\n');
636
+ }
637
+ process.exit(1);
638
+ }
639
+ // Resolve phrase input. `-` means read all of stdin.
640
+ let phrase = cliOpts.phrase;
641
+ if (phrase === '-') {
642
+ phrase = await readAllStdin();
643
+ }
644
+ const result = await runNonInteractiveOnboard({
645
+ credentialsPath: opts.credentialsPath,
646
+ statePath: opts.statePath,
647
+ mode,
648
+ phrase,
649
+ emitPhrase: cliOpts.emitPhrase === true,
650
+ deriveScopeAddress: opts.deriveScopeAddress,
651
+ });
652
+ if (cliOpts.json) {
653
+ // 3.3.1-rc.18 (issue #95) — emit deprecation on stderr when
654
+ // the JSON payload is about to include the plaintext phrase.
655
+ // stderr is intentional: stdout must remain a single
656
+ // machine-parseable JSON line.
657
+ if (cliOpts.emitPhrase && result.ok && result.mnemonic) {
658
+ process.stderr.write(PHRASE_PRINT_DEPRECATION_WARNING);
659
+ }
660
+ process.stdout.write(JSON.stringify(result) + '\n');
661
+ }
662
+ else {
663
+ if (result.ok) {
664
+ if (result.mnemonic) {
665
+ process.stderr.write(PHRASE_PRINT_DEPRECATION_WARNING);
666
+ process.stderr.write('WARNING: --emit-phrase was set. The plaintext recovery phrase was returned.\n' +
667
+ 'For agent-driven flows, prefer reading ~/.totalreclaw/credentials.json directly ' +
668
+ 'in the user\'s terminal instead of echoing the phrase.\n');
669
+ }
670
+ process.stdout.write(`onboarding: ok action=${result.action} ` +
671
+ (result.scope_address ? `scope_address=${result.scope_address} ` : '') +
672
+ `credentials=${result.credentials_path}\n`);
673
+ }
674
+ else {
675
+ process.stderr.write(`onboarding: ${result.error}: ${result.error_detail ?? ''}\n`);
676
+ }
677
+ }
678
+ process.exit(result.ok ? 0 : 1);
679
+ }
680
+ if (cliOpts.json) {
681
+ process.stderr.write('--json requires --non-interactive. Use: openclaw totalreclaw onboard --non-interactive --json --mode generate\n');
682
+ process.exit(1);
683
+ }
684
+ // Interactive path — original 3.2.0 behaviour preserved in full.
685
+ const io = buildDefaultIo();
686
+ try {
687
+ const result = await runOnboardingWizard({
688
+ credentialsPath: opts.credentialsPath,
689
+ statePath: opts.statePath,
690
+ io,
691
+ });
692
+ if (result.error) {
693
+ opts.logger.warn(`onboarding wizard exited with error: ${result.error}`);
694
+ io.close();
695
+ process.exit(1);
696
+ }
697
+ if (result.choice === 'generate' || result.choice === 'import') {
698
+ opts.logger.info(`onboarding: state=active createdBy=${result.state?.createdBy}`);
699
+ }
700
+ }
701
+ catch (err) {
702
+ const msg = err instanceof Error ? err.message : String(err);
703
+ opts.logger.error(`onboarding wizard crashed: ${msg}`);
704
+ io.close();
705
+ process.exit(2);
706
+ }
707
+ io.close();
708
+ });
709
+ tr.command('status')
710
+ .description('Show TotalReclaw onboarding state — never displays the recovery phrase')
711
+ .action(() => {
712
+ printStatus(opts.credentialsPath, opts.statePath, process.stdout);
713
+ });
714
+ }
715
+ // ---------------------------------------------------------------------------
716
+ // stdin helper — reads until EOF. Used when --phrase=- is given.
717
+ // ---------------------------------------------------------------------------
718
+ async function readAllStdin() {
719
+ const chunks = [];
720
+ process.stdin.setEncoding('utf-8');
721
+ return new Promise((resolve) => {
722
+ const onData = (chunk) => chunks.push(chunk);
723
+ const onEnd = () => {
724
+ process.stdin.off('data', onData);
725
+ process.stdin.off('end', onEnd);
726
+ resolve(chunks.join(''));
727
+ };
728
+ process.stdin.on('data', onData);
729
+ process.stdin.on('end', onEnd);
730
+ // If stdin is a TTY with no data (e.g. user forgot to pipe), resolve
731
+ // after a short grace so we don't hang forever. Tests override by
732
+ // piping a string.
733
+ if (process.stdin.isTTY) {
734
+ setTimeout(() => {
735
+ process.stdin.off('data', onData);
736
+ process.stdin.off('end', onEnd);
737
+ resolve(chunks.join(''));
738
+ }, 50);
739
+ }
740
+ });
741
+ }
742
+ // Ensure this module is reachable for import resolution in ESM tests.
743
+ export const __modulePath = (() => {
744
+ try {
745
+ return path.resolve(path.dirname(new URL(import.meta.url).pathname));
746
+ }
747
+ catch {
748
+ return __dirname;
749
+ }
750
+ })();