@totalreclaw/totalreclaw 3.3.1-rc.10 → 3.3.1-rc.11

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/CHANGELOG.md CHANGED
@@ -4,9 +4,54 @@ All notable changes to `@totalreclaw/totalreclaw` (the OpenClaw plugin) are docu
4
4
 
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
+ ## [3.3.1-rc.11] — 2026-04-23
8
+
9
+ OpenClaw-side universal pair reachability — the plugin's `totalreclaw_pair` tool now routes through the relay WebSocket by default, mirroring the Python `2.3.1rc10` pivot on the Hermes side. The URL returned to the user is `https://api-staging.totalreclaw.xyz/pair/p/<token>#pk=<gateway_pubkey>` instead of the previous `http://<gateway-host>:<port>/plugin/totalreclaw/pair/finish?sid=<sid>#pk=…`. Managed hosts, Docker-in-cloud setups, phone-scan-QR flows, and split-network operators can now complete pairing without the browser needing loopback or LAN access to the gateway.
10
+
11
+ Paired with Hermes Python `2.3.1rc11` — both clients now reach for the relay by default, and `TOTALRECLAW_PAIR_MODE=local` on either side restores the rc.4–rc.10 loopback flow for air-gapped / self-hosted deployments.
12
+
13
+ ### Added
14
+
15
+ - **`skill/plugin/pair-remote-client.ts`** — new. TypeScript mirror of `python/src/totalreclaw/pair/remote_client.py` (rc.10 Hermes):
16
+ - `openRemotePairSession({ relayBaseUrl?, pin?, clientId?, mode? })` — generates an ephemeral x25519 keypair via the existing `pair-crypto.ts` module, opens a WebSocket to `/pair/session/open`, sends `{type:"open", gateway_pubkey, pin, client_id, mode}`, and returns a `RemotePairSession` handle containing the user-facing URL (with `#pk=` fragment), PIN, token, expiry, and the live WebSocket.
17
+ - `awaitPhraseUpload(session, { completePairing, phraseValidator?, timeoutMs? })` — blocks on the kept-open WebSocket until the relay pushes `{type:"forward", client_pubkey, nonce, ciphertext}`. Decrypts locally via `decryptPairingPayload` using the gateway's private key (same ECDH + HKDF + ChaCha20-Poly1305 primitives as rc.10's loopback flow — byte-compatible with Python's `pair.crypto`). Runs the caller-supplied `completePairing` handler and sends `{type:"ack"}` back on success or `{type:"nack", error}` on validator / decrypt / completion failure.
18
+ - `pairViaRelay(...)` — one-shot convenience wrapper for tests and simple callers.
19
+ - **`ws` runtime dep** (`^8.18.3`) + **`@types/ws`** — pure-JS WebSocket client. Transitive already via `@totalreclaw/core`; rc.11 promotes it to a direct dep so the plugin's own import graph is explicit.
20
+ - **`TOTALRECLAW_PAIR_MODE`** env (plugin side) — mirrors the Python env. Unset or any non-`local` value routes through the relay; `local` preserves the rc.4–rc.10 loopback HTTP server served by `pair-http.ts` (`/plugin/totalreclaw/pair/{finish,start,respond,status}`).
21
+ - **`TOTALRECLAW_PAIR_RELAY_URL`** env (plugin side) — self-hosters can point at their own relay. Defaults to `wss://api-staging.totalreclaw.xyz`.
22
+ - **`skill/plugin/pair-remote-client.test.ts`** — 20 assertions across 5 scenarios: happy-path round-trip, invalid-phrase nack, relay open error, decrypt failure, https-to-wss scheme conversion. Runs against a local `ws` server stub — no network dependency.
23
+
24
+ ### Changed
25
+
26
+ - **`totalreclaw_pair` tool** now branches on `CONFIG.pairMode`. In relay mode it returns the URL + PIN immediately and schedules a background task that blocks on the WebSocket until the browser completes (or the TTL lapses). Credentials-write happens in that background task via the same `loadCredentialsJson` / `writeCredentialsJson` / `setRecoveryPhraseOverride` / `writeOnboardingState` side-effect chain that the loopback `pair-http.respond` handler uses — so the onboarding-state flip remains identical. Tool payload shape unchanged (`{url, pin, expires_at_ms, qr_ascii, qr_png_b64, qr_unicode, mode}`) except for a new `transport: 'relay' | 'local'` field that tooling (QA harness, telemetry) can use to confirm which path served a given URL.
27
+
28
+ ### Phrase-safety invariants (preserved)
29
+
30
+ - Relay is blind: the gateway's ephemeral x25519 private key never leaves the plugin host. The relay forwards opaque ciphertext; it cannot derive the symmetric key.
31
+ - PIN is out-of-band: the user reads the PIN from agent chat and types it into the browser. The relay stores the PIN in memory only; logs carry no PIN, no ciphertext, no pubkey, no phrase.
32
+ - Session state is in-memory on the relay with a 5-minute TTL. Redis deferred to Phase 2 per the design blueprint.
33
+ - Backwards-compat: `TOTALRECLAW_PAIR_MODE=local` preserves every bit of the rc.4–rc.10 flow — same loopback HTTP server, same session store, same browser page, same decrypt handler.
34
+
35
+ ### Mechanism / byte-compat
36
+
37
+ The crypto is a literal TypeScript binding against the same `pair-crypto.ts` module `pair-http.ts` already imports. No new cipher suite, no new wire format — only the transport (WebSocket to relay + relay-served HTML page) differs from the loopback path. A ciphertext produced by the relay-served `pair-html.ts` page decrypts under the same gateway private key using the same `decryptPairingPayload(...)` call path. This is deliberate: `pair-crypto.ts` is the byte-compat anchor shared with Python's `pair.crypto`, and rc.11 extends that anchor to the relay wire.
38
+
7
39
  ## [3.3.1-rc.10] — 2026-04-23
8
40
 
9
- Coordinated version bump with Hermes Python `2.3.1rc10`. rc.10 ships the relay-brokered pair flow — see `python/CHANGELOG.md` (the `2.3.1rc10` entry) for the full design. Plugin code in this release is unchanged from rc.9; the `totalreclaw_pair` pair URL on the OpenClaw plugin side still uses the gateway-loopback HTTP server (the OpenClaw plugin runs in-process alongside a browser on the same host for most deployments, so the loopback URL actually reaches the user). The relay-brokered path is currently Hermes-side only — the OpenClaw plugin can pick it up in a later RC if the same universal-reachability problem starts biting OpenClaw users.
41
+ Coordinated version bump with Hermes Python `2.3.1rc10`. rc.10 ships the relay-brokered pair flow — see `python/CHANGELOG.md` (the `2.3.1rc10` entry) for the full design. The `totalreclaw_pair` pair URL on the OpenClaw plugin side still uses the gateway-loopback HTTP server (the OpenClaw plugin runs in-process alongside a browser on the same host for most deployments, so the loopback URL actually reaches the user). The relay-brokered path is currently Hermes-side only — the OpenClaw plugin can pick it up in a later RC if the same universal-reachability problem starts biting OpenClaw users.
42
+
43
+ Bundled into rc.10: the previously-parked rc.5 QR display layer from PR #76 (`pair-qr.ts` + `pair-qr.test.ts`, tool-payload `qr_png_b64` + `qr_unicode` fields, `totalreclaw_setup` / `totalreclaw_onboarding_start` stub removal). All rebased onto main via the chore/rc.10-qr-rebase-pr76 branch.
44
+
45
+ ### Added (rebased from PR #76)
46
+
47
+ - **`skill/plugin/pair-qr.ts`** — new. QR encoder module wrapping `qrcode` (PNG) + `qrcode-terminal` (Unicode block). Same contract as the Python side (`totalreclaw.pair.qr`).
48
+ - **`totalreclaw_pair` tool payload** — the `details` block now carries `qr_png_b64` (base64 PNG for image transports) and `qr_unicode` (terminal block-char string) alongside the existing `qr_ascii`. URL + PIN unchanged.
49
+ - **SKILL.md "Rendering the QR on your transport" section** — per-transport agent rendering guidance (Telegram attachment, terminal inline, web chat `<img>` embed).
50
+ - **`qrcode` + `@types/qrcode`** runtime deps.
51
+
52
+ ### Removed (rc.5 phrase-safety carve-out closure, rebased)
53
+
54
+ - **`totalreclaw_setup` + `totalreclaw_onboarding_start`** agent tools — both were neutered pointer stubs in rc.4; rc.5 auto-QA flagged them as future-regression surface and their mere presence signalled to agents that "phrase handling happens here". Deleted outright in rc.5, preserved through rc.10. `skill/plugin/phrase-safety-registry.test.ts` now asserts neither name is registered.
10
55
 
11
56
  Version bump reason: rc cadence keeps Python + plugin aligned so the release-pipeline tracker carries them through QA as one artifact set.
12
57
 
package/SKILL.md CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: totalreclaw
3
3
  description: "Install + set up TotalReclaw encrypted memory for OpenClaw, then use totalreclaw_remember / totalreclaw_recall. Trigger on 'install TotalReclaw', 'set up TotalReclaw', 'restore my recovery phrase', or any remember / recall request."
4
- version: 3.3.1-rc.6
4
+ version: 3.3.1-rc.11
5
5
  author: TotalReclaw Team
6
6
  license: MIT
7
7
  homepage: https://totalreclaw.xyz
@@ -34,7 +34,7 @@ If the user says "install totalreclaw" (or any variant) AND no `totalreclaw_*` t
34
34
  2. Tell the user: *"Plugin installed. Restarting the gateway so the tools bind."* Then ask the user to restart the gateway: `openclaw gateway restart` (native) or `docker restart tr-openclaw` (Docker). The agent-facing shell CANNOT self-restart the gateway it's running inside of — this step has to be a user action. Wait for the user to confirm the restart is done.
35
35
  3. After restart: verify `totalreclaw_pair` is now in your toolset. If not, tell the user the restart didn't take effect and to retry the restart command. Do not loop the install.
36
36
  4. Check `~/.totalreclaw/credentials.json` (via `test -s ~/.totalreclaw/credentials.json`). If it exists, confirm *"TotalReclaw is already set up."* and stop.
37
- 5. Call `totalreclaw_pair`. Returns `{url, pin, qr_ascii, expires_at_ms}`.
37
+ 5. Call `totalreclaw_pair`. Returns `{url, pin, qr_ascii, qr_png_b64, qr_unicode, expires_at_ms}` (see "Rendering the QR on your transport" below for the rc.5+ additions).
38
38
  6. Relay verbatim: *"Open <url> in your browser. Enter your 12-word recovery phrase (or let the browser generate one). Confirm PIN <pin>. I'll wait for you to say done."*
39
39
  7. After user says done: verify `~/.totalreclaw/credentials.json`. If missing, call `totalreclaw_pair` again (PIN likely expired) and resend.
40
40
  8. Confirm: *"TotalReclaw set up ✓. Ask me to remember or recall anything — try `totalreclaw_remember` or ask me to remember something."*
@@ -47,11 +47,23 @@ Used when the user pastes a verbose prompt describing skill install + plugin ins
47
47
 
48
48
  1. `openclaw plugins install @totalreclaw/totalreclaw` (stable). For RC: `@totalreclaw/totalreclaw@rc`.
49
49
  2. Restart gateway so plugin routes + tools bind. Docker: `docker restart tr-openclaw`. Native: `openclaw gateway restart`. Wait ~5s; confirm the gateway is back up. (If you are running inside the gateway, ask the user to run the restart command and wait for confirmation.)
50
- 3. Call `totalreclaw_pair`. Returns `{url, pin, qr_ascii, expires_at_ms}`.
50
+ 3. Call `totalreclaw_pair`. Returns `{url, pin, qr_ascii, qr_png_b64, qr_unicode, expires_at_ms}` (see "Rendering the QR on your transport" below for the rc.5+ additions).
51
51
  4. Relay to user verbatim: *"Open <url> in your browser. Enter your 12-word recovery phrase (or let the browser generate one). Confirm PIN <pin>. I'll wait for you to say done."*
52
52
  5. After user says done: verify `~/.totalreclaw/credentials.json` exists and is non-empty. If missing, call `totalreclaw_pair` again (PIN likely expired) and resend.
53
53
  6. Confirm: *"TotalReclaw is set up. Ask me to remember or recall anything."*
54
54
 
55
+ ## Rendering the QR on your transport (rc.5+)
56
+
57
+ When you call `totalreclaw_pair` and receive the payload, tell the user: *"Open the URL below or scan this QR code from your phone. PIN: <pin>."*
58
+
59
+ Then render the QR based on your chat transport:
60
+
61
+ - **Transport supports image attachments** (Telegram, Slack, web chat, any transport with file/image upload): decode `qr_png_b64` from base64 to bytes, save to a tempfile with a `.png` suffix, and attach the tempfile as an image in your reply. Also show the URL + PIN as text so users who prefer clicking can still do so.
62
+ - **Terminal-only transport** (OpenClaw native CLI chat, bare SSH shell): emit the `qr_unicode` string inline in your reply. It renders as block characters that display correctly in most terminal fonts; the user scans from their phone.
63
+ - **Web chat with inline-image support**: embed as `<img src="data:image/png;base64,<qr_png_b64>" alt="Pair QR">` if the chat client renders HTML; otherwise fall back to attachment.
64
+
65
+ The QR encodes ONLY the pair URL. The 6-digit PIN is a separate out-of-band confirmation — it is deliberately NOT baked into the QR (dual-channel design: scan + type). Never log, print, or re-emit the PIN outside of the user-facing reply. The PIN is a shared secret between you and the user; it must NOT end up in any other tool call, any log file, or any memory store.
66
+
55
67
  ## Phrase safety (HARD — never break)
56
68
 
57
69
  NEVER echo / generate / ask the user to paste a recovery phrase in chat. NEVER invoke `openclaw totalreclaw onboard`, `totalreclaw setup`, or any phrase-touching CLI via your shell tool (stdout enters LLM context). The ONLY agent-facilitated setup path is `totalreclaw_pair`. If the user pastes a phrase anyway: tell them it is compromised, call `totalreclaw_pair` with `mode=generate` for a fresh wallet.
package/config.ts CHANGED
@@ -105,6 +105,21 @@ export const CONFIG = {
105
105
  // for 15-min TTL windows; 0600 mode.
106
106
  pairSessionsPath: process.env.TOTALRECLAW_PAIR_SESSIONS_PATH || path.join(home, '.totalreclaw', 'pair-sessions.json'),
107
107
 
108
+ // 3.3.1-rc.11 — pair-flow transport selector. Mirrors the Python-side
109
+ // `TOTALRECLAW_PAIR_MODE` env (rc.10). `'relay'` (default) routes
110
+ // `totalreclaw_pair` through the universal-reachability WebSocket relay at
111
+ // `TOTALRECLAW_PAIR_RELAY_URL`. `'local'` preserves the rc.4–rc.10 loopback
112
+ // HTTP flow (the plugin serves `/plugin/totalreclaw/pair/*` via
113
+ // `pair-http.ts`). Air-gapped / self-hosted users can pin `'local'` here.
114
+ pairMode: (() => {
115
+ const v = (process.env.TOTALRECLAW_PAIR_MODE ?? '').trim().toLowerCase();
116
+ return v === 'local' ? 'local' : 'relay';
117
+ })() as 'relay' | 'local',
118
+ // 3.3.1-rc.11 — relay base URL for the WebSocket-brokered pair flow.
119
+ // `wss://` preferred; `https://` is rewritten in the remote-client.
120
+ pairRelayUrl: (process.env.TOTALRECLAW_PAIR_RELAY_URL
121
+ || 'wss://api-staging.totalreclaw.xyz').replace(/\/+$/, ''),
122
+
108
123
  // Chain — chainId is no longer user-configurable. It is auto-detected from
109
124
  // the relay billing response (free = Base Sepolia / 84532, Pro = Gnosis /
110
125
  // 100). The default here is used only before the first billing lookup
package/index.ts CHANGED
@@ -4891,162 +4891,36 @@ const plugin = {
4891
4891
  );
4892
4892
 
4893
4893
  // ---------------------------------------------------------------
4894
- // Tool: totalreclaw_setup (DEPRECATED in 3.2.0)
4894
+ // Tools: totalreclaw_setup + totalreclaw_onboarding_start
4895
+ // REMOVED in 3.3.1-rc.5 (phrase-safety carve-out closure).
4895
4896
  // ---------------------------------------------------------------
4896
4897
  //
4897
- // Pre-3.2.0 behaviour: auto-generate a mnemonic + return it in the tool
4898
- // response body so the LLM surfaces it to the user. That path shipped
4899
- // the recovery phrase to the LLM provider's logs — incompatible with
4900
- // TotalReclaw's "server cannot read your memories" pitch. 3.2.0
4901
- // replaces it with a pointer-only stub.
4898
+ // rc.4 left these two registrations in place as *neutered* stubs
4899
+ // ``totalreclaw_setup`` rejected any ``recovery_phrase`` argument
4900
+ // and returned a CLI-pointer message; ``totalreclaw_onboarding_start``
4901
+ // was already pointer-only. Neither path could leak a phrase in
4902
+ // rc.4, but the rc.4 auto-QA (2026-04-22) flagged them as future-
4903
+ // regression surface: any future patch that re-enables phrase
4904
+ // acceptance (e.g. a flag-driven "power-user" path) would silently
4905
+ // re-open the leak, and their mere presence in the tool registry
4906
+ // keeps signalling to agents that "phrase handling happens here".
4902
4907
  //
4903
- // Kept registered (rather than deleted) for back-compat — LLMs that
4904
- // learned the old tool name from training data won't silently succeed
4905
- // if the user asks them to set up memory. They'll call this tool,
4906
- // receive a pointer to `openclaw totalreclaw onboard`, and the flow
4907
- // continues on the user's TTY.
4908
+ // Per ``project_phrase_safety_rule.md`` the ONLY approved agent-
4909
+ // facilitated setup surface is ``totalreclaw_pair`` (browser-side
4910
+ // crypto keeps the phrase out of the LLM round-trip by construction).
4911
+ // rc.5 deletes both registrations outright. The underlying CLI
4912
+ // wizard (``openclaw totalreclaw onboard``) is unchanged — users
4913
+ // run it in their own terminal, outside any agent shell.
4908
4914
  //
4909
- // The `recovery_phrase` param is kept in the schema so existing tool
4910
- // calls parse but ANY phrase the caller passes is rejected, and the
4911
- // tool NEVER writes credentials.json. All real setup happens in the
4912
- // CLI wizard.
4913
- api.registerTool(
4914
- {
4915
- name: 'totalreclaw_setup',
4916
- label: 'TotalReclaw setup (deprecated — redirect to CLI)',
4917
- description:
4918
- 'DEPRECATED in 3.2.0. This tool no longer accepts recovery phrases or performs ' +
4919
- 'setup. It returns a pointer to `openclaw totalreclaw onboard` — the secure CLI ' +
4920
- 'wizard that runs on the user\'s terminal so the phrase never touches the LLM ' +
4921
- 'provider. Prefer calling `totalreclaw_onboarding_start` for the same pointer.',
4922
- parameters: {
4923
- type: 'object',
4924
- properties: {
4925
- recovery_phrase: {
4926
- type: 'string',
4927
- description:
4928
- 'Legacy parameter — IGNORED in 3.2.0. If provided, the tool returns a ' +
4929
- 'security warning explaining that phrases must never be pasted through ' +
4930
- 'chat. Use the `openclaw totalreclaw onboard` CLI wizard to import an ' +
4931
- 'existing phrase safely.',
4932
- },
4933
- },
4934
- additionalProperties: false,
4935
- },
4936
- async execute(_toolCallId: string, params: { recovery_phrase?: string }) {
4937
- // Phrase-passing is a security boundary violation in 3.2.0. Reject
4938
- // with a message that explains WHY — the LLM might try again with
4939
- // a different shape otherwise.
4940
- if (typeof params?.recovery_phrase === 'string' && params.recovery_phrase.trim().length > 0) {
4941
- api.logger.warn(
4942
- 'totalreclaw_setup: rejected phrase-passing call (3.2.0 deprecation).',
4943
- );
4944
- return {
4945
- content: [{
4946
- type: 'text',
4947
- text:
4948
- 'For security, TotalReclaw no longer accepts a recovery phrase through ' +
4949
- 'chat. Pasting a phrase into this tool would ship it to the LLM provider, ' +
4950
- 'which defeats the whole point of end-to-end encryption.\n\n' +
4951
- 'Ask the user to open a terminal on their machine and run:\n\n' +
4952
- ' openclaw totalreclaw onboard\n\n' +
4953
- 'The wizard imports an existing phrase via a hidden stdin prompt that ' +
4954
- 'never touches the LLM, the transcript, or the network.',
4955
- }],
4956
- };
4957
- }
4958
-
4959
- // No-arg call against an already-active state: confirm + move on.
4960
- const state = resolveOnboardingState(CREDENTIALS_PATH, CONFIG.onboardingStatePath);
4961
- if (state.onboardingState === 'active') {
4962
- return {
4963
- content: [{
4964
- type: 'text',
4965
- text:
4966
- 'TotalReclaw is already set up and active on this machine. Memory tools ' +
4967
- 'are unblocked — you can call `totalreclaw_remember` and `totalreclaw_recall` ' +
4968
- 'directly. If the user wants to rotate phrases, have them delete ' +
4969
- '`~/.totalreclaw/credentials.json` and run `openclaw totalreclaw onboard` again.',
4970
- }],
4971
- };
4972
- }
4973
-
4974
- // Fresh state, no phrase: redirect to the CLI wizard.
4975
- return {
4976
- content: [{
4977
- type: 'text',
4978
- text:
4979
- 'TotalReclaw setup must run on the user\'s local terminal so the recovery ' +
4980
- 'phrase never touches the LLM. Ask the user to open a terminal and run:\n\n' +
4981
- ' openclaw totalreclaw onboard\n\n' +
4982
- 'The wizard walks through generate-new-phrase or import-existing-phrase. ' +
4983
- 'After it completes, memory tools become available automatically. See the ' +
4984
- '`totalreclaw_onboarding_start` tool for the same pointer in a more ' +
4985
- 'discoverable shape.',
4986
- }],
4987
- };
4988
- },
4989
- },
4990
- { name: 'totalreclaw_setup' },
4991
- );
4992
-
4993
- // ---------------------------------------------------------------
4994
- // Tool: totalreclaw_onboarding_start (3.2.0 pointer-only tool)
4995
- // ---------------------------------------------------------------
4915
+ // Audit assertion: ``phrase-safety-registry.test.ts`` asserts
4916
+ // neither name is present in the ``api.registerTool`` call list.
4917
+ // Re-adding either fails CI.
4996
4918
  //
4997
- // When the user asks the LLM to set up TotalReclaw, this tool directs
4998
- // them to the CLI wizard. The response body is a non-secret pointer —
4999
- // it NEVER contains a recovery phrase — so it can safely flow through
5000
- // the LLM provider and the transcript.
5001
- api.registerTool(
5002
- {
5003
- name: 'totalreclaw_onboarding_start',
5004
- label: 'TotalReclaw — start onboarding',
5005
- description:
5006
- 'Call this when the user wants to set up TotalReclaw memory or asks about ' +
5007
- 'enabling memory features. This tool does NOT generate, display, or accept ' +
5008
- 'a recovery phrase — it returns a short pointer that tells the user to run ' +
5009
- 'the onboarding wizard in their local terminal. All phrase handling happens ' +
5010
- 'outside the LLM. If TotalReclaw is already active, the tool returns a ' +
5011
- 'confirmation.',
5012
- parameters: {
5013
- type: 'object',
5014
- properties: {},
5015
- additionalProperties: false,
5016
- },
5017
- async execute() {
5018
- const state = resolveOnboardingState(CREDENTIALS_PATH, CONFIG.onboardingStatePath);
5019
- if (state.onboardingState === 'active') {
5020
- return {
5021
- content: [{
5022
- type: 'text',
5023
- text:
5024
- 'TotalReclaw is already set up on this machine. Your encryption keys are ' +
5025
- 'ready — `totalreclaw_remember`, `totalreclaw_recall`, and the other memory ' +
5026
- 'tools are unblocked. Run `openclaw totalreclaw status` in a terminal for ' +
5027
- 'more detail.',
5028
- }],
5029
- };
5030
- }
5031
- return {
5032
- content: [{
5033
- type: 'text',
5034
- text:
5035
- 'TotalReclaw onboarding requires a local terminal so your recovery phrase ' +
5036
- 'never touches the LLM provider. On the same machine as your OpenClaw ' +
5037
- 'gateway, open a terminal and run:\n\n' +
5038
- ' openclaw totalreclaw onboard\n\n' +
5039
- 'The wizard will ask whether you want to generate a new phrase or import an ' +
5040
- 'existing TotalReclaw phrase. Both paths display/accept the phrase only on ' +
5041
- 'your terminal — nothing crosses the network. After the wizard completes, ' +
5042
- 'come back here and I\'ll be able to use `totalreclaw_remember` and ' +
5043
- '`totalreclaw_recall`.',
5044
- }],
5045
- };
5046
- },
5047
- },
5048
- { name: 'totalreclaw_onboarding_start' },
5049
- );
4919
+ // Historical tombstone (so LLM-assisted contributors don't re-add
4920
+ // the former shape from training-data memory): rc.4 registered two
4921
+ // tools by the names "totalreclaw_setup" and
4922
+ // "totalreclaw_onboarding_start" as pointer-only stubs. Both were
4923
+ // deleted in rc.5. Do not re-introduce.
5050
4924
 
5051
4925
  // ---------------------------------------------------------------
5052
4926
  // Tool: totalreclaw_onboard — REMOVED in 3.3.1-rc.4 (phrase-safety).
@@ -5122,18 +4996,99 @@ const plugin = {
5122
4996
  const rawMode = params?.mode;
5123
4997
  const mode: 'generate' | 'import' =
5124
4998
  rawMode === 'import' ? 'import' : 'generate';
4999
+ const pairMode = CONFIG.pairMode;
5125
5000
  try {
5126
- const { createPairSession } = await import('./pair-session-store.js');
5127
- const { generateGatewayKeypair } = await import('./pair-crypto.js');
5001
+ // 3.3.1-rc.11 relay-brokered pair by default (universal reachability).
5002
+ // `TOTALRECLAW_PAIR_MODE=local` preserves the rc.4–rc.10 loopback flow
5003
+ // for air-gapped / self-hosted setups. Both paths return the same
5004
+ // tool payload (`{url, pin, expires_at_ms, qr_*, mode, instructions}`);
5005
+ // only the URL origin differs.
5006
+ let url: string;
5007
+ let pin: string;
5008
+ let sidOrToken: string;
5009
+ let expiresAtMs: number;
5010
+ let localSession: import('./pair-session-store.js').PairSession | undefined;
5011
+
5012
+ if (pairMode === 'relay') {
5013
+ const { openRemotePairSession, awaitPhraseUpload } = await import(
5014
+ './pair-remote-client.js'
5015
+ );
5016
+ const remoteSession = await openRemotePairSession({
5017
+ relayBaseUrl: CONFIG.pairRelayUrl,
5018
+ mode: mode === 'generate' ? 'generate' : 'import',
5019
+ });
5020
+ url = remoteSession.url;
5021
+ pin = remoteSession.pin;
5022
+ sidOrToken = remoteSession.token;
5023
+ // Relay sends ISO-8601; convert to ms for tool payload parity.
5024
+ const parsed = Date.parse(remoteSession.expiresAt);
5025
+ expiresAtMs = Number.isFinite(parsed)
5026
+ ? parsed
5027
+ : Date.now() + 5 * 60_000;
5028
+ // Background task — writes credentials.json + flips state when
5029
+ // the browser completes the flow. Tool handler returns
5030
+ // immediately so the agent can tell the user the URL + PIN.
5031
+ void (async () => {
5032
+ try {
5033
+ await awaitPhraseUpload(remoteSession, {
5034
+ phraseValidator: (p: string) =>
5035
+ validateMnemonic(p, wordlist),
5036
+ completePairing: async ({ mnemonic }) => {
5037
+ try {
5038
+ const creds =
5039
+ loadCredentialsJson(CREDENTIALS_PATH) ?? {};
5040
+ const next = { ...creds, mnemonic };
5041
+ if (!writeCredentialsJson(CREDENTIALS_PATH, next)) {
5042
+ return { state: 'error', error: 'credentials_write_failed' };
5043
+ }
5044
+ setRecoveryPhraseOverride(mnemonic);
5045
+ writeOnboardingState(CONFIG.onboardingStatePath, {
5046
+ onboardingState: 'active',
5047
+ createdBy: mode === 'generate' ? 'generate' : 'import',
5048
+ credentialsCreatedAt: new Date().toISOString(),
5049
+ version: '3.3.1-rc.11',
5050
+ });
5051
+ api.logger.info(
5052
+ `totalreclaw_pair(relay): session ${remoteSession.token.slice(0, 8)}… completed; credentials written`,
5053
+ );
5054
+ return { state: 'active' };
5055
+ } catch (err: unknown) {
5056
+ const msg = err instanceof Error ? err.message : String(err);
5057
+ api.logger.error(
5058
+ `totalreclaw_pair(relay): completePairing failed: ${msg}`,
5059
+ );
5060
+ return { state: 'error', error: msg };
5061
+ }
5062
+ },
5063
+ });
5064
+ } catch (bgErr: unknown) {
5065
+ // Expected on TTL expiry / user-aborts — log at warn, not error.
5066
+ const bgMsg = bgErr instanceof Error ? bgErr.message : String(bgErr);
5067
+ api.logger.warn(
5068
+ `totalreclaw_pair(relay): background task ended for token=${remoteSession.token.slice(0, 8)}…: ${bgMsg}`,
5069
+ );
5070
+ }
5071
+ })();
5072
+ } else {
5073
+ // Local loopback path (rc.10 behaviour).
5074
+ const { createPairSession } = await import('./pair-session-store.js');
5075
+ const { generateGatewayKeypair } = await import('./pair-crypto.js');
5076
+ const kp = generateGatewayKeypair();
5077
+ const session = await createPairSession(CONFIG.pairSessionsPath, {
5078
+ mode,
5079
+ operatorContext: { channel: 'agent' },
5080
+ rngPrivateKey: () => Buffer.from(kp.skB64, 'base64url'),
5081
+ rngPublicKey: () => Buffer.from(kp.pkB64, 'base64url'),
5082
+ });
5083
+ url = buildPairingUrl(api, session);
5084
+ pin = session.secondaryCode;
5085
+ sidOrToken = session.sid;
5086
+ expiresAtMs = session.expiresAtMs;
5087
+ localSession = session;
5088
+ }
5089
+
5090
+ // QR renderers — same for both modes; input is the URL string.
5128
5091
  const { defaultRenderQr } = await import('./pair-cli.js');
5129
- const kp = generateGatewayKeypair();
5130
- const session = await createPairSession(CONFIG.pairSessionsPath, {
5131
- mode,
5132
- operatorContext: { channel: 'agent' },
5133
- rngPrivateKey: () => Buffer.from(kp.skB64, 'base64url'),
5134
- rngPublicKey: () => Buffer.from(kp.pkB64, 'base64url'),
5135
- });
5136
- const url = buildPairingUrl(api, session);
5137
5092
  const qrAscii = await new Promise<string>((resolve) => {
5138
5093
  let settled = false;
5139
5094
  const t = setTimeout(() => {
@@ -5156,16 +5111,40 @@ const plugin = {
5156
5111
  resolve('');
5157
5112
  }
5158
5113
  });
5114
+
5115
+ // 3.3.1-rc.5 — PNG + Unicode QR for multi-transport rendering.
5116
+ let qrPngB64 = '';
5117
+ let qrUnicode = '';
5118
+ try {
5119
+ const { encodePng, encodeUnicode } = await import('./pair-qr.js');
5120
+ const [pngBuf, uni] = await Promise.all([
5121
+ encodePng(url),
5122
+ encodeUnicode(url),
5123
+ ]);
5124
+ qrPngB64 = pngBuf.toString('base64');
5125
+ qrUnicode = uni;
5126
+ } catch (qrErr: unknown) {
5127
+ api.logger.warn(
5128
+ `totalreclaw_pair: QR encode failed (non-fatal): ${
5129
+ qrErr instanceof Error ? qrErr.message : String(qrErr)
5130
+ }`,
5131
+ );
5132
+ }
5133
+
5159
5134
  api.logger.info(
5160
- `totalreclaw_pair: session ${session.sid.slice(0, 8)}… mode=${mode} url=${url}`,
5135
+ `totalreclaw_pair: session ${sidOrToken.slice(0, 8)}… mode=${mode} transport=${pairMode} url=${url} qr_png=${qrPngB64.length} qr_unicode=${qrUnicode.length}`,
5161
5136
  );
5137
+ // Voidly reference localSession so TS does not flag the unused
5138
+ // local-branch binding. Future rc.12 diagnostics can expose
5139
+ // `session.mode` / `session.status` separately.
5140
+ void localSession;
5162
5141
  return {
5163
5142
  content: [{
5164
5143
  type: 'text',
5165
5144
  text:
5166
5145
  `Pairing session started.\n\n` +
5167
5146
  `URL: ${url}\n\n` +
5168
- `PIN (type this into the browser): ${session.secondaryCode}\n\n` +
5147
+ `PIN (type this into the browser): ${pin}\n\n` +
5169
5148
  (qrAscii ? `QR code:\n\n${qrAscii}\n\n` : '') +
5170
5149
  `Instructions for the user:\n` +
5171
5150
  `1. Open the URL above on their phone or another browser (scan the QR or copy-paste).\n` +
@@ -5179,12 +5158,18 @@ const plugin = {
5179
5158
  `This session expires in ~5 minutes. Run this tool again if you need a fresh URL.`,
5180
5159
  }],
5181
5160
  details: {
5182
- sid: session.sid,
5161
+ sid: sidOrToken,
5183
5162
  url,
5184
- pin: session.secondaryCode,
5163
+ pin,
5185
5164
  mode,
5186
- expires_at_ms: session.expiresAtMs,
5165
+ expires_at_ms: expiresAtMs,
5187
5166
  qr_ascii: qrAscii,
5167
+ qr_png_b64: qrPngB64,
5168
+ qr_unicode: qrUnicode,
5169
+ // rc.11 — surface the transport so downstream tooling (QA
5170
+ // harness asserters, telemetry) can confirm which path
5171
+ // served the URL. Either 'relay' or 'local'.
5172
+ transport: pairMode,
5188
5173
  },
5189
5174
  };
5190
5175
  } catch (err: unknown) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@totalreclaw/totalreclaw",
3
- "version": "3.3.1-rc.10",
3
+ "version": "3.3.1-rc.11",
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": [
@@ -31,11 +31,15 @@
31
31
  "author": "TotalReclaw Team",
32
32
  "license": "MIT",
33
33
  "dependencies": {
34
+ "@huggingface/transformers": "^4.0.1",
34
35
  "@totalreclaw/client": "^1.2.0",
35
36
  "@totalreclaw/core": "^2.1.1",
36
- "@huggingface/transformers": "^4.0.1",
37
+ "@types/qrcode": "^1.5.6",
38
+ "@types/ws": "^8.5.12",
37
39
  "onnxruntime-node": "^1.24.0",
38
- "qrcode-terminal": "^0.12.0"
40
+ "qrcode": "^1.5.4",
41
+ "qrcode-terminal": "^0.12.0",
42
+ "ws": "^8.18.3"
39
43
  },
40
44
  "files": [
41
45
  "*.ts",
@@ -50,7 +54,7 @@
50
54
  "skill.json"
51
55
  ],
52
56
  "scripts": {
53
- "test": "npx tsx manifest-shape.test.ts && npx tsx config-schema.test.ts && npx tsx llm-profile-reader.test.ts && npx tsx llm-client.test.ts && npx tsx llm-client-retry.test.ts && npx tsx gateway-url.test.ts && npx tsx retype-setscope.test.ts && npx tsx tool-gating.test.ts && npx tsx onboarding-noninteractive.test.ts && npx tsx pair-cli-json.test.ts && npx tsx qa-bug-report.test.ts && npx tsx nonce-serialization.test.ts && npx tsx phrase-safety-registry.test.ts",
57
+ "test": "npx tsx manifest-shape.test.ts && npx tsx config-schema.test.ts && npx tsx llm-profile-reader.test.ts && npx tsx llm-client.test.ts && npx tsx llm-client-retry.test.ts && npx tsx gateway-url.test.ts && npx tsx retype-setscope.test.ts && npx tsx tool-gating.test.ts && npx tsx onboarding-noninteractive.test.ts && npx tsx pair-cli-json.test.ts && npx tsx pair-qr.test.ts && npx tsx pair-remote-client.test.ts && npx tsx qa-bug-report.test.ts && npx tsx nonce-serialization.test.ts && npx tsx phrase-safety-registry.test.ts",
54
58
  "check-scanner": "node ../scripts/check-scanner.mjs",
55
59
  "prepublishOnly": "node ../scripts/check-scanner.mjs"
56
60
  },
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
+ }
@@ -0,0 +1,540 @@
1
+ /**
2
+ * pair-remote-client — gateway-side WebSocket client for the relay-brokered
3
+ * pair flow (plugin rc.11).
4
+ *
5
+ * TypeScript mirror of ``python/src/totalreclaw/pair/remote_client.py``. Wire
6
+ * formats (WebSocket frame shapes, URL layout, base64url encoding) match the
7
+ * Python implementation byte-for-byte so either side can open a session that
8
+ * the relay (`totalreclaw-relay`) + browser page (`pair-html.ts`) already
9
+ * understand. Crypto primitives come from the shared ``pair-crypto.ts``
10
+ * module — the same ECDH + HKDF + ChaCha20-Poly1305 stack the loopback HTTP
11
+ * server uses.
12
+ *
13
+ * Flow (this file implements the gateway half):
14
+ *
15
+ * 1. Generate an ephemeral x25519 keypair (`generateGatewayKeypair`).
16
+ * 2. Open a short-lived WebSocket to `wss://<relay>/pair/session/open`.
17
+ * 3. Send `{type: "open", gateway_pubkey, pin, client_id, mode?}`.
18
+ * 4. Receive `{type: "opened", token, short_url, expires_at}` — use these
19
+ * to build the user-facing pair URL (token + `#pk=<gateway_pubkey>`).
20
+ * 5. Block on the WebSocket until the relay pushes
21
+ * `{type: "forward", client_pubkey, nonce, ciphertext}`.
22
+ * 6. Decrypt locally via `decryptPairingPayload` using the gateway private
23
+ * key. If decrypt succeeds and phrase is valid, call the caller's
24
+ * `completePairing` handler (writes credentials.json).
25
+ * 7. Send `{type: "ack"}` back; close the WebSocket.
26
+ *
27
+ * Phrase-safety invariants preserved:
28
+ * - Relay sees only ciphertext; it cannot derive the symmetric key without
29
+ * the gateway's private key.
30
+ * - The gateway pubkey transits the relay as a label in the open frame so
31
+ * the relay can display the session, but is ALSO bound into the URL
32
+ * fragment the user opens — the fragment never hits the relay.
33
+ * - Phrase NEVER enters any logs. PIN is never logged.
34
+ * - No relay credentials are required — auth is the single-use PIN +
35
+ * 5-minute TTL + gateway ECDH private key.
36
+ *
37
+ * Scope / scanner surface:
38
+ * - NO `fs.*` primitives (delegates credentials writes to the caller via
39
+ * `completePairing`). Safe for the check-scanner cross-rule guard.
40
+ * - NO env-var reads. Caller passes `relayBaseUrl` explicitly; the plugin
41
+ * sources it from the `TOTALRECLAW_PAIR_RELAY_URL` env (via `config.ts`)
42
+ * or falls back to the staging default.
43
+ */
44
+
45
+ import { randomBytes, randomInt } from 'node:crypto';
46
+
47
+ import WebSocket from 'ws';
48
+
49
+ import {
50
+ decryptPairingPayload,
51
+ generateGatewayKeypair,
52
+ type GatewayKeypair,
53
+ } from './pair-crypto.js';
54
+
55
+ // ---------------------------------------------------------------------------
56
+ // Constants
57
+ // ---------------------------------------------------------------------------
58
+
59
+ /** Default relay endpoint. Caller passes `TOTALRECLAW_PAIR_RELAY_URL` via config. */
60
+ export const DEFAULT_RELAY_URL = 'wss://api-staging.totalreclaw.xyz';
61
+
62
+ /** WebSocket connect + handshake timeout (ms). */
63
+ const OPEN_TIMEOUT_MS = 10_000;
64
+
65
+ /** Default blocking-await-for-forward timeout (5 minutes — matches relay TTL). */
66
+ const DEFAULT_AWAIT_TIMEOUT_MS = 5 * 60_000;
67
+
68
+ // ---------------------------------------------------------------------------
69
+ // Types
70
+ // ---------------------------------------------------------------------------
71
+
72
+ /** Pair mode forwarded in the open frame. Relay uses it to pick the HTML panel. */
73
+ export type PairRelayMode = 'generate' | 'import' | 'either';
74
+
75
+ /**
76
+ * Handle returned by `openRemotePairSession`. Carries the user-facing URL
77
+ * + PIN + keypair + a live WebSocket. The caller normally hands the URL /
78
+ * PIN to the user via chat, then calls `awaitPhraseUpload(session, ...)` to
79
+ * block until the browser completes.
80
+ */
81
+ export interface RemotePairSession {
82
+ /** User-facing pair URL (https://… plus `#pk=` fragment). */
83
+ url: string;
84
+ /** 6-digit PIN the user types into the browser. */
85
+ pin: string;
86
+ /** Opaque session token issued by the relay. */
87
+ token: string;
88
+ /** ISO-8601 timestamp when the relay will drop the session. */
89
+ expiresAt: string;
90
+ /** Ephemeral gateway keypair for this session. `skB64` stays in-process. */
91
+ keypair: GatewayKeypair;
92
+ /** Relay mode forwarded in the open frame. */
93
+ mode: PairRelayMode;
94
+ /** Live WebSocket. Internal — the caller does not interact with it. */
95
+ _ws: WebSocket;
96
+ }
97
+
98
+ /** Outcome of the caller-supplied completion handler. */
99
+ export interface RelayCompletionResult {
100
+ state: 'active' | 'error';
101
+ accountId?: string;
102
+ error?: string;
103
+ }
104
+
105
+ /**
106
+ * Completion handler signature. Receives the decrypted recovery phrase as a
107
+ * plain string + the live session. Expected to write credentials.json + flip
108
+ * onboarding state. MUST NOT log or return the phrase. The returned
109
+ * `RelayCompletionResult` decides whether the relay sees `ack` or `nack`.
110
+ */
111
+ export type RelayCompletePairingHandler = (inputs: {
112
+ mnemonic: string;
113
+ session: RemotePairSession;
114
+ }) => Promise<RelayCompletionResult>;
115
+
116
+ /** Optional phrase validator — caller can pass `validateMnemonic` from `@scure/bip39`. */
117
+ export type PhraseValidator = (phrase: string) => boolean;
118
+
119
+ /** Default validator — 12 or 24 lowercase ASCII words. Matches pair-http default. */
120
+ function defaultBip39CountValidator(phrase: string): boolean {
121
+ const words = phrase.split(' ');
122
+ if (words.length !== 12 && words.length !== 24) return false;
123
+ return words.every((w) => /^[a-z]+$/.test(w));
124
+ }
125
+
126
+ /** 6-digit uniform PIN. Uses `node:crypto.randomInt` (cryptographically random). */
127
+ function defaultPin(): string {
128
+ const n = randomInt(0, 1_000_000);
129
+ return n.toString(10).padStart(6, '0');
130
+ }
131
+
132
+ /** Random hex client id. Opaque to the relay. */
133
+ function defaultClientId(): string {
134
+ return 'gw-' + randomBytes(8).toString('hex');
135
+ }
136
+
137
+ /**
138
+ * Assemble the user-facing pair URL. Converts `wss://` → `https://` and
139
+ * `ws://` → `http://` for the URL the user opens in a browser. The gateway
140
+ * pubkey lives in the URL fragment so it never hits relay logs.
141
+ */
142
+ function buildUserUrl(relayBase: string, token: string, pkB64: string): string {
143
+ let httpBase = relayBase;
144
+ if (httpBase.startsWith('wss://')) {
145
+ httpBase = 'https://' + httpBase.slice('wss://'.length);
146
+ } else if (httpBase.startsWith('ws://')) {
147
+ httpBase = 'http://' + httpBase.slice('ws://'.length);
148
+ }
149
+ return `${httpBase}/pair/p/${token}#pk=${pkB64}`;
150
+ }
151
+
152
+ /**
153
+ * Normalise the relay base URL for the WebSocket connect. We always hit
154
+ * `wss://` for the open-frame WS even if the caller passed an `https://`
155
+ * browser-facing URL in the config (most self-hosters will pass one URL for
156
+ * both). Strips trailing slashes.
157
+ */
158
+ function wsConnectBase(relayBase: string): string {
159
+ let base = relayBase.replace(/\/+$/, '');
160
+ if (base.startsWith('https://')) {
161
+ base = 'wss://' + base.slice('https://'.length);
162
+ } else if (base.startsWith('http://')) {
163
+ base = 'ws://' + base.slice('http://'.length);
164
+ }
165
+ return base;
166
+ }
167
+
168
+ // ---------------------------------------------------------------------------
169
+ // Options
170
+ // ---------------------------------------------------------------------------
171
+
172
+ export interface OpenRemotePairOptions {
173
+ /** Relay base URL. Defaults to `DEFAULT_RELAY_URL`. */
174
+ relayBaseUrl?: string;
175
+ /** Override the auto-generated PIN (tests). */
176
+ pin?: string;
177
+ /** Override the auto-generated client id (tests). */
178
+ clientId?: string;
179
+ /** Pair mode advertised in the open frame. Defaults to 'either'. */
180
+ mode?: PairRelayMode;
181
+ /** Override the random keypair generator (tests). */
182
+ keypair?: GatewayKeypair;
183
+ /** Override the WebSocket constructor (tests inject a stub). */
184
+ webSocketImpl?: typeof WebSocket;
185
+ /** Override `Date.now` for deterministic expiry strings (tests). */
186
+ now?: () => number;
187
+ }
188
+
189
+ export interface AwaitPhraseUploadOptions {
190
+ /** Completion handler — writes credentials and returns state. */
191
+ completePairing: RelayCompletePairingHandler;
192
+ /** Optional phrase validator. Defaults to 12/24-word lowercase-ASCII. */
193
+ phraseValidator?: PhraseValidator;
194
+ /** Timeout for the forward frame arrival (ms). Default 5 min. */
195
+ timeoutMs?: number;
196
+ }
197
+
198
+ // ---------------------------------------------------------------------------
199
+ // Open
200
+ // ---------------------------------------------------------------------------
201
+
202
+ /**
203
+ * Open a pair session on the relay. Returns a handle with the user-facing
204
+ * URL, 6-digit PIN, expiry, keypair, and a live WebSocket the caller holds
205
+ * until `awaitPhraseUpload` resolves.
206
+ *
207
+ * Throws if the relay responds with `{type: "error"}` or an unexpected frame.
208
+ */
209
+ export async function openRemotePairSession(
210
+ opts: OpenRemotePairOptions = {},
211
+ ): Promise<RemotePairSession> {
212
+ const relayBase = (opts.relayBaseUrl ?? DEFAULT_RELAY_URL).replace(/\/+$/, '');
213
+ const wsBase = wsConnectBase(relayBase);
214
+ const wsUrl = `${wsBase}/pair/session/open`;
215
+ const WebSocketImpl = opts.webSocketImpl ?? WebSocket;
216
+ const keypair = opts.keypair ?? generateGatewayKeypair();
217
+ const pin = opts.pin ?? defaultPin();
218
+ const clientId = opts.clientId ?? defaultClientId();
219
+ const mode: PairRelayMode = opts.mode ?? 'either';
220
+
221
+ const ws: WebSocket = new WebSocketImpl(wsUrl, {
222
+ handshakeTimeout: OPEN_TIMEOUT_MS,
223
+ });
224
+
225
+ // Wait for the WS to open (so `send` doesn't race the handshake).
226
+ try {
227
+ await waitOpen(ws, OPEN_TIMEOUT_MS);
228
+ } catch (err) {
229
+ safeClose(ws);
230
+ throw err;
231
+ }
232
+
233
+ // Send the open frame.
234
+ try {
235
+ ws.send(
236
+ JSON.stringify({
237
+ type: 'open',
238
+ gateway_pubkey: keypair.pkB64,
239
+ pin,
240
+ client_id: clientId,
241
+ mode,
242
+ }),
243
+ );
244
+ } catch (err) {
245
+ safeClose(ws);
246
+ throw err instanceof Error ? err : new Error(String(err));
247
+ }
248
+
249
+ // Wait for the opened frame.
250
+ let raw: Buffer | ArrayBuffer | string;
251
+ try {
252
+ raw = await waitNextMessage(ws, OPEN_TIMEOUT_MS);
253
+ } catch (err) {
254
+ safeClose(ws);
255
+ throw err;
256
+ }
257
+
258
+ let msg: { type?: string; [k: string]: unknown };
259
+ try {
260
+ const text = typeof raw === 'string' ? raw : Buffer.from(raw as ArrayBuffer).toString('utf-8');
261
+ msg = JSON.parse(text);
262
+ } catch {
263
+ safeClose(ws);
264
+ throw new Error('pair-remote-client: opened frame not valid JSON');
265
+ }
266
+
267
+ if (msg.type === 'error') {
268
+ const errStr = typeof msg.error === 'string' ? msg.error : 'relay_error';
269
+ safeClose(ws);
270
+ throw new Error(`pair-remote-client: session/open failed: ${errStr}`);
271
+ }
272
+
273
+ if (msg.type !== 'opened') {
274
+ safeClose(ws);
275
+ throw new Error(`pair-remote-client: unexpected response type '${String(msg.type)}'`);
276
+ }
277
+
278
+ const token = typeof msg.token === 'string' ? msg.token : '';
279
+ const expiresAt = typeof msg.expires_at === 'string' ? msg.expires_at : '';
280
+ if (!token || !expiresAt) {
281
+ safeClose(ws);
282
+ throw new Error('pair-remote-client: opened frame missing token or expires_at');
283
+ }
284
+
285
+ const url = buildUserUrl(relayBase, token, keypair.pkB64);
286
+
287
+ return {
288
+ url,
289
+ pin,
290
+ token,
291
+ expiresAt,
292
+ keypair,
293
+ mode,
294
+ _ws: ws,
295
+ };
296
+ }
297
+
298
+ // ---------------------------------------------------------------------------
299
+ // Await + decrypt + ack
300
+ // ---------------------------------------------------------------------------
301
+
302
+ /**
303
+ * Block on the WebSocket until the relay pushes the encrypted phrase, then
304
+ * decrypt and invoke `completePairing`. Sends `{type: "ack"}` on success or
305
+ * `{type: "nack", error: "..."}` on failure, then closes the WebSocket.
306
+ *
307
+ * Returns the `RelayCompletionResult` produced by the caller's handler.
308
+ *
309
+ * Caller semantics: most plugin callers schedule this as a background task so
310
+ * the `totalreclaw_pair` tool handler can return the URL + PIN to the agent
311
+ * immediately, and the phrase-upload wait happens asynchronously while the
312
+ * agent chats with the user.
313
+ */
314
+ export async function awaitPhraseUpload(
315
+ session: RemotePairSession,
316
+ opts: AwaitPhraseUploadOptions,
317
+ ): Promise<RelayCompletionResult> {
318
+ const validate = opts.phraseValidator ?? defaultBip39CountValidator;
319
+ const timeoutMs = opts.timeoutMs ?? DEFAULT_AWAIT_TIMEOUT_MS;
320
+ const ws = session._ws;
321
+
322
+ let raw: Buffer | ArrayBuffer | string;
323
+ try {
324
+ raw = await waitNextMessage(ws, timeoutMs);
325
+ } catch (err) {
326
+ safeClose(ws);
327
+ throw err;
328
+ }
329
+
330
+ let msg: { type?: string; [k: string]: unknown };
331
+ try {
332
+ const text = typeof raw === 'string' ? raw : Buffer.from(raw as ArrayBuffer).toString('utf-8');
333
+ msg = JSON.parse(text);
334
+ } catch {
335
+ safeSend(ws, { type: 'nack', error: 'bad_json' });
336
+ safeClose(ws);
337
+ throw new Error('pair-remote-client: forward frame not valid JSON');
338
+ }
339
+
340
+ if (msg.type !== 'forward') {
341
+ safeSend(ws, { type: 'nack', error: 'expected_forward' });
342
+ safeClose(ws);
343
+ throw new Error(`pair-remote-client: unexpected frame '${String(msg.type)}'`);
344
+ }
345
+
346
+ const clientPubkey = typeof msg.client_pubkey === 'string' ? msg.client_pubkey : '';
347
+ const nonce = typeof msg.nonce === 'string' ? msg.nonce : '';
348
+ const ciphertext = typeof msg.ciphertext === 'string' ? msg.ciphertext : '';
349
+ if (!clientPubkey || !nonce || !ciphertext) {
350
+ safeSend(ws, { type: 'nack', error: 'bad_forward_body' });
351
+ safeClose(ws);
352
+ throw new Error('pair-remote-client: forward frame missing required fields');
353
+ }
354
+
355
+ // Decrypt locally (ciphertext + shared-secret derivation never leave this host).
356
+ let plaintext: Buffer;
357
+ try {
358
+ plaintext = decryptPairingPayload({
359
+ skGatewayB64: session.keypair.skB64,
360
+ pkDeviceB64: clientPubkey,
361
+ sid: session.token,
362
+ nonceB64: nonce,
363
+ ciphertextB64: ciphertext,
364
+ });
365
+ } catch (err) {
366
+ safeSend(ws, { type: 'nack', error: 'decrypt_failed' });
367
+ safeClose(ws);
368
+ throw err instanceof Error ? err : new Error(String(err));
369
+ }
370
+
371
+ // Decode + normalize. Match pair-http's BIP-39 norm: NFKC → lowercase → trim → single-space.
372
+ let mnemonic: string;
373
+ try {
374
+ mnemonic = plaintext
375
+ .toString('utf-8')
376
+ .normalize('NFKC')
377
+ .toLowerCase()
378
+ .trim()
379
+ .split(/\s+/)
380
+ .join(' ');
381
+ } catch (err) {
382
+ safeSend(ws, { type: 'nack', error: 'bad_utf8' });
383
+ safeClose(ws);
384
+ throw err instanceof Error ? err : new Error(String(err));
385
+ } finally {
386
+ // Best-effort: scrub the raw plaintext buffer.
387
+ plaintext.fill(0);
388
+ }
389
+
390
+ if (!validate(mnemonic)) {
391
+ safeSend(ws, { type: 'nack', error: 'invalid_mnemonic' });
392
+ safeClose(ws);
393
+ throw new Error('pair-remote-client: phrase failed BIP-39 validation');
394
+ }
395
+
396
+ // Hand off to the caller-supplied completion handler. Wrapped in try/finally
397
+ // so we always drop our own reference to the mnemonic.
398
+ let result: RelayCompletionResult;
399
+ try {
400
+ result = await opts.completePairing({ mnemonic, session });
401
+ } catch (err) {
402
+ safeSend(ws, { type: 'nack', error: 'completion_failed' });
403
+ safeClose(ws);
404
+ throw err instanceof Error ? err : new Error(String(err));
405
+ } finally {
406
+ // Drop our reference. JS strings are immutable so we can't zero them;
407
+ // rebinding at least drops the reference from this closure.
408
+ mnemonic = '';
409
+ }
410
+
411
+ if (result.state !== 'active') {
412
+ safeSend(ws, { type: 'nack', error: result.error ?? 'completion_failed' });
413
+ safeClose(ws);
414
+ return result;
415
+ }
416
+
417
+ safeSend(ws, { type: 'ack' });
418
+ safeClose(ws);
419
+ return result;
420
+ }
421
+
422
+ /**
423
+ * One-shot convenience: open session + await phrase upload + run completion.
424
+ * Tool handlers normally split this into two calls so the agent can tell the
425
+ * user the URL + PIN before blocking. This helper exists for tests and for
426
+ * simpler callers.
427
+ */
428
+ export async function pairViaRelay(opts: {
429
+ completePairing: RelayCompletePairingHandler;
430
+ relayBaseUrl?: string;
431
+ pin?: string;
432
+ mode?: PairRelayMode;
433
+ phraseValidator?: PhraseValidator;
434
+ timeoutMs?: number;
435
+ }): Promise<RelayCompletionResult> {
436
+ const session = await openRemotePairSession({
437
+ relayBaseUrl: opts.relayBaseUrl,
438
+ pin: opts.pin,
439
+ mode: opts.mode,
440
+ });
441
+ return awaitPhraseUpload(session, {
442
+ completePairing: opts.completePairing,
443
+ phraseValidator: opts.phraseValidator,
444
+ timeoutMs: opts.timeoutMs,
445
+ });
446
+ }
447
+
448
+ // ---------------------------------------------------------------------------
449
+ // WS helpers
450
+ // ---------------------------------------------------------------------------
451
+
452
+ function waitOpen(ws: WebSocket, timeoutMs: number): Promise<void> {
453
+ return new Promise((resolve, reject) => {
454
+ if (ws.readyState === WebSocket.OPEN) {
455
+ resolve();
456
+ return;
457
+ }
458
+ const onOpen = (): void => {
459
+ cleanup();
460
+ resolve();
461
+ };
462
+ const onError = (err: Error): void => {
463
+ cleanup();
464
+ reject(err instanceof Error ? err : new Error(String(err)));
465
+ };
466
+ const onClose = (code: number): void => {
467
+ cleanup();
468
+ reject(new Error(`pair-remote-client: ws closed before open (${code})`));
469
+ };
470
+ const timer = setTimeout(() => {
471
+ cleanup();
472
+ reject(new Error('pair-remote-client: ws open timeout'));
473
+ }, timeoutMs);
474
+ const cleanup = (): void => {
475
+ clearTimeout(timer);
476
+ ws.off('open', onOpen);
477
+ ws.off('error', onError);
478
+ ws.off('close', onClose);
479
+ };
480
+ ws.on('open', onOpen);
481
+ ws.on('error', onError);
482
+ ws.on('close', onClose);
483
+ });
484
+ }
485
+
486
+ function waitNextMessage(
487
+ ws: WebSocket,
488
+ timeoutMs: number,
489
+ ): Promise<Buffer | ArrayBuffer | string> {
490
+ return new Promise((resolve, reject) => {
491
+ const onMessage = (data: Buffer | ArrayBuffer | string): void => {
492
+ cleanup();
493
+ resolve(data);
494
+ };
495
+ const onError = (err: Error): void => {
496
+ cleanup();
497
+ reject(err instanceof Error ? err : new Error(String(err)));
498
+ };
499
+ const onClose = (code: number): void => {
500
+ cleanup();
501
+ reject(new Error(`pair-remote-client: ws closed before message (${code})`));
502
+ };
503
+ const timer = setTimeout(() => {
504
+ cleanup();
505
+ reject(new Error('pair-remote-client: ws message timeout'));
506
+ }, timeoutMs);
507
+ const cleanup = (): void => {
508
+ clearTimeout(timer);
509
+ ws.off('message', onMessage);
510
+ ws.off('error', onError);
511
+ ws.off('close', onClose);
512
+ };
513
+ ws.on('message', onMessage);
514
+ ws.on('error', onError);
515
+ ws.on('close', onClose);
516
+ });
517
+ }
518
+
519
+ function safeSend(ws: WebSocket, msg: unknown): void {
520
+ try {
521
+ if (ws.readyState === WebSocket.OPEN) {
522
+ ws.send(JSON.stringify(msg));
523
+ }
524
+ } catch {
525
+ /* swallow */
526
+ }
527
+ }
528
+
529
+ function safeClose(ws: WebSocket): void {
530
+ try {
531
+ if (
532
+ ws.readyState === WebSocket.OPEN ||
533
+ ws.readyState === WebSocket.CONNECTING
534
+ ) {
535
+ ws.close();
536
+ }
537
+ } catch {
538
+ /* swallow */
539
+ }
540
+ }