@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.
- package/CHANGELOG.md +330 -0
- package/SKILL.md +50 -83
- package/api-client.ts +18 -11
- package/config.ts +117 -3
- package/crypto.ts +10 -2
- package/dist/api-client.js +226 -0
- package/dist/billing-cache.js +100 -0
- package/dist/claims-helper.js +606 -0
- package/dist/config.js +280 -0
- package/dist/consolidation.js +258 -0
- package/dist/contradiction-sync.js +1034 -0
- package/dist/crypto.js +138 -0
- package/dist/digest-sync.js +361 -0
- package/dist/download-ux.js +63 -0
- package/dist/embedding.js +86 -0
- package/dist/extractor.js +1225 -0
- package/dist/first-run.js +103 -0
- package/dist/fs-helpers.js +563 -0
- package/dist/gateway-url.js +197 -0
- package/dist/generate-mnemonic.js +13 -0
- package/dist/hot-cache-wrapper.js +101 -0
- package/dist/import-adapters/base-adapter.js +64 -0
- package/dist/import-adapters/chatgpt-adapter.js +238 -0
- package/dist/import-adapters/claude-adapter.js +114 -0
- package/dist/import-adapters/gemini-adapter.js +201 -0
- package/dist/import-adapters/index.js +26 -0
- package/dist/import-adapters/mcp-memory-adapter.js +219 -0
- package/dist/import-adapters/mem0-adapter.js +158 -0
- package/dist/import-adapters/types.js +1 -0
- package/dist/index.js +5348 -0
- package/dist/llm-client.js +686 -0
- package/dist/llm-profile-reader.js +346 -0
- package/dist/lsh.js +62 -0
- package/dist/onboarding-cli.js +750 -0
- package/dist/pair-cli.js +344 -0
- package/dist/pair-crypto.js +359 -0
- package/dist/pair-http.js +404 -0
- package/dist/pair-page.js +826 -0
- package/dist/pair-qr.js +107 -0
- package/dist/pair-remote-client.js +410 -0
- package/dist/pair-session-store.js +566 -0
- package/dist/pin.js +542 -0
- package/dist/qa-bug-report.js +301 -0
- package/dist/relay-headers.js +44 -0
- package/dist/reranker.js +442 -0
- package/dist/retype-setscope.js +348 -0
- package/dist/semantic-dedup.js +75 -0
- package/dist/subgraph-search.js +289 -0
- package/dist/subgraph-store.js +694 -0
- package/dist/tool-gating.js +58 -0
- package/download-ux.ts +91 -0
- package/embedding.ts +32 -9
- package/fs-helpers.ts +124 -0
- package/gateway-url.ts +57 -9
- package/index.ts +586 -357
- package/llm-client.ts +211 -23
- package/lsh.ts +7 -2
- package/onboarding-cli.ts +114 -1
- package/package.json +19 -5
- package/pair-cli.ts +76 -8
- package/pair-crypto.ts +34 -24
- package/pair-page.ts +28 -17
- package/pair-qr.ts +152 -0
- package/pair-remote-client.ts +540 -0
- package/qa-bug-report.ts +381 -0
- package/relay-headers.ts +50 -0
- package/reranker.ts +73 -0
- package/retype-setscope.ts +12 -0
- package/subgraph-search.ts +4 -3
- package/subgraph-store.ts +109 -16
package/dist/pair-qr.js
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
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
|
+
* Error class raised by the rc.5 QR encoders.
|
|
27
|
+
*
|
|
28
|
+
* Extends the built-in `Error` so `instanceof Error` checks in callers
|
|
29
|
+
* keep working.
|
|
30
|
+
*/
|
|
31
|
+
export class QREncodeError extends Error {
|
|
32
|
+
constructor(message) {
|
|
33
|
+
super(message);
|
|
34
|
+
this.name = 'QREncodeError';
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
// QR v40 maxes out at ~2953 alphanumeric bytes at ECC-L. We reject
|
|
38
|
+
// payloads over 2 KiB — the pair URL should never approach this. This
|
|
39
|
+
// is defence-in-depth against a caller accidentally feeding a
|
|
40
|
+
// phrase-length blob.
|
|
41
|
+
const MAX_PAYLOAD_BYTES = 2048;
|
|
42
|
+
function validatePayload(url) {
|
|
43
|
+
if (typeof url !== 'string') {
|
|
44
|
+
throw new QREncodeError(`url must be a string, got ${typeof url}`);
|
|
45
|
+
}
|
|
46
|
+
if (url.length === 0) {
|
|
47
|
+
throw new QREncodeError('url must not be empty');
|
|
48
|
+
}
|
|
49
|
+
const bytes = Buffer.byteLength(url, 'utf-8');
|
|
50
|
+
if (bytes > MAX_PAYLOAD_BYTES) {
|
|
51
|
+
throw new QREncodeError(`url too large for QR encoding: ${bytes} bytes (max ${MAX_PAYLOAD_BYTES}). ` +
|
|
52
|
+
'This limit prevents accidentally encoding phrase-length blobs; a pair ' +
|
|
53
|
+
'URL should be ~80-150 bytes.');
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
async function loadQrCodeModule() {
|
|
57
|
+
const raw = (await import('qrcode'));
|
|
58
|
+
// qrcode ships CJS; the `default` export contains the surface under
|
|
59
|
+
// both Node's ESM interop and tsx's loader.
|
|
60
|
+
const mod = raw.default ?? raw;
|
|
61
|
+
return mod;
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Render `url` as a PNG QR code.
|
|
65
|
+
*
|
|
66
|
+
* @throws {QREncodeError} if the URL is empty, non-string, or exceeds
|
|
67
|
+
* the 2 KiB safety cap.
|
|
68
|
+
*/
|
|
69
|
+
export async function encodePng(url, options = {}) {
|
|
70
|
+
validatePayload(url);
|
|
71
|
+
const qr = await loadQrCodeModule();
|
|
72
|
+
try {
|
|
73
|
+
return await qr.toBuffer(url, {
|
|
74
|
+
errorCorrectionLevel: options.ecc ?? 'M',
|
|
75
|
+
scale: Math.max(1, options.boxSize ?? 10),
|
|
76
|
+
margin: Math.max(0, options.border ?? 4),
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
catch (err) {
|
|
80
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
81
|
+
throw new QREncodeError(`QR encoding failed: ${msg}`);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Render `url` as a Unicode block QR string (for terminal output).
|
|
86
|
+
*
|
|
87
|
+
* Uses half-block glyphs so each character represents two vertical
|
|
88
|
+
* pixels — the resulting string renders square-ish in terminals with
|
|
89
|
+
* ~2:1 line-height. Emitted as a single newline-delimited string.
|
|
90
|
+
*
|
|
91
|
+
* @throws {QREncodeError} on invalid input.
|
|
92
|
+
*/
|
|
93
|
+
export async function encodeUnicode(url, options = {}) {
|
|
94
|
+
validatePayload(url);
|
|
95
|
+
const qr = await loadQrCodeModule();
|
|
96
|
+
try {
|
|
97
|
+
return await qr.toString(url, {
|
|
98
|
+
type: 'utf8',
|
|
99
|
+
errorCorrectionLevel: options.ecc ?? 'M',
|
|
100
|
+
margin: Math.max(0, options.border ?? 2),
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
catch (err) {
|
|
104
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
105
|
+
throw new QREncodeError(`QR encoding failed: ${msg}`);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
@@ -0,0 +1,410 @@
|
|
|
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 + AES-256-GCM 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
|
+
import { randomBytes, randomInt } from 'node:crypto';
|
|
45
|
+
import WebSocket from 'ws';
|
|
46
|
+
import { decryptPairingPayload, generateGatewayKeypair, } from './pair-crypto.js';
|
|
47
|
+
// ---------------------------------------------------------------------------
|
|
48
|
+
// Constants
|
|
49
|
+
// ---------------------------------------------------------------------------
|
|
50
|
+
/** Default relay endpoint. Caller passes `TOTALRECLAW_PAIR_RELAY_URL` via config. */
|
|
51
|
+
export const DEFAULT_RELAY_URL = 'wss://api-staging.totalreclaw.xyz';
|
|
52
|
+
/** WebSocket connect + handshake timeout (ms). */
|
|
53
|
+
const OPEN_TIMEOUT_MS = 10_000;
|
|
54
|
+
/** Default blocking-await-for-forward timeout (5 minutes — matches relay TTL). */
|
|
55
|
+
const DEFAULT_AWAIT_TIMEOUT_MS = 5 * 60_000;
|
|
56
|
+
/** Default validator — 12 or 24 lowercase ASCII words. Matches pair-http default. */
|
|
57
|
+
function defaultBip39CountValidator(phrase) {
|
|
58
|
+
const words = phrase.split(' ');
|
|
59
|
+
if (words.length !== 12 && words.length !== 24)
|
|
60
|
+
return false;
|
|
61
|
+
return words.every((w) => /^[a-z]+$/.test(w));
|
|
62
|
+
}
|
|
63
|
+
/** 6-digit uniform PIN. Uses `node:crypto.randomInt` (cryptographically random). */
|
|
64
|
+
function defaultPin() {
|
|
65
|
+
const n = randomInt(0, 1_000_000);
|
|
66
|
+
return n.toString(10).padStart(6, '0');
|
|
67
|
+
}
|
|
68
|
+
/** Random hex client id. Opaque to the relay. */
|
|
69
|
+
function defaultClientId() {
|
|
70
|
+
return 'gw-' + randomBytes(8).toString('hex');
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Assemble the user-facing pair URL. Converts `wss://` → `https://` and
|
|
74
|
+
* `ws://` → `http://` for the URL the user opens in a browser. The gateway
|
|
75
|
+
* pubkey lives in the URL fragment so it never hits relay logs.
|
|
76
|
+
*/
|
|
77
|
+
function buildUserUrl(relayBase, token, pkB64) {
|
|
78
|
+
let httpBase = relayBase;
|
|
79
|
+
if (httpBase.startsWith('wss://')) {
|
|
80
|
+
httpBase = 'https://' + httpBase.slice('wss://'.length);
|
|
81
|
+
}
|
|
82
|
+
else if (httpBase.startsWith('ws://')) {
|
|
83
|
+
httpBase = 'http://' + httpBase.slice('ws://'.length);
|
|
84
|
+
}
|
|
85
|
+
return `${httpBase}/pair/p/${token}#pk=${pkB64}`;
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Normalise the relay base URL for the WebSocket connect. We always hit
|
|
89
|
+
* `wss://` for the open-frame WS even if the caller passed an `https://`
|
|
90
|
+
* browser-facing URL in the config (most self-hosters will pass one URL for
|
|
91
|
+
* both). Strips trailing slashes.
|
|
92
|
+
*/
|
|
93
|
+
function wsConnectBase(relayBase) {
|
|
94
|
+
let base = relayBase.replace(/\/+$/, '');
|
|
95
|
+
if (base.startsWith('https://')) {
|
|
96
|
+
base = 'wss://' + base.slice('https://'.length);
|
|
97
|
+
}
|
|
98
|
+
else if (base.startsWith('http://')) {
|
|
99
|
+
base = 'ws://' + base.slice('http://'.length);
|
|
100
|
+
}
|
|
101
|
+
return base;
|
|
102
|
+
}
|
|
103
|
+
// ---------------------------------------------------------------------------
|
|
104
|
+
// Open
|
|
105
|
+
// ---------------------------------------------------------------------------
|
|
106
|
+
/**
|
|
107
|
+
* Open a pair session on the relay. Returns a handle with the user-facing
|
|
108
|
+
* URL, 6-digit PIN, expiry, keypair, and a live WebSocket the caller holds
|
|
109
|
+
* until `awaitPhraseUpload` resolves.
|
|
110
|
+
*
|
|
111
|
+
* Throws if the relay responds with `{type: "error"}` or an unexpected frame.
|
|
112
|
+
*/
|
|
113
|
+
export async function openRemotePairSession(opts = {}) {
|
|
114
|
+
const relayBase = (opts.relayBaseUrl ?? DEFAULT_RELAY_URL).replace(/\/+$/, '');
|
|
115
|
+
const wsBase = wsConnectBase(relayBase);
|
|
116
|
+
const wsUrl = `${wsBase}/pair/session/open`;
|
|
117
|
+
const WebSocketImpl = opts.webSocketImpl ?? WebSocket;
|
|
118
|
+
const keypair = opts.keypair ?? generateGatewayKeypair();
|
|
119
|
+
const pin = opts.pin ?? defaultPin();
|
|
120
|
+
const clientId = opts.clientId ?? defaultClientId();
|
|
121
|
+
const mode = opts.mode ?? 'either';
|
|
122
|
+
const ws = new WebSocketImpl(wsUrl, {
|
|
123
|
+
handshakeTimeout: OPEN_TIMEOUT_MS,
|
|
124
|
+
});
|
|
125
|
+
// Wait for the WS to open (so `send` doesn't race the handshake).
|
|
126
|
+
try {
|
|
127
|
+
await waitOpen(ws, OPEN_TIMEOUT_MS);
|
|
128
|
+
}
|
|
129
|
+
catch (err) {
|
|
130
|
+
safeClose(ws);
|
|
131
|
+
throw err;
|
|
132
|
+
}
|
|
133
|
+
// Send the open frame.
|
|
134
|
+
try {
|
|
135
|
+
ws.send(JSON.stringify({
|
|
136
|
+
type: 'open',
|
|
137
|
+
gateway_pubkey: keypair.pkB64,
|
|
138
|
+
pin,
|
|
139
|
+
client_id: clientId,
|
|
140
|
+
mode,
|
|
141
|
+
}));
|
|
142
|
+
}
|
|
143
|
+
catch (err) {
|
|
144
|
+
safeClose(ws);
|
|
145
|
+
throw err instanceof Error ? err : new Error(String(err));
|
|
146
|
+
}
|
|
147
|
+
// Wait for the opened frame.
|
|
148
|
+
let raw;
|
|
149
|
+
try {
|
|
150
|
+
raw = await waitNextMessage(ws, OPEN_TIMEOUT_MS);
|
|
151
|
+
}
|
|
152
|
+
catch (err) {
|
|
153
|
+
safeClose(ws);
|
|
154
|
+
throw err;
|
|
155
|
+
}
|
|
156
|
+
let msg;
|
|
157
|
+
try {
|
|
158
|
+
const text = typeof raw === 'string' ? raw : Buffer.from(raw).toString('utf-8');
|
|
159
|
+
msg = JSON.parse(text);
|
|
160
|
+
}
|
|
161
|
+
catch {
|
|
162
|
+
safeClose(ws);
|
|
163
|
+
throw new Error('pair-remote-client: opened frame not valid JSON');
|
|
164
|
+
}
|
|
165
|
+
if (msg.type === 'error') {
|
|
166
|
+
const errStr = typeof msg.error === 'string' ? msg.error : 'relay_error';
|
|
167
|
+
safeClose(ws);
|
|
168
|
+
throw new Error(`pair-remote-client: session/open failed: ${errStr}`);
|
|
169
|
+
}
|
|
170
|
+
if (msg.type !== 'opened') {
|
|
171
|
+
safeClose(ws);
|
|
172
|
+
throw new Error(`pair-remote-client: unexpected response type '${String(msg.type)}'`);
|
|
173
|
+
}
|
|
174
|
+
const token = typeof msg.token === 'string' ? msg.token : '';
|
|
175
|
+
const expiresAt = typeof msg.expires_at === 'string' ? msg.expires_at : '';
|
|
176
|
+
if (!token || !expiresAt) {
|
|
177
|
+
safeClose(ws);
|
|
178
|
+
throw new Error('pair-remote-client: opened frame missing token or expires_at');
|
|
179
|
+
}
|
|
180
|
+
const url = buildUserUrl(relayBase, token, keypair.pkB64);
|
|
181
|
+
return {
|
|
182
|
+
url,
|
|
183
|
+
pin,
|
|
184
|
+
token,
|
|
185
|
+
expiresAt,
|
|
186
|
+
keypair,
|
|
187
|
+
mode,
|
|
188
|
+
_ws: ws,
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
// ---------------------------------------------------------------------------
|
|
192
|
+
// Await + decrypt + ack
|
|
193
|
+
// ---------------------------------------------------------------------------
|
|
194
|
+
/**
|
|
195
|
+
* Block on the WebSocket until the relay pushes the encrypted phrase, then
|
|
196
|
+
* decrypt and invoke `completePairing`. Sends `{type: "ack"}` on success or
|
|
197
|
+
* `{type: "nack", error: "..."}` on failure, then closes the WebSocket.
|
|
198
|
+
*
|
|
199
|
+
* Returns the `RelayCompletionResult` produced by the caller's handler.
|
|
200
|
+
*
|
|
201
|
+
* Caller semantics: most plugin callers schedule this as a background task so
|
|
202
|
+
* the `totalreclaw_pair` tool handler can return the URL + PIN to the agent
|
|
203
|
+
* immediately, and the phrase-upload wait happens asynchronously while the
|
|
204
|
+
* agent chats with the user.
|
|
205
|
+
*/
|
|
206
|
+
export async function awaitPhraseUpload(session, opts) {
|
|
207
|
+
const validate = opts.phraseValidator ?? defaultBip39CountValidator;
|
|
208
|
+
const timeoutMs = opts.timeoutMs ?? DEFAULT_AWAIT_TIMEOUT_MS;
|
|
209
|
+
const ws = session._ws;
|
|
210
|
+
let raw;
|
|
211
|
+
try {
|
|
212
|
+
raw = await waitNextMessage(ws, timeoutMs);
|
|
213
|
+
}
|
|
214
|
+
catch (err) {
|
|
215
|
+
safeClose(ws);
|
|
216
|
+
throw err;
|
|
217
|
+
}
|
|
218
|
+
let msg;
|
|
219
|
+
try {
|
|
220
|
+
const text = typeof raw === 'string' ? raw : Buffer.from(raw).toString('utf-8');
|
|
221
|
+
msg = JSON.parse(text);
|
|
222
|
+
}
|
|
223
|
+
catch {
|
|
224
|
+
safeSend(ws, { type: 'nack', error: 'bad_json' });
|
|
225
|
+
safeClose(ws);
|
|
226
|
+
throw new Error('pair-remote-client: forward frame not valid JSON');
|
|
227
|
+
}
|
|
228
|
+
if (msg.type !== 'forward') {
|
|
229
|
+
safeSend(ws, { type: 'nack', error: 'expected_forward' });
|
|
230
|
+
safeClose(ws);
|
|
231
|
+
throw new Error(`pair-remote-client: unexpected frame '${String(msg.type)}'`);
|
|
232
|
+
}
|
|
233
|
+
const clientPubkey = typeof msg.client_pubkey === 'string' ? msg.client_pubkey : '';
|
|
234
|
+
const nonce = typeof msg.nonce === 'string' ? msg.nonce : '';
|
|
235
|
+
const ciphertext = typeof msg.ciphertext === 'string' ? msg.ciphertext : '';
|
|
236
|
+
if (!clientPubkey || !nonce || !ciphertext) {
|
|
237
|
+
safeSend(ws, { type: 'nack', error: 'bad_forward_body' });
|
|
238
|
+
safeClose(ws);
|
|
239
|
+
throw new Error('pair-remote-client: forward frame missing required fields');
|
|
240
|
+
}
|
|
241
|
+
// Decrypt locally (ciphertext + shared-secret derivation never leave this host).
|
|
242
|
+
let plaintext;
|
|
243
|
+
try {
|
|
244
|
+
plaintext = decryptPairingPayload({
|
|
245
|
+
skGatewayB64: session.keypair.skB64,
|
|
246
|
+
pkDeviceB64: clientPubkey,
|
|
247
|
+
sid: session.token,
|
|
248
|
+
nonceB64: nonce,
|
|
249
|
+
ciphertextB64: ciphertext,
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
catch (err) {
|
|
253
|
+
safeSend(ws, { type: 'nack', error: 'decrypt_failed' });
|
|
254
|
+
safeClose(ws);
|
|
255
|
+
throw err instanceof Error ? err : new Error(String(err));
|
|
256
|
+
}
|
|
257
|
+
// Decode + normalize. Match pair-http's BIP-39 norm: NFKC → lowercase → trim → single-space.
|
|
258
|
+
let mnemonic;
|
|
259
|
+
try {
|
|
260
|
+
mnemonic = plaintext
|
|
261
|
+
.toString('utf-8')
|
|
262
|
+
.normalize('NFKC')
|
|
263
|
+
.toLowerCase()
|
|
264
|
+
.trim()
|
|
265
|
+
.split(/\s+/)
|
|
266
|
+
.join(' ');
|
|
267
|
+
}
|
|
268
|
+
catch (err) {
|
|
269
|
+
safeSend(ws, { type: 'nack', error: 'bad_utf8' });
|
|
270
|
+
safeClose(ws);
|
|
271
|
+
throw err instanceof Error ? err : new Error(String(err));
|
|
272
|
+
}
|
|
273
|
+
finally {
|
|
274
|
+
// Best-effort: scrub the raw plaintext buffer.
|
|
275
|
+
plaintext.fill(0);
|
|
276
|
+
}
|
|
277
|
+
if (!validate(mnemonic)) {
|
|
278
|
+
safeSend(ws, { type: 'nack', error: 'invalid_mnemonic' });
|
|
279
|
+
safeClose(ws);
|
|
280
|
+
throw new Error('pair-remote-client: phrase failed BIP-39 validation');
|
|
281
|
+
}
|
|
282
|
+
// Hand off to the caller-supplied completion handler. Wrapped in try/finally
|
|
283
|
+
// so we always drop our own reference to the mnemonic.
|
|
284
|
+
let result;
|
|
285
|
+
try {
|
|
286
|
+
result = await opts.completePairing({ mnemonic, session });
|
|
287
|
+
}
|
|
288
|
+
catch (err) {
|
|
289
|
+
safeSend(ws, { type: 'nack', error: 'completion_failed' });
|
|
290
|
+
safeClose(ws);
|
|
291
|
+
throw err instanceof Error ? err : new Error(String(err));
|
|
292
|
+
}
|
|
293
|
+
finally {
|
|
294
|
+
// Drop our reference. JS strings are immutable so we can't zero them;
|
|
295
|
+
// rebinding at least drops the reference from this closure.
|
|
296
|
+
mnemonic = '';
|
|
297
|
+
}
|
|
298
|
+
if (result.state !== 'active') {
|
|
299
|
+
safeSend(ws, { type: 'nack', error: result.error ?? 'completion_failed' });
|
|
300
|
+
safeClose(ws);
|
|
301
|
+
return result;
|
|
302
|
+
}
|
|
303
|
+
safeSend(ws, { type: 'ack' });
|
|
304
|
+
safeClose(ws);
|
|
305
|
+
return result;
|
|
306
|
+
}
|
|
307
|
+
/**
|
|
308
|
+
* One-shot convenience: open session + await phrase upload + run completion.
|
|
309
|
+
* Tool handlers normally split this into two calls so the agent can tell the
|
|
310
|
+
* user the URL + PIN before blocking. This helper exists for tests and for
|
|
311
|
+
* simpler callers.
|
|
312
|
+
*/
|
|
313
|
+
export async function pairViaRelay(opts) {
|
|
314
|
+
const session = await openRemotePairSession({
|
|
315
|
+
relayBaseUrl: opts.relayBaseUrl,
|
|
316
|
+
pin: opts.pin,
|
|
317
|
+
mode: opts.mode,
|
|
318
|
+
});
|
|
319
|
+
return awaitPhraseUpload(session, {
|
|
320
|
+
completePairing: opts.completePairing,
|
|
321
|
+
phraseValidator: opts.phraseValidator,
|
|
322
|
+
timeoutMs: opts.timeoutMs,
|
|
323
|
+
});
|
|
324
|
+
}
|
|
325
|
+
// ---------------------------------------------------------------------------
|
|
326
|
+
// WS helpers
|
|
327
|
+
// ---------------------------------------------------------------------------
|
|
328
|
+
function waitOpen(ws, timeoutMs) {
|
|
329
|
+
return new Promise((resolve, reject) => {
|
|
330
|
+
if (ws.readyState === WebSocket.OPEN) {
|
|
331
|
+
resolve();
|
|
332
|
+
return;
|
|
333
|
+
}
|
|
334
|
+
const onOpen = () => {
|
|
335
|
+
cleanup();
|
|
336
|
+
resolve();
|
|
337
|
+
};
|
|
338
|
+
const onError = (err) => {
|
|
339
|
+
cleanup();
|
|
340
|
+
reject(err instanceof Error ? err : new Error(String(err)));
|
|
341
|
+
};
|
|
342
|
+
const onClose = (code) => {
|
|
343
|
+
cleanup();
|
|
344
|
+
reject(new Error(`pair-remote-client: ws closed before open (${code})`));
|
|
345
|
+
};
|
|
346
|
+
const timer = setTimeout(() => {
|
|
347
|
+
cleanup();
|
|
348
|
+
reject(new Error('pair-remote-client: ws open timeout'));
|
|
349
|
+
}, timeoutMs);
|
|
350
|
+
const cleanup = () => {
|
|
351
|
+
clearTimeout(timer);
|
|
352
|
+
ws.off('open', onOpen);
|
|
353
|
+
ws.off('error', onError);
|
|
354
|
+
ws.off('close', onClose);
|
|
355
|
+
};
|
|
356
|
+
ws.on('open', onOpen);
|
|
357
|
+
ws.on('error', onError);
|
|
358
|
+
ws.on('close', onClose);
|
|
359
|
+
});
|
|
360
|
+
}
|
|
361
|
+
function waitNextMessage(ws, timeoutMs) {
|
|
362
|
+
return new Promise((resolve, reject) => {
|
|
363
|
+
const onMessage = (data) => {
|
|
364
|
+
cleanup();
|
|
365
|
+
resolve(data);
|
|
366
|
+
};
|
|
367
|
+
const onError = (err) => {
|
|
368
|
+
cleanup();
|
|
369
|
+
reject(err instanceof Error ? err : new Error(String(err)));
|
|
370
|
+
};
|
|
371
|
+
const onClose = (code) => {
|
|
372
|
+
cleanup();
|
|
373
|
+
reject(new Error(`pair-remote-client: ws closed before message (${code})`));
|
|
374
|
+
};
|
|
375
|
+
const timer = setTimeout(() => {
|
|
376
|
+
cleanup();
|
|
377
|
+
reject(new Error('pair-remote-client: ws message timeout'));
|
|
378
|
+
}, timeoutMs);
|
|
379
|
+
const cleanup = () => {
|
|
380
|
+
clearTimeout(timer);
|
|
381
|
+
ws.off('message', onMessage);
|
|
382
|
+
ws.off('error', onError);
|
|
383
|
+
ws.off('close', onClose);
|
|
384
|
+
};
|
|
385
|
+
ws.on('message', onMessage);
|
|
386
|
+
ws.on('error', onError);
|
|
387
|
+
ws.on('close', onClose);
|
|
388
|
+
});
|
|
389
|
+
}
|
|
390
|
+
function safeSend(ws, msg) {
|
|
391
|
+
try {
|
|
392
|
+
if (ws.readyState === WebSocket.OPEN) {
|
|
393
|
+
ws.send(JSON.stringify(msg));
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
catch {
|
|
397
|
+
/* swallow */
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
function safeClose(ws) {
|
|
401
|
+
try {
|
|
402
|
+
if (ws.readyState === WebSocket.OPEN ||
|
|
403
|
+
ws.readyState === WebSocket.CONNECTING) {
|
|
404
|
+
ws.close();
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
catch {
|
|
408
|
+
/* swallow */
|
|
409
|
+
}
|
|
410
|
+
}
|