@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,278 @@
|
|
|
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
|
+
import { validateMnemonic } from '@scure/bip39';
|
|
47
|
+
import { wordlist } from '@scure/bip39/wordlists/english';
|
|
48
|
+
import { loadCredentialsJson, writeCredentialsJson, writeOnboardingState, } from './fs-helpers.js';
|
|
49
|
+
import { awaitPhraseUpload, openRemotePairSession, } from './pair-remote-client.js';
|
|
50
|
+
import { setRecoveryPhraseOverride } from './config.js';
|
|
51
|
+
import { encodePng, encodeUnicode } from './pair-qr.js';
|
|
52
|
+
/**
|
|
53
|
+
* Run the relay-mode pair CLI. Mirrors `runPairCli`'s exit-code semantics:
|
|
54
|
+
* - `completed` (status 0)
|
|
55
|
+
* - `canceled` (Ctrl+C — status 130)
|
|
56
|
+
* - `expired` / `rejected` / `error` (status 1)
|
|
57
|
+
*
|
|
58
|
+
* Resolves with the outcome; the caller (`registerPairCli` action) maps
|
|
59
|
+
* the outcome to `process.exit(...)`.
|
|
60
|
+
*/
|
|
61
|
+
export async function runRelayPairCli(mode, opts) {
|
|
62
|
+
const outputMode = opts.outputMode ?? 'human';
|
|
63
|
+
const stdout = opts.io.stdout;
|
|
64
|
+
// 1. Open the relay session. The relay returns the user-facing URL +
|
|
65
|
+
// PIN + token + expiresAt. The keypair stays in-process.
|
|
66
|
+
let session;
|
|
67
|
+
try {
|
|
68
|
+
session = await openRemotePairSession({
|
|
69
|
+
relayBaseUrl: opts.relayBaseUrl,
|
|
70
|
+
mode: mode === 'generate' ? 'generate' : 'import',
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
catch (err) {
|
|
74
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
75
|
+
opts.io.stderr.write(`\nFailed to open relay pairing session: ${msg}\n` +
|
|
76
|
+
`If the relay is unreachable from this gateway, retry with --local for the loopback URL flow.\n`);
|
|
77
|
+
return { status: 'error', error: msg };
|
|
78
|
+
}
|
|
79
|
+
// ISO-8601 → ms for tool-payload parity with the agent tool.
|
|
80
|
+
const parsedExpiresMs = Date.parse(session.expiresAt);
|
|
81
|
+
const expiresAtMs = Number.isFinite(parsedExpiresMs)
|
|
82
|
+
? parsedExpiresMs
|
|
83
|
+
: Date.now() + 5 * 60_000;
|
|
84
|
+
// 2. Render the QR ASCII (skipped in url-pin / pair-only modes — the
|
|
85
|
+
// same as `runPairCli`). 10s timeout guard against a renderer that
|
|
86
|
+
// never fires its callback.
|
|
87
|
+
const skipsQr = outputMode === 'url-pin' || outputMode === 'pair-only';
|
|
88
|
+
const qrAscii = skipsQr
|
|
89
|
+
? ''
|
|
90
|
+
: await new Promise((resolve) => {
|
|
91
|
+
let settled = false;
|
|
92
|
+
const t = setTimeout(() => {
|
|
93
|
+
if (!settled) {
|
|
94
|
+
settled = true;
|
|
95
|
+
resolve('');
|
|
96
|
+
}
|
|
97
|
+
}, 10_000);
|
|
98
|
+
try {
|
|
99
|
+
opts.renderQr(session.url, (ascii) => {
|
|
100
|
+
if (settled)
|
|
101
|
+
return;
|
|
102
|
+
settled = true;
|
|
103
|
+
clearTimeout(t);
|
|
104
|
+
resolve(ascii);
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
catch (err) {
|
|
108
|
+
if (settled)
|
|
109
|
+
return;
|
|
110
|
+
settled = true;
|
|
111
|
+
clearTimeout(t);
|
|
112
|
+
resolve(`(QR renderer crashed: ${err instanceof Error ? err.message : String(err)})`);
|
|
113
|
+
}
|
|
114
|
+
});
|
|
115
|
+
// 3. Emit the visible surface — single JSON line for non-human modes,
|
|
116
|
+
// multi-line banner + QR for human mode. Identical layout to the
|
|
117
|
+
// local-mode runner so callers can swap transparently.
|
|
118
|
+
if (outputMode === 'url-pin') {
|
|
119
|
+
const payload = {
|
|
120
|
+
v: 1,
|
|
121
|
+
url: session.url,
|
|
122
|
+
pin: session.pin,
|
|
123
|
+
expires_at_ms: expiresAtMs,
|
|
124
|
+
};
|
|
125
|
+
stdout.write(JSON.stringify(payload) + '\n');
|
|
126
|
+
}
|
|
127
|
+
else if (outputMode === 'pair-only') {
|
|
128
|
+
const payload = {
|
|
129
|
+
v: 1,
|
|
130
|
+
pair_url: session.url,
|
|
131
|
+
pin: session.pin,
|
|
132
|
+
expires_at_ms: expiresAtMs,
|
|
133
|
+
};
|
|
134
|
+
stdout.write(JSON.stringify(payload) + '\n');
|
|
135
|
+
}
|
|
136
|
+
else if (outputMode === 'json') {
|
|
137
|
+
const payload = {
|
|
138
|
+
v: 1,
|
|
139
|
+
sid: session.token,
|
|
140
|
+
url: session.url,
|
|
141
|
+
pin: session.pin,
|
|
142
|
+
mode,
|
|
143
|
+
expires_at_ms: expiresAtMs,
|
|
144
|
+
qr_ascii: qrAscii,
|
|
145
|
+
};
|
|
146
|
+
stdout.write(JSON.stringify(payload) + '\n');
|
|
147
|
+
}
|
|
148
|
+
else {
|
|
149
|
+
// Human-mode banner. Mirror `pair-cli.ts` COPY surface, but tweak the
|
|
150
|
+
// header so operators see "Relay" not "Local" (so it's obvious the
|
|
151
|
+
// URL is universal-reachable, not gateway-loopback).
|
|
152
|
+
stdout.write('\nTotalReclaw — Relay pairing\n\n' +
|
|
153
|
+
'Your TotalReclaw recovery phrase will be created (or imported) in your\n' +
|
|
154
|
+
'BROWSER and delivered to this gateway encrypted end-to-end via the\n' +
|
|
155
|
+
'relay (the relay only sees ciphertext). The phrase never touches the\n' +
|
|
156
|
+
'LLM, the session transcript, or the relay server in plaintext.\n\n' +
|
|
157
|
+
'Scan the QR code below with your phone, or open the URL on any device\n' +
|
|
158
|
+
'(no LAN / Tailscale / port-forward required). Then type the 6-digit\n' +
|
|
159
|
+
'code shown here into the browser.\n');
|
|
160
|
+
stdout.write(mode === 'generate'
|
|
161
|
+
? '\nMode: GENERATE — your browser will create a NEW 12-word recovery phrase.\n' +
|
|
162
|
+
'You will be asked to write it down and retype 3 words before the\n' +
|
|
163
|
+
'gateway accepts it.\n'
|
|
164
|
+
: '\nMode: IMPORT — your browser will accept an existing TotalReclaw\n' +
|
|
165
|
+
'recovery phrase that you already have. Paste it in the browser; it\n' +
|
|
166
|
+
'will be validated locally and encrypted before upload.\n');
|
|
167
|
+
if (qrAscii) {
|
|
168
|
+
stdout.write('\n' + qrAscii + '\n');
|
|
169
|
+
}
|
|
170
|
+
else {
|
|
171
|
+
stdout.write('\n(QR not rendered — use the URL below)\n');
|
|
172
|
+
}
|
|
173
|
+
stdout.write('\nSecondary code (type this into the browser):\n\n ' +
|
|
174
|
+
session.pin.split('').join(' ') +
|
|
175
|
+
'\n\nURL (QR encodes this plus a one-time public key):\n\n ' +
|
|
176
|
+
session.url +
|
|
177
|
+
'\n\nSecurity:\n' +
|
|
178
|
+
' * Do NOT share your screen during pairing.\n' +
|
|
179
|
+
' * Do NOT screenshot this terminal.\n' +
|
|
180
|
+
' * The browser page will warn you never to reuse this recovery\n' +
|
|
181
|
+
' phrase for wallets, banking, email, or any other service.\n' +
|
|
182
|
+
'\nWaiting for browser to connect… (press Ctrl+C to cancel)\n');
|
|
183
|
+
}
|
|
184
|
+
// 4. Optional PNG / Unicode QR for richer transports — same as the
|
|
185
|
+
// agent tool. Best-effort; non-fatal on encode failure.
|
|
186
|
+
if (!skipsQr && outputMode !== 'human') {
|
|
187
|
+
// JSON consumers already have qr_ascii; PNG/Unicode would belong in
|
|
188
|
+
// a separate response shape. Keeping the runner surface in-band with
|
|
189
|
+
// the local runner means we don't add fields here. Skip silently.
|
|
190
|
+
void encodePng;
|
|
191
|
+
void encodeUnicode;
|
|
192
|
+
}
|
|
193
|
+
// 5. Set up Ctrl+C cancellation. The relay session can't be
|
|
194
|
+
// server-side rejected from the client (no rejectPairSession-equivalent
|
|
195
|
+
// over the WS), but closing the WebSocket terminates the session.
|
|
196
|
+
let canceled = false;
|
|
197
|
+
const releaseInterrupt = opts.io.onInterrupt(() => {
|
|
198
|
+
canceled = true;
|
|
199
|
+
try {
|
|
200
|
+
session._ws.close();
|
|
201
|
+
}
|
|
202
|
+
catch {
|
|
203
|
+
/* ignore */
|
|
204
|
+
}
|
|
205
|
+
});
|
|
206
|
+
// 6. Block on the relay until the browser uploads the encrypted
|
|
207
|
+
// phrase, then write credentials + flip onboarding-state. Mirrors
|
|
208
|
+
// the agent-tool's `awaitPhraseUpload` callback inline so we have a
|
|
209
|
+
// single source of truth for credential persistence.
|
|
210
|
+
const emitStatus = (text) => {
|
|
211
|
+
if (outputMode === 'human')
|
|
212
|
+
stdout.write(text);
|
|
213
|
+
};
|
|
214
|
+
try {
|
|
215
|
+
const result = await awaitPhraseUpload(session, {
|
|
216
|
+
phraseValidator: (p) => validateMnemonic(p, wordlist),
|
|
217
|
+
completePairing: async ({ mnemonic }) => {
|
|
218
|
+
try {
|
|
219
|
+
let scopeAddress;
|
|
220
|
+
try {
|
|
221
|
+
scopeAddress = await opts.deriveScopeAddress(mnemonic);
|
|
222
|
+
}
|
|
223
|
+
catch (deriveErr) {
|
|
224
|
+
opts.logger.warn(`pair-cli (relay): scope_address derivation failed (will retry lazily): ${deriveErr instanceof Error ? deriveErr.message : String(deriveErr)}`);
|
|
225
|
+
}
|
|
226
|
+
const creds = loadCredentialsJson(opts.credentialsPath) ?? {};
|
|
227
|
+
const next = { ...creds, mnemonic };
|
|
228
|
+
if (scopeAddress)
|
|
229
|
+
next.scope_address = scopeAddress;
|
|
230
|
+
if (!writeCredentialsJson(opts.credentialsPath, next)) {
|
|
231
|
+
return { state: 'error', error: 'credentials_write_failed' };
|
|
232
|
+
}
|
|
233
|
+
setRecoveryPhraseOverride(mnemonic);
|
|
234
|
+
writeOnboardingState(opts.onboardingStatePath, {
|
|
235
|
+
onboardingState: 'active',
|
|
236
|
+
createdBy: mode === 'generate' ? 'generate' : 'import',
|
|
237
|
+
credentialsCreatedAt: new Date().toISOString(),
|
|
238
|
+
version: opts.pluginVersion,
|
|
239
|
+
});
|
|
240
|
+
opts.logger.info(`pair-cli (relay): session ${session.token.slice(0, 8)}… completed; credentials written` +
|
|
241
|
+
(scopeAddress ? ` (scope_address=${scopeAddress})` : ''));
|
|
242
|
+
return { state: 'active' };
|
|
243
|
+
}
|
|
244
|
+
catch (err) {
|
|
245
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
246
|
+
opts.logger.error(`pair-cli (relay): completePairing failed: ${msg}`);
|
|
247
|
+
return { state: 'error', error: msg };
|
|
248
|
+
}
|
|
249
|
+
},
|
|
250
|
+
});
|
|
251
|
+
if (canceled) {
|
|
252
|
+
emitStatus('\nCanceled. Pairing session invalidated.\n');
|
|
253
|
+
return { status: 'canceled', sid: session.token };
|
|
254
|
+
}
|
|
255
|
+
if (result.state === 'active') {
|
|
256
|
+
emitStatus('\nPairing complete. Account is active.\n');
|
|
257
|
+
return { status: 'completed', sid: session.token };
|
|
258
|
+
}
|
|
259
|
+
emitStatus(`\nPairing failed: ${result.error ?? 'unknown_error'}\n`);
|
|
260
|
+
return { status: 'error', sid: session.token, error: result.error ?? 'unknown_error' };
|
|
261
|
+
}
|
|
262
|
+
catch (err) {
|
|
263
|
+
if (canceled) {
|
|
264
|
+
emitStatus('\nCanceled. Pairing session invalidated.\n');
|
|
265
|
+
return { status: 'canceled', sid: session.token };
|
|
266
|
+
}
|
|
267
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
268
|
+
if (msg.includes('timeout')) {
|
|
269
|
+
emitStatus('\nSession expired. Run the command again to restart.\n');
|
|
270
|
+
return { status: 'expired', sid: session.token };
|
|
271
|
+
}
|
|
272
|
+
emitStatus(`\nPairing error: ${msg}\n`);
|
|
273
|
+
return { status: 'error', sid: session.token, error: msg };
|
|
274
|
+
}
|
|
275
|
+
finally {
|
|
276
|
+
releaseInterrupt();
|
|
277
|
+
}
|
|
278
|
+
}
|
package/dist/pair-cli.js
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
|
|
@@ -287,9 +301,12 @@ export function registerPairCli(program, deps) {
|
|
|
287
301
|
.description('TotalReclaw encrypted memory — pairing + onboarding + status');
|
|
288
302
|
}
|
|
289
303
|
tr.command('pair [mode]')
|
|
290
|
-
.description('Pair a remote browser device to this gateway
|
|
291
|
-
|
|
304
|
+
.description('Pair a remote browser device to this gateway via the relay (default; ' +
|
|
305
|
+
'works through NAT and inside Docker). Use --local to fall back to ' +
|
|
306
|
+
'gateway-loopback URLs for air-gapped setups.')
|
|
307
|
+
.option('--json', 'Emit a single JSON payload (url/pin/qr_ascii) instead of the human-readable banner. Enables agent-driven pairing.')
|
|
292
308
|
.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.')
|
|
309
|
+
.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.')
|
|
293
310
|
.option('--timeout <sec>', 'Session TTL in seconds (default: 900 = 15 min, matches pair-session-store default)')
|
|
294
311
|
.action(async (...args) => {
|
|
295
312
|
// commander passes: [modeArg, options, cmd]
|
|
@@ -311,15 +328,55 @@ export function registerPairCli(program, deps) {
|
|
|
311
328
|
ttlSeconds = parsed;
|
|
312
329
|
}
|
|
313
330
|
const io = buildDefaultPairCliIo();
|
|
331
|
+
// 3.3.4-rc.1 — flip the default to relay-mode. The agent tool
|
|
332
|
+
// `totalreclaw_pair` has used the relay since rc.11; the CLI was
|
|
333
|
+
// the last surface still defaulting to gateway-loopback URLs,
|
|
334
|
+
// which are unreachable from a remote browser when the gateway
|
|
335
|
+
// runs in Docker (the rc.6+ default deployment). `--local`
|
|
336
|
+
// restores the legacy flow for air-gapped operators.
|
|
337
|
+
const useRelay = shouldUseRelayMode({
|
|
338
|
+
local: opts.local,
|
|
339
|
+
hasRelayRunner: typeof deps.runRelayPairCli === 'function',
|
|
340
|
+
});
|
|
314
341
|
try {
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
342
|
+
let outcome;
|
|
343
|
+
if (useRelay) {
|
|
344
|
+
outcome = await deps.runRelayPairCli(mode, {
|
|
345
|
+
renderQr: defaultRenderQr,
|
|
346
|
+
io,
|
|
347
|
+
outputMode,
|
|
348
|
+
ttlSeconds,
|
|
349
|
+
});
|
|
350
|
+
}
|
|
351
|
+
else {
|
|
352
|
+
if (opts.local) {
|
|
353
|
+
// Tell the operator they explicitly opted in. Suppress in
|
|
354
|
+
// JSON modes — the JSON contract must stay stdout-clean.
|
|
355
|
+
if (outputMode === 'human') {
|
|
356
|
+
io.stderr.write('\n[--local] Using gateway-loopback URL flow. The user\'s browser ' +
|
|
357
|
+
'must be reachable from this gateway\'s bound interface (LAN, Tailscale, ' +
|
|
358
|
+
'or localhost on the same machine).\n');
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
else if (!deps.runRelayPairCli) {
|
|
362
|
+
// No relay runner wired — older composition. Warn once on
|
|
363
|
+
// stderr in human mode so the operator knows why URLs may
|
|
364
|
+
// be unreachable from a remote browser.
|
|
365
|
+
if (outputMode === 'human') {
|
|
366
|
+
io.stderr.write('\n[pair-cli] relay-mode runner not available — falling back to local-mode. ' +
|
|
367
|
+
'Pair URLs will use this gateway\'s bound interface. Upgrade the plugin ' +
|
|
368
|
+
'or pass --local to silence this warning.\n');
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
outcome = await runPairCli(mode, {
|
|
372
|
+
sessionsPath: deps.sessionsPath,
|
|
373
|
+
renderPairingUrl: deps.renderPairingUrl,
|
|
374
|
+
renderQr: defaultRenderQr,
|
|
375
|
+
io,
|
|
376
|
+
outputMode,
|
|
377
|
+
ttlSeconds,
|
|
378
|
+
});
|
|
379
|
+
}
|
|
323
380
|
if (outcome.status !== 'completed') {
|
|
324
381
|
process.exit(outcome.status === 'canceled' ? 130 : 1);
|
|
325
382
|
}
|
|
@@ -331,6 +388,17 @@ export function registerPairCli(program, deps) {
|
|
|
331
388
|
}
|
|
332
389
|
});
|
|
333
390
|
}
|
|
391
|
+
/**
|
|
392
|
+
* 3.3.4-rc.1 — pure decision function: given the parsed action flags
|
|
393
|
+
* and whether a relay runner is wired, return whether the relay path
|
|
394
|
+
* should be taken. Exported for unit-testing the default-mode flip
|
|
395
|
+
* without invoking either runner.
|
|
396
|
+
*/
|
|
397
|
+
export function shouldUseRelayMode(opts) {
|
|
398
|
+
if (opts.local)
|
|
399
|
+
return false;
|
|
400
|
+
return opts.hasRelayRunner;
|
|
401
|
+
}
|
|
334
402
|
// ---------------------------------------------------------------------------
|
|
335
403
|
// Utils
|
|
336
404
|
// ---------------------------------------------------------------------------
|
package/dist/subgraph-store.js
CHANGED
|
@@ -675,7 +675,7 @@ export function isSubgraphMode() {
|
|
|
675
675
|
*
|
|
676
676
|
* After the v1 env var cleanup, clients only need:
|
|
677
677
|
* - TOTALRECLAW_RECOVERY_PHRASE -- BIP-39 mnemonic
|
|
678
|
-
* - TOTALRECLAW_SERVER_URL -- relay server URL (default: https://api.totalreclaw.xyz)
|
|
678
|
+
* - 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)
|
|
679
679
|
* - TOTALRECLAW_SELF_HOSTED -- set "true" to use self-hosted server (default: managed service)
|
|
680
680
|
*
|
|
681
681
|
* Chain ID is no longer configurable via env — it is auto-detected from the
|
|
@@ -683,7 +683,8 @@ export function isSubgraphMode() {
|
|
|
683
683
|
*/
|
|
684
684
|
export function getSubgraphConfig() {
|
|
685
685
|
return {
|
|
686
|
-
|
|
686
|
+
// 3.3.3-rc.1: staging by default in source; stable workflow seds.
|
|
687
|
+
relayUrl: CONFIG.serverUrl || 'https://api-staging.totalreclaw.xyz',
|
|
687
688
|
mnemonic: CONFIG.recoveryPhrase,
|
|
688
689
|
cachePath: CONFIG.cachePath,
|
|
689
690
|
chainId: CONFIG.chainId,
|
package/embedding.ts
CHANGED
|
@@ -86,9 +86,51 @@ function defaultCacheRoot(): string {
|
|
|
86
86
|
return path.join(os.homedir(), '.totalreclaw', 'embedder');
|
|
87
87
|
}
|
|
88
88
|
|
|
89
|
+
/**
|
|
90
|
+
* Last-known-good embedder bundle tag. Used ONLY as a hard-fallback when
|
|
91
|
+
* `configureEmbedder()` is never called by the orchestrator (defensive
|
|
92
|
+
* path — production code always wires it via index.ts register()).
|
|
93
|
+
*
|
|
94
|
+
* 3.3.4-rc.1 — pinned to v3.3.3-rc.1 because that is the most recent
|
|
95
|
+
* release at fix-time with a published `embedder-v1.tar.gz` asset. Earlier
|
|
96
|
+
* fallback `'0.0.0-dev'` (rc.22 → 3.3.3-rc.1) hard-coded a placeholder
|
|
97
|
+
* that resolved to a 404 GitHub Release URL; QA on 3.3.3-rc.1 (Pedro
|
|
98
|
+
* 2026-04-30) caught it because the cascade-cause (broken
|
|
99
|
+
* `readPluginVersion()` resolution) made the fallback fire on every cold
|
|
100
|
+
* start. Bumping this constant per RC is fine — the publish workflow auto-
|
|
101
|
+
* publishes the bundle for every RC tag (see scripts/build-embedder-
|
|
102
|
+
* bundle.mjs in the public repo).
|
|
103
|
+
*/
|
|
104
|
+
const LAST_KNOWN_GOOD_RC_TAG = '3.3.3-rc.1';
|
|
105
|
+
|
|
89
106
|
function activeRuntimeConfig(): EmbedderRuntimeConfig {
|
|
90
107
|
if (runtimeConfig) return runtimeConfig;
|
|
91
|
-
return { cacheRoot: defaultCacheRoot(), rcTag:
|
|
108
|
+
return { cacheRoot: defaultCacheRoot(), rcTag: LAST_KNOWN_GOOD_RC_TAG };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* 3.3.3-rc.1 (issue #187 — ONNX decouple): prefetch the embedder bundle
|
|
113
|
+
* WITHOUT loading the model into memory. Used to download the
|
|
114
|
+
* ~700 MB tarball pre-pair so the user does not hit the network round-trip
|
|
115
|
+
* mid-conversation. Idempotent — subsequent calls are cache-hit no-ops.
|
|
116
|
+
*
|
|
117
|
+
* Returns:
|
|
118
|
+
* - `'cache_hit'` if the bundle was already extracted + verified.
|
|
119
|
+
* - `'fetched'` if the bundle was downloaded this call.
|
|
120
|
+
* - throws on transport / extraction failure.
|
|
121
|
+
*
|
|
122
|
+
* Pre-flight is the caller's job (disk-space, network reachability) — this
|
|
123
|
+
* function focuses on the cache-resolve + fetch-on-miss path so it can also
|
|
124
|
+
* be reused as a fast cache-validation probe.
|
|
125
|
+
*/
|
|
126
|
+
export async function prefetchEmbedderBundle(opts?: { log?: (msg: string) => void }): Promise<'cache_hit' | 'fetched'> {
|
|
127
|
+
const cfg = activeRuntimeConfig();
|
|
128
|
+
const loaded = await loadEmbedder({
|
|
129
|
+
cacheRoot: cfg.cacheRoot,
|
|
130
|
+
rcTag: cfg.rcTag,
|
|
131
|
+
log: opts?.log,
|
|
132
|
+
});
|
|
133
|
+
return loaded.wasFetched ? 'fetched' : 'cache_hit';
|
|
92
134
|
}
|
|
93
135
|
|
|
94
136
|
/** Lazily initialized state. */
|
package/fs-helpers.ts
CHANGED
|
@@ -124,9 +124,17 @@ export function ensureMemoryHeaderFile(
|
|
|
124
124
|
* Read the plugin's own version string from `package.json`.
|
|
125
125
|
*
|
|
126
126
|
* Behaviour:
|
|
127
|
-
* -
|
|
127
|
+
* - Tries `package.json` next to the caller-provided directory first
|
|
128
128
|
* (typically `path.dirname(fileURLToPath(import.meta.url))` from the
|
|
129
|
-
* caller).
|
|
129
|
+
* caller — i.e., the directory of the running ESM module).
|
|
130
|
+
* - If that misses, walks up to 5 parent directories looking for a
|
|
131
|
+
* `package.json` whose `name` is `@totalreclaw/totalreclaw`. This
|
|
132
|
+
* covers the OpenClaw plugin sandbox case where the loaded module
|
|
133
|
+
* lives at `<pluginRoot>/dist/index.js` while `package.json` lives
|
|
134
|
+
* at `<pluginRoot>/package.json` (3.3.4-rc.1 fix — without this
|
|
135
|
+
* walk-up, the `.loaded.json` manifest gets `version=unknown` and
|
|
136
|
+
* all RC-gated logic that depends on the version string fails
|
|
137
|
+
* silently in production OpenClaw deployments).
|
|
130
138
|
* - Returns the `version` field, or `null` on any I/O / parse error.
|
|
131
139
|
*
|
|
132
140
|
* Used by the RC-gated `totalreclaw_report_qa_bug` tool registration in
|
|
@@ -137,12 +145,48 @@ export function ensureMemoryHeaderFile(
|
|
|
137
145
|
* helper — see the file-header guardrail.
|
|
138
146
|
*/
|
|
139
147
|
export function readPluginVersion(packageJsonDir: string): string | null {
|
|
148
|
+
// Direct hit (source-tree dev path; tests).
|
|
149
|
+
const direct = tryReadPluginPackageJson(path.join(packageJsonDir, 'package.json'));
|
|
150
|
+
if (direct) return direct;
|
|
151
|
+
|
|
152
|
+
// Walk up — the running ESM module typically lives at
|
|
153
|
+
// `<pluginRoot>/dist/index.js`, so `packageJsonDir` is `<pluginRoot>/dist`
|
|
154
|
+
// and `package.json` is one level up. Bound the walk so a misconfigured
|
|
155
|
+
// path doesn't traverse the entire filesystem; 5 levels is more than
|
|
156
|
+
// enough for any realistic plugin layout (dist/, dist/cjs/, build/lib/).
|
|
157
|
+
let current = packageJsonDir;
|
|
158
|
+
for (let depth = 0; depth < 5; depth++) {
|
|
159
|
+
const parent = path.dirname(current);
|
|
160
|
+
if (parent === current) break; // root reached
|
|
161
|
+
const candidate = path.join(parent, 'package.json');
|
|
162
|
+
const version = tryReadPluginPackageJson(candidate);
|
|
163
|
+
if (version) return version;
|
|
164
|
+
current = parent;
|
|
165
|
+
}
|
|
166
|
+
return null;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Try to read `package.json` at `pkgPath`. Returns the `version` only if
|
|
171
|
+
* the file's `name` field matches `@totalreclaw/totalreclaw` — guards
|
|
172
|
+
* against accidentally returning the version of an outer host-package
|
|
173
|
+
* (e.g. when the plugin is bundled inside a parent app's tree).
|
|
174
|
+
*
|
|
175
|
+
* If `name` is absent (legacy / minimal package.json), accept the version
|
|
176
|
+
* anyway as a fallback — this is the existing behaviour preserved for
|
|
177
|
+
* anyone who manually trimmed their package.json.
|
|
178
|
+
*/
|
|
179
|
+
function tryReadPluginPackageJson(pkgPath: string): string | null {
|
|
140
180
|
try {
|
|
141
|
-
const pkgPath = path.join(packageJsonDir, 'package.json');
|
|
142
181
|
if (!fs.existsSync(pkgPath)) return null;
|
|
143
182
|
const raw = fs.readFileSync(pkgPath, 'utf-8');
|
|
144
|
-
const parsed = JSON.parse(raw) as { version?: string };
|
|
145
|
-
|
|
183
|
+
const parsed = JSON.parse(raw) as { version?: string; name?: string };
|
|
184
|
+
if (typeof parsed.version !== 'string') return null;
|
|
185
|
+
if (typeof parsed.name === 'string' && parsed.name !== '@totalreclaw/totalreclaw') {
|
|
186
|
+
// Wrong package — keep walking.
|
|
187
|
+
return null;
|
|
188
|
+
}
|
|
189
|
+
return parsed.version;
|
|
146
190
|
} catch {
|
|
147
191
|
return null;
|
|
148
192
|
}
|