@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
package/pair-cli.ts CHANGED
@@ -85,8 +85,22 @@ export interface PairCliOutcome {
85
85
  * as the session reaches a terminal state — same status-code
86
86
  * semantics as 'human' (0 on completed, 1 on expired/rejected/error,
87
87
  * 130 on canceled).
88
+ * - 'url-pin': (3.3.1-rc.15, issue #87) headless container-agent fallback.
89
+ * Emits ONLY `{ v, url, pin, expires_at_ms }` — no QR ASCII, no SID,
90
+ * no mode echo. Use when a container-based agent cannot see the
91
+ * `totalreclaw_pair` tool (OpenClaw gateway-to-container tool-injection
92
+ * gap) and must shell out to the CLI. Guarantees zero phrase material
93
+ * on stdout by construction — pair-crypto is x25519-only and the slim
94
+ * payload carries nothing BIP-39-adjacent.
95
+ * - 'pair-only': (3.3.1-rc.18, issue #95) the same surface as 'url-pin',
96
+ * but the URL field is named `pair_url` (matching the spec wording
97
+ * for `openclaw totalreclaw onboard --pair-only`). Used by the
98
+ * onboard CLI's `--pair-only` flag to provide a phrase-safe
99
+ * alternative to the interactive phrase-print path. Emits ONLY
100
+ * `{ v, pair_url, pin, expires_at_ms }`. Same zero-phrase invariant
101
+ * as 'url-pin' — the underlying pair flow does no BIP-39 work.
88
102
  */
89
- export type PairCliOutputMode = 'human' | 'json';
103
+ export type PairCliOutputMode = 'human' | 'json' | 'url-pin' | 'pair-only';
90
104
 
91
105
  /**
92
106
  * JSON payload emitted by runPairCli when outputMode === 'json'. Printed
@@ -103,6 +117,31 @@ export interface PairCliJsonPayload {
103
117
  qr_ascii: string;
104
118
  }
105
119
 
120
+ /**
121
+ * Slim payload for outputMode === 'url-pin'. Intentionally a subset of
122
+ * `PairCliJsonPayload` with no QR ASCII, SID, or mode echo. Issue #87.
123
+ */
124
+ export interface PairCliUrlPinPayload {
125
+ v: 1;
126
+ url: string;
127
+ pin: string;
128
+ expires_at_ms: number;
129
+ }
130
+
131
+ /**
132
+ * Slim payload for outputMode === 'pair-only'. Same shape as
133
+ * `PairCliUrlPinPayload` but with `pair_url` instead of `url` — the
134
+ * key name matches the spec for `onboard --pair-only` (issue #95).
135
+ * Phrase invariant: zero BIP-39 material on stdout by construction
136
+ * (the pair flow is x25519-only).
137
+ */
138
+ export interface PairCliPairOnlyPayload {
139
+ v: 1;
140
+ pair_url: string;
141
+ pin: string;
142
+ expires_at_ms: number;
143
+ }
144
+
106
145
  // ---------------------------------------------------------------------------
107
146
  // Default stdout IO
108
147
  // ---------------------------------------------------------------------------
@@ -213,9 +252,13 @@ export async function runPairCli(
213
252
  return { status: 'error', error: msg };
214
253
  }
215
254
 
216
- // 2. Render the QR promise-based so both human + json modes share it.
255
+ // 2. Build the URL unconditionally, but only render the QR for modes
256
+ // that actually emit it. url-pin and pair-only modes skip the
257
+ // renderer entirely — no CPU cost, no qrcode-terminal import, no
258
+ // ASCII on stdout.
217
259
  const url = deps.renderPairingUrl(session);
218
- const qrAscii = await new Promise<string>((resolve) => {
260
+ const skipsQr = outputMode === 'url-pin' || outputMode === 'pair-only';
261
+ const qrAscii = skipsQr ? '' : await new Promise<string>((resolve) => {
219
262
  // Guard against QR renderers that never fire their callback (shouldn't
220
263
  // happen with qrcode-terminal, but defensive): a 10-second timeout
221
264
  // returns an empty string so we never hang the pairing flow.
@@ -241,8 +284,25 @@ export async function runPairCli(
241
284
  }
242
285
  });
243
286
 
244
- // 3. Emit the visible surface (JSON first — single line — or human copy).
245
- if (outputMode === 'json') {
287
+ // 3. Emit the visible surface (JSON/url-pin/pair-only first — single
288
+ // line or human copy).
289
+ if (outputMode === 'url-pin') {
290
+ const payload: PairCliUrlPinPayload = {
291
+ v: 1,
292
+ url,
293
+ pin: session.secondaryCode,
294
+ expires_at_ms: session.expiresAtMs,
295
+ };
296
+ stdout.write(JSON.stringify(payload) + '\n');
297
+ } else if (outputMode === 'pair-only') {
298
+ const payload: PairCliPairOnlyPayload = {
299
+ v: 1,
300
+ pair_url: url,
301
+ pin: session.secondaryCode,
302
+ expires_at_ms: session.expiresAtMs,
303
+ };
304
+ stdout.write(JSON.stringify(payload) + '\n');
305
+ } else if (outputMode === 'json') {
246
306
  const payload: PairCliJsonPayload = {
247
307
  v: 1,
248
308
  sid: session.sid,
@@ -276,7 +336,10 @@ export async function runPairCli(
276
336
  canceled = true;
277
337
  });
278
338
 
279
- // 5. Poll
339
+ // 5. Poll — status transitions only surface in human mode; json /
340
+ // url-pin / pair-only modes stay silent after the single payload
341
+ // line so agents parsing stdout get one JSON line and an exit
342
+ // code, nothing else.
280
343
  const emitStatus = (text: string): void => {
281
344
  if (outputMode === 'human') stdout.write(text);
282
345
  };
@@ -399,14 +462,19 @@ export function registerPairCli(
399
462
  'Pair a remote browser device to this gateway (mode = generate | import; default generate)',
400
463
  )
401
464
  .option('--json', 'Emit a single JSON payload (url/pin/sid/qr_ascii) instead of the human-readable banner. Enables agent-driven pairing.')
465
+ .option('--url-pin-only', 'Emit ONLY {v,url,pin,expires_at_ms} — no QR ASCII, no SID, no mode echo. Headless fallback for container-based agents where the totalreclaw_pair tool is not injected (issue #87). Zero phrase exposure on stdout.')
402
466
  .option('--timeout <sec>', 'Session TTL in seconds (default: 900 = 15 min, matches pair-session-store default)')
403
467
  .action(async (...args: unknown[]) => {
404
468
  // commander passes: [modeArg, options, cmd]
405
469
  const modeRaw = typeof args[0] === 'string' ? args[0] : undefined;
406
- const opts = (args[1] ?? {}) as { json?: boolean; timeout?: string | number };
470
+ const opts = (args[1] ?? {}) as { json?: boolean; urlPinOnly?: boolean; timeout?: string | number };
407
471
  const mode: PairCliMode =
408
472
  modeRaw === 'import' || modeRaw === 'imp' ? 'import' : 'generate';
409
- const outputMode: PairCliOutputMode = opts.json ? 'json' : 'human';
473
+ // --url-pin-only wins over --json when both are passed, since it is
474
+ // strictly the tighter surface (no QR, no SID). The flag is a subset.
475
+ const outputMode: PairCliOutputMode = opts.urlPinOnly
476
+ ? 'url-pin'
477
+ : opts.json ? 'json' : 'human';
410
478
  let ttlSeconds: number | undefined;
411
479
  if (typeof opts.timeout === 'number' && Number.isFinite(opts.timeout)) {
412
480
  ttlSeconds = opts.timeout;
package/pair-crypto.ts CHANGED
@@ -1,19 +1,24 @@
1
1
  /**
2
- * pair-crypto — gateway-side cryptographic primitives for the v3.3.0
3
- * QR-pairing flow.
2
+ * pair-crypto — gateway-side cryptographic primitives for the v3.3.x
3
+ * relay-brokered pair flow.
4
4
  *
5
- * Cipher suite (per design doc section 3a-3b, ratified 2026-04-20):
5
+ * Cipher suite (design doc 3a-3b, cipher swap ratified 2026-04-23 / rc.12):
6
6
  * - ECDH on x25519 for key agreement.
7
7
  * - HKDF-SHA256 for symmetric-key derivation from the shared secret.
8
- * - ChaCha20-Poly1305 AEAD for the ciphertext payload, with the sid
9
- * bound as associated data (AD = sid UTF-8 bytes).
8
+ * - AES-256-GCM AEAD for the ciphertext payload, with the sid bound as
9
+ * associated data (AD = sid UTF-8 bytes, 12-byte nonce, 16-byte tag).
10
+ *
11
+ * rc.4..rc.11 used ChaCha20-Poly1305, but the Web Crypto API does NOT
12
+ * implement ChaCha20-Poly1305 in Chrome / Safari / Edge. The pair-page
13
+ * submit path silently threw `Algorithm: Unrecognized name` before
14
+ * reaching the network. rc.12 swaps the cipher suite to AES-256-GCM
15
+ * (universally supported in WebCrypto) and bumps HKDF_INFO to v2 so
16
+ * cross-version mis-pairs fail closed rather than garble.
10
17
  *
11
18
  * Every primitive is provided by the Node built-in `node:crypto` module
12
19
  * on Node 18.19+ and above. NO third-party crypto dependency is added
13
- * to the plugin for the gateway side. (The BROWSER side of the flow in
14
- * P2 uses WebCrypto with a `@noble/curves` + `@noble/ciphers` fallback
15
- * for older Safari — those ship as part of the served pairing page and
16
- * do NOT affect the plugin's server-side dep tree.)
20
+ * to the plugin for the gateway side. (The BROWSER side of the flow uses
21
+ * WebCrypto's AES-GCM directly no shim needed.)
17
22
  *
18
23
  * Scope and guarantees
19
24
  * --------------------
@@ -33,11 +38,11 @@
33
38
  *
34
39
  * Interoperability with browser WebCrypto
35
40
  * ---------------------------------------
36
- * The WebCrypto x25519 + HKDF + ChaCha20-Poly1305 APIs are bit-for-bit
37
- * compatible with Node's `crypto` as long as:
41
+ * The WebCrypto x25519 + HKDF + AES-GCM APIs are bit-for-bit compatible
42
+ * with Node's `crypto` as long as:
38
43
  * - Raw 32-byte public/private keys are used (not DER/SPKI).
39
44
  * - HKDF parameters are (hash=SHA-256, salt=sid bytes, info fixed
40
- * ASCII string, length=32 bytes → 256-bit AEAD key).
45
+ * ASCII string "totalreclaw-pair-v2", length=32 bytes).
41
46
  * - AEAD uses a 12-byte random nonce + 16-byte tag, AD = sid bytes.
42
47
  * See tests for fixed test vectors.
43
48
  */
@@ -60,18 +65,23 @@ import {
60
65
 
61
66
  /**
62
67
  * HKDF "info" parameter — fixes the domain separation for this protocol.
63
- * MUST match the browser-side constant in the pair-page bundle (P2).
64
- * Versioned so we can roll to a new KDF without breaking old ciphertexts.
68
+ * MUST match the browser-side constant in the pair-page bundle + the
69
+ * relay-served pair-html page.
70
+ *
71
+ * Versioned so we can roll to a new KDF or cipher suite without silently
72
+ * producing garbage with old ciphertexts. rc.12: bumped from v1 to v2
73
+ * after cipher-suite swap from ChaCha20-Poly1305 → AES-256-GCM (see
74
+ * module header comment for context).
65
75
  */
66
- export const HKDF_INFO = 'totalreclaw-pair-v1';
76
+ export const HKDF_INFO = 'totalreclaw-pair-v2';
67
77
 
68
- /** HKDF output length — 32 bytes = 256-bit ChaCha20-Poly1305 key. */
78
+ /** HKDF output length — 32 bytes = 256-bit AES-256-GCM key. */
69
79
  export const AEAD_KEY_BYTES = 32;
70
80
 
71
- /** ChaCha20-Poly1305 nonce length — 12 bytes per RFC 7539. */
81
+ /** AES-GCM nonce length — 12 bytes (SP 800-38D recommendation). */
72
82
  export const AEAD_NONCE_BYTES = 12;
73
83
 
74
- /** ChaCha20-Poly1305 auth tag length — 16 bytes standard. */
84
+ /** AES-GCM auth tag length — 16 bytes (128 bits, standard). */
75
85
  export const AEAD_TAG_BYTES = 16;
76
86
 
77
87
  /** Raw x25519 public/private key length — 32 bytes per RFC 7748. */
@@ -101,7 +111,7 @@ export interface GatewayKeypair {
101
111
 
102
112
  /** Fully-derived session keys — caller uses kEnc for AEAD ops. */
103
113
  export interface SessionKeys {
104
- /** 32-byte ChaCha20-Poly1305 key. */
114
+ /** 32-byte AES-256-GCM key. */
105
115
  kEnc: Buffer;
106
116
  }
107
117
 
@@ -323,9 +333,9 @@ export function deriveAeadKeyFromEcdh(opts: {
323
333
  // ---------------------------------------------------------------------------
324
334
 
325
335
  /**
326
- * Decrypt a ChaCha20-Poly1305 AEAD ciphertext. Returns the plaintext
327
- * on success; throws if the tag is invalid (which includes both
328
- * tampering and wrong-key attempts).
336
+ * Decrypt an AES-256-GCM AEAD ciphertext. Returns the plaintext on
337
+ * success; throws if the tag is invalid (which includes both tampering
338
+ * and wrong-key attempts).
329
339
  *
330
340
  * Ciphertext is expected in the combined form `plaintext || tag`, where
331
341
  * tag is the trailing 16 bytes. The caller MUST supply the same
@@ -351,7 +361,7 @@ export function aeadDecrypt(opts: {
351
361
  const ct = combined.subarray(0, combined.length - AEAD_TAG_BYTES);
352
362
  const tag = combined.subarray(combined.length - AEAD_TAG_BYTES);
353
363
 
354
- const decipher = createDecipheriv('chacha20-poly1305', opts.kEnc, nonce, {
364
+ const decipher = createDecipheriv('aes-256-gcm', opts.kEnc, nonce, {
355
365
  authTagLength: AEAD_TAG_BYTES,
356
366
  });
357
367
  decipher.setAAD(Buffer.from(opts.sid, 'utf-8'), { plaintextLength: ct.length });
@@ -409,7 +419,7 @@ export function aeadEncryptWithSessionKey(opts: {
409
419
  }
410
420
 
411
421
  const pt = Buffer.isBuffer(opts.plaintext) ? opts.plaintext : Buffer.from(opts.plaintext);
412
- const cipher = createCipheriv('chacha20-poly1305', opts.kEnc, nonceBuf, {
422
+ const cipher = createCipheriv('aes-256-gcm', opts.kEnc, nonceBuf, {
413
423
  authTagLength: AEAD_TAG_BYTES,
414
424
  });
415
425
  cipher.setAAD(Buffer.from(opts.sid, 'utf-8'), { plaintextLength: pt.length });
package/pair-page.ts CHANGED
@@ -5,6 +5,17 @@
5
5
  * the URL fragment (`#pk=...`), runs the client-side pairing flow
6
6
  * ENTIRELY in the browser, and POSTs the encrypted payload back.
7
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
+ *
8
19
  * Brand tokens imported from the v5b.html public site (colors, font
9
20
  * stack). Typography falls back to system fonts for mobile parity —
10
21
  * we don't ship Euclid Circular A bytes over the pairing HTTP surface.
@@ -364,7 +375,9 @@ button.secondary:hover:not(:disabled) { border-color: var(--border-accent); colo
364
375
  // Client-observed time at page load (used to adjust for clock skew).
365
376
  const CLIENT_EPOCH_AT_LOAD = Date.now();
366
377
 
367
- const HKDF_INFO = "totalreclaw-pair-v1";
378
+ // v2: cipher-suite swap in rc.12 (see pair-crypto.ts header). Keep
379
+ // this constant in lockstep with the gateway-side pair-crypto.ts.
380
+ const HKDF_INFO = "totalreclaw-pair-v2";
368
381
 
369
382
  // ---------- Small utilities ----------
370
383
  function $(sel, root) { return (root || document).querySelector(sel); }
@@ -473,10 +486,9 @@ button.secondary:hover:not(:disabled) { border-color: var(--border-accent); colo
473
486
  }
474
487
 
475
488
  // ---------- Crypto shims: prefer WebCrypto; fall back to JS path ----------
476
- // WebCrypto's x25519 + HKDF + ChaCha20-Poly1305 availability is Safari 17+
477
- // and modern Chromium / Firefox. If absent we render an error — the page
478
- // is self-contained, and we elect not to bundle @noble/curves + ciphers
479
- // for the MVP (tracked as Wave 3.1 polish follow-up).
489
+ // WebCrypto's x25519 + HKDF + AES-GCM availability is Safari 17+ and
490
+ // modern Chromium 133+ / Firefox 130+. If absent we render an error
491
+ // — the page is self-contained and we do not bundle any JS crypto shim.
480
492
  async function ensureWebCryptoSupport() {
481
493
  if (!window.crypto || !crypto.subtle) return false;
482
494
  try {
@@ -505,29 +517,28 @@ button.secondary:hover:not(:disabled) { border-color: var(--border-accent); colo
505
517
  );
506
518
  return new Uint8Array(bits);
507
519
  }
508
- // AEAD: WebCrypto offers AES-GCM universally; ChaCha20-Poly1305 support is
509
- // newer. We attempt chacha first; if it throws, we abort (do NOT silently
510
- // swap ciphers that would mismatch the gateway).
511
- async function aeadEncryptChaCha(keyBytes, nonce, sid, plaintext) {
520
+ // AEAD: AES-256-GCM (universal in WebCrypto). Cipher swap rationale
521
+ // lives in pair-crypto.ts header comment (rc.12 changelog entry).
522
+ async function aeadEncryptAesGcm(keyBytes, nonce, sid, plaintext) {
512
523
  const key = await crypto.subtle.importKey(
513
524
  "raw", keyBytes,
514
- { name: "ChaCha20-Poly1305" },
525
+ { name: "AES-GCM" },
515
526
  false, ["encrypt"],
516
527
  );
517
528
  const adBytes = new TextEncoder().encode(sid);
518
529
  const ct = new Uint8Array(await crypto.subtle.encrypt(
519
- { name: "ChaCha20-Poly1305", iv: nonce, additionalData: adBytes, tagLength: 128 },
530
+ { name: "AES-GCM", iv: nonce, additionalData: adBytes, tagLength: 128 },
520
531
  key, plaintext,
521
532
  ));
522
533
  return ct;
523
534
  }
524
535
 
525
- async function chaChaSupported() {
536
+ async function aesGcmSupported() {
526
537
  try {
527
538
  const k = new Uint8Array(32);
528
539
  const n = new Uint8Array(12);
529
- const key = await crypto.subtle.importKey("raw", k, { name: "ChaCha20-Poly1305" }, false, ["encrypt"]);
530
- await crypto.subtle.encrypt({ name: "ChaCha20-Poly1305", iv: n, additionalData: new Uint8Array(0), tagLength: 128 }, key, new Uint8Array(0));
540
+ const key = await crypto.subtle.importKey("raw", k, { name: "AES-GCM" }, false, ["encrypt"]);
541
+ await crypto.subtle.encrypt({ name: "AES-GCM", iv: n, additionalData: new Uint8Array(0), tagLength: 128 }, key, new Uint8Array(0));
531
542
  return true;
532
543
  } catch (e) { return false; }
533
544
  }
@@ -742,8 +753,8 @@ button.secondary:hover:not(:disabled) { border-color: var(--border-accent); colo
742
753
  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+."));
743
754
  return;
744
755
  }
745
- if (!(await chaChaSupported())) {
746
- render(renderError("Your browser does not support ChaCha20-Poly1305. Update your browser or use a different device."));
756
+ if (!(await aesGcmSupported())) {
757
+ render(renderError("Your browser does not support AES-GCM. Update your browser or use a different device."));
747
758
  return;
748
759
  }
749
760
 
@@ -762,7 +773,7 @@ button.secondary:hover:not(:disabled) { border-color: var(--border-accent); colo
762
773
  const nonce = new Uint8Array(12);
763
774
  crypto.getRandomValues(nonce);
764
775
  const ptBytes = new TextEncoder().encode(mnemonic);
765
- const ct = await aeadEncryptChaCha(kEnc, nonce, SID, ptBytes);
776
+ const ct = await aeadEncryptAesGcm(kEnc, nonce, SID, ptBytes);
766
777
 
767
778
  // 6. Zero sensitive buffers BEFORE sending. The JS GC will run
768
779
  // whenever it runs; explicit zeroing is best-effort but honours
package/pair-qr.ts ADDED
@@ -0,0 +1,152 @@
1
+ /**
2
+ * pair-qr — QR encoders for the rc.5 pair-tool payload.
3
+ *
4
+ * Two helpers that render the pair URL as either a PNG (for
5
+ * image-capable chat transports like Telegram) or a Unicode block
6
+ * string (for terminal-only transports like the OpenClaw native CLI).
7
+ *
8
+ * Phrase-safety invariant (see
9
+ * `project_phrase_safety_rule.md` in the internal repo):
10
+ * The QR payload is ONLY the pair URL. The 6-digit PIN is a separate
11
+ * out-of-band confirmation — it is NEVER baked into the QR. The URL
12
+ * itself carries only the session token + gateway ephemeral pubkey
13
+ * (fragment-encoded).
14
+ *
15
+ * Size profile for a typical ~110-char pair URL:
16
+ * - `encodePng` defaults (scale=10, margin=4): ~4 KiB PNG, ~5.5 KiB
17
+ * base64. Fits comfortably in a tool-call response.
18
+ * - `encodeUnicode` (margin=2): ~1.1 KiB, 23 lines.
19
+ *
20
+ * We wrap the `qrcode` npm package (~50 KiB, pure TS, no native
21
+ * bindings) so neither tsx dev runs nor the published plugin depend on
22
+ * the bundler understanding CJS vs ESM subpath imports. All we use is
23
+ * the high-level `toBuffer` / `toString` surface.
24
+ */
25
+
26
+ // Lazy import shape — avoids pulling `qrcode` into module load when the
27
+ // tool is never invoked. The `qrcode` types ship with the package but
28
+ // we describe the tiny surface we use here so a future type-drift in
29
+ // upstream doesn't break the build.
30
+ type QrCodeModule = {
31
+ toBuffer(
32
+ text: string,
33
+ options?: { errorCorrectionLevel?: 'L' | 'M' | 'Q' | 'H'; scale?: number; margin?: number },
34
+ ): Promise<Buffer>;
35
+ toString(
36
+ text: string,
37
+ options?: { type?: 'utf8'; errorCorrectionLevel?: 'L' | 'M' | 'Q' | 'H'; margin?: number },
38
+ ): Promise<string>;
39
+ };
40
+
41
+ /**
42
+ * Error class raised by the rc.5 QR encoders.
43
+ *
44
+ * Extends the built-in `Error` so `instanceof Error` checks in callers
45
+ * keep working.
46
+ */
47
+ export class QREncodeError extends Error {
48
+ constructor(message: string) {
49
+ super(message);
50
+ this.name = 'QREncodeError';
51
+ }
52
+ }
53
+
54
+ // QR v40 maxes out at ~2953 alphanumeric bytes at ECC-L. We reject
55
+ // payloads over 2 KiB — the pair URL should never approach this. This
56
+ // is defence-in-depth against a caller accidentally feeding a
57
+ // phrase-length blob.
58
+ const MAX_PAYLOAD_BYTES = 2048;
59
+
60
+ function validatePayload(url: unknown): asserts url is string {
61
+ if (typeof url !== 'string') {
62
+ throw new QREncodeError(`url must be a string, got ${typeof url}`);
63
+ }
64
+ if (url.length === 0) {
65
+ throw new QREncodeError('url must not be empty');
66
+ }
67
+ const bytes = Buffer.byteLength(url, 'utf-8');
68
+ if (bytes > MAX_PAYLOAD_BYTES) {
69
+ throw new QREncodeError(
70
+ `url too large for QR encoding: ${bytes} bytes (max ${MAX_PAYLOAD_BYTES}). ` +
71
+ 'This limit prevents accidentally encoding phrase-length blobs; a pair ' +
72
+ 'URL should be ~80-150 bytes.',
73
+ );
74
+ }
75
+ }
76
+
77
+ async function loadQrCodeModule(): Promise<QrCodeModule> {
78
+ const raw = (await import('qrcode' as string)) as unknown as
79
+ | (QrCodeModule & { default?: QrCodeModule })
80
+ | { default: QrCodeModule };
81
+ // qrcode ships CJS; the `default` export contains the surface under
82
+ // both Node's ESM interop and tsx's loader.
83
+ const mod = (raw as { default?: QrCodeModule }).default ?? (raw as QrCodeModule);
84
+ return mod;
85
+ }
86
+
87
+ export interface EncodePngOptions {
88
+ /** Error-correction level. Defaults to `'M'` (15%). */
89
+ ecc?: 'L' | 'M' | 'Q' | 'H';
90
+ /** Pixels per module. Defaults to 10 → ~300x300 px image. */
91
+ boxSize?: number;
92
+ /** Quiet-zone width in modules. Defaults to 4 (QR standard minimum). */
93
+ border?: number;
94
+ }
95
+
96
+ export interface EncodeUnicodeOptions {
97
+ /** Error-correction level. Defaults to `'M'`. */
98
+ ecc?: 'L' | 'M' | 'Q' | 'H';
99
+ /** Quiet-zone width in modules. Defaults to 2. */
100
+ border?: number;
101
+ }
102
+
103
+ /**
104
+ * Render `url` as a PNG QR code.
105
+ *
106
+ * @throws {QREncodeError} if the URL is empty, non-string, or exceeds
107
+ * the 2 KiB safety cap.
108
+ */
109
+ export async function encodePng(
110
+ url: string,
111
+ options: EncodePngOptions = {},
112
+ ): Promise<Buffer> {
113
+ validatePayload(url);
114
+ const qr = await loadQrCodeModule();
115
+ try {
116
+ return await qr.toBuffer(url, {
117
+ errorCorrectionLevel: options.ecc ?? 'M',
118
+ scale: Math.max(1, options.boxSize ?? 10),
119
+ margin: Math.max(0, options.border ?? 4),
120
+ });
121
+ } catch (err) {
122
+ const msg = err instanceof Error ? err.message : String(err);
123
+ throw new QREncodeError(`QR encoding failed: ${msg}`);
124
+ }
125
+ }
126
+
127
+ /**
128
+ * Render `url` as a Unicode block QR string (for terminal output).
129
+ *
130
+ * Uses half-block glyphs so each character represents two vertical
131
+ * pixels — the resulting string renders square-ish in terminals with
132
+ * ~2:1 line-height. Emitted as a single newline-delimited string.
133
+ *
134
+ * @throws {QREncodeError} on invalid input.
135
+ */
136
+ export async function encodeUnicode(
137
+ url: string,
138
+ options: EncodeUnicodeOptions = {},
139
+ ): Promise<string> {
140
+ validatePayload(url);
141
+ const qr = await loadQrCodeModule();
142
+ try {
143
+ return await qr.toString(url, {
144
+ type: 'utf8',
145
+ errorCorrectionLevel: options.ecc ?? 'M',
146
+ margin: Math.max(0, options.border ?? 2),
147
+ });
148
+ } catch (err) {
149
+ const msg = err instanceof Error ? err.message : String(err);
150
+ throw new QREncodeError(`QR encoding failed: ${msg}`);
151
+ }
152
+ }