@totalreclaw/totalreclaw 3.2.3 → 3.3.0-rc.2
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 +917 -0
- package/README.md +31 -2
- package/config.ts +5 -0
- package/first-run.ts +131 -0
- package/index.ts +256 -9
- package/onboarding-cli.ts +9 -2
- package/package.json +4 -2
- package/pair-cli.ts +351 -0
- package/pair-crypto.ts +474 -0
- package/pair-http.ts +527 -0
- package/pair-page.ts +841 -0
- package/pair-session-store.ts +764 -0
- package/subgraph-store.ts +2 -2
package/pair-cli.ts
ADDED
|
@@ -0,0 +1,351 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pair-cli — the `openclaw totalreclaw pair` CLI subcommand.
|
|
3
|
+
*
|
|
4
|
+
* Purpose
|
|
5
|
+
* -------
|
|
6
|
+
* Starts a remote-onboarding session FROM the gateway host's terminal.
|
|
7
|
+
* Creates a pair-session, renders the QR + URL + 6-digit secondary code
|
|
8
|
+
* to stdout, then polls /status until the browser completes the flow.
|
|
9
|
+
*
|
|
10
|
+
* This is the gateway-operator's surface. The operator reads the QR
|
|
11
|
+
* with their phone (or opens the URL on their laptop browser); the
|
|
12
|
+
* browser takes over from there.
|
|
13
|
+
*
|
|
14
|
+
* Scope and scanner surface
|
|
15
|
+
* -------------------------
|
|
16
|
+
* Has `fetch` (for status polling) AND `POST` (never actually POSTs,
|
|
17
|
+
* but the word lives in comments describing the paired browser POST).
|
|
18
|
+
* MUST NOT also read disk or env vars. All state operations delegate
|
|
19
|
+
* to pair-session-store; the CLI itself is a thin coordinator.
|
|
20
|
+
*
|
|
21
|
+
* Zero logging of secret material. The secondary code IS printed to
|
|
22
|
+
* stdout (required for the user to type), but never logged to file
|
|
23
|
+
* and never to api.logger.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import readline from 'node:readline';
|
|
27
|
+
|
|
28
|
+
import {
|
|
29
|
+
createPairSession,
|
|
30
|
+
getPairSession,
|
|
31
|
+
rejectPairSession,
|
|
32
|
+
type PairSession,
|
|
33
|
+
type PairSessionMode,
|
|
34
|
+
} from './pair-session-store.js';
|
|
35
|
+
import { generateGatewayKeypair } from './pair-crypto.js';
|
|
36
|
+
|
|
37
|
+
// ---------------------------------------------------------------------------
|
|
38
|
+
// Types
|
|
39
|
+
// ---------------------------------------------------------------------------
|
|
40
|
+
|
|
41
|
+
export interface PairCliIo {
|
|
42
|
+
stdout: NodeJS.WritableStream;
|
|
43
|
+
stderr: NodeJS.WritableStream;
|
|
44
|
+
/** Install a Ctrl+C handler that invokes `cb`; returns an uninstaller. */
|
|
45
|
+
onInterrupt(cb: () => void): () => void;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface PairCliDeps {
|
|
49
|
+
sessionsPath: string;
|
|
50
|
+
/** Caller-injected function that returns the full `url#pk=` string
|
|
51
|
+
* for the browser. Takes the session and returns the URL with the
|
|
52
|
+
* public-key fragment embedded. Signature keeps URL resolution out
|
|
53
|
+
* of this module (same rationale as pair-http). */
|
|
54
|
+
renderPairingUrl(session: PairSession): string;
|
|
55
|
+
/** QR renderer — takes a text payload + callback. Injectable for tests. */
|
|
56
|
+
renderQr(payload: string, cb: (ascii: string) => void): void;
|
|
57
|
+
/** Poll interval in ms. Default 1500. */
|
|
58
|
+
pollIntervalMs?: number;
|
|
59
|
+
/** Override for Date.now(). */
|
|
60
|
+
now?: () => number;
|
|
61
|
+
io: PairCliIo;
|
|
62
|
+
/** Optional TTL override (sec → ms conversion happens here). */
|
|
63
|
+
ttlSeconds?: number;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export type PairCliMode = PairSessionMode;
|
|
67
|
+
|
|
68
|
+
export interface PairCliOutcome {
|
|
69
|
+
status: 'completed' | 'canceled' | 'expired' | 'rejected' | 'error';
|
|
70
|
+
sid?: string;
|
|
71
|
+
error?: string;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// ---------------------------------------------------------------------------
|
|
75
|
+
// Default stdout IO
|
|
76
|
+
// ---------------------------------------------------------------------------
|
|
77
|
+
|
|
78
|
+
export function buildDefaultPairCliIo(): PairCliIo {
|
|
79
|
+
return {
|
|
80
|
+
stdout: process.stdout,
|
|
81
|
+
stderr: process.stderr,
|
|
82
|
+
onInterrupt(cb) {
|
|
83
|
+
const handler = () => {
|
|
84
|
+
try { cb(); } catch { /* swallow */ }
|
|
85
|
+
};
|
|
86
|
+
process.once('SIGINT', handler);
|
|
87
|
+
return () => process.off('SIGINT', handler);
|
|
88
|
+
},
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// ---------------------------------------------------------------------------
|
|
93
|
+
// Copy — same security principles as onboarding-cli COPY but terser.
|
|
94
|
+
// ---------------------------------------------------------------------------
|
|
95
|
+
|
|
96
|
+
const COPY = {
|
|
97
|
+
intro:
|
|
98
|
+
'\nTotalReclaw — Remote pairing\n\n' +
|
|
99
|
+
'Your TotalReclaw recovery phrase will be created (or imported) in your\n' +
|
|
100
|
+
'BROWSER and delivered to this gateway encrypted end-to-end. The phrase\n' +
|
|
101
|
+
'never touches the LLM, the session transcript, or the relay server\n' +
|
|
102
|
+
'in plaintext.\n\n' +
|
|
103
|
+
'Scan the QR code below with your phone, or open the URL on any\n' +
|
|
104
|
+
'device. Then type the 6-digit code shown here into the browser.\n',
|
|
105
|
+
introGenerate:
|
|
106
|
+
'\nMode: GENERATE — your browser will create a NEW 12-word recovery phrase.\n' +
|
|
107
|
+
'You will be asked to write it down and retype 3 words before the\n' +
|
|
108
|
+
'gateway accepts it.\n',
|
|
109
|
+
introImport:
|
|
110
|
+
'\nMode: IMPORT — your browser will accept an existing TotalReclaw\n' +
|
|
111
|
+
'recovery phrase that you already have. Paste it in the browser; it\n' +
|
|
112
|
+
'will be validated locally and encrypted before upload.\n',
|
|
113
|
+
codeLabel: '\nSecondary code (type this into the browser):\n\n ',
|
|
114
|
+
urlLabel:
|
|
115
|
+
'\n\nURL (QR encodes this plus a one-time public key):\n\n ',
|
|
116
|
+
securityWarning:
|
|
117
|
+
'\n\nSecurity:\n' +
|
|
118
|
+
' * Do NOT share your screen during pairing.\n' +
|
|
119
|
+
' * Do NOT screenshot this terminal.\n' +
|
|
120
|
+
' * The browser page will warn you never to reuse this recovery\n' +
|
|
121
|
+
' phrase for wallets, banking, email, or any other service.\n',
|
|
122
|
+
awaiting: '\nWaiting for browser to connect… (press Ctrl+C to cancel)',
|
|
123
|
+
deviceConnected: '\nBrowser connected. Waiting for encrypted payload…',
|
|
124
|
+
completed: '\nPairing complete. Account is active.',
|
|
125
|
+
canceled: '\nCanceled. Pairing session invalidated.',
|
|
126
|
+
expired: '\nSession expired. Run the command again to restart.',
|
|
127
|
+
rejected: '\nPairing rejected (too many wrong codes, or gateway aborted).',
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
function renderUnsafelyVisibleCode(code: string): string {
|
|
131
|
+
// Pad digits with spaces so terminal copy-paste can't accidentally
|
|
132
|
+
// pick them up as a single token.
|
|
133
|
+
return code.split('').join(' ');
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// ---------------------------------------------------------------------------
|
|
137
|
+
// Public entry point
|
|
138
|
+
// ---------------------------------------------------------------------------
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Start a pairing session, display the QR + code + URL, and poll
|
|
142
|
+
* until terminal state. Returns the final outcome.
|
|
143
|
+
*
|
|
144
|
+
* Blocks until the session finishes, expires, or the operator hits
|
|
145
|
+
* Ctrl+C.
|
|
146
|
+
*/
|
|
147
|
+
export async function runPairCli(
|
|
148
|
+
mode: PairCliMode,
|
|
149
|
+
deps: PairCliDeps,
|
|
150
|
+
): Promise<PairCliOutcome> {
|
|
151
|
+
const now = deps.now ?? Date.now;
|
|
152
|
+
const pollInterval = Math.max(500, deps.pollIntervalMs ?? 1500);
|
|
153
|
+
const io = deps.io;
|
|
154
|
+
const stdout = io.stdout;
|
|
155
|
+
|
|
156
|
+
// 1. Generate keypair + create the session
|
|
157
|
+
const kp = generateGatewayKeypair();
|
|
158
|
+
let session: PairSession;
|
|
159
|
+
try {
|
|
160
|
+
session = await createPairSession(deps.sessionsPath, {
|
|
161
|
+
mode,
|
|
162
|
+
operatorContext: { channel: 'cli' },
|
|
163
|
+
ttlMs: deps.ttlSeconds !== undefined ? deps.ttlSeconds * 1000 : undefined,
|
|
164
|
+
rngPrivateKey: () => Buffer.from(kp.skB64, 'base64url'),
|
|
165
|
+
rngPublicKey: () => Buffer.from(kp.pkB64, 'base64url'),
|
|
166
|
+
now,
|
|
167
|
+
});
|
|
168
|
+
} catch (err) {
|
|
169
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
170
|
+
io.stderr.write(`\nFailed to create pairing session: ${msg}\n`);
|
|
171
|
+
return { status: 'error', error: msg };
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// 2. Render the QR + text
|
|
175
|
+
const url = deps.renderPairingUrl(session);
|
|
176
|
+
stdout.write(COPY.intro);
|
|
177
|
+
stdout.write(mode === 'generate' ? COPY.introGenerate : COPY.introImport);
|
|
178
|
+
await new Promise<void>((resolve) => {
|
|
179
|
+
deps.renderQr(url, (ascii) => {
|
|
180
|
+
stdout.write('\n' + ascii + '\n');
|
|
181
|
+
resolve();
|
|
182
|
+
});
|
|
183
|
+
});
|
|
184
|
+
stdout.write(COPY.codeLabel);
|
|
185
|
+
stdout.write(renderUnsafelyVisibleCode(session.secondaryCode));
|
|
186
|
+
stdout.write(COPY.urlLabel);
|
|
187
|
+
stdout.write(url);
|
|
188
|
+
stdout.write(COPY.securityWarning);
|
|
189
|
+
stdout.write(COPY.awaiting);
|
|
190
|
+
stdout.write('\n');
|
|
191
|
+
|
|
192
|
+
// 3. Set up Ctrl+C to cancel the session server-side
|
|
193
|
+
let canceled = false;
|
|
194
|
+
const releaseInterrupt = io.onInterrupt(() => {
|
|
195
|
+
canceled = true;
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
// 4. Poll
|
|
199
|
+
let lastStatus = session.status;
|
|
200
|
+
let showedDeviceConnected = false;
|
|
201
|
+
try {
|
|
202
|
+
while (true) {
|
|
203
|
+
if (canceled) {
|
|
204
|
+
await rejectPairSession(deps.sessionsPath, session.sid, now);
|
|
205
|
+
stdout.write(COPY.canceled + '\n');
|
|
206
|
+
return { status: 'canceled', sid: session.sid };
|
|
207
|
+
}
|
|
208
|
+
await sleep(pollInterval);
|
|
209
|
+
const fresh = await getPairSession(deps.sessionsPath, session.sid, now);
|
|
210
|
+
if (!fresh) {
|
|
211
|
+
// Pruned — session is gone entirely.
|
|
212
|
+
stdout.write(COPY.expired + '\n');
|
|
213
|
+
return { status: 'expired', sid: session.sid };
|
|
214
|
+
}
|
|
215
|
+
if (fresh.status !== lastStatus) {
|
|
216
|
+
lastStatus = fresh.status;
|
|
217
|
+
if (fresh.status === 'device_connected' && !showedDeviceConnected) {
|
|
218
|
+
stdout.write(COPY.deviceConnected + '\n');
|
|
219
|
+
showedDeviceConnected = true;
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
if (fresh.status === 'completed') {
|
|
223
|
+
stdout.write(COPY.completed + '\n');
|
|
224
|
+
return { status: 'completed', sid: session.sid };
|
|
225
|
+
}
|
|
226
|
+
if (fresh.status === 'expired') {
|
|
227
|
+
stdout.write(COPY.expired + '\n');
|
|
228
|
+
return { status: 'expired', sid: session.sid };
|
|
229
|
+
}
|
|
230
|
+
if (fresh.status === 'rejected') {
|
|
231
|
+
stdout.write(COPY.rejected + '\n');
|
|
232
|
+
return { status: 'rejected', sid: session.sid };
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
} finally {
|
|
236
|
+
releaseInterrupt();
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// ---------------------------------------------------------------------------
|
|
241
|
+
// Wrap qrcode-terminal in a promise-friendly renderer. Dynamic import
|
|
242
|
+
// keeps the module out of the plugin's register() hot path.
|
|
243
|
+
// ---------------------------------------------------------------------------
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Default QR renderer using `qrcode-terminal`. Lazy-imports so the
|
|
247
|
+
* module only loads when the CLI is actually invoked.
|
|
248
|
+
*/
|
|
249
|
+
export function defaultRenderQr(payload: string, cb: (ascii: string) => void): void {
|
|
250
|
+
// `qrcode-terminal` ships no type declarations; we describe the
|
|
251
|
+
// public surface we rely on inline via a cast.
|
|
252
|
+
type QrMod = {
|
|
253
|
+
generate(text: string, opts: { small?: boolean }, cb: (ascii: string) => void): void;
|
|
254
|
+
};
|
|
255
|
+
import('qrcode-terminal' as string).then((rawMod: unknown) => {
|
|
256
|
+
const mod = rawMod as { default?: QrMod } & QrMod;
|
|
257
|
+
const qr: QrMod = mod.default ?? mod;
|
|
258
|
+
qr.generate(payload, { small: true }, cb);
|
|
259
|
+
}).catch((err: unknown) => {
|
|
260
|
+
cb(`(QR renderer unavailable: ${err instanceof Error ? err.message : String(err)})`);
|
|
261
|
+
});
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// ---------------------------------------------------------------------------
|
|
265
|
+
// CLI registrar — hooked from `index.ts registerCli`.
|
|
266
|
+
// ---------------------------------------------------------------------------
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* Register the `openclaw totalreclaw pair [generate|import]` subcommand
|
|
270
|
+
* on the caller's commander program. The onboarding-cli's
|
|
271
|
+
* `registerOnboardingCli` function already attaches `totalreclaw` as a
|
|
272
|
+
* top-level command with `onboard`+`status` subcommands; we hook in by
|
|
273
|
+
* finding that command and adding `pair` alongside.
|
|
274
|
+
*
|
|
275
|
+
* If the commander program is provided without the prior attachments,
|
|
276
|
+
* we create `totalreclaw pair` fresh. The caller in index.ts decides
|
|
277
|
+
* composition.
|
|
278
|
+
*/
|
|
279
|
+
/**
|
|
280
|
+
* Minimal structural shape of commander's `Command` used by this file.
|
|
281
|
+
* We don't import from `commander` because it's not a declared
|
|
282
|
+
* dependency of the plugin (it's injected by OpenClaw's CLI runtime
|
|
283
|
+
* at call time).
|
|
284
|
+
*/
|
|
285
|
+
type CommanderCommand = {
|
|
286
|
+
name(): string;
|
|
287
|
+
command(name: string): CommanderCommand;
|
|
288
|
+
description(text: string): CommanderCommand;
|
|
289
|
+
action(fn: (...args: unknown[]) => Promise<void> | void): CommanderCommand;
|
|
290
|
+
commands: CommanderCommand[];
|
|
291
|
+
};
|
|
292
|
+
|
|
293
|
+
export function registerPairCli(
|
|
294
|
+
program: CommanderCommand,
|
|
295
|
+
deps: {
|
|
296
|
+
sessionsPath: string;
|
|
297
|
+
renderPairingUrl(session: PairSession): string;
|
|
298
|
+
logger: { info(msg: string): void; warn(msg: string): void; error(msg: string): void };
|
|
299
|
+
},
|
|
300
|
+
): void {
|
|
301
|
+
// If the onboarding-cli already attached `totalreclaw`, reuse it.
|
|
302
|
+
// Otherwise create a fresh top-level command.
|
|
303
|
+
let tr: CommanderCommand | undefined = program.commands.find(
|
|
304
|
+
(c: CommanderCommand) => c.name() === 'totalreclaw',
|
|
305
|
+
);
|
|
306
|
+
if (!tr) {
|
|
307
|
+
tr = program
|
|
308
|
+
.command('totalreclaw')
|
|
309
|
+
.description('TotalReclaw encrypted memory — pairing + onboarding + status');
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
tr.command('pair [mode]')
|
|
313
|
+
.description(
|
|
314
|
+
'Pair a remote browser device to this gateway (mode = generate | import; default generate)',
|
|
315
|
+
)
|
|
316
|
+
.action(async (...args: unknown[]) => {
|
|
317
|
+
const modeRaw = typeof args[0] === 'string' ? args[0] : undefined;
|
|
318
|
+
const mode: PairCliMode =
|
|
319
|
+
modeRaw === 'import' || modeRaw === 'imp' ? 'import' : 'generate';
|
|
320
|
+
const io = buildDefaultPairCliIo();
|
|
321
|
+
try {
|
|
322
|
+
const outcome = await runPairCli(mode, {
|
|
323
|
+
sessionsPath: deps.sessionsPath,
|
|
324
|
+
renderPairingUrl: deps.renderPairingUrl,
|
|
325
|
+
renderQr: defaultRenderQr,
|
|
326
|
+
io,
|
|
327
|
+
});
|
|
328
|
+
if (outcome.status !== 'completed') {
|
|
329
|
+
process.exit(outcome.status === 'canceled' ? 130 : 1);
|
|
330
|
+
}
|
|
331
|
+
} catch (err) {
|
|
332
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
333
|
+
deps.logger.error(`pair-cli crashed: ${msg}`);
|
|
334
|
+
process.exit(2);
|
|
335
|
+
}
|
|
336
|
+
});
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
// ---------------------------------------------------------------------------
|
|
340
|
+
// Utils
|
|
341
|
+
// ---------------------------------------------------------------------------
|
|
342
|
+
|
|
343
|
+
function sleep(ms: number): Promise<void> {
|
|
344
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
// Keep readline import reachable (pair-cli doesn't use it directly yet,
|
|
348
|
+
// but future interactive prompts will land here; prevents tree-shaking
|
|
349
|
+
// from dropping a future dep). TypeScript requires the import to have
|
|
350
|
+
// an effect.
|
|
351
|
+
void readline;
|