@totalreclaw/totalreclaw 3.2.3 → 3.3.0-rc.2

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/pair-cli.ts ADDED
@@ -0,0 +1,351 @@
1
+ /**
2
+ * pair-cli — the `openclaw totalreclaw pair` CLI subcommand.
3
+ *
4
+ * Purpose
5
+ * -------
6
+ * Starts a remote-onboarding session FROM the gateway host's terminal.
7
+ * Creates a pair-session, renders the QR + URL + 6-digit secondary code
8
+ * to stdout, then polls /status until the browser completes the flow.
9
+ *
10
+ * This is the gateway-operator's surface. The operator reads the QR
11
+ * with their phone (or opens the URL on their laptop browser); the
12
+ * browser takes over from there.
13
+ *
14
+ * Scope and scanner surface
15
+ * -------------------------
16
+ * Has `fetch` (for status polling) AND `POST` (never actually POSTs,
17
+ * but the word lives in comments describing the paired browser POST).
18
+ * MUST NOT also read disk or env vars. All state operations delegate
19
+ * to pair-session-store; the CLI itself is a thin coordinator.
20
+ *
21
+ * Zero logging of secret material. The secondary code IS printed to
22
+ * stdout (required for the user to type), but never logged to file
23
+ * and never to api.logger.
24
+ */
25
+
26
+ import readline from 'node:readline';
27
+
28
+ import {
29
+ createPairSession,
30
+ getPairSession,
31
+ rejectPairSession,
32
+ type PairSession,
33
+ type PairSessionMode,
34
+ } from './pair-session-store.js';
35
+ import { generateGatewayKeypair } from './pair-crypto.js';
36
+
37
+ // ---------------------------------------------------------------------------
38
+ // Types
39
+ // ---------------------------------------------------------------------------
40
+
41
+ export interface PairCliIo {
42
+ stdout: NodeJS.WritableStream;
43
+ stderr: NodeJS.WritableStream;
44
+ /** Install a Ctrl+C handler that invokes `cb`; returns an uninstaller. */
45
+ onInterrupt(cb: () => void): () => void;
46
+ }
47
+
48
+ export interface PairCliDeps {
49
+ sessionsPath: string;
50
+ /** Caller-injected function that returns the full `url#pk=` string
51
+ * for the browser. Takes the session and returns the URL with the
52
+ * public-key fragment embedded. Signature keeps URL resolution out
53
+ * of this module (same rationale as pair-http). */
54
+ renderPairingUrl(session: PairSession): string;
55
+ /** QR renderer — takes a text payload + callback. Injectable for tests. */
56
+ renderQr(payload: string, cb: (ascii: string) => void): void;
57
+ /** Poll interval in ms. Default 1500. */
58
+ pollIntervalMs?: number;
59
+ /** Override for Date.now(). */
60
+ now?: () => number;
61
+ io: PairCliIo;
62
+ /** Optional TTL override (sec → ms conversion happens here). */
63
+ ttlSeconds?: number;
64
+ }
65
+
66
+ export type PairCliMode = PairSessionMode;
67
+
68
+ export interface PairCliOutcome {
69
+ status: 'completed' | 'canceled' | 'expired' | 'rejected' | 'error';
70
+ sid?: string;
71
+ error?: string;
72
+ }
73
+
74
+ // ---------------------------------------------------------------------------
75
+ // Default stdout IO
76
+ // ---------------------------------------------------------------------------
77
+
78
+ export function buildDefaultPairCliIo(): PairCliIo {
79
+ return {
80
+ stdout: process.stdout,
81
+ stderr: process.stderr,
82
+ onInterrupt(cb) {
83
+ const handler = () => {
84
+ try { cb(); } catch { /* swallow */ }
85
+ };
86
+ process.once('SIGINT', handler);
87
+ return () => process.off('SIGINT', handler);
88
+ },
89
+ };
90
+ }
91
+
92
+ // ---------------------------------------------------------------------------
93
+ // Copy — same security principles as onboarding-cli COPY but terser.
94
+ // ---------------------------------------------------------------------------
95
+
96
+ const COPY = {
97
+ intro:
98
+ '\nTotalReclaw — Remote pairing\n\n' +
99
+ 'Your TotalReclaw recovery phrase will be created (or imported) in your\n' +
100
+ 'BROWSER and delivered to this gateway encrypted end-to-end. The phrase\n' +
101
+ 'never touches the LLM, the session transcript, or the relay server\n' +
102
+ 'in plaintext.\n\n' +
103
+ 'Scan the QR code below with your phone, or open the URL on any\n' +
104
+ 'device. Then type the 6-digit code shown here into the browser.\n',
105
+ introGenerate:
106
+ '\nMode: GENERATE — your browser will create a NEW 12-word recovery phrase.\n' +
107
+ 'You will be asked to write it down and retype 3 words before the\n' +
108
+ 'gateway accepts it.\n',
109
+ introImport:
110
+ '\nMode: IMPORT — your browser will accept an existing TotalReclaw\n' +
111
+ 'recovery phrase that you already have. Paste it in the browser; it\n' +
112
+ 'will be validated locally and encrypted before upload.\n',
113
+ codeLabel: '\nSecondary code (type this into the browser):\n\n ',
114
+ urlLabel:
115
+ '\n\nURL (QR encodes this plus a one-time public key):\n\n ',
116
+ securityWarning:
117
+ '\n\nSecurity:\n' +
118
+ ' * Do NOT share your screen during pairing.\n' +
119
+ ' * Do NOT screenshot this terminal.\n' +
120
+ ' * The browser page will warn you never to reuse this recovery\n' +
121
+ ' phrase for wallets, banking, email, or any other service.\n',
122
+ awaiting: '\nWaiting for browser to connect… (press Ctrl+C to cancel)',
123
+ deviceConnected: '\nBrowser connected. Waiting for encrypted payload…',
124
+ completed: '\nPairing complete. Account is active.',
125
+ canceled: '\nCanceled. Pairing session invalidated.',
126
+ expired: '\nSession expired. Run the command again to restart.',
127
+ rejected: '\nPairing rejected (too many wrong codes, or gateway aborted).',
128
+ };
129
+
130
+ function renderUnsafelyVisibleCode(code: string): string {
131
+ // Pad digits with spaces so terminal copy-paste can't accidentally
132
+ // pick them up as a single token.
133
+ return code.split('').join(' ');
134
+ }
135
+
136
+ // ---------------------------------------------------------------------------
137
+ // Public entry point
138
+ // ---------------------------------------------------------------------------
139
+
140
+ /**
141
+ * Start a pairing session, display the QR + code + URL, and poll
142
+ * until terminal state. Returns the final outcome.
143
+ *
144
+ * Blocks until the session finishes, expires, or the operator hits
145
+ * Ctrl+C.
146
+ */
147
+ export async function runPairCli(
148
+ mode: PairCliMode,
149
+ deps: PairCliDeps,
150
+ ): Promise<PairCliOutcome> {
151
+ const now = deps.now ?? Date.now;
152
+ const pollInterval = Math.max(500, deps.pollIntervalMs ?? 1500);
153
+ const io = deps.io;
154
+ const stdout = io.stdout;
155
+
156
+ // 1. Generate keypair + create the session
157
+ const kp = generateGatewayKeypair();
158
+ let session: PairSession;
159
+ try {
160
+ session = await createPairSession(deps.sessionsPath, {
161
+ mode,
162
+ operatorContext: { channel: 'cli' },
163
+ ttlMs: deps.ttlSeconds !== undefined ? deps.ttlSeconds * 1000 : undefined,
164
+ rngPrivateKey: () => Buffer.from(kp.skB64, 'base64url'),
165
+ rngPublicKey: () => Buffer.from(kp.pkB64, 'base64url'),
166
+ now,
167
+ });
168
+ } catch (err) {
169
+ const msg = err instanceof Error ? err.message : String(err);
170
+ io.stderr.write(`\nFailed to create pairing session: ${msg}\n`);
171
+ return { status: 'error', error: msg };
172
+ }
173
+
174
+ // 2. Render the QR + text
175
+ const url = deps.renderPairingUrl(session);
176
+ stdout.write(COPY.intro);
177
+ stdout.write(mode === 'generate' ? COPY.introGenerate : COPY.introImport);
178
+ await new Promise<void>((resolve) => {
179
+ deps.renderQr(url, (ascii) => {
180
+ stdout.write('\n' + ascii + '\n');
181
+ resolve();
182
+ });
183
+ });
184
+ stdout.write(COPY.codeLabel);
185
+ stdout.write(renderUnsafelyVisibleCode(session.secondaryCode));
186
+ stdout.write(COPY.urlLabel);
187
+ stdout.write(url);
188
+ stdout.write(COPY.securityWarning);
189
+ stdout.write(COPY.awaiting);
190
+ stdout.write('\n');
191
+
192
+ // 3. Set up Ctrl+C to cancel the session server-side
193
+ let canceled = false;
194
+ const releaseInterrupt = io.onInterrupt(() => {
195
+ canceled = true;
196
+ });
197
+
198
+ // 4. Poll
199
+ let lastStatus = session.status;
200
+ let showedDeviceConnected = false;
201
+ try {
202
+ while (true) {
203
+ if (canceled) {
204
+ await rejectPairSession(deps.sessionsPath, session.sid, now);
205
+ stdout.write(COPY.canceled + '\n');
206
+ return { status: 'canceled', sid: session.sid };
207
+ }
208
+ await sleep(pollInterval);
209
+ const fresh = await getPairSession(deps.sessionsPath, session.sid, now);
210
+ if (!fresh) {
211
+ // Pruned — session is gone entirely.
212
+ stdout.write(COPY.expired + '\n');
213
+ return { status: 'expired', sid: session.sid };
214
+ }
215
+ if (fresh.status !== lastStatus) {
216
+ lastStatus = fresh.status;
217
+ if (fresh.status === 'device_connected' && !showedDeviceConnected) {
218
+ stdout.write(COPY.deviceConnected + '\n');
219
+ showedDeviceConnected = true;
220
+ }
221
+ }
222
+ if (fresh.status === 'completed') {
223
+ stdout.write(COPY.completed + '\n');
224
+ return { status: 'completed', sid: session.sid };
225
+ }
226
+ if (fresh.status === 'expired') {
227
+ stdout.write(COPY.expired + '\n');
228
+ return { status: 'expired', sid: session.sid };
229
+ }
230
+ if (fresh.status === 'rejected') {
231
+ stdout.write(COPY.rejected + '\n');
232
+ return { status: 'rejected', sid: session.sid };
233
+ }
234
+ }
235
+ } finally {
236
+ releaseInterrupt();
237
+ }
238
+ }
239
+
240
+ // ---------------------------------------------------------------------------
241
+ // Wrap qrcode-terminal in a promise-friendly renderer. Dynamic import
242
+ // keeps the module out of the plugin's register() hot path.
243
+ // ---------------------------------------------------------------------------
244
+
245
+ /**
246
+ * Default QR renderer using `qrcode-terminal`. Lazy-imports so the
247
+ * module only loads when the CLI is actually invoked.
248
+ */
249
+ export function defaultRenderQr(payload: string, cb: (ascii: string) => void): void {
250
+ // `qrcode-terminal` ships no type declarations; we describe the
251
+ // public surface we rely on inline via a cast.
252
+ type QrMod = {
253
+ generate(text: string, opts: { small?: boolean }, cb: (ascii: string) => void): void;
254
+ };
255
+ import('qrcode-terminal' as string).then((rawMod: unknown) => {
256
+ const mod = rawMod as { default?: QrMod } & QrMod;
257
+ const qr: QrMod = mod.default ?? mod;
258
+ qr.generate(payload, { small: true }, cb);
259
+ }).catch((err: unknown) => {
260
+ cb(`(QR renderer unavailable: ${err instanceof Error ? err.message : String(err)})`);
261
+ });
262
+ }
263
+
264
+ // ---------------------------------------------------------------------------
265
+ // CLI registrar — hooked from `index.ts registerCli`.
266
+ // ---------------------------------------------------------------------------
267
+
268
+ /**
269
+ * Register the `openclaw totalreclaw pair [generate|import]` subcommand
270
+ * on the caller's commander program. The onboarding-cli's
271
+ * `registerOnboardingCli` function already attaches `totalreclaw` as a
272
+ * top-level command with `onboard`+`status` subcommands; we hook in by
273
+ * finding that command and adding `pair` alongside.
274
+ *
275
+ * If the commander program is provided without the prior attachments,
276
+ * we create `totalreclaw pair` fresh. The caller in index.ts decides
277
+ * composition.
278
+ */
279
+ /**
280
+ * Minimal structural shape of commander's `Command` used by this file.
281
+ * We don't import from `commander` because it's not a declared
282
+ * dependency of the plugin (it's injected by OpenClaw's CLI runtime
283
+ * at call time).
284
+ */
285
+ type CommanderCommand = {
286
+ name(): string;
287
+ command(name: string): CommanderCommand;
288
+ description(text: string): CommanderCommand;
289
+ action(fn: (...args: unknown[]) => Promise<void> | void): CommanderCommand;
290
+ commands: CommanderCommand[];
291
+ };
292
+
293
+ export function registerPairCli(
294
+ program: CommanderCommand,
295
+ deps: {
296
+ sessionsPath: string;
297
+ renderPairingUrl(session: PairSession): string;
298
+ logger: { info(msg: string): void; warn(msg: string): void; error(msg: string): void };
299
+ },
300
+ ): void {
301
+ // If the onboarding-cli already attached `totalreclaw`, reuse it.
302
+ // Otherwise create a fresh top-level command.
303
+ let tr: CommanderCommand | undefined = program.commands.find(
304
+ (c: CommanderCommand) => c.name() === 'totalreclaw',
305
+ );
306
+ if (!tr) {
307
+ tr = program
308
+ .command('totalreclaw')
309
+ .description('TotalReclaw encrypted memory — pairing + onboarding + status');
310
+ }
311
+
312
+ tr.command('pair [mode]')
313
+ .description(
314
+ 'Pair a remote browser device to this gateway (mode = generate | import; default generate)',
315
+ )
316
+ .action(async (...args: unknown[]) => {
317
+ const modeRaw = typeof args[0] === 'string' ? args[0] : undefined;
318
+ const mode: PairCliMode =
319
+ modeRaw === 'import' || modeRaw === 'imp' ? 'import' : 'generate';
320
+ const io = buildDefaultPairCliIo();
321
+ try {
322
+ const outcome = await runPairCli(mode, {
323
+ sessionsPath: deps.sessionsPath,
324
+ renderPairingUrl: deps.renderPairingUrl,
325
+ renderQr: defaultRenderQr,
326
+ io,
327
+ });
328
+ if (outcome.status !== 'completed') {
329
+ process.exit(outcome.status === 'canceled' ? 130 : 1);
330
+ }
331
+ } catch (err) {
332
+ const msg = err instanceof Error ? err.message : String(err);
333
+ deps.logger.error(`pair-cli crashed: ${msg}`);
334
+ process.exit(2);
335
+ }
336
+ });
337
+ }
338
+
339
+ // ---------------------------------------------------------------------------
340
+ // Utils
341
+ // ---------------------------------------------------------------------------
342
+
343
+ function sleep(ms: number): Promise<void> {
344
+ return new Promise((resolve) => setTimeout(resolve, ms));
345
+ }
346
+
347
+ // Keep readline import reachable (pair-cli doesn't use it directly yet,
348
+ // but future interactive prompts will land here; prevents tree-shaking
349
+ // from dropping a future dep). TypeScript requires the import to have
350
+ // an effect.
351
+ void readline;