@totalreclaw/totalreclaw 3.3.2 → 3.3.4-rc.1
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 +149 -0
- package/SKILL.md +18 -6
- package/config.ts +24 -1
- package/dist/config.js +22 -1
- package/dist/embedding.js +41 -1
- package/dist/fs-helpers.js +50 -4
- package/dist/index.js +309 -27
- package/dist/llm-client.js +3 -1
- package/dist/pair-cli-relay.js +278 -0
- package/dist/pair-cli.js +85 -17
- package/dist/subgraph-store.js +3 -2
- package/embedding.ts +43 -1
- package/fs-helpers.ts +49 -5
- package/index.ts +322 -24
- package/llm-client.ts +8 -1
- package/package.json +5 -6
- package/pair-cli-relay.ts +336 -0
- package/pair-cli.ts +118 -18
- package/skill.json +3 -3
- package/subgraph-store.ts +3 -2
- package/postinstall.mjs +0 -260
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pair-cli-relay — relay-mode runner for the `openclaw totalreclaw pair`
|
|
3
|
+
* CLI subcommand (3.3.4-rc.1).
|
|
4
|
+
*
|
|
5
|
+
* Background
|
|
6
|
+
* ----------
|
|
7
|
+
* The CLI default through 3.3.3-rc.1 was the loopback / LAN URL flow
|
|
8
|
+
* (`pair-cli.ts` `runPairCli` + `pair-session-store`). On Docker
|
|
9
|
+
* deployments — i.e. the rc.6+ default — that emits `http://localhost:18789/…`
|
|
10
|
+
* which is unreachable from the user's browser. QA on 3.3.3-rc.1 (Pedro
|
|
11
|
+
* 2026-04-30) confirmed this is the *primary* CLI-fallback failure mode:
|
|
12
|
+
* agent loses the `totalreclaw_pair` tool binding, falls back to
|
|
13
|
+
* `openclaw totalreclaw pair generate --url-pin-only`, gets a localhost
|
|
14
|
+
* URL, user can't open it.
|
|
15
|
+
*
|
|
16
|
+
* 3.3.4-rc.1 flips the CLI default to relay-mode. This file implements
|
|
17
|
+
* the runner. It mirrors the relay flow already used by the agent tool
|
|
18
|
+
* (`index.ts` `totalreclaw_pair` handler) so the CLI and the tool emit
|
|
19
|
+
* URLs from the same relay (`api-staging.totalreclaw.xyz` / `api.…`).
|
|
20
|
+
*
|
|
21
|
+
* Output formats
|
|
22
|
+
* --------------
|
|
23
|
+
* Same `PairCliOutputMode` surface as the local flow:
|
|
24
|
+
* - `human` — multi-line banner + QR ASCII + URL + PIN (default)
|
|
25
|
+
* - `json` — single-line `{v:1,sid,url,pin,mode,expires_at_ms,qr_ascii}`
|
|
26
|
+
* - `url-pin` — single-line `{v:1,url,pin,expires_at_ms}` (no QR)
|
|
27
|
+
* - `pair-only` — single-line `{v:1,pair_url,pin,expires_at_ms}` (no QR)
|
|
28
|
+
*
|
|
29
|
+
* The `sid` field in JSON mode carries the relay token (relay-issued
|
|
30
|
+
* opaque session id) so the agent can correlate emit + completion.
|
|
31
|
+
*
|
|
32
|
+
* Phrase safety
|
|
33
|
+
* -------------
|
|
34
|
+
* The same invariant the agent-tool path enforces: relay sees only
|
|
35
|
+
* ciphertext, gateway decrypts locally via x25519 ECDH + AES-GCM, the
|
|
36
|
+
* mnemonic is written to credentials.json by `completePairing` and never
|
|
37
|
+
* crosses any logger / stdout. PIN is on stdout (required) but never
|
|
38
|
+
* logged.
|
|
39
|
+
*
|
|
40
|
+
* Scanner / scope
|
|
41
|
+
* ---------------
|
|
42
|
+
* Touches `fs` indirectly via the credential-write completion handler
|
|
43
|
+
* passed in. No env-var reads here — caller resolves URL / paths from
|
|
44
|
+
* `CONFIG`. See `index.ts` wire-up.
|
|
45
|
+
*/
|
|
46
|
+
|
|
47
|
+
import { validateMnemonic } from '@scure/bip39';
|
|
48
|
+
import { wordlist } from '@scure/bip39/wordlists/english';
|
|
49
|
+
|
|
50
|
+
import {
|
|
51
|
+
loadCredentialsJson,
|
|
52
|
+
writeCredentialsJson,
|
|
53
|
+
writeOnboardingState,
|
|
54
|
+
} from './fs-helpers.js';
|
|
55
|
+
import {
|
|
56
|
+
awaitPhraseUpload,
|
|
57
|
+
openRemotePairSession,
|
|
58
|
+
} from './pair-remote-client.js';
|
|
59
|
+
import { setRecoveryPhraseOverride } from './config.js';
|
|
60
|
+
import { encodePng, encodeUnicode } from './pair-qr.js';
|
|
61
|
+
import type {
|
|
62
|
+
PairCliIo,
|
|
63
|
+
PairCliJsonPayload,
|
|
64
|
+
PairCliMode,
|
|
65
|
+
PairCliOutcome,
|
|
66
|
+
PairCliOutputMode,
|
|
67
|
+
PairCliPairOnlyPayload,
|
|
68
|
+
PairCliUrlPinPayload,
|
|
69
|
+
} from './pair-cli.js';
|
|
70
|
+
|
|
71
|
+
export interface RelayPairCliRunnerOpts {
|
|
72
|
+
/** Relay base URL (`wss://api-staging.totalreclaw.xyz` for RC, `wss://api.…` for stable). */
|
|
73
|
+
relayBaseUrl: string;
|
|
74
|
+
/** Where credentials.json lives — written by completePairing. */
|
|
75
|
+
credentialsPath: string;
|
|
76
|
+
/** Where onboarding-state.json lives — flipped to `active` on success. */
|
|
77
|
+
onboardingStatePath: string;
|
|
78
|
+
/** Plugin version stamped into onboarding-state.json. */
|
|
79
|
+
pluginVersion: string;
|
|
80
|
+
/** Scope-address derivation. Best-effort — null on failure. */
|
|
81
|
+
deriveScopeAddress: (mnemonic: string) => Promise<string | undefined>;
|
|
82
|
+
/** Logger — never receives PIN / phrase / token-tail material. */
|
|
83
|
+
logger: { info(msg: string): void; warn(msg: string): void; error(msg: string): void };
|
|
84
|
+
/** QR ASCII renderer — same callback shape as `qrcode-terminal`. */
|
|
85
|
+
renderQr: (payload: string, cb: (ascii: string) => void) => void;
|
|
86
|
+
/** stdio surface for stdout / stderr / Ctrl+C. */
|
|
87
|
+
io: PairCliIo;
|
|
88
|
+
/** Output mode — defaults to `'human'`. */
|
|
89
|
+
outputMode?: PairCliOutputMode;
|
|
90
|
+
/**
|
|
91
|
+
* 3.3.4-rc.1 — currently informational. The relay-side TTL is set by the
|
|
92
|
+
* relay; this runner accepts the option for surface parity with the local
|
|
93
|
+
* runner. We do not extend it past the relay default because the relay is
|
|
94
|
+
* authoritative for session expiry.
|
|
95
|
+
*/
|
|
96
|
+
ttlSeconds?: number;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Run the relay-mode pair CLI. Mirrors `runPairCli`'s exit-code semantics:
|
|
101
|
+
* - `completed` (status 0)
|
|
102
|
+
* - `canceled` (Ctrl+C — status 130)
|
|
103
|
+
* - `expired` / `rejected` / `error` (status 1)
|
|
104
|
+
*
|
|
105
|
+
* Resolves with the outcome; the caller (`registerPairCli` action) maps
|
|
106
|
+
* the outcome to `process.exit(...)`.
|
|
107
|
+
*/
|
|
108
|
+
export async function runRelayPairCli(
|
|
109
|
+
mode: PairCliMode,
|
|
110
|
+
opts: RelayPairCliRunnerOpts,
|
|
111
|
+
): Promise<PairCliOutcome> {
|
|
112
|
+
const outputMode: PairCliOutputMode = opts.outputMode ?? 'human';
|
|
113
|
+
const stdout = opts.io.stdout;
|
|
114
|
+
|
|
115
|
+
// 1. Open the relay session. The relay returns the user-facing URL +
|
|
116
|
+
// PIN + token + expiresAt. The keypair stays in-process.
|
|
117
|
+
let session: Awaited<ReturnType<typeof openRemotePairSession>>;
|
|
118
|
+
try {
|
|
119
|
+
session = await openRemotePairSession({
|
|
120
|
+
relayBaseUrl: opts.relayBaseUrl,
|
|
121
|
+
mode: mode === 'generate' ? 'generate' : 'import',
|
|
122
|
+
});
|
|
123
|
+
} catch (err) {
|
|
124
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
125
|
+
opts.io.stderr.write(
|
|
126
|
+
`\nFailed to open relay pairing session: ${msg}\n` +
|
|
127
|
+
`If the relay is unreachable from this gateway, retry with --local for the loopback URL flow.\n`,
|
|
128
|
+
);
|
|
129
|
+
return { status: 'error', error: msg };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// ISO-8601 → ms for tool-payload parity with the agent tool.
|
|
133
|
+
const parsedExpiresMs = Date.parse(session.expiresAt);
|
|
134
|
+
const expiresAtMs = Number.isFinite(parsedExpiresMs)
|
|
135
|
+
? parsedExpiresMs
|
|
136
|
+
: Date.now() + 5 * 60_000;
|
|
137
|
+
|
|
138
|
+
// 2. Render the QR ASCII (skipped in url-pin / pair-only modes — the
|
|
139
|
+
// same as `runPairCli`). 10s timeout guard against a renderer that
|
|
140
|
+
// never fires its callback.
|
|
141
|
+
const skipsQr = outputMode === 'url-pin' || outputMode === 'pair-only';
|
|
142
|
+
const qrAscii = skipsQr
|
|
143
|
+
? ''
|
|
144
|
+
: await new Promise<string>((resolve) => {
|
|
145
|
+
let settled = false;
|
|
146
|
+
const t = setTimeout(() => {
|
|
147
|
+
if (!settled) {
|
|
148
|
+
settled = true;
|
|
149
|
+
resolve('');
|
|
150
|
+
}
|
|
151
|
+
}, 10_000);
|
|
152
|
+
try {
|
|
153
|
+
opts.renderQr(session.url, (ascii) => {
|
|
154
|
+
if (settled) return;
|
|
155
|
+
settled = true;
|
|
156
|
+
clearTimeout(t);
|
|
157
|
+
resolve(ascii);
|
|
158
|
+
});
|
|
159
|
+
} catch (err) {
|
|
160
|
+
if (settled) return;
|
|
161
|
+
settled = true;
|
|
162
|
+
clearTimeout(t);
|
|
163
|
+
resolve(`(QR renderer crashed: ${err instanceof Error ? err.message : String(err)})`);
|
|
164
|
+
}
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
// 3. Emit the visible surface — single JSON line for non-human modes,
|
|
168
|
+
// multi-line banner + QR for human mode. Identical layout to the
|
|
169
|
+
// local-mode runner so callers can swap transparently.
|
|
170
|
+
if (outputMode === 'url-pin') {
|
|
171
|
+
const payload: PairCliUrlPinPayload = {
|
|
172
|
+
v: 1,
|
|
173
|
+
url: session.url,
|
|
174
|
+
pin: session.pin,
|
|
175
|
+
expires_at_ms: expiresAtMs,
|
|
176
|
+
};
|
|
177
|
+
stdout.write(JSON.stringify(payload) + '\n');
|
|
178
|
+
} else if (outputMode === 'pair-only') {
|
|
179
|
+
const payload: PairCliPairOnlyPayload = {
|
|
180
|
+
v: 1,
|
|
181
|
+
pair_url: session.url,
|
|
182
|
+
pin: session.pin,
|
|
183
|
+
expires_at_ms: expiresAtMs,
|
|
184
|
+
};
|
|
185
|
+
stdout.write(JSON.stringify(payload) + '\n');
|
|
186
|
+
} else if (outputMode === 'json') {
|
|
187
|
+
const payload: PairCliJsonPayload = {
|
|
188
|
+
v: 1,
|
|
189
|
+
sid: session.token,
|
|
190
|
+
url: session.url,
|
|
191
|
+
pin: session.pin,
|
|
192
|
+
mode,
|
|
193
|
+
expires_at_ms: expiresAtMs,
|
|
194
|
+
qr_ascii: qrAscii,
|
|
195
|
+
};
|
|
196
|
+
stdout.write(JSON.stringify(payload) + '\n');
|
|
197
|
+
} else {
|
|
198
|
+
// Human-mode banner. Mirror `pair-cli.ts` COPY surface, but tweak the
|
|
199
|
+
// header so operators see "Relay" not "Local" (so it's obvious the
|
|
200
|
+
// URL is universal-reachable, not gateway-loopback).
|
|
201
|
+
stdout.write(
|
|
202
|
+
'\nTotalReclaw — Relay pairing\n\n' +
|
|
203
|
+
'Your TotalReclaw recovery phrase will be created (or imported) in your\n' +
|
|
204
|
+
'BROWSER and delivered to this gateway encrypted end-to-end via the\n' +
|
|
205
|
+
'relay (the relay only sees ciphertext). The phrase never touches the\n' +
|
|
206
|
+
'LLM, the session transcript, or the relay server in plaintext.\n\n' +
|
|
207
|
+
'Scan the QR code below with your phone, or open the URL on any device\n' +
|
|
208
|
+
'(no LAN / Tailscale / port-forward required). Then type the 6-digit\n' +
|
|
209
|
+
'code shown here into the browser.\n',
|
|
210
|
+
);
|
|
211
|
+
stdout.write(
|
|
212
|
+
mode === 'generate'
|
|
213
|
+
? '\nMode: GENERATE — your browser will create a NEW 12-word recovery phrase.\n' +
|
|
214
|
+
'You will be asked to write it down and retype 3 words before the\n' +
|
|
215
|
+
'gateway accepts it.\n'
|
|
216
|
+
: '\nMode: IMPORT — your browser will accept an existing TotalReclaw\n' +
|
|
217
|
+
'recovery phrase that you already have. Paste it in the browser; it\n' +
|
|
218
|
+
'will be validated locally and encrypted before upload.\n',
|
|
219
|
+
);
|
|
220
|
+
if (qrAscii) {
|
|
221
|
+
stdout.write('\n' + qrAscii + '\n');
|
|
222
|
+
} else {
|
|
223
|
+
stdout.write('\n(QR not rendered — use the URL below)\n');
|
|
224
|
+
}
|
|
225
|
+
stdout.write(
|
|
226
|
+
'\nSecondary code (type this into the browser):\n\n ' +
|
|
227
|
+
session.pin.split('').join(' ') +
|
|
228
|
+
'\n\nURL (QR encodes this plus a one-time public key):\n\n ' +
|
|
229
|
+
session.url +
|
|
230
|
+
'\n\nSecurity:\n' +
|
|
231
|
+
' * Do NOT share your screen during pairing.\n' +
|
|
232
|
+
' * Do NOT screenshot this terminal.\n' +
|
|
233
|
+
' * The browser page will warn you never to reuse this recovery\n' +
|
|
234
|
+
' phrase for wallets, banking, email, or any other service.\n' +
|
|
235
|
+
'\nWaiting for browser to connect… (press Ctrl+C to cancel)\n',
|
|
236
|
+
);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// 4. Optional PNG / Unicode QR for richer transports — same as the
|
|
240
|
+
// agent tool. Best-effort; non-fatal on encode failure.
|
|
241
|
+
if (!skipsQr && outputMode !== 'human') {
|
|
242
|
+
// JSON consumers already have qr_ascii; PNG/Unicode would belong in
|
|
243
|
+
// a separate response shape. Keeping the runner surface in-band with
|
|
244
|
+
// the local runner means we don't add fields here. Skip silently.
|
|
245
|
+
void encodePng;
|
|
246
|
+
void encodeUnicode;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// 5. Set up Ctrl+C cancellation. The relay session can't be
|
|
250
|
+
// server-side rejected from the client (no rejectPairSession-equivalent
|
|
251
|
+
// over the WS), but closing the WebSocket terminates the session.
|
|
252
|
+
let canceled = false;
|
|
253
|
+
const releaseInterrupt = opts.io.onInterrupt(() => {
|
|
254
|
+
canceled = true;
|
|
255
|
+
try {
|
|
256
|
+
session._ws.close();
|
|
257
|
+
} catch {
|
|
258
|
+
/* ignore */
|
|
259
|
+
}
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
// 6. Block on the relay until the browser uploads the encrypted
|
|
263
|
+
// phrase, then write credentials + flip onboarding-state. Mirrors
|
|
264
|
+
// the agent-tool's `awaitPhraseUpload` callback inline so we have a
|
|
265
|
+
// single source of truth for credential persistence.
|
|
266
|
+
const emitStatus = (text: string): void => {
|
|
267
|
+
if (outputMode === 'human') stdout.write(text);
|
|
268
|
+
};
|
|
269
|
+
|
|
270
|
+
try {
|
|
271
|
+
const result = await awaitPhraseUpload(session, {
|
|
272
|
+
phraseValidator: (p: string) => validateMnemonic(p, wordlist),
|
|
273
|
+
completePairing: async ({ mnemonic }) => {
|
|
274
|
+
try {
|
|
275
|
+
let scopeAddress: string | undefined;
|
|
276
|
+
try {
|
|
277
|
+
scopeAddress = await opts.deriveScopeAddress(mnemonic);
|
|
278
|
+
} catch (deriveErr) {
|
|
279
|
+
opts.logger.warn(
|
|
280
|
+
`pair-cli (relay): scope_address derivation failed (will retry lazily): ${
|
|
281
|
+
deriveErr instanceof Error ? deriveErr.message : String(deriveErr)
|
|
282
|
+
}`,
|
|
283
|
+
);
|
|
284
|
+
}
|
|
285
|
+
const creds = loadCredentialsJson(opts.credentialsPath) ?? {};
|
|
286
|
+
const next: typeof creds = { ...creds, mnemonic };
|
|
287
|
+
if (scopeAddress) next.scope_address = scopeAddress;
|
|
288
|
+
if (!writeCredentialsJson(opts.credentialsPath, next)) {
|
|
289
|
+
return { state: 'error', error: 'credentials_write_failed' };
|
|
290
|
+
}
|
|
291
|
+
setRecoveryPhraseOverride(mnemonic);
|
|
292
|
+
writeOnboardingState(opts.onboardingStatePath, {
|
|
293
|
+
onboardingState: 'active',
|
|
294
|
+
createdBy: mode === 'generate' ? 'generate' : 'import',
|
|
295
|
+
credentialsCreatedAt: new Date().toISOString(),
|
|
296
|
+
version: opts.pluginVersion,
|
|
297
|
+
});
|
|
298
|
+
opts.logger.info(
|
|
299
|
+
`pair-cli (relay): session ${session.token.slice(0, 8)}… completed; credentials written` +
|
|
300
|
+
(scopeAddress ? ` (scope_address=${scopeAddress})` : ''),
|
|
301
|
+
);
|
|
302
|
+
return { state: 'active' };
|
|
303
|
+
} catch (err: unknown) {
|
|
304
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
305
|
+
opts.logger.error(`pair-cli (relay): completePairing failed: ${msg}`);
|
|
306
|
+
return { state: 'error', error: msg };
|
|
307
|
+
}
|
|
308
|
+
},
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
if (canceled) {
|
|
312
|
+
emitStatus('\nCanceled. Pairing session invalidated.\n');
|
|
313
|
+
return { status: 'canceled', sid: session.token };
|
|
314
|
+
}
|
|
315
|
+
if (result.state === 'active') {
|
|
316
|
+
emitStatus('\nPairing complete. Account is active.\n');
|
|
317
|
+
return { status: 'completed', sid: session.token };
|
|
318
|
+
}
|
|
319
|
+
emitStatus(`\nPairing failed: ${result.error ?? 'unknown_error'}\n`);
|
|
320
|
+
return { status: 'error', sid: session.token, error: result.error ?? 'unknown_error' };
|
|
321
|
+
} catch (err) {
|
|
322
|
+
if (canceled) {
|
|
323
|
+
emitStatus('\nCanceled. Pairing session invalidated.\n');
|
|
324
|
+
return { status: 'canceled', sid: session.token };
|
|
325
|
+
}
|
|
326
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
327
|
+
if (msg.includes('timeout')) {
|
|
328
|
+
emitStatus('\nSession expired. Run the command again to restart.\n');
|
|
329
|
+
return { status: 'expired', sid: session.token };
|
|
330
|
+
}
|
|
331
|
+
emitStatus(`\nPairing error: ${msg}\n`);
|
|
332
|
+
return { status: 'error', sid: session.token, error: msg };
|
|
333
|
+
} finally {
|
|
334
|
+
releaseInterrupt();
|
|
335
|
+
}
|
|
336
|
+
}
|
package/pair-cli.ts
CHANGED
|
@@ -3,20 +3,34 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Purpose
|
|
5
5
|
* -------
|
|
6
|
-
* Starts a
|
|
7
|
-
*
|
|
8
|
-
*
|
|
6
|
+
* Starts a pairing session from the gateway host's terminal and renders
|
|
7
|
+
* the URL + 6-digit PIN + ASCII QR. The user opens the URL in a browser
|
|
8
|
+
* (on phone or laptop), confirms the PIN, and uploads their recovery
|
|
9
|
+
* phrase end-to-end-encrypted.
|
|
9
10
|
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
11
|
+
* Two URL flavours
|
|
12
|
+
* ----------------
|
|
13
|
+
* * **Relay mode (3.3.4-rc.1 default).** The CLI opens a WebSocket against
|
|
14
|
+
* the relay (`api-staging.totalreclaw.xyz` for RC, `api.totalreclaw.xyz`
|
|
15
|
+
* for stable) and gets back a `https://<relay>/pair/p/<token>#pk=…` URL
|
|
16
|
+
* the user can reach from any device on any network. This is the same
|
|
17
|
+
* surface the agent-tool `totalreclaw_pair` uses. It works behind NAT,
|
|
18
|
+
* in Docker, on managed services — anywhere outbound HTTPS works.
|
|
19
|
+
*
|
|
20
|
+
* * **Local mode (`--local`).** The legacy loopback flow: a session lands
|
|
21
|
+
* in `pair-session-store` and the URL points at the gateway's own
|
|
22
|
+
* bound interface (`http://localhost:18789/...`, or LAN/Tailscale IP
|
|
23
|
+
* if autodetected). Required for fully-air-gapped operators who want
|
|
24
|
+
* the relay out of the loop. Browser must be on a network that can
|
|
25
|
+
* reach the gateway.
|
|
13
26
|
*
|
|
14
27
|
* Scope and scanner surface
|
|
15
28
|
* -------------------------
|
|
16
29
|
* Has `fetch` (for status polling) AND `POST` (never actually POSTs,
|
|
17
30
|
* but the word lives in comments describing the paired browser POST).
|
|
18
31
|
* MUST NOT also read disk or env vars. All state operations delegate
|
|
19
|
-
* to pair-session-store; the CLI itself is a thin
|
|
32
|
+
* to pair-session-store / pair-remote-client; the CLI itself is a thin
|
|
33
|
+
* coordinator.
|
|
20
34
|
*
|
|
21
35
|
* Zero logging of secret material. The secondary code IS printed to
|
|
22
36
|
* stdout (required for the user to type), but never logged to file
|
|
@@ -444,6 +458,15 @@ export function registerPairCli(
|
|
|
444
458
|
sessionsPath: string;
|
|
445
459
|
renderPairingUrl(session: PairSession): string;
|
|
446
460
|
logger: { info(msg: string): void; warn(msg: string): void; error(msg: string): void };
|
|
461
|
+
/**
|
|
462
|
+
* 3.3.4-rc.1 — relay-mode runner. When supplied, the CLI defaults to
|
|
463
|
+
* relay-mode (relay-brokered URL via `api-staging.totalreclaw.xyz` /
|
|
464
|
+
* `api.totalreclaw.xyz`). The runner is responsible for opening the
|
|
465
|
+
* WS session and polling the relay, mirroring `runPairCli`'s exit
|
|
466
|
+
* codes. If absent (very old plugin loader), the CLI silently falls
|
|
467
|
+
* back to local-mode and warns.
|
|
468
|
+
*/
|
|
469
|
+
runRelayPairCli?: (mode: PairCliMode, opts: RelayPairCliOpts) => Promise<PairCliOutcome>;
|
|
447
470
|
},
|
|
448
471
|
): void {
|
|
449
472
|
// If the onboarding-cli already attached `totalreclaw`, reuse it.
|
|
@@ -459,15 +482,23 @@ export function registerPairCli(
|
|
|
459
482
|
|
|
460
483
|
tr.command('pair [mode]')
|
|
461
484
|
.description(
|
|
462
|
-
'Pair a remote browser device to this gateway
|
|
485
|
+
'Pair a remote browser device to this gateway via the relay (default; ' +
|
|
486
|
+
'works through NAT and inside Docker). Use --local to fall back to ' +
|
|
487
|
+
'gateway-loopback URLs for air-gapped setups.',
|
|
463
488
|
)
|
|
464
|
-
.option('--json', 'Emit a single JSON payload (url/pin/
|
|
489
|
+
.option('--json', 'Emit a single JSON payload (url/pin/qr_ascii) instead of the human-readable banner. Enables agent-driven pairing.')
|
|
465
490
|
.option('--url-pin-only', 'Emit ONLY {v,url,pin,expires_at_ms} — no QR ASCII, no SID, no mode echo. Headless fallback for container-based agents where the totalreclaw_pair tool is not injected (issue #87). Zero phrase exposure on stdout.')
|
|
491
|
+
.option('--local', '(3.3.4-rc.1) Use the loopback / LAN URL flow instead of the relay. URLs point at this gateway\'s bound interface (e.g. http://localhost:18789/…) and require the user\'s browser to be on a reachable network. Default since rc.6 was relay; this flag preserves the air-gapped path.')
|
|
466
492
|
.option('--timeout <sec>', 'Session TTL in seconds (default: 900 = 15 min, matches pair-session-store default)')
|
|
467
493
|
.action(async (...args: unknown[]) => {
|
|
468
494
|
// commander passes: [modeArg, options, cmd]
|
|
469
495
|
const modeRaw = typeof args[0] === 'string' ? args[0] : undefined;
|
|
470
|
-
const opts = (args[1] ?? {}) as {
|
|
496
|
+
const opts = (args[1] ?? {}) as {
|
|
497
|
+
json?: boolean;
|
|
498
|
+
urlPinOnly?: boolean;
|
|
499
|
+
local?: boolean;
|
|
500
|
+
timeout?: string | number;
|
|
501
|
+
};
|
|
471
502
|
const mode: PairCliMode =
|
|
472
503
|
modeRaw === 'import' || modeRaw === 'imp' ? 'import' : 'generate';
|
|
473
504
|
// --url-pin-only wins over --json when both are passed, since it is
|
|
@@ -483,15 +514,57 @@ export function registerPairCli(
|
|
|
483
514
|
if (Number.isFinite(parsed) && parsed > 0) ttlSeconds = parsed;
|
|
484
515
|
}
|
|
485
516
|
const io = buildDefaultPairCliIo();
|
|
517
|
+
// 3.3.4-rc.1 — flip the default to relay-mode. The agent tool
|
|
518
|
+
// `totalreclaw_pair` has used the relay since rc.11; the CLI was
|
|
519
|
+
// the last surface still defaulting to gateway-loopback URLs,
|
|
520
|
+
// which are unreachable from a remote browser when the gateway
|
|
521
|
+
// runs in Docker (the rc.6+ default deployment). `--local`
|
|
522
|
+
// restores the legacy flow for air-gapped operators.
|
|
523
|
+
const useRelay = shouldUseRelayMode({
|
|
524
|
+
local: opts.local,
|
|
525
|
+
hasRelayRunner: typeof deps.runRelayPairCli === 'function',
|
|
526
|
+
});
|
|
486
527
|
try {
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
528
|
+
let outcome: PairCliOutcome;
|
|
529
|
+
if (useRelay) {
|
|
530
|
+
outcome = await deps.runRelayPairCli!(mode, {
|
|
531
|
+
renderQr: defaultRenderQr,
|
|
532
|
+
io,
|
|
533
|
+
outputMode,
|
|
534
|
+
ttlSeconds,
|
|
535
|
+
});
|
|
536
|
+
} else {
|
|
537
|
+
if (opts.local) {
|
|
538
|
+
// Tell the operator they explicitly opted in. Suppress in
|
|
539
|
+
// JSON modes — the JSON contract must stay stdout-clean.
|
|
540
|
+
if (outputMode === 'human') {
|
|
541
|
+
io.stderr.write(
|
|
542
|
+
'\n[--local] Using gateway-loopback URL flow. The user\'s browser ' +
|
|
543
|
+
'must be reachable from this gateway\'s bound interface (LAN, Tailscale, ' +
|
|
544
|
+
'or localhost on the same machine).\n',
|
|
545
|
+
);
|
|
546
|
+
}
|
|
547
|
+
} else if (!deps.runRelayPairCli) {
|
|
548
|
+
// No relay runner wired — older composition. Warn once on
|
|
549
|
+
// stderr in human mode so the operator knows why URLs may
|
|
550
|
+
// be unreachable from a remote browser.
|
|
551
|
+
if (outputMode === 'human') {
|
|
552
|
+
io.stderr.write(
|
|
553
|
+
'\n[pair-cli] relay-mode runner not available — falling back to local-mode. ' +
|
|
554
|
+
'Pair URLs will use this gateway\'s bound interface. Upgrade the plugin ' +
|
|
555
|
+
'or pass --local to silence this warning.\n',
|
|
556
|
+
);
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
outcome = await runPairCli(mode, {
|
|
560
|
+
sessionsPath: deps.sessionsPath,
|
|
561
|
+
renderPairingUrl: deps.renderPairingUrl,
|
|
562
|
+
renderQr: defaultRenderQr,
|
|
563
|
+
io,
|
|
564
|
+
outputMode,
|
|
565
|
+
ttlSeconds,
|
|
566
|
+
});
|
|
567
|
+
}
|
|
495
568
|
if (outcome.status !== 'completed') {
|
|
496
569
|
process.exit(outcome.status === 'canceled' ? 130 : 1);
|
|
497
570
|
}
|
|
@@ -503,6 +576,33 @@ export function registerPairCli(
|
|
|
503
576
|
});
|
|
504
577
|
}
|
|
505
578
|
|
|
579
|
+
/**
|
|
580
|
+
* 3.3.4-rc.1 — options for the relay-mode CLI runner. Mirrors the human
|
|
581
|
+
* surface of `runPairCli` (output mode, QR renderer, IO, TTL) but does
|
|
582
|
+
* NOT take `sessionsPath` / `renderPairingUrl` because the relay flow
|
|
583
|
+
* mints its own URL via the relay's `opened` frame.
|
|
584
|
+
*/
|
|
585
|
+
export interface RelayPairCliOpts {
|
|
586
|
+
renderQr: (payload: string, cb: (ascii: string) => void) => void;
|
|
587
|
+
io: PairCliIo;
|
|
588
|
+
outputMode?: PairCliOutputMode;
|
|
589
|
+
ttlSeconds?: number;
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
/**
|
|
593
|
+
* 3.3.4-rc.1 — pure decision function: given the parsed action flags
|
|
594
|
+
* and whether a relay runner is wired, return whether the relay path
|
|
595
|
+
* should be taken. Exported for unit-testing the default-mode flip
|
|
596
|
+
* without invoking either runner.
|
|
597
|
+
*/
|
|
598
|
+
export function shouldUseRelayMode(opts: {
|
|
599
|
+
local?: boolean;
|
|
600
|
+
hasRelayRunner: boolean;
|
|
601
|
+
}): boolean {
|
|
602
|
+
if (opts.local) return false;
|
|
603
|
+
return opts.hasRelayRunner;
|
|
604
|
+
}
|
|
605
|
+
|
|
506
606
|
// ---------------------------------------------------------------------------
|
|
507
607
|
// Utils
|
|
508
608
|
// ---------------------------------------------------------------------------
|
package/skill.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "totalreclaw",
|
|
3
|
-
"version": "3.3.
|
|
3
|
+
"version": "3.3.4-rc.1",
|
|
4
4
|
"description": "End-to-end encrypted memory for AI agents — portable, yours forever. XChaCha20-Poly1305 E2EE: server never sees plaintext.",
|
|
5
5
|
"author": "TotalReclaw Team",
|
|
6
6
|
"license": "MIT",
|
|
@@ -193,8 +193,8 @@
|
|
|
193
193
|
"config": {
|
|
194
194
|
"serverUrl": {
|
|
195
195
|
"type": "string",
|
|
196
|
-
"default": "https://api.totalreclaw.xyz",
|
|
197
|
-
"description": "TotalReclaw server URL (only change for self-hosted mode)"
|
|
196
|
+
"default": "https://api-staging.totalreclaw.xyz",
|
|
197
|
+
"description": "TotalReclaw server URL (only change for self-hosted mode). Source default points at staging; stable releases swap to https://api.totalreclaw.xyz at publish time per PR #165."
|
|
198
198
|
},
|
|
199
199
|
"autoExtractEveryTurns": {
|
|
200
200
|
"type": "number",
|
package/subgraph-store.ts
CHANGED
|
@@ -805,7 +805,7 @@ export function isSubgraphMode(): boolean {
|
|
|
805
805
|
*
|
|
806
806
|
* After the v1 env var cleanup, clients only need:
|
|
807
807
|
* - TOTALRECLAW_RECOVERY_PHRASE -- BIP-39 mnemonic
|
|
808
|
-
* - TOTALRECLAW_SERVER_URL -- relay server URL (default: https://api.totalreclaw.xyz)
|
|
808
|
+
* - TOTALRECLAW_SERVER_URL -- relay server URL (source default: https://api-staging.totalreclaw.xyz; stable build: https://api.totalreclaw.xyz — swapped in at publish time per PR #165)
|
|
809
809
|
* - TOTALRECLAW_SELF_HOSTED -- set "true" to use self-hosted server (default: managed service)
|
|
810
810
|
*
|
|
811
811
|
* Chain ID is no longer configurable via env — it is auto-detected from the
|
|
@@ -813,7 +813,8 @@ export function isSubgraphMode(): boolean {
|
|
|
813
813
|
*/
|
|
814
814
|
export function getSubgraphConfig(): SubgraphStoreConfig {
|
|
815
815
|
return {
|
|
816
|
-
|
|
816
|
+
// 3.3.3-rc.1: staging by default in source; stable workflow seds.
|
|
817
|
+
relayUrl: CONFIG.serverUrl || 'https://api-staging.totalreclaw.xyz',
|
|
817
818
|
mnemonic: CONFIG.recoveryPhrase,
|
|
818
819
|
cachePath: CONFIG.cachePath,
|
|
819
820
|
chainId: CONFIG.chainId,
|