@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.
@@ -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 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.
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
- * 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.
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 coordinator.
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 (mode = generate | import; default generate)')
291
- .option('--json', 'Emit a single JSON payload (url/pin/sid/qr_ascii) instead of the human-readable banner. Enables agent-driven pairing.')
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
- const outcome = await runPairCli(mode, {
316
- sessionsPath: deps.sessionsPath,
317
- renderPairingUrl: deps.renderPairingUrl,
318
- renderQr: defaultRenderQr,
319
- io,
320
- outputMode,
321
- ttlSeconds,
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
  // ---------------------------------------------------------------------------
@@ -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
- relayUrl: CONFIG.serverUrl || 'https://api.totalreclaw.xyz',
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: '0.0.0-dev' };
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
- * - Resolves `package.json` next to the caller-provided directory
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
- return typeof parsed.version === 'string' ? parsed.version : null;
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
  }