@totalreclaw/totalreclaw 3.3.1-rc.2 → 3.3.1-rc.21

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 (70) hide show
  1. package/CHANGELOG.md +330 -0
  2. package/SKILL.md +50 -83
  3. package/api-client.ts +18 -11
  4. package/config.ts +117 -3
  5. package/crypto.ts +10 -2
  6. package/dist/api-client.js +226 -0
  7. package/dist/billing-cache.js +100 -0
  8. package/dist/claims-helper.js +606 -0
  9. package/dist/config.js +280 -0
  10. package/dist/consolidation.js +258 -0
  11. package/dist/contradiction-sync.js +1034 -0
  12. package/dist/crypto.js +138 -0
  13. package/dist/digest-sync.js +361 -0
  14. package/dist/download-ux.js +63 -0
  15. package/dist/embedding.js +86 -0
  16. package/dist/extractor.js +1225 -0
  17. package/dist/first-run.js +103 -0
  18. package/dist/fs-helpers.js +563 -0
  19. package/dist/gateway-url.js +197 -0
  20. package/dist/generate-mnemonic.js +13 -0
  21. package/dist/hot-cache-wrapper.js +101 -0
  22. package/dist/import-adapters/base-adapter.js +64 -0
  23. package/dist/import-adapters/chatgpt-adapter.js +238 -0
  24. package/dist/import-adapters/claude-adapter.js +114 -0
  25. package/dist/import-adapters/gemini-adapter.js +201 -0
  26. package/dist/import-adapters/index.js +26 -0
  27. package/dist/import-adapters/mcp-memory-adapter.js +219 -0
  28. package/dist/import-adapters/mem0-adapter.js +158 -0
  29. package/dist/import-adapters/types.js +1 -0
  30. package/dist/index.js +5348 -0
  31. package/dist/llm-client.js +686 -0
  32. package/dist/llm-profile-reader.js +346 -0
  33. package/dist/lsh.js +62 -0
  34. package/dist/onboarding-cli.js +750 -0
  35. package/dist/pair-cli.js +344 -0
  36. package/dist/pair-crypto.js +359 -0
  37. package/dist/pair-http.js +404 -0
  38. package/dist/pair-page.js +826 -0
  39. package/dist/pair-qr.js +107 -0
  40. package/dist/pair-remote-client.js +410 -0
  41. package/dist/pair-session-store.js +566 -0
  42. package/dist/pin.js +542 -0
  43. package/dist/qa-bug-report.js +301 -0
  44. package/dist/relay-headers.js +44 -0
  45. package/dist/reranker.js +442 -0
  46. package/dist/retype-setscope.js +348 -0
  47. package/dist/semantic-dedup.js +75 -0
  48. package/dist/subgraph-search.js +289 -0
  49. package/dist/subgraph-store.js +694 -0
  50. package/dist/tool-gating.js +58 -0
  51. package/download-ux.ts +91 -0
  52. package/embedding.ts +32 -9
  53. package/fs-helpers.ts +124 -0
  54. package/gateway-url.ts +57 -9
  55. package/index.ts +586 -357
  56. package/llm-client.ts +211 -23
  57. package/lsh.ts +7 -2
  58. package/onboarding-cli.ts +114 -1
  59. package/package.json +19 -5
  60. package/pair-cli.ts +76 -8
  61. package/pair-crypto.ts +34 -24
  62. package/pair-page.ts +28 -17
  63. package/pair-qr.ts +152 -0
  64. package/pair-remote-client.ts +540 -0
  65. package/qa-bug-report.ts +381 -0
  66. package/relay-headers.ts +50 -0
  67. package/reranker.ts +73 -0
  68. package/retype-setscope.ts +12 -0
  69. package/subgraph-search.ts +4 -3
  70. package/subgraph-store.ts +109 -16
@@ -0,0 +1,826 @@
1
+ /**
2
+ * pair-page — the self-contained HTML + JS + CSS bundle served by the
3
+ * `/plugin/totalreclaw/pair/finish` handler. The phone or laptop
4
+ * browser loads this, reads the gateway's ephemeral public key from
5
+ * the URL fragment (`#pk=...`), runs the client-side pairing flow
6
+ * ENTIRELY in the browser, and POSTs the encrypted payload back.
7
+ *
8
+ * rc.13 status: this OpenClaw-plugin local-mode page has NOT been
9
+ * ported to the wizard UX used by the relay (production) and the
10
+ * Python local-mode pages. Local-mode on the OpenClaw plugin is rarely
11
+ * exercised — the plugin defaults to a relay flow via the Hermes
12
+ * Python sidecar and only falls back here for air-gapped setups. The
13
+ * wizard UX port for this file is tracked for rc.14 alongside the
14
+ * design decision on whether to share a single CSS+JS asset across
15
+ * all three pair pages (relay / Python / plugin) or keep them
16
+ * independently inlined. For now, this file retains its rc.10–rc.12
17
+ * UX shape and the rc.12 AES-GCM cipher swap.
18
+ *
19
+ * Brand tokens imported from the v5b.html public site (colors, font
20
+ * stack). Typography falls back to system fonts for mobile parity —
21
+ * we don't ship Euclid Circular A bytes over the pairing HTTP surface.
22
+ *
23
+ * Scope and guarantees
24
+ * --------------------
25
+ * - ENTIRELY self-contained: no external asset references, no CDN,
26
+ * no Google Fonts, no script-src. The page uses only inline CSS,
27
+ * inline JS, and Web Platform APIs. Works offline once loaded.
28
+ * - No `fs.*` reads, no env-var reads — this module just exports a
29
+ * function that builds and returns the page text. Scanner clean by
30
+ * construction.
31
+ * - The browser code runs with no secret-bearing network sends
32
+ * except the final encrypted AEAD blob. All mnemonic handling
33
+ * happens in-JS; no console.log calls; cleared from memory after
34
+ * submission.
35
+ * - Mobile-first CSS, scales up to desktop.
36
+ *
37
+ * Security copy (user ratification 2026-04-20, phone-page wave):
38
+ * - Frame as "your TotalReclaw account key", not "recovery phrase".
39
+ * - Bolded "Use it ONLY with TotalReclaw" warning.
40
+ * - Concrete storage suggestions (password manager / encrypted
41
+ * notes / written-in-safe).
42
+ * - "With it you can:" + "Without it:" consequence blocks.
43
+ * - All copy surfaces BEFORE the acknowledgment gate.
44
+ */
45
+ import { wordlist as BIP39_ENGLISH_WORDLIST } from '@scure/bip39/wordlists/english.js';
46
+ // ---------------------------------------------------------------------------
47
+ // Pre-computed BIP-39 wordlist literal — emitted into the page once at
48
+ // import time so we don't re-stringify on every render. The `@scure/bip39`
49
+ // wordlist is a newline-delimited string; we split and JSON.stringify to
50
+ // emit a safe JS array literal.
51
+ // ---------------------------------------------------------------------------
52
+ function buildBip39Literal() {
53
+ // `@scure/bip39` exports the English wordlist as a `string[]` of length 2048.
54
+ const words = BIP39_ENGLISH_WORDLIST;
55
+ if (!Array.isArray(words) || words.length !== 2048) {
56
+ throw new Error(`pair-page: expected 2048 BIP-39 words, got ${Array.isArray(words) ? words.length : typeof words}`);
57
+ }
58
+ // JSON.stringify produces double-quoted strings; concatenate into a
59
+ // compact literal (no leading/trailing comma).
60
+ return words.map((w) => JSON.stringify(w)).join(',');
61
+ }
62
+ const BIP39_WORDLIST_JS_ARRAY = buildBip39Literal();
63
+ /**
64
+ * Escape a string for safe embedding between `<script>` tags.
65
+ *
66
+ * `JSON.stringify` produces a valid JS string literal but does NOT
67
+ * escape `<`, `>`, `&`, or U+2028/U+2029. All four are dangerous when
68
+ * the resulting string lands inside a `<script>` block:
69
+ * - `</script>` in the payload would close the script element and
70
+ * let subsequent bytes parse as HTML (XSS).
71
+ * - `<!--` / `-->` can start an HTML comment that would hide code
72
+ * from the browser under some parser modes.
73
+ * - U+2028 / U+2029 are line terminators in JS even though JSON
74
+ * permits them, which can break tooling.
75
+ *
76
+ * Our inputs (sid, apiBase, mode) are internally-controlled today, but
77
+ * defense-in-depth is cheap and matches OWASP's recommended pattern.
78
+ */
79
+ function escForJsString(s) {
80
+ return JSON.stringify(s)
81
+ .replace(/</g, '\\u003c')
82
+ .replace(/>/g, '\\u003e')
83
+ .replace(/&/g, '\\u0026')
84
+ .replace(/\u2028/g, '\\u2028')
85
+ .replace(/\u2029/g, '\\u2029');
86
+ }
87
+ /**
88
+ * Build the full HTML page. The mode selector + runtime parameters are
89
+ * baked in as JS constants so the page doesn't need any additional
90
+ * query round-trip on load.
91
+ */
92
+ export function renderPairPage(inputs) {
93
+ const { sid, mode, expiresAtMs, apiBase, nowMs } = inputs;
94
+ // All JS-embedded values must pass through escForJsString — it emits
95
+ // proper JS string literals (including quotes) so we interpolate them
96
+ // as bare expressions.
97
+ const SID = escForJsString(sid);
98
+ const MODE = escForJsString(mode);
99
+ const API_BASE = escForJsString(apiBase);
100
+ const EXPIRES_AT = String(expiresAtMs);
101
+ const NOW = String(nowMs);
102
+ return `<!doctype html>
103
+ <html lang="en">
104
+ <head>
105
+ <meta charset="utf-8" />
106
+ <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
107
+ <meta name="robots" content="noindex, nofollow" />
108
+ <meta name="color-scheme" content="dark" />
109
+ <title>TotalReclaw &mdash; Pair your account</title>
110
+ <style>
111
+ /* Brand tokens from public site v5b.html */
112
+ :root {
113
+ --bg: #0B0B1A;
114
+ --bg-card: rgba(16, 14, 32, 0.65);
115
+ --text: rgba(255, 255, 255, 0.78);
116
+ --text-dim: rgba(255, 255, 255, 0.50);
117
+ --text-bright: #F0EDF8;
118
+ --text-white: rgba(255, 255, 255, 0.96);
119
+ --purple: #7B5CFF;
120
+ --purple-dim: rgba(123, 92, 255, 0.14);
121
+ --purple-ring: rgba(123, 92, 255, 0.28);
122
+ --orange: #D4943A;
123
+ --success: #34d399;
124
+ --danger: #f87171;
125
+ --border: rgba(255, 255, 255, 0.08);
126
+ --border-accent: rgba(123, 92, 255, 0.24);
127
+ --mono: 'SF Mono', 'Fira Code', 'Cascadia Code', 'Menlo', 'Consolas', monospace;
128
+ --sans: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Helvetica Neue', Arial, sans-serif;
129
+ }
130
+
131
+ * { box-sizing: border-box; }
132
+ html, body { margin: 0; padding: 0; }
133
+ html { font-size: 16px; }
134
+ @media (min-width: 720px) { html { font-size: 17px; } }
135
+ body {
136
+ background: var(--bg);
137
+ color: var(--text);
138
+ font-family: var(--sans);
139
+ min-height: 100vh;
140
+ -webkit-font-smoothing: antialiased;
141
+ -moz-osx-font-smoothing: grayscale;
142
+ background-image:
143
+ radial-gradient(circle at 50% 0%, rgba(123, 92, 255, 0.12), transparent 55%),
144
+ radial-gradient(circle at 10% 90%, rgba(212, 148, 58, 0.06), transparent 45%);
145
+ background-attachment: fixed;
146
+ }
147
+
148
+ .wrap {
149
+ max-width: 560px;
150
+ margin: 0 auto;
151
+ padding: 1.5rem 1.25rem 3rem;
152
+ }
153
+ @media (min-width: 720px) { .wrap { padding: 3rem 1.5rem; } }
154
+
155
+ .brand {
156
+ display: flex; align-items: center; gap: 0.6rem;
157
+ margin-bottom: 1.5rem;
158
+ font-weight: 600; font-size: 0.95rem;
159
+ color: var(--text-bright); letter-spacing: 0.02em;
160
+ }
161
+ .brand-dot {
162
+ width: 10px; height: 10px; border-radius: 50%;
163
+ background: var(--purple);
164
+ box-shadow: 0 0 14px var(--purple-ring);
165
+ }
166
+
167
+ .card {
168
+ background: var(--bg-card);
169
+ border: 1px solid var(--border);
170
+ border-radius: 14px;
171
+ padding: 1.5rem;
172
+ backdrop-filter: blur(14px);
173
+ -webkit-backdrop-filter: blur(14px);
174
+ animation: fade-in 0.4s ease-out;
175
+ }
176
+ @media (min-width: 720px) { .card { padding: 2rem; } }
177
+
178
+ h1 {
179
+ margin: 0 0 0.5rem;
180
+ font-size: 1.55rem; font-weight: 600; letter-spacing: -0.01em;
181
+ color: var(--text-white); line-height: 1.2;
182
+ }
183
+ h2 {
184
+ margin: 1.75rem 0 0.75rem;
185
+ font-size: 1.05rem; font-weight: 600;
186
+ color: var(--text-bright); letter-spacing: 0.01em;
187
+ }
188
+ p { margin: 0 0 0.85rem; line-height: 1.55; }
189
+ strong { color: var(--text-bright); font-weight: 600; }
190
+ em { color: var(--orange); font-style: normal; font-weight: 600; }
191
+ small { color: var(--text-dim); font-size: 0.82rem; }
192
+
193
+ ul { padding-left: 1.1rem; margin: 0.25rem 0 0.85rem; }
194
+ li { margin-bottom: 0.35rem; line-height: 1.5; }
195
+
196
+ .countdown {
197
+ display: inline-flex; align-items: center; gap: 0.35rem;
198
+ margin-top: 0.25rem;
199
+ padding: 0.22rem 0.6rem;
200
+ background: var(--purple-dim); border: 1px solid var(--border-accent);
201
+ border-radius: 999px;
202
+ font-size: 0.76rem; color: var(--text-bright); font-weight: 500;
203
+ font-variant-numeric: tabular-nums;
204
+ }
205
+ .countdown.expired { background: rgba(248, 113, 113, 0.12); border-color: rgba(248, 113, 113, 0.35); color: var(--danger); }
206
+
207
+ .callout {
208
+ border-left: 3px solid var(--purple);
209
+ padding: 0.75rem 0.95rem; margin: 1rem 0;
210
+ background: var(--purple-dim);
211
+ border-radius: 0 8px 8px 0;
212
+ font-size: 0.92rem;
213
+ }
214
+ .callout.warn { border-left-color: var(--orange); background: rgba(212, 148, 58, 0.07); }
215
+ .callout.danger { border-left-color: var(--danger); background: rgba(248, 113, 113, 0.08); }
216
+
217
+ input[type=text], input[type=number], textarea {
218
+ width: 100%;
219
+ padding: 0.75rem 0.9rem;
220
+ margin: 0.25rem 0 0.5rem;
221
+ background: rgba(255,255,255,0.03);
222
+ color: var(--text-bright);
223
+ border: 1px solid var(--border);
224
+ border-radius: 8px;
225
+ font-family: var(--mono);
226
+ font-size: 1rem;
227
+ transition: border-color 0.2s, background 0.2s;
228
+ }
229
+ input:focus, textarea:focus {
230
+ outline: none;
231
+ border-color: var(--purple);
232
+ background: rgba(123, 92, 255, 0.05);
233
+ }
234
+ input.code-input {
235
+ font-size: 1.25rem; letter-spacing: 0.3em; text-align: center;
236
+ font-variant-numeric: tabular-nums;
237
+ }
238
+ textarea { min-height: 5.5em; resize: vertical; }
239
+
240
+ button {
241
+ display: inline-flex; align-items: center; justify-content: center; gap: 0.4rem;
242
+ padding: 0.8rem 1.25rem;
243
+ background: var(--purple); color: var(--text-white);
244
+ border: none; border-radius: 8px;
245
+ font-family: var(--sans); font-size: 0.95rem; font-weight: 500;
246
+ cursor: pointer;
247
+ transition: background 0.2s, transform 0.1s, box-shadow 0.2s;
248
+ min-width: 140px;
249
+ }
250
+ button:hover:not(:disabled) { background: #8b6dff; box-shadow: 0 4px 20px rgba(123, 92, 255, 0.35); }
251
+ button:active:not(:disabled) { transform: translateY(1px); }
252
+ button:disabled { opacity: 0.45; cursor: not-allowed; }
253
+ button.secondary {
254
+ background: transparent; color: var(--text);
255
+ border: 1px solid var(--border);
256
+ }
257
+ button.secondary:hover:not(:disabled) { border-color: var(--border-accent); color: var(--text-bright); background: var(--purple-dim); box-shadow: none; }
258
+
259
+ .btn-row { display: flex; flex-wrap: wrap; gap: 0.6rem; margin-top: 1rem; }
260
+
261
+ .mnemonic-grid {
262
+ display: grid;
263
+ grid-template-columns: repeat(2, 1fr);
264
+ gap: 0.5rem 0.75rem;
265
+ margin: 0.75rem 0;
266
+ font-family: var(--mono);
267
+ font-size: 0.95rem;
268
+ }
269
+ @media (min-width: 520px) { .mnemonic-grid { grid-template-columns: repeat(3, 1fr); } }
270
+ @media (min-width: 720px) { .mnemonic-grid { grid-template-columns: repeat(4, 1fr); } }
271
+
272
+ .mnemonic-word {
273
+ display: flex; align-items: baseline; gap: 0.35rem;
274
+ padding: 0.55rem 0.7rem;
275
+ background: rgba(255,255,255,0.035);
276
+ border: 1px solid var(--border);
277
+ border-radius: 7px;
278
+ animation: word-in 0.3s ease-out backwards;
279
+ }
280
+ .mnemonic-word .num {
281
+ color: var(--text-dim); font-size: 0.78rem; font-weight: 500;
282
+ min-width: 1.5rem; text-align: right;
283
+ }
284
+ .mnemonic-word .word { color: var(--text-bright); font-weight: 500; }
285
+
286
+ .pulse {
287
+ display: inline-block;
288
+ width: 10px; height: 10px; border-radius: 50%;
289
+ background: var(--purple);
290
+ box-shadow: 0 0 0 0 rgba(123, 92, 255, 0.5);
291
+ animation: pulse 1.6s infinite ease-out;
292
+ margin-right: 0.5rem; vertical-align: middle;
293
+ }
294
+ .check {
295
+ display: inline-block; width: 14px; height: 14px;
296
+ color: var(--success); vertical-align: middle; margin-right: 0.5rem;
297
+ }
298
+
299
+ @keyframes fade-in {
300
+ from { opacity: 0; transform: translateY(6px); }
301
+ to { opacity: 1; transform: translateY(0); }
302
+ }
303
+ @keyframes word-in {
304
+ from { opacity: 0; transform: translateY(4px); }
305
+ to { opacity: 1; transform: translateY(0); }
306
+ }
307
+ @keyframes pulse {
308
+ 0% { box-shadow: 0 0 0 0 rgba(123, 92, 255, 0.5); }
309
+ 75% { box-shadow: 0 0 0 12px rgba(123, 92, 255, 0); }
310
+ 100% { box-shadow: 0 0 0 0 rgba(123, 92, 255, 0); }
311
+ }
312
+
313
+ .hidden { display: none !important; }
314
+ .center { text-align: center; }
315
+ .mt-2 { margin-top: 1.25rem; }
316
+ .mono { font-family: var(--mono); }
317
+
318
+ /* Reduced-motion preference */
319
+ @media (prefers-reduced-motion: reduce) {
320
+ *, *::before, *::after { animation-duration: 0.01s !important; transition-duration: 0.01s !important; }
321
+ }
322
+ </style>
323
+ </head>
324
+ <body>
325
+ <div class="wrap">
326
+ <div class="brand">
327
+ <span class="brand-dot"></span>
328
+ <span>TotalReclaw</span>
329
+ </div>
330
+
331
+ <div class="card" id="stage">
332
+ <!-- Stages render dynamically. Initial content is Stage 0: code entry. -->
333
+ <div id="stage-body">Loading pairing session&hellip;</div>
334
+ <div class="countdown" id="countdown" aria-live="polite"></div>
335
+ </div>
336
+
337
+ <p class="mt-2 center"><small>End-to-end encrypted. Gateway does not see your key in plaintext.</small></p>
338
+ </div>
339
+
340
+ <script>
341
+ "use strict";
342
+ (function() {
343
+ // ---------- Constants injected by the server ----------
344
+ const SID = ${SID};
345
+ const MODE = ${MODE}; // "generate" or "import"
346
+ const API_BASE = ${API_BASE}; // e.g. "/plugin/totalreclaw/pair"
347
+ const EXPIRES_AT_MS = ${EXPIRES_AT};
348
+ const SERVER_NOW_MS = ${NOW};
349
+ // Client-observed time at page load (used to adjust for clock skew).
350
+ const CLIENT_EPOCH_AT_LOAD = Date.now();
351
+
352
+ // v2: cipher-suite swap in rc.12 (see pair-crypto.ts header). Keep
353
+ // this constant in lockstep with the gateway-side pair-crypto.ts.
354
+ const HKDF_INFO = "totalreclaw-pair-v2";
355
+
356
+ // ---------- Small utilities ----------
357
+ function $(sel, root) { return (root || document).querySelector(sel); }
358
+ function el(html) { const t = document.createElement("template"); t.innerHTML = html.trim(); return t.content.firstElementChild; }
359
+ function render(nodeOrHtml) {
360
+ const body = $("#stage-body");
361
+ body.innerHTML = "";
362
+ if (typeof nodeOrHtml === "string") body.innerHTML = nodeOrHtml;
363
+ else body.appendChild(nodeOrHtml);
364
+ }
365
+ function b64urlFromBytes(bytes) {
366
+ let s = "";
367
+ for (let i = 0; i < bytes.length; i++) s += String.fromCharCode(bytes[i]);
368
+ return btoa(s).replace(/\\+/g, "-").replace(/\\//g, "_").replace(/=+$/, "");
369
+ }
370
+ function bytesFromB64url(s) {
371
+ const pad = s.length % 4 === 2 ? "==" : s.length % 4 === 3 ? "=" : "";
372
+ const bin = atob(s.replace(/-/g, "+").replace(/_/g, "/") + pad);
373
+ const out = new Uint8Array(bin.length);
374
+ for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
375
+ return out;
376
+ }
377
+ function zeroBytes(u8) { if (u8 && u8.fill) u8.fill(0); }
378
+
379
+ // ---------- Parse the pk from the URL fragment ----------
380
+ function parseFragmentPk() {
381
+ const hash = window.location.hash || "";
382
+ const m = /(?:^#|&)pk=([A-Za-z0-9_-]+)/.exec(hash);
383
+ return m ? m[1] : null;
384
+ }
385
+ const PK_GATEWAY_B64 = parseFragmentPk();
386
+
387
+ // ---------- Countdown ----------
388
+ function updateCountdown() {
389
+ const elNode = $("#countdown");
390
+ if (!elNode) return;
391
+ const clientNow = Date.now();
392
+ const serverNow = SERVER_NOW_MS + (clientNow - CLIENT_EPOCH_AT_LOAD);
393
+ const remaining = Math.max(0, EXPIRES_AT_MS - serverNow);
394
+ const mm = Math.floor(remaining / 60000);
395
+ const ss = Math.floor((remaining % 60000) / 1000);
396
+ if (remaining === 0) {
397
+ elNode.classList.add("expired");
398
+ elNode.textContent = "Expired";
399
+ } else {
400
+ elNode.textContent = "Expires in " + mm + ":" + String(ss).padStart(2, "0");
401
+ }
402
+ }
403
+ setInterval(updateCountdown, 1000);
404
+
405
+ // ---------- Bip39 wordlist (English), inlined as base64url for compactness ----------
406
+ // The full English BIP-39 wordlist — 2048 words, one per line. This is
407
+ // the canonical list from the BIP-39 spec. We inline it so the page is
408
+ // fully self-contained. ~13KB raw, compresses well over gzip.
409
+ const BIP39_WORDLIST = [${BIP39_WORDLIST_JS_ARRAY}];
410
+
411
+ if (BIP39_WORDLIST.length !== 2048) {
412
+ render(renderError("Internal: bundled wordlist has " + BIP39_WORDLIST.length + " entries (expected 2048). Cannot proceed."));
413
+ return;
414
+ }
415
+
416
+ function generateMnemonic128() {
417
+ const entropy = new Uint8Array(16);
418
+ crypto.getRandomValues(entropy);
419
+ return entropyToMnemonic(entropy);
420
+ }
421
+
422
+ async function entropyToMnemonic(entropy) {
423
+ // BIP-39: checksum = first (len/4) bits of SHA-256(entropy), appended.
424
+ // 128-bit entropy → 4-bit checksum → 132 bits → 12 words × 11 bits.
425
+ const digest = new Uint8Array(await crypto.subtle.digest("SHA-256", entropy));
426
+ const csBits = entropy.length / 4; // 4 for 128
427
+ const bits = [];
428
+ for (let i = 0; i < entropy.length; i++) {
429
+ for (let b = 7; b >= 0; b--) bits.push((entropy[i] >> b) & 1);
430
+ }
431
+ for (let i = 0; i < csBits; i++) bits.push((digest[0] >> (7 - i)) & 1);
432
+ const words = [];
433
+ for (let i = 0; i < bits.length; i += 11) {
434
+ let n = 0;
435
+ for (let k = 0; k < 11; k++) n = (n << 1) | bits[i + k];
436
+ words.push(BIP39_WORDLIST[n]);
437
+ }
438
+ return words.join(" ");
439
+ }
440
+
441
+ async function validateMnemonic(phrase) {
442
+ const words = phrase.trim().toLowerCase().split(/\\s+/);
443
+ if (words.length !== 12) return false;
444
+ const bits = [];
445
+ for (const w of words) {
446
+ const idx = BIP39_WORDLIST.indexOf(w);
447
+ if (idx < 0) return false;
448
+ for (let b = 10; b >= 0; b--) bits.push((idx >> b) & 1);
449
+ }
450
+ // 132 bits: 128 entropy + 4 checksum.
451
+ const entropy = new Uint8Array(16);
452
+ for (let i = 0; i < 128; i++) entropy[i >> 3] |= bits[i] << (7 - (i & 7));
453
+ const digest = new Uint8Array(await crypto.subtle.digest("SHA-256", entropy));
454
+ for (let i = 0; i < 4; i++) {
455
+ const got = bits[128 + i];
456
+ const want = (digest[0] >> (7 - i)) & 1;
457
+ if (got !== want) return false;
458
+ }
459
+ return true;
460
+ }
461
+
462
+ // ---------- Crypto shims: prefer WebCrypto; fall back to JS path ----------
463
+ // WebCrypto's x25519 + HKDF + AES-GCM availability is Safari 17+ and
464
+ // modern Chromium 133+ / Firefox 130+. If absent we render an error
465
+ // — the page is self-contained and we do not bundle any JS crypto shim.
466
+ async function ensureWebCryptoSupport() {
467
+ if (!window.crypto || !crypto.subtle) return false;
468
+ try {
469
+ const kp = await crypto.subtle.generateKey({ name: "X25519" }, true, ["deriveBits"]);
470
+ return !!kp.privateKey && !!kp.publicKey;
471
+ } catch (e) {
472
+ return false;
473
+ }
474
+ }
475
+
476
+ async function x25519GenerateKeyPair() {
477
+ const kp = await crypto.subtle.generateKey({ name: "X25519" }, true, ["deriveBits"]);
478
+ const rawPub = new Uint8Array(await crypto.subtle.exportKey("raw", kp.publicKey));
479
+ return { keyPair: kp, rawPubB64: b64urlFromBytes(rawPub) };
480
+ }
481
+ async function x25519DeriveShared(privateKey, peerPubRaw) {
482
+ const peerPub = await crypto.subtle.importKey("raw", peerPubRaw, { name: "X25519" }, false, []);
483
+ const sharedBits = await crypto.subtle.deriveBits({ name: "X25519", public: peerPub }, privateKey, 256);
484
+ return new Uint8Array(sharedBits);
485
+ }
486
+ async function hkdfSha256(sharedBytes, saltBytes, infoBytes, outLen) {
487
+ const key = await crypto.subtle.importKey("raw", sharedBytes, { name: "HKDF" }, false, ["deriveBits"]);
488
+ const bits = await crypto.subtle.deriveBits(
489
+ { name: "HKDF", hash: "SHA-256", salt: saltBytes, info: infoBytes },
490
+ key, outLen * 8,
491
+ );
492
+ return new Uint8Array(bits);
493
+ }
494
+ // AEAD: AES-256-GCM (universal in WebCrypto). Cipher swap rationale
495
+ // lives in pair-crypto.ts header comment (rc.12 changelog entry).
496
+ async function aeadEncryptAesGcm(keyBytes, nonce, sid, plaintext) {
497
+ const key = await crypto.subtle.importKey(
498
+ "raw", keyBytes,
499
+ { name: "AES-GCM" },
500
+ false, ["encrypt"],
501
+ );
502
+ const adBytes = new TextEncoder().encode(sid);
503
+ const ct = new Uint8Array(await crypto.subtle.encrypt(
504
+ { name: "AES-GCM", iv: nonce, additionalData: adBytes, tagLength: 128 },
505
+ key, plaintext,
506
+ ));
507
+ return ct;
508
+ }
509
+
510
+ async function aesGcmSupported() {
511
+ try {
512
+ const k = new Uint8Array(32);
513
+ const n = new Uint8Array(12);
514
+ const key = await crypto.subtle.importKey("raw", k, { name: "AES-GCM" }, false, ["encrypt"]);
515
+ await crypto.subtle.encrypt({ name: "AES-GCM", iv: n, additionalData: new Uint8Array(0), tagLength: 128 }, key, new Uint8Array(0));
516
+ return true;
517
+ } catch (e) { return false; }
518
+ }
519
+
520
+ // ---------- Stage renderers ----------
521
+ function renderError(msg) {
522
+ const d = el('<div><h1>Something went wrong</h1><p>' + escapeHtml(msg) + '</p><p><small>Start over from your terminal: <code>openclaw totalreclaw pair</code></small></p></div>');
523
+ return d;
524
+ }
525
+ function escapeHtml(s) {
526
+ return String(s).replace(/[&<>"]/g, function(c) {
527
+ return c === "&" ? "&amp;" : c === "<" ? "&lt;" : c === ">" ? "&gt;" : "&quot;";
528
+ });
529
+ }
530
+
531
+ // Stage 1: code entry
532
+ function renderCodeEntry() {
533
+ const node = el(
534
+ '<div>' +
535
+ '<h1>Pair with your gateway</h1>' +
536
+ '<p>Enter the 6-digit code shown in your terminal. This prevents a bystander from hijacking this pairing.</p>' +
537
+ '<input id="code-in" class="code-input" inputmode="numeric" pattern="[0-9]*" maxlength="6" autocomplete="one-time-code" autofocus />' +
538
+ '<div id="code-error" class="callout danger hidden"></div>' +
539
+ '<div class="btn-row">' +
540
+ '<button id="code-continue">Continue</button>' +
541
+ '</div>' +
542
+ '</div>'
543
+ );
544
+ render(node);
545
+ const input = $("#code-in");
546
+ input.addEventListener("input", function() {
547
+ input.value = input.value.replace(/\\D+/g, "").slice(0, 6);
548
+ });
549
+ $("#code-continue").addEventListener("click", function() { onCodeSubmit(); });
550
+ input.addEventListener("keydown", function(e) { if (e.key === "Enter") onCodeSubmit(); });
551
+ }
552
+
553
+ async function onCodeSubmit() {
554
+ const code = $("#code-in").value;
555
+ if (!/^\\d{6}$/.test(code)) {
556
+ showInlineError("code-error", "Enter all 6 digits.");
557
+ return;
558
+ }
559
+ $("#code-continue").disabled = true;
560
+
561
+ try {
562
+ const url = API_BASE + "/start?sid=" + encodeURIComponent(SID) + "&c=" + encodeURIComponent(code);
563
+ const r = await window.fetch(url, { method: "GET", cache: "no-store" });
564
+ if (r.status === 403) {
565
+ const j = await r.json().catch(function(){ return {}; });
566
+ showInlineError("code-error", j.error === "attempts_exhausted"
567
+ ? "Too many wrong codes. This pairing session is locked out. Start over from your terminal."
568
+ : "Code doesn't match. Double-check your terminal.");
569
+ $("#code-continue").disabled = false;
570
+ return;
571
+ }
572
+ if (r.status === 410) { showInlineError("code-error", "Session expired. Start over from your terminal."); return; }
573
+ if (r.status === 404) { showInlineError("code-error", "Session not found. Start over from your terminal."); return; }
574
+ if (!r.ok) { showInlineError("code-error", "Gateway error: " + r.status); return; }
575
+ const meta = await r.json();
576
+ // Proceed to the recovery-phrase stage based on mode.
577
+ if (MODE === "generate") await renderGenerateFlow(meta);
578
+ else await renderImportFlow(meta);
579
+ } catch (err) {
580
+ showInlineError("code-error", "Network error. Try again.");
581
+ $("#code-continue").disabled = false;
582
+ }
583
+ }
584
+
585
+ function showInlineError(id, msg) {
586
+ const e = $("#" + id);
587
+ if (!e) return;
588
+ e.textContent = msg;
589
+ e.classList.remove("hidden");
590
+ }
591
+
592
+ // Stage 2a: Generate a recovery phrase in-browser, show it with safety copy + ack gate
593
+ async function renderGenerateFlow(meta) {
594
+ const mnemonic = await generateMnemonic128();
595
+ const words = mnemonic.split(" ");
596
+ let gridHtml = '';
597
+ for (let i = 0; i < words.length; i++) {
598
+ gridHtml += '<div class="mnemonic-word" style="animation-delay:' + (i * 30) + 'ms"><span class="num">' + (i+1) + '.</span><span class="word">' + escapeHtml(words[i]) + '</span></div>';
599
+ }
600
+ const node = el(
601
+ '<div>' +
602
+ '<h1>This is your TotalReclaw recovery phrase</h1>' +
603
+ '<p>A new recovery phrase has been generated. Write it down now, somewhere safe. This is the only way to restore your account later.</p>' +
604
+ '<h2>Your recovery phrase</h2>' +
605
+ '<div class="mnemonic-grid">' + gridHtml + '</div>' +
606
+ '<div class="btn-row"><button id="copy" class="secondary">Copy to clipboard</button></div>' +
607
+ '<div class="callout warn"><strong>Use it ONLY with TotalReclaw.</strong> <em>Never</em> reuse this phrase for a crypto wallet, banking, email, or any other service. TotalReclaw recovery phrases must be dedicated.</div>' +
608
+ '<h2>Store it somewhere safe</h2>' +
609
+ '<p>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.</p>' +
610
+ '<ul>' +
611
+ '<li>A password manager (1Password, Bitwarden, Apple Keychain, etc.)</li>' +
612
+ '<li>An encrypted notes app (Notes with end-to-end encryption, Standard Notes, Obsidian vault)</li>' +
613
+ '<li>Written on paper in a physical safe</li>' +
614
+ '</ul>' +
615
+ '<h2>With this recovery phrase you can:</h2>' +
616
+ '<ul>' +
617
+ '<li>Restore your TotalReclaw account on any new device</li>' +
618
+ '<li>Import your memories into Hermes, OpenClaw, the MCP client, or any other TotalReclaw-enabled agent</li>' +
619
+ '<li>Reset your gateway without losing a single memory</li>' +
620
+ '</ul>' +
621
+ '<div class="callout danger"><strong>Without it:</strong> you permanently lose access to all memories across all agents. TotalReclaw cannot recover it for you.</div>' +
622
+ '<h2>Confirm</h2>' +
623
+ '<p>Before you continue, retype three of your words:</p>' +
624
+ '<div id="ack-probes"></div>' +
625
+ '<div id="ack-error" class="callout danger hidden"></div>' +
626
+ '<div class="btn-row">' +
627
+ '<button id="ack-submit">I have saved it &mdash; continue</button>' +
628
+ '</div>' +
629
+ '</div>'
630
+ );
631
+ render(node);
632
+
633
+ $("#copy").addEventListener("click", function() {
634
+ navigator.clipboard.writeText(mnemonic).then(function() {
635
+ $("#copy").textContent = "Copied \u2713";
636
+ }).catch(function() {});
637
+ });
638
+
639
+ // Random 3 distinct probe indices
640
+ const probe = pickDistinctIndices(words.length, 3);
641
+ const probeEl = $("#ack-probes");
642
+ for (const idx of probe) {
643
+ const row = el(
644
+ '<div style="display:flex; align-items:center; gap:0.75rem; margin:0.5rem 0;">' +
645
+ '<span style="min-width:5ch; color:var(--text-dim);">Word #' + (idx+1) + '</span>' +
646
+ '<input type="text" autocapitalize="none" autocorrect="off" spellcheck="false" data-idx="' + idx + '" />' +
647
+ '</div>'
648
+ );
649
+ probeEl.appendChild(row);
650
+ }
651
+
652
+ $("#ack-submit").addEventListener("click", async function() {
653
+ const inputs = probeEl.querySelectorAll("input[data-idx]");
654
+ for (const inp of inputs) {
655
+ const idx = parseInt(inp.getAttribute("data-idx"), 10);
656
+ if (inp.value.trim().toLowerCase() !== words[idx]) {
657
+ showInlineError("ack-error", "Word #" + (idx+1) + " doesn't match. Check your written copy and try again.");
658
+ return;
659
+ }
660
+ }
661
+ $("#ack-error").classList.add("hidden");
662
+ $("#ack-submit").disabled = true;
663
+ await submitEncrypted(mnemonic);
664
+ });
665
+ }
666
+
667
+ function pickDistinctIndices(n, k) {
668
+ const pool = [];
669
+ for (let i = 0; i < n; i++) pool.push(i);
670
+ const out = [];
671
+ for (let i = 0; i < k; i++) {
672
+ const j = Math.floor(Math.random() * pool.length);
673
+ out.push(pool[j]); pool.splice(j, 1);
674
+ }
675
+ out.sort(function(a,b) { return a-b; });
676
+ return out;
677
+ }
678
+
679
+ // Stage 2b: Import an existing phrase
680
+ async function renderImportFlow(meta) {
681
+ const node = el(
682
+ '<div>' +
683
+ '<h1>Import your TotalReclaw recovery phrase</h1>' +
684
+ '<p>Enter your 12-word recovery phrase to restore your account. It is processed ENTIRELY in your browser and encrypted before it leaves this page.</p>' +
685
+ '<div class="callout warn"><strong>Use it ONLY with TotalReclaw.</strong> Do <em>not</em> paste a phrase that controls a crypto wallet, banking account, email, or any other service. TotalReclaw recovery phrases must be dedicated.</div>' +
686
+ '<textarea id="phrase" autocapitalize="none" autocorrect="off" spellcheck="false" placeholder="word1 word2 word3 ... word12"></textarea>' +
687
+ '<div id="phrase-error" class="callout danger hidden"></div>' +
688
+ '<p><small>Checksum is verified in your browser. Invalid phrases are rejected before upload.</small></p>' +
689
+ '<div class="btn-row">' +
690
+ '<button id="import-submit">Upload encrypted</button>' +
691
+ '</div>' +
692
+ '</div>'
693
+ );
694
+ render(node);
695
+ $("#import-submit").addEventListener("click", async function() {
696
+ const raw = $("#phrase").value.normalize("NFKC").toLowerCase().trim().split(/\\s+/).join(" ");
697
+ const ok = await validateMnemonic(raw);
698
+ if (!ok) {
699
+ showInlineError("phrase-error", "That is not a valid 12-word recovery phrase. Check spelling, word count, and that the checksum is intact.");
700
+ return;
701
+ }
702
+ $("#phrase-error").classList.add("hidden");
703
+ $("#import-submit").disabled = true;
704
+ await submitEncrypted(raw);
705
+ });
706
+ }
707
+
708
+ // Stage 3: encrypt + upload
709
+ async function submitEncrypted(mnemonic) {
710
+ render('<div class="center"><span class="pulse"></span><span>Encrypting\u2026</span></div>');
711
+
712
+ // 1. Sanity: we MUST have pk_G from the URL fragment. Without it we cannot
713
+ // authenticate the gateway key and the whole point of the protocol is
714
+ // defeated.
715
+ if (!PK_GATEWAY_B64) {
716
+ render(renderError("Missing gateway public key in the URL fragment. This link is malformed; start over from your terminal."));
717
+ return;
718
+ }
719
+ const pkGateway = bytesFromB64url(PK_GATEWAY_B64);
720
+ if (pkGateway.length !== 32) {
721
+ render(renderError("Gateway public key is the wrong size. Start over from your terminal."));
722
+ return;
723
+ }
724
+
725
+ // 2. Ensure WebCrypto supports our ciphers.
726
+ if (!(await ensureWebCryptoSupport())) {
727
+ render(renderError("Your browser does not support modern cryptographic APIs (X25519). Please update your browser and try again, or use a different device. Supported: Chrome 123+, Firefox 130+, Safari 17+."));
728
+ return;
729
+ }
730
+ if (!(await aesGcmSupported())) {
731
+ render(renderError("Your browser does not support AES-GCM. Update your browser or use a different device."));
732
+ return;
733
+ }
734
+
735
+ try {
736
+ // 3. Generate ephemeral x25519 keypair
737
+ const dev = await x25519GenerateKeyPair();
738
+ render('<div class="center"><span class="pulse"></span><span>Pairing\u2026 deriving shared key</span></div>');
739
+
740
+ // 4. ECDH + HKDF
741
+ const shared = await x25519DeriveShared(dev.keyPair.privateKey, pkGateway);
742
+ const salt = new TextEncoder().encode(SID);
743
+ const info = new TextEncoder().encode(HKDF_INFO);
744
+ const kEnc = await hkdfSha256(shared, salt, info, 32);
745
+
746
+ // 5. Encrypt (AEAD with sid as AD)
747
+ const nonce = new Uint8Array(12);
748
+ crypto.getRandomValues(nonce);
749
+ const ptBytes = new TextEncoder().encode(mnemonic);
750
+ const ct = await aeadEncryptAesGcm(kEnc, nonce, SID, ptBytes);
751
+
752
+ // 6. Zero sensitive buffers BEFORE sending. The JS GC will run
753
+ // whenever it runs; explicit zeroing is best-effort but honours
754
+ // the design doc's "zero after post-ack" requirement.
755
+ zeroBytes(kEnc); zeroBytes(shared); zeroBytes(ptBytes);
756
+
757
+ // 7. Submit
758
+ render('<div class="center"><span class="pulse"></span><span>Uploading encrypted recovery phrase\u2026</span></div>');
759
+ const body = {
760
+ v: 1,
761
+ sid: SID,
762
+ pk_d: dev.rawPubB64,
763
+ nonce: b64urlFromBytes(nonce),
764
+ ct: b64urlFromBytes(ct),
765
+ };
766
+ const r = await window.fetch(API_BASE + "/respond", {
767
+ method: "POST",
768
+ headers: {"Content-Type": "application/json"},
769
+ cache: "no-store",
770
+ body: JSON.stringify(body),
771
+ });
772
+ if (r.status >= 200 && r.status < 300) {
773
+ const res = await r.json().catch(function(){ return {ok: true}; });
774
+ renderSuccess(res);
775
+ } else if (r.status === 410) {
776
+ render(renderError("Session expired before you submitted. Start over from your terminal."));
777
+ } else if (r.status === 409) {
778
+ render(renderError("This pairing session has already been used. Start over if you need to re-pair."));
779
+ } else if (r.status === 400) {
780
+ const j = await r.json().catch(function(){ return {}; });
781
+ render(renderError("Encryption failed validation on the gateway. Usually caused by a bad QR scan or a mid-session clock change. Start over. (" + (j.error || r.status) + ")"));
782
+ } else {
783
+ render(renderError("Gateway error: " + r.status));
784
+ }
785
+ } catch (err) {
786
+ render(renderError("Encryption or upload failed: " + (err && err.message ? err.message : String(err))));
787
+ }
788
+ }
789
+
790
+ function renderSuccess(res) {
791
+ // 3.3.0-rc.2: success screen carries the canonical storage-guidance copy
792
+ // so users see it one more time right after submit (some skipped past
793
+ // it during the generate flow). Same wording as first-run.ts
794
+ // COPY.STORAGE_GUIDANCE.
795
+ const node = el(
796
+ '<div>' +
797
+ '<h1><svg class="check" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M2 8.5l4 4 8-10" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"/></svg>Paired</h1>' +
798
+ '<p>Your TotalReclaw account is now active on this gateway. You can close this tab and go back to your terminal or chat client.</p>' +
799
+ '<div class="callout warn">Your recovery phrase is 12 words. Store it somewhere safe &mdash; a password manager works well. Use it only for TotalReclaw. Don&#39;t reuse it anywhere else. Don&#39;t put funds on it.</div>' +
800
+ '<p><small>Account id: <span class="mono">' + escapeHtml((res && res.accountId) || "(generated)") + '</span></small></p>' +
801
+ '</div>'
802
+ );
803
+ render(node);
804
+ const c = $("#countdown"); if (c) c.textContent = "Done";
805
+ }
806
+
807
+ // ---------- Boot ----------
808
+ window.addEventListener("DOMContentLoaded", function() {
809
+ updateCountdown();
810
+ if (!PK_GATEWAY_B64) {
811
+ render(renderError("No gateway key in the URL fragment. The QR scanner may have stripped it. Try re-scanning or paste the full URL from your terminal."));
812
+ return;
813
+ }
814
+ // Basic browser-feature smoke test.
815
+ if (!(window.crypto && crypto.subtle && crypto.getRandomValues)) {
816
+ render(renderError("Your browser does not expose WebCrypto. Update the browser or use Chrome/Safari/Firefox."));
817
+ return;
818
+ }
819
+ renderCodeEntry();
820
+ });
821
+ })();
822
+ </script>
823
+ </body>
824
+ </html>
825
+ `;
826
+ }