@totalreclaw/totalreclaw 3.3.1 → 3.3.2-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/fs-helpers.ts CHANGED
@@ -495,6 +495,130 @@ export function wipePartialInstall(pluginRootDir: string): boolean {
495
495
  }
496
496
  }
497
497
 
498
+ // ---------------------------------------------------------------------------
499
+ // Plugin load manifest (.loaded.json / .error.json) — 3.3.2-rc.1 #186
500
+ // ---------------------------------------------------------------------------
501
+
502
+ /**
503
+ * Filenames written into the plugin root dir at the end of register() and
504
+ * (on failure) from the surrounding try/catch. The presence of `.loaded.json`
505
+ * is the canonical filesystem signal that the plugin's register() body ran
506
+ * to completion AND the SDK tool/route/hook registries received calls.
507
+ *
508
+ * Acceptance criteria (issue #186):
509
+ * - `cat ~/.openclaw/extensions/totalreclaw/.loaded.json` shows
510
+ * `{loadedAt, tools, version}` after every successful gateway start.
511
+ * - `cat ~/.openclaw/extensions/totalreclaw/.error.json` shows
512
+ * `{loadedAt, error, stack}` if register() threw.
513
+ * - Both are overwritten on each register() call so the agent can rely
514
+ * on the timestamp matching the most recent gateway start.
515
+ *
516
+ * Why these MUST be synchronous writes (same constraint as
517
+ * `registerHttpRoute` per the comment in index.ts around the route
518
+ * registration site): the SDK loader treats register() returning as the
519
+ * signal to freeze the registries. An async `fs.promises.writeFile` would
520
+ * settle one microtask AFTER the loader has moved on, so the manifest
521
+ * could miss tools that registered late OR drop entirely if the gateway
522
+ * exits before the microtask runs. `writeFileSync` is the only safe choice.
523
+ */
524
+ export const PLUGIN_LOADED_MANIFEST = '.loaded.json';
525
+ export const PLUGIN_ERROR_MANIFEST = '.error.json';
526
+
527
+ /** Schema written to `.loaded.json` — see PLUGIN_LOADED_MANIFEST. */
528
+ export interface PluginLoadManifest {
529
+ /** Unix epoch milliseconds when register() finished. */
530
+ loadedAt: number;
531
+ /** Tool names passed to api.registerTool() during register(). */
532
+ tools: string[];
533
+ /** Plugin version string from package.json (or "unknown"). */
534
+ version: string;
535
+ }
536
+
537
+ /** Schema written to `.error.json` when register() throws. */
538
+ export interface PluginLoadError {
539
+ loadedAt: number;
540
+ error: string;
541
+ stack?: string;
542
+ version?: string;
543
+ }
544
+
545
+ /**
546
+ * Resolve the plugin root dir from the loaded module's directory. The plugin
547
+ * is shipped with `dist/index.js` as the entry, so `import.meta.url` resolves
548
+ * to `<root>/dist/`. We walk up one level to put the manifests next to
549
+ * `package.json`. Defensive: if the caller already passed the root (no
550
+ * trailing `dist`), we still return a sensible path.
551
+ */
552
+ function resolvePluginRootForManifest(pluginDir: string): string {
553
+ const base = path.basename(pluginDir);
554
+ return base === 'dist' ? path.resolve(pluginDir, '..') : pluginDir;
555
+ }
556
+
557
+ /**
558
+ * Write the success manifest. SYNCHRONOUS — the SDK freezes the plugin
559
+ * registries the moment register() returns; `fs.promises.writeFile` would
560
+ * race that freeze and the manifest could miss late tool registrations or
561
+ * never land at all if the process exits before the microtask runs.
562
+ *
563
+ * Best-effort: returns `true` on success, `false` on any I/O error. Never
564
+ * throws — failing to write the manifest is a diagnostic loss, not a
565
+ * correctness loss, so we don't propagate.
566
+ *
567
+ * The manifest is written at mode 0644 (world-readable). It contains no
568
+ * secrets — only a timestamp, the (publicly known) tool names, and the
569
+ * plugin version. Cleared first so a stale `.error.json` from a previous
570
+ * failed boot doesn't survive a successful boot.
571
+ */
572
+ export function writePluginManifest(
573
+ pluginDir: string,
574
+ manifest: PluginLoadManifest,
575
+ ): boolean {
576
+ try {
577
+ const root = resolvePluginRootForManifest(pluginDir);
578
+ if (!fs.existsSync(root)) return false;
579
+ const loadedPath = path.join(root, PLUGIN_LOADED_MANIFEST);
580
+ const errorPath = path.join(root, PLUGIN_ERROR_MANIFEST);
581
+ // Best-effort error-file cleanup — a successful boot supersedes any
582
+ // prior failure marker. If the unlink fails (e.g. permission), the
583
+ // .loaded.json timestamp still tells the agent which is current.
584
+ try {
585
+ if (fs.existsSync(errorPath)) fs.unlinkSync(errorPath);
586
+ } catch {
587
+ // Swallow — best-effort.
588
+ }
589
+ fs.writeFileSync(loadedPath, JSON.stringify(manifest, null, 2));
590
+ return true;
591
+ } catch {
592
+ return false;
593
+ }
594
+ }
595
+
596
+ /**
597
+ * Write the error manifest. SYNCHRONOUS for the same reason as
598
+ * `writePluginManifest`. Called from the try/catch surrounding the
599
+ * register() body so the agent has a filesystem signal that the plugin
600
+ * registered AT LEAST attempted to load and failed in a specific way.
601
+ *
602
+ * Does NOT clear `.loaded.json` from a prior successful boot — keeping
603
+ * the older success marker around lets the agent see "last good boot was
604
+ * X, current boot failed at Y" without spelunking logs. The newer
605
+ * `.error.json` timestamp wins as "current state".
606
+ */
607
+ export function writePluginError(
608
+ pluginDir: string,
609
+ error: PluginLoadError,
610
+ ): boolean {
611
+ try {
612
+ const root = resolvePluginRootForManifest(pluginDir);
613
+ if (!fs.existsSync(root)) return false;
614
+ const errorPath = path.join(root, PLUGIN_ERROR_MANIFEST);
615
+ fs.writeFileSync(errorPath, JSON.stringify(error, null, 2));
616
+ return true;
617
+ } catch {
618
+ return false;
619
+ }
620
+ }
621
+
498
622
  /**
499
623
  * Drop the `.tr-partial-install` marker into `pluginRootDir`. Idempotent
500
624
  * (overwrites any existing marker) and best-effort — returns `true` on
package/index.ts CHANGED
@@ -164,6 +164,8 @@ import {
164
164
  cleanupInstallStagingDirs,
165
165
  detectPartialInstall,
166
166
  clearPartialInstallMarker,
167
+ writePluginManifest,
168
+ writePluginError,
167
169
  type OnboardingState,
168
170
  } from './fs-helpers.js';
169
171
  import { isRcBuild } from './qa-bug-report.js';
@@ -2856,6 +2858,50 @@ const plugin = {
2856
2858
  },
2857
2859
 
2858
2860
  register(api: OpenClawPluginApi) {
2861
+ // ---------------------------------------------------------------
2862
+ // 3.3.2-rc.1 (issue #186) — load manifest instrumentation
2863
+ // ---------------------------------------------------------------
2864
+ //
2865
+ // Capture every `api.registerTool({name, ...})` call so we can write
2866
+ // a `.loaded.json` manifest at the end of register(). Wrap the body
2867
+ // in try/catch so a register-time throw produces `.error.json` for
2868
+ // agent-side filesystem verification (the CLI hangs in some Docker
2869
+ // setups — issue #182 — so the manifest is the canonical "did the
2870
+ // plugin load?" probe).
2871
+ //
2872
+ // Implementation: we intercept the api.registerTool method by
2873
+ // wrapping it on the api object passed in. The wrapper inspects the
2874
+ // `name` field (every TR registerTool call sets it) and forwards
2875
+ // verbatim. NO behavior change to the SDK call — the original method
2876
+ // is invoked with original args and `this` binding.
2877
+ //
2878
+ // Synchronous writes ONLY (see writePluginManifest doc): the SDK
2879
+ // freezes plugin registries the moment register() returns; an async
2880
+ // write would race that freeze.
2881
+ const _registeredToolNames: string[] = [];
2882
+ const _originalRegisterTool = api.registerTool.bind(api);
2883
+ api.registerTool = (tool: unknown, opts?: { name?: string; names?: string[] }) => {
2884
+ try {
2885
+ const t = tool as { name?: unknown } | null | undefined;
2886
+ if (t && typeof t === 'object' && typeof t.name === 'string' && t.name.length > 0) {
2887
+ _registeredToolNames.push(t.name);
2888
+ }
2889
+ } catch {
2890
+ // Manifest is diagnostic; never let bookkeeping break tool registration.
2891
+ }
2892
+ _originalRegisterTool(tool, opts);
2893
+ };
2894
+
2895
+ // Lazily resolved inside the try below — needed by both the manifest
2896
+ // write and the error path. `dist/` after build, package root in tests.
2897
+ let _pluginDirForManifest: string | null = null;
2898
+
2899
+ // NOTE: the body of register() below is intentionally NOT re-indented
2900
+ // under this `try` block — re-indenting would touch every line in a
2901
+ // 3,500-line function and obscure the actual hotfix diff. The closing
2902
+ // `} catch (registerErr: unknown) { ... }` is at the very end of
2903
+ // register() (search for "register() threw").
2904
+ try {
2859
2905
  // ---------------------------------------------------------------
2860
2906
  // RC-build detection (3.3.1-rc.3)
2861
2907
  // ---------------------------------------------------------------
@@ -2880,6 +2926,7 @@ const plugin = {
2880
2926
  // `require('node:url')` — undefined under bare-ESM Node, broke the
2881
2927
  // before_agent_start hook in the published rc.20 bundle (issue #124).
2882
2928
  const pluginDir = nodePath.dirname(fileURLToPath(import.meta.url));
2929
+ _pluginDirForManifest = pluginDir; // captured for #186 .loaded.json/.error.json
2883
2930
  pluginVersion = readPluginVersion(pluginDir);
2884
2931
  rcMode = isRcBuild(pluginVersion);
2885
2932
  if (rcMode) {
@@ -5162,19 +5209,24 @@ const plugin = {
5162
5209
  api.registerTool(
5163
5210
  {
5164
5211
  name: 'totalreclaw_pair',
5165
- label: 'QR pair start remote pairing session',
5212
+ label: 'Set up the user\'s TotalReclaw account (encrypted, browser-side)',
5166
5213
  description:
5167
- 'Start a remote pairing session so the user can create or import a TotalReclaw ' +
5168
- 'recovery phrase from their phone or another browser. Returns a pairing URL, a ' +
5169
- '6-digit PIN, and an ASCII QR code that the agent relays to the user. The recovery ' +
5170
- 'phrase itself is generated/entered in the BROWSER and uploaded end-to-end encrypted ' +
5171
- 'to this gateway it NEVER touches the LLM provider or the chat transcript.\n\n' +
5172
- 'This is the CANONICAL agent-facilitated setup surfaceuse it whenever the user ' +
5173
- 'asks you to set up TotalReclaw, regardless of whether they have terminal access. ' +
5174
- 'Browser-side crypto keeps the recovery phrase out of the LLM context entirely. ' +
5175
- 'If a user explicitly prefers local-terminal setup with no browser, point them at ' +
5176
- '`totalreclaw_onboarding_start` (a pointer to the CLI wizard they run on their own ' +
5177
- 'terminal, NOT through your shell tool).',
5214
+ 'Set up the user\'s TotalReclaw account (encrypted, browser-side recovery-phrase ' +
5215
+ 'generation or import). Starts a brief remote session so the user can create or ' +
5216
+ 'import a TotalReclaw recovery phrase from their phone or another browser. ' +
5217
+ 'Returns an account-setup URL, a 6-digit PIN, and an ASCII QR code that the agent ' +
5218
+ 'relays to the user. The recovery phrase itself is generated/entered in the ' +
5219
+ 'BROWSER and uploaded end-to-end encrypted to this gateway — it NEVER touches ' +
5220
+ 'the LLM provider or the chat transcript.\n\n' +
5221
+ 'This is the CANONICAL agent-facilitated account-setup surface use it whenever ' +
5222
+ 'the user asks you to set up TotalReclaw, regardless of whether they have ' +
5223
+ 'terminal access. Narrate it to the user as "setting up your TotalReclaw ' +
5224
+ 'account". Browser-side crypto keeps the recovery phrase out of the LLM context ' +
5225
+ 'entirely. If a user explicitly prefers local-terminal account setup with no ' +
5226
+ 'browser, point them at `totalreclaw_onboarding_start` (a pointer to the CLI ' +
5227
+ 'wizard they run on their own terminal, NOT through your shell tool).\n\n' +
5228
+ 'Tool name `totalreclaw_pair` is kept for backward compatibility — function-wise ' +
5229
+ 'this is the account-setup tool.',
5178
5230
  parameters: {
5179
5231
  type: 'object',
5180
5232
  properties: {
@@ -6291,6 +6343,60 @@ const plugin = {
6291
6343
  },
6292
6344
  { priority: 5 },
6293
6345
  );
6346
+
6347
+ // ---------------------------------------------------------------
6348
+ // 3.3.2-rc.1 (issue #186) — write `.loaded.json` manifest
6349
+ // ---------------------------------------------------------------
6350
+ //
6351
+ // Final step of register(): drop the success manifest so the agent
6352
+ // can `cat ~/.openclaw/extensions/totalreclaw/.loaded.json` to
6353
+ // verify which tools bound. Synchronous (see writePluginManifest doc).
6354
+ // Never throws — diagnostic loss only on I/O failure.
6355
+ if (_pluginDirForManifest) {
6356
+ try {
6357
+ const ok = writePluginManifest(_pluginDirForManifest, {
6358
+ loadedAt: Date.now(),
6359
+ tools: _registeredToolNames.slice(),
6360
+ version: pluginVersion ?? 'unknown',
6361
+ });
6362
+ if (ok) {
6363
+ api.logger.info(
6364
+ `TotalReclaw: wrote .loaded.json manifest (${_registeredToolNames.length} tools, version=${pluginVersion ?? 'unknown'})`,
6365
+ );
6366
+ }
6367
+ } catch {
6368
+ // Best-effort; helper swallows internally too.
6369
+ }
6370
+ }
6371
+ } catch (registerErr: unknown) {
6372
+ // ---------------------------------------------------------------
6373
+ // 3.3.2-rc.1 (issue #186) — write `.error.json` on register() throw
6374
+ // ---------------------------------------------------------------
6375
+ //
6376
+ // Some surface threw out of register(). Drop a structured error
6377
+ // marker the agent can grep. Best-effort logging then re-throw so
6378
+ // the SDK sees the original failure.
6379
+ const errMsg = registerErr instanceof Error ? registerErr.message : String(registerErr);
6380
+ const errStack = registerErr instanceof Error ? registerErr.stack : undefined;
6381
+ try {
6382
+ api.logger.error(`TotalReclaw: register() threw: ${errMsg}`);
6383
+ } catch {
6384
+ // Logger may be unavailable (very early failure path).
6385
+ }
6386
+ if (_pluginDirForManifest) {
6387
+ try {
6388
+ writePluginError(_pluginDirForManifest, {
6389
+ loadedAt: Date.now(),
6390
+ error: errMsg,
6391
+ stack: errStack,
6392
+ version: 'unknown',
6393
+ });
6394
+ } catch {
6395
+ // Best-effort.
6396
+ }
6397
+ }
6398
+ throw registerErr;
6399
+ }
6294
6400
  },
6295
6401
  };
6296
6402
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@totalreclaw/totalreclaw",
3
- "version": "3.3.1",
3
+ "version": "3.3.2-rc.2",
4
4
  "description": "End-to-end encrypted, agent-portable memory for OpenClaw and any LLM-agent runtime. XChaCha20-Poly1305 with protobuf v4 + on-chain Memory Taxonomy v1 (claim / preference / directive / commitment / episode / summary).",
5
5
  "type": "module",
6
6
  "keywords": [
@@ -58,24 +58,31 @@
58
58
  "README.md",
59
59
  "CHANGELOG.md",
60
60
  "CLAWHUB.md",
61
- "skill.json"
61
+ "skill.json",
62
+ "postinstall.mjs"
62
63
  ],
63
64
  "scripts": {
64
65
  "build": "rm -rf dist && tsc -p tsconfig.json --noCheck",
65
66
  "verify-tarball": "node ../scripts/verify-tarball.mjs",
66
- "test": "npx tsx manifest-shape.test.ts && npx tsx config-schema.test.ts && npx tsx config.test.ts && npx tsx relay-headers.test.ts && npx tsx scope-address-visible.test.ts && npx tsx llm-profile-reader.test.ts && npx tsx llm-client.test.ts && npx tsx llm-client-retry.test.ts && npx tsx gateway-url.test.ts && npx tsx retype-setscope.test.ts && npx tsx tool-gating.test.ts && npx tsx onboarding-noninteractive.test.ts && npx tsx pair-cli-json.test.ts && npx tsx pair-qr.test.ts && npx tsx pair-remote-client.test.ts && npx tsx qa-bug-report.test.ts && npx tsx nonce-serialization.test.ts && npx tsx phrase-safety-registry.test.ts && npx tsx test_issue_92_onnx_download_ux.test.ts && npx tsx onboard-pair-only.test.ts && npx tsx import-time-smoke.test.ts && npx tsx install-staging-cleanup.test.ts && npx tsx partial-install-detection.test.ts && npx tsx install-reload-idempotency.test.ts && npx tsx json-stdout-cleanliness.test.ts",
67
+ "test": "npx tsx manifest-shape.test.ts && npx tsx config-schema.test.ts && npx tsx config.test.ts && npx tsx relay-headers.test.ts && npx tsx scope-address-visible.test.ts && npx tsx llm-profile-reader.test.ts && npx tsx llm-client.test.ts && npx tsx llm-client-retry.test.ts && npx tsx gateway-url.test.ts && npx tsx retype-setscope.test.ts && npx tsx tool-gating.test.ts && npx tsx onboarding-noninteractive.test.ts && npx tsx pair-cli-json.test.ts && npx tsx pair-qr.test.ts && npx tsx pair-remote-client.test.ts && npx tsx qa-bug-report.test.ts && npx tsx nonce-serialization.test.ts && npx tsx phrase-safety-registry.test.ts && npx tsx test_issue_92_onnx_download_ux.test.ts && npx tsx onboard-pair-only.test.ts && npx tsx import-time-smoke.test.ts && npx tsx install-staging-cleanup.test.ts && npx tsx partial-install-detection.test.ts && npx tsx install-reload-idempotency.test.ts && npx tsx json-stdout-cleanliness.test.ts && npx tsx load-manifest.test.ts && npx tsx postinstall-validation.test.ts",
67
68
  "smoke:dist": "npx tsx dist-esm-smoke.test.ts",
68
69
  "check-scanner": "node ../scripts/check-scanner.mjs",
69
70
  "check-version-drift": "node ../scripts/check-version-drift.mjs",
70
71
  "sync-version": "node ../scripts/sync-version.mjs",
71
72
  "preinstall": "node -e \"try{require('fs').writeFileSync('.tr-partial-install','');}catch{}\"",
72
- "postinstall": "node -e \"try{require('fs').unlinkSync('.tr-partial-install');}catch{}\"",
73
+ "postinstall": "node ./postinstall.mjs",
73
74
  "prepack": "npm run build",
74
75
  "prepublishOnly": "node ../scripts/check-scanner.mjs && node ../scripts/check-version-drift.mjs"
75
76
  },
76
77
  "openclaw": {
77
78
  "extensions": [
78
79
  "./dist/index.js"
79
- ]
80
+ ],
81
+ "compat": {
82
+ "pluginApi": ">=2026.3.22"
83
+ },
84
+ "build": {
85
+ "openclawVersion": "2026.4.24"
86
+ }
80
87
  }
81
88
  }
@@ -0,0 +1,260 @@
1
+ // scanner-sim: allow — postinstall scripts run during `npm install`, NOT inside the OpenClaw runtime sandbox. Per check-scanner.mjs guidance ("Moving the subprocess call into a separate post-install helper that OpenClaw sandboxes (NOT covered by this scanner)"), this file is the intended home for child_process usage. The plugin's runtime code (index.ts, etc.) stays scanner-clean; this file only runs once at install-time.
2
+ /**
3
+ * postinstall.mjs — TotalReclaw plugin post-install lifecycle script.
4
+ *
5
+ * Runs after `npm install` finishes inside the plugin extension dir
6
+ * (`~/.openclaw/extensions/totalreclaw/`). Three jobs, in order:
7
+ *
8
+ * 1. Clean the partial-install marker (`.tr-partial-install`) that
9
+ * `preinstall` dropped. Mirrors the inline shim that shipped in
10
+ * pre-3.3.2 releases.
11
+ * 2. (3.3.2-rc.1 / issue #188) Smoke-check critical deps. After `npm
12
+ * install` claims success we require() the modules whose absence
13
+ * bricked rc.22 first-attempt installs (`@scure/bip39`,
14
+ * `@scure/bip39/wordlists/english.js`, `@totalreclaw/core`,
15
+ * `@totalreclaw/client`, `qrcode`, `ws`). If any throws, the
16
+ * post-install fails LOUDLY — better than the rc.21 silent
17
+ * half-install where `enabled: true` shipped with a missing dep.
18
+ * 3. (3.3.2-rc.1 / issue #190) Sweep `<extensions>/.openclaw-install-stage-*`
19
+ * siblings. The runtime register() helper handles this on plugin
20
+ * load too, but doing it here means a re-install starts from a
21
+ * clean parent dir — no "duplicate plugin id detected; global
22
+ * plugin will be overridden by global plugin" warning during the
23
+ * install itself.
24
+ *
25
+ * Constraints:
26
+ * - Must be idempotent: re-running on a clean tree is a no-op.
27
+ * - Must not import any production module that itself runs `register()`
28
+ * or makes outbound calls. We use only Node stdlib + dynamic require()
29
+ * of the smoke-check deps.
30
+ * - Must run in CommonJS-compatible Node ESM (the plugin's package.json
31
+ * declares `"type": "module"`, so this file uses `.mjs` and
32
+ * `createRequire` to call require() against the plugin's node_modules).
33
+ *
34
+ * Phrase-safety note: this file does NOT touch credentials.json, mnemonics,
35
+ * keys, or any phrase code path. It only validates module loading and
36
+ * cleans staging directories.
37
+ */
38
+
39
+ import fs from 'node:fs';
40
+ import path from 'node:path';
41
+ import { fileURLToPath } from 'node:url';
42
+ import { createRequire } from 'node:module';
43
+ import { execSync } from 'node:child_process';
44
+
45
+ const here = path.dirname(fileURLToPath(import.meta.url));
46
+ const require = createRequire(import.meta.url);
47
+
48
+ const PARTIAL_INSTALL_MARKER = '.tr-partial-install';
49
+
50
+ // Order matters: light, fast modules first so a failure surfaces quickly.
51
+ // `@scure/bip39/wordlists/english.js` is the EXACT path that bricked rc.21
52
+ // (issue #188 — `Cannot find module '@scure/bip39/wordlists/english.js'`).
53
+ const CRITICAL_DEPS = [
54
+ '@scure/bip39',
55
+ '@scure/bip39/wordlists/english.js',
56
+ '@totalreclaw/core',
57
+ '@totalreclaw/client',
58
+ 'qrcode',
59
+ 'ws',
60
+ ];
61
+
62
+ function log(msg) {
63
+ process.stdout.write(`[totalreclaw postinstall] ${msg}\n`);
64
+ }
65
+
66
+ function warn(msg) {
67
+ process.stderr.write(`[totalreclaw postinstall] WARN: ${msg}\n`);
68
+ }
69
+
70
+ // ---------------------------------------------------------------------------
71
+ // Step 1 — clear .tr-partial-install marker
72
+ // ---------------------------------------------------------------------------
73
+
74
+ function clearPartialInstallMarker() {
75
+ try {
76
+ const markerPath = path.join(here, PARTIAL_INSTALL_MARKER);
77
+ if (fs.existsSync(markerPath)) {
78
+ fs.unlinkSync(markerPath);
79
+ log('cleared .tr-partial-install marker');
80
+ }
81
+ } catch (err) {
82
+ // Best-effort. The runtime register() also clears this defensively.
83
+ warn(`could not clear .tr-partial-install marker: ${err.message}`);
84
+ }
85
+ }
86
+
87
+ // ---------------------------------------------------------------------------
88
+ // Step 2 — atomic critical-dep validation (issue #188)
89
+ // ---------------------------------------------------------------------------
90
+
91
+ /**
92
+ * Try to require() each critical dep. Returns the list of names that
93
+ * failed; an empty array means everything resolved.
94
+ */
95
+ function smokeCheckDeps() {
96
+ const missing = [];
97
+ for (const dep of CRITICAL_DEPS) {
98
+ try {
99
+ require(dep);
100
+ } catch (err) {
101
+ missing.push({ dep, message: err.message });
102
+ }
103
+ }
104
+ return missing;
105
+ }
106
+
107
+ /**
108
+ * Recovery path: if smoke-check fails, blow away the local node_modules
109
+ * tree the parent install populated and re-run `npm install --no-audit
110
+ * --no-fund --no-save --offline=false` once. This is meant to recover
111
+ * from race-condition partial-fetches (issue #188), NOT from a missing
112
+ * dep in package.json.
113
+ *
114
+ * If the second attempt also fails, exit non-zero so `openclaw plugins
115
+ * install` surfaces the failure to the agent rather than writing
116
+ * `enabled: true` over a broken install.
117
+ *
118
+ * Skipped if `TOTALRECLAW_SKIP_POSTINSTALL_RETRY=1` (CI / sandboxes that
119
+ * cannot reach the registry from inside the postinstall hook).
120
+ */
121
+ function retryNpmInstall() {
122
+ if (process.env.TOTALRECLAW_SKIP_POSTINSTALL_RETRY === '1') {
123
+ warn('TOTALRECLAW_SKIP_POSTINSTALL_RETRY=1 — skipping retry');
124
+ return false;
125
+ }
126
+ try {
127
+ log('first-attempt smoke check failed — clearing node_modules and retrying npm install once...');
128
+ const nm = path.join(here, 'node_modules');
129
+ if (fs.existsSync(nm)) {
130
+ fs.rmSync(nm, { recursive: true, force: true });
131
+ }
132
+ // Note: we deliberately re-invoke npm install here. The `--ignore-scripts`
133
+ // flag is critical — without it we'd re-trigger this same postinstall
134
+ // and recurse forever.
135
+ execSync('npm install --no-audit --no-fund --ignore-scripts', {
136
+ cwd: here,
137
+ stdio: 'inherit',
138
+ });
139
+ log('retry npm install completed; re-validating deps');
140
+ return true;
141
+ } catch (err) {
142
+ warn(`retry npm install failed: ${err.message}`);
143
+ return false;
144
+ }
145
+ }
146
+
147
+ function validateDepsOrFail() {
148
+ const firstMiss = smokeCheckDeps();
149
+ if (firstMiss.length === 0) {
150
+ log(`smoke check OK (${CRITICAL_DEPS.length} critical deps resolved)`);
151
+ return;
152
+ }
153
+
154
+ warn(`smoke check failed on first attempt:`);
155
+ for (const m of firstMiss) {
156
+ warn(` - ${m.dep}: ${m.message}`);
157
+ }
158
+
159
+ const retried = retryNpmInstall();
160
+ if (!retried) {
161
+ process.exitCode = 1;
162
+ throw new Error(
163
+ `TotalReclaw postinstall: critical deps missing after npm install — ` +
164
+ `[${firstMiss.map((m) => m.dep).join(', ')}]. ` +
165
+ `Re-run \`openclaw plugins install @totalreclaw/totalreclaw\` to retry, ` +
166
+ `or set TOTALRECLAW_SKIP_POSTINSTALL_RETRY=1 to bypass and surface the ` +
167
+ `original error.`,
168
+ );
169
+ }
170
+
171
+ const secondMiss = smokeCheckDeps();
172
+ if (secondMiss.length === 0) {
173
+ log(`smoke check OK after retry (${CRITICAL_DEPS.length} deps resolved)`);
174
+ return;
175
+ }
176
+
177
+ process.exitCode = 1;
178
+ throw new Error(
179
+ `TotalReclaw postinstall: deps still missing after retry — ` +
180
+ `[${secondMiss.map((m) => m.dep).join(', ')}]. ` +
181
+ `This is likely a permanent breakage (registry outage, package rename, ` +
182
+ `or corrupted node_modules). The plugin will not load. Original errors:\n` +
183
+ secondMiss.map((m) => ` - ${m.dep}: ${m.message}`).join('\n'),
184
+ );
185
+ }
186
+
187
+ // ---------------------------------------------------------------------------
188
+ // Step 3 — sweep `.openclaw-install-stage-*` siblings (issue #190)
189
+ // ---------------------------------------------------------------------------
190
+
191
+ /**
192
+ * Resolve the OpenClaw extensions dir from the plugin's own location.
193
+ * The plugin lives at `<extensions>/totalreclaw/` so the parent is the
194
+ * extensions root. Returns null if the layout is not what we expect
195
+ * (npm tarball linked outside an `<extensions>/` parent — e.g. dev
196
+ * checkout) so we never delete random siblings.
197
+ */
198
+ function resolveExtensionsDir() {
199
+ // `here` is the plugin root (this file is at the package root, NOT in dist/).
200
+ // The parent should be the OpenClaw extensions directory.
201
+ const parent = path.resolve(here, '..');
202
+ // Heuristic check: only sweep if we look like we're inside an OpenClaw
203
+ // install dir. We accept (a) the well-known `extensions` dirname, OR
204
+ // (b) the presence of any sibling `.openclaw-install-stage-*` (which is
205
+ // proof we're inside an extensions dir).
206
+ if (path.basename(parent) === 'extensions') return parent;
207
+ try {
208
+ const entries = fs.readdirSync(parent);
209
+ if (entries.some((n) => n.startsWith('.openclaw-install-stage-'))) {
210
+ return parent;
211
+ }
212
+ } catch {
213
+ // Parent unreadable — bail safely.
214
+ }
215
+ return null;
216
+ }
217
+
218
+ function sweepStagingSiblings() {
219
+ const extensionsDir = resolveExtensionsDir();
220
+ if (!extensionsDir) {
221
+ log('no extensions parent detected (dev checkout?) — skipping staging sweep');
222
+ return;
223
+ }
224
+ let removed = 0;
225
+ let entries;
226
+ try {
227
+ entries = fs.readdirSync(extensionsDir);
228
+ } catch (err) {
229
+ warn(`could not list ${extensionsDir}: ${err.message}`);
230
+ return;
231
+ }
232
+ for (const name of entries) {
233
+ if (!name.startsWith('.openclaw-install-stage-')) continue;
234
+ const target = path.join(extensionsDir, name);
235
+ try {
236
+ const st = fs.lstatSync(target);
237
+ if (!st.isDirectory()) continue;
238
+ fs.rmSync(target, { recursive: true, force: true });
239
+ removed++;
240
+ log(`removed stale staging dir: ${name}`);
241
+ } catch (err) {
242
+ warn(`could not remove ${name}: ${err.message}`);
243
+ }
244
+ }
245
+ if (removed === 0) {
246
+ log('no stale staging dirs to sweep');
247
+ } else {
248
+ log(`swept ${removed} stale staging dir(s)`);
249
+ }
250
+ }
251
+
252
+ // ---------------------------------------------------------------------------
253
+ // Main
254
+ // ---------------------------------------------------------------------------
255
+
256
+ clearPartialInstallMarker();
257
+ sweepStagingSiblings();
258
+ validateDepsOrFail();
259
+
260
+ log('postinstall complete');
package/skill.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "totalreclaw",
3
- "version": "3.3.1-rc.22",
3
+ "version": "3.3.2-rc.2",
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",
@@ -175,6 +175,19 @@
175
175
  "description": "Preview consolidation without deleting"
176
176
  }
177
177
  }
178
+ },
179
+ {
180
+ "name": "totalreclaw_pair",
181
+ "description": "Set up the user's TotalReclaw account (encrypted, browser-side recovery-phrase generation or import). Returns an account-setup URL, a 6-digit PIN, and an ASCII QR code that the agent relays to the user. The recovery phrase is generated/entered in the BROWSER and uploaded end-to-end encrypted to this gateway — it NEVER touches the LLM provider or the chat transcript. This is the canonical agent-facilitated account-setup surface. Tool name kept for backward compatibility — function-wise this is the account-setup tool.",
182
+ "parameters": {
183
+ "mode": {
184
+ "type": "string",
185
+ "required": false,
186
+ "enum": ["generate", "import"],
187
+ "default": "generate",
188
+ "description": "\"generate\" = browser will create a NEW recovery phrase. \"import\" = browser will accept an EXISTING phrase pasted by the user in their browser (never through chat)."
189
+ }
190
+ }
178
191
  }
179
192
  ],
180
193
  "config": {