@totalreclaw/totalreclaw 3.3.12-rc.9 → 3.3.12

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 (79) hide show
  1. package/SKILL.md +33 -27
  2. package/api-client.ts +17 -9
  3. package/batch-gate.ts +42 -0
  4. package/billing-cache.ts +72 -23
  5. package/claims-helper.ts +2 -1
  6. package/config.ts +95 -13
  7. package/consolidation.ts +6 -13
  8. package/contradiction-sync.ts +19 -14
  9. package/credential-provider.ts +184 -0
  10. package/crypto.ts +27 -160
  11. package/dist/api-client.js +17 -9
  12. package/dist/batch-gate.js +40 -0
  13. package/dist/billing-cache.js +54 -24
  14. package/dist/claims-helper.js +2 -1
  15. package/dist/config.js +91 -13
  16. package/dist/consolidation.js +6 -15
  17. package/dist/contradiction-sync.js +15 -15
  18. package/dist/credential-provider.js +145 -0
  19. package/dist/crypto.js +17 -137
  20. package/dist/download-ux.js +11 -7
  21. package/dist/embedder-loader.js +266 -0
  22. package/dist/embedding.js +36 -3
  23. package/dist/entry.js +123 -0
  24. package/dist/fs-helpers.js +75 -379
  25. package/dist/import-adapters/gemini-adapter.js +29 -159
  26. package/dist/index.js +864 -2631
  27. package/dist/memory-runtime.js +459 -0
  28. package/dist/native-memory.js +123 -0
  29. package/dist/onboarding-cli.js +4 -8
  30. package/dist/pair-cli-relay.js +1 -8
  31. package/dist/pair-cli.js +1 -1
  32. package/dist/pair-crypto.js +16 -358
  33. package/dist/pair-http.js +147 -4
  34. package/dist/relay.js +140 -0
  35. package/dist/reranker.js +13 -8
  36. package/dist/semantic-dedup.js +5 -7
  37. package/dist/skill-register.js +97 -0
  38. package/dist/subgraph-search.js +3 -1
  39. package/dist/subgraph-store.js +348 -290
  40. package/dist/tool-gating.js +39 -26
  41. package/dist/tools.js +367 -0
  42. package/dist/tr-cli-export-helper.js +3 -3
  43. package/dist/tr-cli.js +65 -156
  44. package/dist/trajectory-poller.js +155 -9
  45. package/dist/vault-crypto.js +551 -0
  46. package/download-ux.ts +12 -6
  47. package/embedder-loader.ts +293 -1
  48. package/embedding.ts +43 -3
  49. package/entry.ts +132 -0
  50. package/fs-helpers.ts +93 -458
  51. package/import-adapters/gemini-adapter.ts +38 -183
  52. package/index.ts +912 -2917
  53. package/memory-runtime.ts +723 -0
  54. package/native-memory.ts +196 -0
  55. package/onboarding-cli.ts +3 -9
  56. package/openclaw.plugin.json +5 -17
  57. package/package.json +6 -3
  58. package/pair-cli-relay.ts +0 -9
  59. package/pair-cli.ts +1 -1
  60. package/pair-crypto.ts +41 -483
  61. package/pair-http.ts +194 -5
  62. package/postinstall.mjs +138 -0
  63. package/relay.ts +172 -0
  64. package/reranker.ts +13 -8
  65. package/semantic-dedup.ts +5 -6
  66. package/skill-register.ts +146 -0
  67. package/skill.json +1 -1
  68. package/subgraph-search.ts +3 -1
  69. package/subgraph-store.ts +367 -307
  70. package/tool-gating.ts +39 -26
  71. package/tools.ts +499 -0
  72. package/tr-cli-export-helper.ts +3 -3
  73. package/tr-cli.ts +69 -182
  74. package/trajectory-poller.ts +162 -10
  75. package/vault-crypto.ts +705 -0
  76. package/auto-pair-on-load.ts +0 -308
  77. package/dist/auto-pair-on-load.js +0 -197
  78. package/dist/pair-pending-injection.js +0 -125
  79. package/pair-pending-injection.ts +0 -205
@@ -1,359 +1,17 @@
1
1
  /**
2
- * pair-crypto — gateway-side cryptographic primitives for the v3.3.x
3
- * relay-brokered pair flow.
4
- *
5
- * Cipher suite (design doc 3a-3b, cipher swap ratified 2026-04-23 / rc.12):
6
- * - ECDH on x25519 for key agreement.
7
- * - HKDF-SHA256 for symmetric-key derivation from the shared secret.
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.
17
- *
18
- * Every primitive is provided by the Node built-in `node:crypto` module
19
- * on Node 18.19+ and above. NO third-party crypto dependency is added
20
- * to the plugin for the gateway side. (The BROWSER side of the flow uses
21
- * WebCrypto's AES-GCM directly — no shim needed.)
22
- *
23
- * Scope and guarantees
24
- * --------------------
25
- * - This module ONLY runs on the gateway host. The device side
26
- * (browser) implements the mirror of these functions in a separate
27
- * bundle served by pair-http.ts.
28
- * - Round-trip correctness: deriveSessionKey on both sides with
29
- * matching (sk_local, pk_remote) produces the same key.
30
- * - AD (sid) binding: a ciphertext encrypted for session A CANNOT
31
- * decrypt under session B's keys, even with identical plaintext
32
- * and nonce. This is the AEAD contract; validated by tests.
33
- * - No logging of key material. The only public thing this module
34
- * emits is base64url-encoded raw public keys (32 bytes → 43 chars),
35
- * which are safe for the QR payload.
36
- * - No `fs.*` calls, no `process.env` reads, no network primitives.
37
- * Keeps scanner surface isolated (see `check-scanner.mjs`).
38
- *
39
- * Interoperability with browser WebCrypto
40
- * ---------------------------------------
41
- * The WebCrypto x25519 + HKDF + AES-GCM APIs are bit-for-bit compatible
42
- * with Node's `crypto` as long as:
43
- * - Raw 32-byte public/private keys are used (not DER/SPKI).
44
- * - HKDF parameters are (hash=SHA-256, salt=sid bytes, info fixed
45
- * ASCII string "totalreclaw-pair-v2", length=32 bytes).
46
- * - AEAD uses a 12-byte random nonce + 16-byte tag, AD = sid bytes.
47
- * See tests for fixed test vectors.
48
- */
49
- import { createPrivateKey, createPublicKey, diffieHellman, generateKeyPairSync, hkdfSync, createCipheriv, createDecipheriv, randomBytes, timingSafeEqual, } from 'node:crypto';
50
- // ---------------------------------------------------------------------------
51
- // Constants
52
- // ---------------------------------------------------------------------------
53
- /**
54
- * HKDF "info" parameter — fixes the domain separation for this protocol.
55
- * MUST match the browser-side constant in the pair-page bundle + the
56
- * relay-served pair-html page.
57
- *
58
- * Versioned so we can roll to a new KDF or cipher suite without silently
59
- * producing garbage with old ciphertexts. rc.12: bumped from v1 to v2
60
- * after cipher-suite swap from ChaCha20-Poly1305 → AES-256-GCM (see
61
- * module header comment for context).
62
- */
63
- export const HKDF_INFO = 'totalreclaw-pair-v2';
64
- /** HKDF output length — 32 bytes = 256-bit AES-256-GCM key. */
65
- export const AEAD_KEY_BYTES = 32;
66
- /** AES-GCM nonce length — 12 bytes (SP 800-38D recommendation). */
67
- export const AEAD_NONCE_BYTES = 12;
68
- /** AES-GCM auth tag length — 16 bytes (128 bits, standard). */
69
- export const AEAD_TAG_BYTES = 16;
70
- /** Raw x25519 public/private key length — 32 bytes per RFC 7748. */
71
- export const X25519_KEY_BYTES = 32;
72
- // ---------------------------------------------------------------------------
73
- // Key generation / conversion
74
- // ---------------------------------------------------------------------------
75
- /**
76
- * Generate a fresh ephemeral x25519 keypair for a pairing session.
77
- *
78
- * Returns raw 32-byte values base64url-encoded. The caller persists the
79
- * private half in pair-session-store (under the session record's 0600
80
- * file) and embeds the public half in the QR URL fragment.
81
- *
82
- * The returned keys are raw x25519 — NOT DER/SPKI/PEM. The browser
83
- * side's WebCrypto needs raw bytes to import.
84
- */
85
- export function generateGatewayKeypair() {
86
- const { publicKey, privateKey } = generateKeyPairSync('x25519');
87
- return {
88
- skB64: extractRawPrivate(privateKey),
89
- pkB64: extractRawPublic(publicKey),
90
- };
91
- }
92
- /**
93
- * Re-constitute a Node `KeyObject` from raw base64url public-key bytes.
94
- * Uses the JWK OKP encoding because Node doesn't accept raw-format
95
- * inputs to `createPublicKey` directly.
96
- */
97
- function publicKeyFromB64(pkB64) {
98
- const raw = Buffer.from(pkB64, 'base64url');
99
- if (raw.length !== X25519_KEY_BYTES) {
100
- throw new Error(`pair-crypto: public key must be ${X25519_KEY_BYTES} bytes (got ${raw.length})`);
101
- }
102
- return createPublicKey({
103
- key: { kty: 'OKP', crv: 'X25519', x: raw.toString('base64url') },
104
- format: 'jwk',
105
- });
106
- }
107
- /**
108
- * Re-constitute a Node `KeyObject` from raw base64url private-key bytes.
109
- *
110
- * JWK OKP private keys require the `x` (public) field too; we derive it
111
- * by first constructing a temporary KeyObject from `d` alone, then
112
- * exporting its public half. This is cheap (one scalarmult) and keeps
113
- * the call sites clean.
114
- *
115
- * Mirror of the browser-side WebCrypto `importKey('raw', ...)` path.
116
- */
117
- function privateKeyFromB64(skB64) {
118
- const raw = Buffer.from(skB64, 'base64url');
119
- if (raw.length !== X25519_KEY_BYTES) {
120
- throw new Error(`pair-crypto: private key must be ${X25519_KEY_BYTES} bytes (got ${raw.length})`);
121
- }
122
- // The JWK OKP format requires `x` (public) alongside `d` (private).
123
- // Derive `x` by first constructing a public KeyObject from the scalar
124
- // via the OKP JWK path — Node accepts `d` alone and returns a usable
125
- // private KeyObject from which we can derive the public half.
126
- const tempPriv = createPrivateKey({
127
- key: { kty: 'OKP', crv: 'X25519', d: raw.toString('base64url'), x: '' },
128
- format: 'jwk',
129
- });
130
- // Derive the public half.
131
- const pubObj = createPublicKey(tempPriv);
132
- const pubJwk = pubObj.export({ format: 'jwk' });
133
- // Re-construct with the full (x, d) pair. This is the canonical form
134
- // the rest of the code holds onto.
135
- return createPrivateKey({
136
- key: { kty: 'OKP', crv: 'X25519', d: raw.toString('base64url'), x: pubJwk.x },
137
- format: 'jwk',
138
- });
139
- }
140
- /** Extract the raw 32-byte public-key bytes from a Node KeyObject → base64url. */
141
- function extractRawPublic(pk) {
142
- const jwk = pk.export({ format: 'jwk' });
143
- if (!jwk.x)
144
- throw new Error('pair-crypto: public key JWK is missing the x field');
145
- return jwk.x; // JWK `x` is already base64url-encoded raw bytes.
146
- }
147
- /** Extract the raw 32-byte private-key bytes from a Node KeyObject → base64url. */
148
- function extractRawPrivate(sk) {
149
- const jwk = sk.export({ format: 'jwk' });
150
- if (!jwk.d)
151
- throw new Error('pair-crypto: private key JWK is missing the d field');
152
- return jwk.d;
153
- }
154
- /**
155
- * Derive the public key from a raw base64url private key. Exposed for
156
- * tests and for the session-store's self-consistency checks — the
157
- * default `createPairSession` doesn't call this (it generates both
158
- * halves at once via `generateGatewayKeypair`).
159
- */
160
- export function derivePublicFromPrivate(skB64) {
161
- const sk = privateKeyFromB64(skB64);
162
- const pk = createPublicKey(sk);
163
- return extractRawPublic(pk);
164
- }
165
- // ---------------------------------------------------------------------------
166
- // ECDH + HKDF
167
- // ---------------------------------------------------------------------------
168
- /**
169
- * Perform x25519 ECDH between (local private key, remote public key),
170
- * producing a 32-byte shared secret.
171
- *
172
- * BOTH sides running this with swapped halves MUST produce the same
173
- * shared secret. This is the foundation of the pairing key agreement.
174
- *
175
- * Validation: throws if either key is the wrong byte length, or if
176
- * the underlying `diffieHellman` call fails (which Node will do for
177
- * invalid curve points).
178
- */
179
- export function computeSharedSecret(opts) {
180
- const sk = privateKeyFromB64(opts.skLocalB64);
181
- const pk = publicKeyFromB64(opts.pkRemoteB64);
182
- const shared = diffieHellman({ privateKey: sk, publicKey: pk });
183
- if (shared.length !== X25519_KEY_BYTES) {
184
- throw new Error(`pair-crypto: ECDH output wrong length (got ${shared.length}, expected ${X25519_KEY_BYTES})`);
185
- }
186
- return shared;
187
- }
188
- /**
189
- * Derive the AEAD key from a shared secret via HKDF-SHA256. Uses the
190
- * session id as the salt and the fixed protocol tag as the info.
191
- *
192
- * Mathematical sketch (per RFC 5869):
193
- * PRK = HMAC-SHA256(salt, shared)
194
- * OKM = HMAC-SHA256(PRK, info || 0x01)[:L]
195
- *
196
- * Where L = 32 bytes = AEAD_KEY_BYTES.
197
- *
198
- * The sid binding means a ciphertext encrypted for session A is
199
- * DECRYPTABLE only under session A's derived key. Replaying ct from
200
- * session A into session B's decrypt path produces a different key →
201
- * AEAD tag fails → rejected.
202
- */
203
- export function deriveSessionKeys(opts) {
204
- if (opts.sharedSecret.length !== X25519_KEY_BYTES) {
205
- throw new Error('pair-crypto: shared secret must be 32 bytes');
206
- }
207
- if (typeof opts.sid !== 'string' || opts.sid.length === 0) {
208
- throw new Error('pair-crypto: sid is required for HKDF salt binding');
209
- }
210
- const salt = Buffer.from(opts.sid, 'utf-8');
211
- const info = Buffer.from(HKDF_INFO, 'utf-8');
212
- const okm = hkdfSync('sha256', opts.sharedSecret, salt, info, AEAD_KEY_BYTES);
213
- return { kEnc: Buffer.from(okm) };
214
- }
215
- /**
216
- * One-shot convenience: ECDH + HKDF in a single call. Used by both the
217
- * HTTP respond handler (decrypt side) and the tests.
218
- */
219
- export function deriveAeadKeyFromEcdh(opts) {
220
- const shared = computeSharedSecret({
221
- skLocalB64: opts.skLocalB64,
222
- pkRemoteB64: opts.pkRemoteB64,
223
- });
224
- return deriveSessionKeys({ sharedSecret: shared, sid: opts.sid });
225
- }
226
- // ---------------------------------------------------------------------------
227
- // AEAD
228
- // ---------------------------------------------------------------------------
229
- /**
230
- * Decrypt an AES-256-GCM AEAD ciphertext. Returns the plaintext on
231
- * success; throws if the tag is invalid (which includes both tampering
232
- * and wrong-key attempts).
233
- *
234
- * Ciphertext is expected in the combined form `plaintext || tag`, where
235
- * tag is the trailing 16 bytes. The caller MUST supply the same
236
- * (kEnc, nonce, sid-as-AD) used for encryption.
237
- */
238
- export function aeadDecrypt(opts) {
239
- const nonce = Buffer.from(opts.nonceB64, 'base64url');
240
- if (nonce.length !== AEAD_NONCE_BYTES) {
241
- throw new Error(`pair-crypto: nonce must be ${AEAD_NONCE_BYTES} bytes (got ${nonce.length})`);
242
- }
243
- if (opts.kEnc.length !== AEAD_KEY_BYTES) {
244
- throw new Error(`pair-crypto: AEAD key must be ${AEAD_KEY_BYTES} bytes`);
245
- }
246
- const combined = Buffer.from(opts.ciphertextB64, 'base64url');
247
- if (combined.length < AEAD_TAG_BYTES) {
248
- throw new Error('pair-crypto: ciphertext too short to contain tag');
249
- }
250
- const ct = combined.subarray(0, combined.length - AEAD_TAG_BYTES);
251
- const tag = combined.subarray(combined.length - AEAD_TAG_BYTES);
252
- const decipher = createDecipheriv('aes-256-gcm', opts.kEnc, nonce, {
253
- authTagLength: AEAD_TAG_BYTES,
254
- });
255
- decipher.setAAD(Buffer.from(opts.sid, 'utf-8'), { plaintextLength: ct.length });
256
- decipher.setAuthTag(tag);
257
- const pt1 = decipher.update(ct);
258
- const pt2 = decipher.final();
259
- return Buffer.concat([pt1, pt2]);
260
- }
261
- /**
262
- * One-shot decrypt: ECDH + HKDF + AEAD in one call. This is what the
263
- * HTTP respond handler calls. Returns the plaintext Buffer on success.
264
- *
265
- * Throws on ANY failure (wrong key length, invalid curve point, tag
266
- * mismatch). The caller catches and returns 400 to the device.
267
- */
268
- export function decryptPairingPayload(inputs) {
269
- const { kEnc } = deriveAeadKeyFromEcdh({
270
- skLocalB64: inputs.skGatewayB64,
271
- pkRemoteB64: inputs.pkDeviceB64,
272
- sid: inputs.sid,
273
- });
274
- return aeadDecrypt({
275
- kEnc,
276
- nonceB64: inputs.nonceB64,
277
- sid: inputs.sid,
278
- ciphertextB64: inputs.ciphertextB64,
279
- });
280
- }
281
- /**
282
- * Encrypt a plaintext payload. Used by tests; also used by any future
283
- * gateway-to-device encrypted ACK path.
284
- *
285
- * Emits (nonce, ciphertext||tag) both base64url-encoded. If the caller
286
- * passes an explicit `nonceB64`, it is used verbatim; otherwise a
287
- * fresh 12-byte random nonce is generated.
288
- */
289
- export function aeadEncryptWithSessionKey(opts) {
290
- if (opts.kEnc.length !== AEAD_KEY_BYTES) {
291
- throw new Error(`pair-crypto: AEAD key must be ${AEAD_KEY_BYTES} bytes`);
292
- }
293
- const nonceBuf = opts.nonceB64 !== undefined
294
- ? Buffer.from(opts.nonceB64, 'base64url')
295
- : randomBytes(AEAD_NONCE_BYTES);
296
- if (nonceBuf.length !== AEAD_NONCE_BYTES) {
297
- throw new Error(`pair-crypto: nonce must be ${AEAD_NONCE_BYTES} bytes`);
298
- }
299
- const pt = Buffer.isBuffer(opts.plaintext) ? opts.plaintext : Buffer.from(opts.plaintext);
300
- const cipher = createCipheriv('aes-256-gcm', opts.kEnc, nonceBuf, {
301
- authTagLength: AEAD_TAG_BYTES,
302
- });
303
- cipher.setAAD(Buffer.from(opts.sid, 'utf-8'), { plaintextLength: pt.length });
304
- const ct = Buffer.concat([cipher.update(pt), cipher.final()]);
305
- const tag = cipher.getAuthTag();
306
- return {
307
- nonceB64: nonceBuf.toString('base64url'),
308
- ciphertextB64: Buffer.concat([ct, tag]).toString('base64url'),
309
- };
310
- }
311
- /**
312
- * One-shot encrypt: ECDH + HKDF + AEAD (caller supplies both sides).
313
- * Used by the test vectors and future 4.x features. The real device
314
- * side does this in the browser, not here.
315
- */
316
- export function encryptPairingPayload(inputs) {
317
- const { kEnc } = deriveAeadKeyFromEcdh({
318
- skLocalB64: inputs.skLocalB64,
319
- pkRemoteB64: inputs.pkRemoteB64,
320
- sid: inputs.sid,
321
- });
322
- return aeadEncryptWithSessionKey({
323
- kEnc,
324
- sid: inputs.sid,
325
- plaintext: inputs.plaintext,
326
- nonceB64: inputs.nonceB64,
327
- });
328
- }
329
- // ---------------------------------------------------------------------------
330
- // Constant-time secondary-code comparison
331
- // ---------------------------------------------------------------------------
332
- /**
333
- * Constant-time compare two 6-digit numeric strings. Used by the HTTP
334
- * start/respond handlers to check the user-supplied secondary code
335
- * against the session's expected code.
336
- *
337
- * Returns true on match, false otherwise. Length mismatch returns
338
- * false without short-circuit leak of which side was shorter.
339
- *
340
- * Uses Node `timingSafeEqual`, which requires equal-length inputs.
341
- * We pad the SHORTER input with NULs and always compare a fixed
342
- * 6-byte window — the length check happens BEFORE the actual compare
343
- * and uses boolean AND at the end so both branches run to completion.
344
- */
345
- export function compareSecondaryCodesCT(a, b) {
346
- const aBuf = Buffer.from(a, 'utf-8');
347
- const bBuf = Buffer.from(b, 'utf-8');
348
- const lenMatch = aBuf.length === bBuf.length;
349
- // Always compare a constant window so the total work is independent
350
- // of the actual input lengths. Pad the shorter to the longer with
351
- // zero bytes; if lengths diverge we force lenMatch=false below.
352
- const max = Math.max(aBuf.length, bBuf.length, 6);
353
- const aPad = Buffer.alloc(max);
354
- const bPad = Buffer.alloc(max);
355
- aBuf.copy(aPad);
356
- bBuf.copy(bPad);
357
- const byteMatch = timingSafeEqual(aPad, bPad);
358
- return lenMatch && byteMatch;
359
- }
2
+ * pair-crypto.tslegacy re-export shim.
3
+ *
4
+ * Phase 1 (Task 1.1) of the OpenClaw native integration
5
+ * (docs/plans/2026-06-21-openclaw-native-integration-plan.md, 2026-06-21):
6
+ * the gateway-side pair primitives (x25519 ECDH, HKDF-SHA256, AES-256-GCM
7
+ * AEAD, constant-time secondary-code comparison) have moved to
8
+ * `vault-crypto.ts`. This file remains as a thin re-export so existing
9
+ * importers (`pair-cli.ts`, `pair-remote-client.ts`, `pair-http.ts`) do
10
+ * not break in this pass — a big-bang rewrite of the import graph is out
11
+ * of scope for the scanner-clean split.
12
+ *
13
+ * Nothing here reads the environment or hits the network; all key
14
+ * material and nonces are passed in by callers. See vault-crypto.ts for
15
+ * the implementation.
16
+ */
17
+ export { HKDF_INFO, AEAD_KEY_BYTES, AEAD_NONCE_BYTES, AEAD_TAG_BYTES, X25519_KEY_BYTES, generateGatewayKeypair, derivePublicFromPrivate, computeSharedSecret, deriveSessionKeys, deriveAeadKeyFromEcdh, aeadDecrypt, decryptPairingPayload, aeadEncryptWithSessionKey, encryptPairingPayload, compareSecondaryCodesCT, } from './vault-crypto.js';
package/dist/pair-http.js CHANGED
@@ -2,7 +2,23 @@
2
2
  * pair-http — gateway-side HTTP route handlers for the v3.3.0 QR-pairing
3
3
  * flow. Registered via `api.registerHttpRoute` from `index.ts`.
4
4
  *
5
- * Three endpoints (all under /plugin/totalreclaw/pair/):
5
+ * Five endpoints (all under /plugin/totalreclaw/pair/):
6
+ *
7
+ * GET /plugin/totalreclaw/pair/init
8
+ * → IN-PROCESS pair trigger (3.3.14). Opens the relay WebSocket
9
+ * directly in the gateway process via `openRemotePairSession`
10
+ * (from pair-remote-client.ts), returns `{url, pin, sid,
11
+ * expires_at_ms}` immediately so the agent can surface URL+PIN
12
+ * to the user, and starts a BACKGROUND `awaitPhraseUpload` that
13
+ * blocks on the WS for the browser's encrypted phrase, decrypts
14
+ * locally, and invokes the injected `completePairing` callback
15
+ * (writes credentials.json + flips onboarding state). The WS
16
+ * lives in the gateway process — immune to the shell-tool's
17
+ * 30s subprocess timeout that killed the `tr pair` CLI path
18
+ * (relay returned 502 on /pair/respond when the subprocess
19
+ * died). This is the primary agent-facilitated pair path; the
20
+ * CLI `tr pair --json` remains as a fallback for non-agent
21
+ * scenarios.
6
22
  *
7
23
  * GET /plugin/totalreclaw/pair/finish?sid=<sid>
8
24
  * → returns the browser pairing page (HTML + inline JS + CSS).
@@ -37,6 +53,12 @@
37
53
  * - NO environment-variable reads. All config values flow in via
38
54
  * `PairHttpConfig`; callers read from `CONFIG` in `config.ts`.
39
55
  *
56
+ * Adding `openRemotePairSession` (which dials an outbound WebSocket to
57
+ * the relay) keeps this file env=N, net=Y → the env-harvesting rule
58
+ * requires BOTH an env read AND a request trigger in the same file;
59
+ * the relay base URL arrives via `PairHttpConfig.relayBaseUrl` (caller-
60
+ * injected), never read from the environment here.
61
+ *
40
62
  * Logging: NEVER logs the secondary code, the mnemonic, the gateway
41
63
  * private key, or raw request bodies. Session ids and status
42
64
  * transitions are logged at info/warn levels for diagnostics.
@@ -44,9 +66,11 @@
44
66
  import { consumePairSession, getPairSession, registerFailedSecondaryCode, rejectPairSession, transitionPairSession, MAX_SECONDARY_CODE_ATTEMPTS, } from './pair-session-store.js';
45
67
  import { compareSecondaryCodesCT, decryptPairingPayload } from './pair-crypto.js';
46
68
  import { renderPairPage } from './pair-page.js';
69
+ import { awaitPhraseUpload, openRemotePairSession, } from './pair-remote-client.js';
47
70
  /**
48
- * Build the four handlers. The caller registers each with
49
- * `api.registerHttpRoute({ path, handler })`.
71
+ * Build the route handlers. The caller registers each with
72
+ * `api.registerHttpRoute({ path, handler })`. When `cfg.relayBaseUrl` is
73
+ * set, the bundle also carries the in-process `/pair/init` handler.
50
74
  */
51
75
  export function buildPairRoutes(cfg) {
52
76
  const apiBase = cfg.apiBase.replace(/\/+$/, '');
@@ -283,7 +307,117 @@ export function buildPairRoutes(cfg) {
283
307
  mode: session.mode,
284
308
  });
285
309
  }
286
- return {
310
+ // ---------------------------------------------------------------
311
+ // 3.3.14 — In-process pair trigger (the 30s-subprocess-kill 502 fix)
312
+ // ---------------------------------------------------------------
313
+ //
314
+ // Defined unconditionally so the handler identity is stable for the
315
+ // lifetime of the bundle; it short-circuits with 503 when no relay
316
+ // URL was wired. Only attached to the returned bundle + registered
317
+ // as a route when `cfg.relayBaseUrl` is set (see the return below).
318
+ async function handleInit(req, res) {
319
+ if (!methodAllowed(req, ['GET'])) {
320
+ sendJson(res, 405, { error: 'method_not_allowed' });
321
+ return;
322
+ }
323
+ const relay = cfg.relayBaseUrl;
324
+ if (!relay) {
325
+ // Caller did not wire a relay URL — the in-process route is inert.
326
+ sendJson(res, 503, { error: 'init_not_configured' });
327
+ return;
328
+ }
329
+ // 1. Open the relay WebSocket IN-PROCESS (the gateway process owns the
330
+ // socket). This is the fix: the WS is no longer held by a CLI
331
+ // subprocess, so OpenClaw's 30s shell-tool timeout cannot kill it
332
+ // and the relay never sees a mid-pair disconnect → no more 502 on
333
+ // /pair/respond.
334
+ let session;
335
+ try {
336
+ const openOpts = {
337
+ relayBaseUrl: relay,
338
+ mode: cfg.initPairMode ?? 'either',
339
+ };
340
+ if (cfg.initWebSocketImpl) {
341
+ openOpts.webSocketImpl = cfg.initWebSocketImpl;
342
+ }
343
+ session = await openRemotePairSession(openOpts);
344
+ }
345
+ catch (err) {
346
+ const msg = err instanceof Error ? err.message : String(err);
347
+ cfg.logger.warn(`pair-http /init: relay session open failed: ${msg}`);
348
+ sendJson(res, 502, { error: 'relay_open_failed', detail: msg });
349
+ return;
350
+ }
351
+ const parsedExpiresMs = Date.parse(session.expiresAt);
352
+ const expiresAtMs = Number.isFinite(parsedExpiresMs)
353
+ ? parsedExpiresMs
354
+ : now() + 5 * 60_000;
355
+ // 2. Respond IMMEDIATELY with the URL + PIN + sid (relay token) +
356
+ // expiry. The agent reads this and surfaces the URL+PIN to the
357
+ // user. The relay token here plays the `sid` role for agent-side
358
+ // correlation (parity with the CLI JSON payload shape).
359
+ sendJson(res, 200, {
360
+ v: 1,
361
+ sid: session.token,
362
+ url: session.url,
363
+ pin: session.pin,
364
+ mode: session.mode,
365
+ expires_at_ms: expiresAtMs,
366
+ });
367
+ // 3. Start the background wait IN THE GATEWAY PROCESS. We do NOT
368
+ // await this from the request handler — the HTTP response has
369
+ // already been sent. The promise resolves when the browser
370
+ // uploads the encrypted phrase (the relay pushes a `forward`
371
+ // frame), the gateway decrypts locally, and `completePairing`
372
+ // writes credentials.json + flips onboarding state. Errors are
373
+ // logged but never reach the HTTP response (it's already gone).
374
+ //
375
+ // The injected `completePairing` callback receives the mnemonic
376
+ // + a session-shaped object. We adapt the relay session to the
377
+ // `PairSession`-like shape the existing handler signature
378
+ // expects; only the fields completePairing actually reads are
379
+ // populated, the rest default. The mnemonic is the load-bearing
380
+ // field (it writes credentials.json); the session shape carries
381
+ // sid/mode for log correlation.
382
+ void (async () => {
383
+ try {
384
+ const result = await awaitPhraseUpload(session, {
385
+ phraseValidator: validate,
386
+ timeoutMs: cfg.initAwaitTimeoutMs,
387
+ completePairing: async ({ mnemonic }) => {
388
+ // Adapt the relay session to the PairSession-like shape the
389
+ // existing CompletePairingHandler signature expects. Only
390
+ // sid + mode are load-bearing for log correlation; the
391
+ // crypto fields are unused (decryption already happened).
392
+ const sessionLike = {
393
+ sid: session.token,
394
+ mode: session.mode === 'generate' ? 'generate' : 'import',
395
+ };
396
+ return cfg.completePairing({ mnemonic, session: sessionLike });
397
+ },
398
+ });
399
+ if (result.state === 'active') {
400
+ cfg.logger.info(`pair-http /init: session ${redactSid(session.token)} completed in-process; onboarding active`);
401
+ }
402
+ else {
403
+ cfg.logger.warn(`pair-http /init: session ${redactSid(session.token)} completion non-active: ${result.error ?? 'unknown'}`);
404
+ }
405
+ }
406
+ catch (err) {
407
+ const msg = err instanceof Error ? err.message : String(err);
408
+ // Timeouts are expected when the user closes the browser without
409
+ // completing; log at info so a benign expire doesn't look like an
410
+ // error in the gateway log.
411
+ if (msg.includes('timeout') || msg.includes('closed')) {
412
+ cfg.logger.info(`pair-http /init: session ${redactSid(session.token)} expired/closed before completion`);
413
+ }
414
+ else {
415
+ cfg.logger.warn(`pair-http /init: session ${redactSid(session.token)} failed: ${msg}`);
416
+ }
417
+ }
418
+ })();
419
+ }
420
+ const bundle = {
287
421
  finishPath: `${apiBase}/finish`,
288
422
  startPath: `${apiBase}/start`,
289
423
  respondPath: `${apiBase}/respond`,
@@ -295,6 +429,15 @@ export function buildPairRoutes(cfg) {
295
429
  status: handleStatus,
296
430
  },
297
431
  };
432
+ // Only surface the in-process /init route when a relay URL is wired.
433
+ // Older callers that construct the bundle without `relayBaseUrl` get
434
+ // the original four-route shape (back-compat for tests + any external
435
+ // consumers of buildPairRoutes).
436
+ if (cfg.relayBaseUrl) {
437
+ bundle.initPath = `${apiBase}/init`;
438
+ bundle.handlers.init = handleInit;
439
+ }
440
+ return bundle;
298
441
  }
299
442
  // ---------------------------------------------------------------------------
300
443
  // Internals: body reading, response helpers, validation