@totalreclaw/totalreclaw 3.2.3 → 3.3.0-rc.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.
package/README.md CHANGED
@@ -96,7 +96,36 @@ openclaw plugins install @totalreclaw/totalreclaw
96
96
 
97
97
  ### 2. Configure
98
98
 
99
- Set one environment variable:
99
+ You have three ways to set up TotalReclaw, depending on where your OpenClaw gateway runs.
100
+
101
+ **Local gateway (laptop / workstation):** run the CLI wizard on the same machine:
102
+
103
+ ```bash
104
+ openclaw totalreclaw onboard
105
+ ```
106
+
107
+ The wizard generates or accepts a 12-word BIP-39 TotalReclaw account key directly on your terminal. The phrase never touches the LLM, the chat transcript, or the network -- it's written straight to `~/.totalreclaw/credentials.json` (mode 0600).
108
+
109
+ **Remote gateway (VPS, home server, shared / team):** use QR-pairing (new in v3.3.0).
110
+
111
+ On the gateway host:
112
+
113
+ ```bash
114
+ openclaw totalreclaw pair # generate a new account key
115
+ openclaw totalreclaw pair import # import an existing TotalReclaw key
116
+ ```
117
+
118
+ You'll see a QR code, a 6-digit secondary code, and a URL. Scan the QR with your phone's camera or open the URL on any modern browser. The browser page:
119
+
120
+ 1. Asks you to enter the 6-digit code (prevents a bystander from hijacking the session).
121
+ 2. Generates or accepts your 12-word account key in-page.
122
+ 3. Encrypts it end-to-end (x25519 + ChaCha20-Poly1305, key derived from a DH shared secret the relay never sees) and delivers it to your gateway.
123
+
124
+ The phrase never enters the LLM, the chat transcript, or the relay server in plaintext. The pairing URL embeds the gateway's ephemeral public key in the URL fragment -- this is TLS-MITM resistant and invisible to any server on the path. See `CHANGELOG.md` §3.3.0 for the full threat model.
125
+
126
+ Browser support: Safari 17+, Chrome 123+, Firefox 130+ (these ship WebCrypto x25519 + ChaCha20-Poly1305).
127
+
128
+ **Legacy / self-hosted:** set the env var directly (useful for containers / CI):
100
129
 
101
130
  ```bash
102
131
  export TOTALRECLAW_RECOVERY_PHRASE="your twelve word recovery phrase here"
@@ -104,7 +133,7 @@ export TOTALRECLAW_RECOVERY_PHRASE="your twelve word recovery phrase here"
104
133
 
105
134
  **That's it.** v1 is the default extraction and write path. Extraction cadence, importance floor, candidate pool size, and dedup thresholds are all server-tuned via the relay's billing response -- no client env vars to set. See [env vars reference](../../docs/guides/env-vars-reference.md).
106
135
 
107
- For self-hosted deployments:
136
+ For self-hosted relays:
108
137
 
109
138
  ```bash
110
139
  export TOTALRECLAW_SERVER_URL="http://your-totalreclaw-server:8080"
package/config.ts CHANGED
@@ -99,6 +99,11 @@ export const CONFIG = {
99
99
  // never contains secrets. Loaded on every plugin init + on every
100
100
  // before_tool_call gate check.
101
101
  onboardingStatePath: process.env.TOTALRECLAW_STATE_PATH || path.join(home, '.totalreclaw', 'state.json'),
102
+ // 3.3.0 QR-pairing session store. Separate file from both credentials.json
103
+ // and state.json so the session-store module does not have to touch either
104
+ // (keeps scanner surface isolated). Contains ephemeral x25519 secret keys
105
+ // for 15-min TTL windows; 0600 mode.
106
+ pairSessionsPath: process.env.TOTALRECLAW_PAIR_SESSIONS_PATH || path.join(home, '.totalreclaw', 'pair-sessions.json'),
102
107
 
103
108
  // Chain — chainId is no longer user-configurable. It is auto-detected from
104
109
  // the relay billing response (free = Base Sepolia / 84532, Pro = Gnosis /
package/index.ts CHANGED
@@ -204,6 +204,16 @@ interface OpenClawPluginApi {
204
204
  config?: unknown;
205
205
  }) => { text: string } | Promise<{ text: string }>;
206
206
  }): void;
207
+ /**
208
+ * 3.3.0 — register an HTTP route on the gateway's HTTP server.
209
+ * Used by the QR-pairing flow to serve the pairing page + the
210
+ * encrypted-payload respond endpoint. Path is exact-match against
211
+ * `new URL(req.url, ...).pathname`; no params supported.
212
+ */
213
+ registerHttpRoute?(params: {
214
+ path: string;
215
+ handler: (req: import('node:http').IncomingMessage, res: import('node:http').ServerResponse) => Promise<void> | void;
216
+ }): void;
207
217
  }
208
218
 
209
219
  // ---------------------------------------------------------------------------
@@ -245,6 +255,70 @@ function humanizeError(rawMessage: string): string {
245
255
  /** Path where we persist userId + salt across restarts. */
246
256
  const CREDENTIALS_PATH = CONFIG.credentialsPath;
247
257
 
258
+ // ---------------------------------------------------------------------------
259
+ // 3.3.0 — pairing URL resolution
260
+ // ---------------------------------------------------------------------------
261
+
262
+ /**
263
+ * Build the full pairing URL (including `#pk=` fragment) for a fresh
264
+ * pairing session. Pulls gateway config from `api.config.gateway`.
265
+ *
266
+ * Resolution order (mirrors the device-pair extension):
267
+ * 1. pluginConfig.publicUrl (if the operator set it explicitly)
268
+ * 2. gateway.remote.url (if the gateway is marked remote)
269
+ * 3. gateway.bind=custom + customBindHost + port
270
+ * 4. gateway.bind=tailnet/lan is acknowledged but we do NOT probe
271
+ * the host here (network calls); we fall back to localhost with
272
+ * a warning log.
273
+ * 5. gateway.port default = 18789 + localhost.
274
+ *
275
+ * Always returns a working URL string; never throws. The caller can
276
+ * log a warning if the URL is localhost and the gateway is remote,
277
+ * but the CLI always prints whatever we give it.
278
+ */
279
+ function buildPairingUrl(
280
+ api: Pick<OpenClawPluginApi, 'config' | 'pluginConfig' | 'logger'>,
281
+ session: { sid: string; pkGatewayB64: string },
282
+ ): string {
283
+ const cfg = api.config as {
284
+ gateway?: {
285
+ port?: number;
286
+ bind?: string;
287
+ customBindHost?: string;
288
+ tls?: { enabled?: boolean };
289
+ remote?: { url?: string };
290
+ };
291
+ } | undefined;
292
+ const pluginCfg = (api.pluginConfig ?? {}) as { publicUrl?: string };
293
+
294
+ const tlsEnabled = cfg?.gateway?.tls?.enabled === true;
295
+ const scheme = tlsEnabled ? 'https' : 'http';
296
+ const port = cfg?.gateway?.port ?? 18789;
297
+
298
+ let base: string;
299
+ if (typeof pluginCfg.publicUrl === 'string' && pluginCfg.publicUrl.trim()) {
300
+ base = pluginCfg.publicUrl.replace(/\/+$/, '');
301
+ // If the user gave us a ws:// URL, rewrite to http(s)://
302
+ base = base.replace(/^wss:\/\//i, 'https://').replace(/^ws:\/\//i, 'http://');
303
+ } else if (typeof cfg?.gateway?.remote?.url === 'string' && cfg.gateway.remote.url.trim()) {
304
+ base = cfg.gateway.remote.url.trim().replace(/\/+$/, '');
305
+ base = base.replace(/^wss:\/\//i, 'https://').replace(/^ws:\/\//i, 'http://');
306
+ } else if (cfg?.gateway?.bind === 'custom' && cfg.gateway.customBindHost) {
307
+ base = `${scheme}://${cfg.gateway.customBindHost}:${port}`;
308
+ } else {
309
+ const bind = cfg?.gateway?.bind;
310
+ if (bind === 'lan' || bind === 'tailnet') {
311
+ api.logger.warn(
312
+ `TotalReclaw: pairing URL is falling back to localhost because gateway.bind=${bind} without explicit host probe. ` +
313
+ 'Set plugins.entries.totalreclaw.config.publicUrl to override.',
314
+ );
315
+ }
316
+ base = `${scheme}://localhost:${port}`;
317
+ }
318
+
319
+ return `${base}/plugin/totalreclaw/pair/finish?sid=${encodeURIComponent(session.sid)}#pk=${encodeURIComponent(session.pkGatewayB64)}`;
320
+ }
321
+
248
322
  // ---------------------------------------------------------------------------
249
323
  // Cosine similarity threshold — skip injection when top result is below this
250
324
  // ---------------------------------------------------------------------------
@@ -2557,6 +2631,14 @@ const plugin = {
2557
2631
  statePath: CONFIG.onboardingStatePath,
2558
2632
  logger: api.logger,
2559
2633
  });
2634
+ // 3.3.0 — `openclaw totalreclaw pair [generate|import]` attaches
2635
+ // alongside the existing `onboard` + `status` subcommands.
2636
+ const { registerPairCli } = await import('./pair-cli.js');
2637
+ registerPairCli(program as import('commander').Command, {
2638
+ sessionsPath: CONFIG.pairSessionsPath,
2639
+ renderPairingUrl: (session) => buildPairingUrl(api, session),
2640
+ logger: api.logger,
2641
+ });
2560
2642
  },
2561
2643
  { commands: ['totalreclaw'] },
2562
2644
  );
@@ -2567,6 +2649,69 @@ const plugin = {
2567
2649
  );
2568
2650
  }
2569
2651
 
2652
+ // ---------------------------------------------------------------
2653
+ // 3.3.0 — HTTP routes for QR-pairing (pair-http)
2654
+ // ---------------------------------------------------------------
2655
+ //
2656
+ // Four endpoints under /plugin/totalreclaw/pair/ are registered on
2657
+ // the gateway's HTTP server. Collectively they serve the browser
2658
+ // pairing page, verify the 6-digit secondary code, accept the
2659
+ // encrypted mnemonic payload, and expose a status polled by the
2660
+ // CLI. See pair-http.ts and the 2026-04-20 design doc.
2661
+ if (typeof api.registerHttpRoute === 'function') {
2662
+ (async () => {
2663
+ try {
2664
+ const { buildPairRoutes } = await import('./pair-http.js');
2665
+ const { validateMnemonic } = await import('@scure/bip39');
2666
+ const { wordlist } = await import('@scure/bip39/wordlists/english.js');
2667
+ const bundle = buildPairRoutes({
2668
+ sessionsPath: CONFIG.pairSessionsPath,
2669
+ apiBase: '/plugin/totalreclaw/pair',
2670
+ logger: api.logger,
2671
+ validateMnemonic: (p) => validateMnemonic(p, wordlist),
2672
+ completePairing: async ({ mnemonic }) => {
2673
+ // Write credentials.json + flip state to 'active' via
2674
+ // fs-helpers. This centralizes disk I/O off the
2675
+ // pair-http surface (scanner isolation).
2676
+ const creds = loadCredentialsJson(CREDENTIALS_PATH) ?? {};
2677
+ const next = { ...creds, mnemonic };
2678
+ if (!writeCredentialsJson(CREDENTIALS_PATH, next)) {
2679
+ return { state: 'error', error: 'credentials_write_failed' };
2680
+ }
2681
+ // Hot-reload: update the runtime override so existing
2682
+ // in-memory state picks up the new phrase without a
2683
+ // process restart.
2684
+ setRecoveryPhraseOverride(mnemonic);
2685
+ // Flip onboarding state. writeOnboardingState is in
2686
+ // fs-helpers; dynamic import to keep it out of any
2687
+ // potential scanner collision surface in this file.
2688
+ const { writeOnboardingState } = await import('./fs-helpers.js');
2689
+ writeOnboardingState(CONFIG.onboardingStatePath, {
2690
+ onboardingState: 'active',
2691
+ createdBy: 'generate',
2692
+ credentialsCreatedAt: new Date().toISOString(),
2693
+ version: '3.3.0',
2694
+ });
2695
+ return { state: 'active' };
2696
+ },
2697
+ });
2698
+ api.registerHttpRoute!({ path: bundle.finishPath, handler: bundle.handlers.finish });
2699
+ api.registerHttpRoute!({ path: bundle.startPath, handler: bundle.handlers.start });
2700
+ api.registerHttpRoute!({ path: bundle.respondPath, handler: bundle.handlers.respond });
2701
+ api.registerHttpRoute!({ path: bundle.statusPath, handler: bundle.handlers.status });
2702
+ api.logger.info('TotalReclaw: registered 4 QR-pairing HTTP routes');
2703
+ } catch (err) {
2704
+ const msg = err instanceof Error ? err.message : String(err);
2705
+ api.logger.error(`TotalReclaw: failed to register pairing HTTP routes: ${msg}`);
2706
+ }
2707
+ })();
2708
+ } else {
2709
+ api.logger.warn(
2710
+ 'api.registerHttpRoute is unavailable on this OpenClaw version — /totalreclaw pair will not work. ' +
2711
+ 'Use `openclaw totalreclaw onboard` on the gateway host instead.',
2712
+ );
2713
+ }
2714
+
2570
2715
  // ---------------------------------------------------------------
2571
2716
  // 3.2.0 — slash command `/totalreclaw {onboard,status}` (in-chat bridge)
2572
2717
  // ---------------------------------------------------------------
@@ -2583,17 +2728,48 @@ const plugin = {
2583
2728
  acceptsArgs: true,
2584
2729
  requireAuth: false,
2585
2730
  handler: async (ctx) => {
2586
- const sub = (ctx.args || '').trim().split(/\s+/)[0]?.toLowerCase() || 'help';
2731
+ const args = (ctx.args || '').trim();
2732
+ const parts = args.split(/\s+/).filter(Boolean);
2733
+ const sub = (parts[0] || 'help').toLowerCase();
2587
2734
  if (sub === 'onboard' || sub === 'setup' || sub === 'init') {
2588
2735
  return {
2589
2736
  text:
2590
- 'To set up TotalReclaw, open a terminal on this machine and run:\n\n' +
2737
+ 'To set up TotalReclaw on a local machine, run:\n\n' +
2591
2738
  ' openclaw totalreclaw onboard\n\n' +
2592
- 'Why a terminal? Your recovery phrase must never pass through the ' +
2593
- 'LLM provider. This chat message is visible to the LLM, so we do ' +
2594
- 'not show the phrase here. The wizard runs entirely on your local ' +
2595
- 'machine the phrase is generated, displayed, and stored on disk ' +
2596
- '(mode 0600) without ever touching the network.',
2739
+ 'For a REMOTE gateway (VPS, home server, etc.) use QR-pairing:\n\n' +
2740
+ ' /totalreclaw pair\n\n' +
2741
+ 'Why not paste the phrase here? Chat messages are visible to the ' +
2742
+ 'LLM. Both flows keep your recovery phrase off the LLM transcript: ' +
2743
+ 'the CLI wizard runs on your terminal, and the QR-pair flow ' +
2744
+ 'encrypts the phrase in your browser before upload.',
2745
+ };
2746
+ }
2747
+ if (sub === 'pair') {
2748
+ // 3.3.0 — remote QR pairing. The slash command is a non-secret
2749
+ // pointer: it tells the operator to run the CLI on the gateway
2750
+ // host (which emits the QR + URL + code). Running the full
2751
+ // pairing protocol directly from this handler would require
2752
+ // sending the URL + code through the chat transcript, which
2753
+ // the LLM would then see — acceptable for the URL + code (both
2754
+ // are non-secret, because the gateway ephemeral pk lives in
2755
+ // the URL fragment and the 6-digit code is one-shot), but
2756
+ // requires the gateway to actually be reachable AND the user
2757
+ // to type a code from chat into a browser on a different
2758
+ // device. Design doc section 4a recommends the CLI path as
2759
+ // primary. Chat-delivery is a future 3.4.0 enhancement.
2760
+ return {
2761
+ text:
2762
+ 'Remote pairing (QR):\n\n' +
2763
+ ' On the gateway host, run:\n\n' +
2764
+ ' openclaw totalreclaw pair # generate new account\n' +
2765
+ ' openclaw totalreclaw pair import # import existing\n\n' +
2766
+ 'It will print a QR code + a 6-digit secondary code + a URL. ' +
2767
+ 'Scan the QR with your phone (or open the URL on any browser). ' +
2768
+ 'Enter the 6-digit code in the browser, write down (or paste) ' +
2769
+ 'your recovery phrase, and the gateway will activate.\n\n' +
2770
+ 'The phrase is generated (or pasted) in your BROWSER and ' +
2771
+ 'encrypted end-to-end before upload. It never touches the ' +
2772
+ 'LLM, this chat, or the relay server in plaintext.',
2597
2773
  };
2598
2774
  }
2599
2775
  if (sub === 'status') {
@@ -2610,13 +2786,14 @@ const plugin = {
2610
2786
  `TotalReclaw onboarding state: ${stateLabel}.\n` +
2611
2787
  (stateLabel === 'active'
2612
2788
  ? 'Memory tools are active on this machine.'
2613
- : 'Memory tools are gated. Run `openclaw totalreclaw onboard` in a terminal to complete setup.'),
2789
+ : 'Memory tools are gated. Run `openclaw totalreclaw onboard` (local) or `openclaw totalreclaw pair` (remote) to complete setup.'),
2614
2790
  };
2615
2791
  }
2616
2792
  return {
2617
2793
  text:
2618
2794
  'TotalReclaw slash commands:\n' +
2619
2795
  ' /totalreclaw onboard — how to set up TotalReclaw securely\n' +
2796
+ ' /totalreclaw pair — remote-gateway QR-pairing (3.3.0)\n' +
2620
2797
  ' /totalreclaw status — current onboarding state',
2621
2798
  };
2622
2799
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@totalreclaw/totalreclaw",
3
- "version": "3.2.3",
3
+ "version": "3.3.0-rc.1",
4
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": [
@@ -34,7 +34,8 @@
34
34
  "@totalreclaw/client": "^1.2.0",
35
35
  "@totalreclaw/core": "^2.1.1",
36
36
  "@huggingface/transformers": "^4.0.1",
37
- "onnxruntime-node": "^1.24.0"
37
+ "onnxruntime-node": "^1.24.0",
38
+ "qrcode-terminal": "^0.12.0"
38
39
  },
39
40
  "files": [
40
41
  "*.ts",
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 account key will be created (or imported) in your\n' +
100
+ 'BROWSER and delivered to this gateway encrypted end-to-end. The key\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 key for\n' +
121
+ ' 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;