@totalreclaw/totalreclaw 3.3.1-rc.20 → 3.3.1-rc.21

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 CHANGED
@@ -4,6 +4,53 @@ All notable changes to `@totalreclaw/totalreclaw` (the OpenClaw plugin) are docu
4
4
 
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
+ ## [Unreleased]
8
+
9
+ ### Install / runtime hygiene (issues #126, #128)
10
+
11
+ Two narrow fixes from the rc.20 user-QA findings — both around install /
12
+ boot-time output cleanliness, no behavior change to the steady-state plugin.
13
+
14
+ - **#126 — clean up `.openclaw-install-stage-*` siblings.** When
15
+ `openclaw plugins install @totalreclaw/totalreclaw` is interrupted mid-
16
+ extract (e.g. by an auto-gateway-restart triggered by the same install),
17
+ the npm staging directory `<extensionsDir>/.openclaw-install-stage-XXXXXX/`
18
+ survives. On the next gateway start, OpenClaw's plugin loader auto-
19
+ discovers BOTH `.../totalreclaw/` AND the orphan staging dir, registers
20
+ duplicate plugins, fires hooks twice, and prints a "duplicate-plugin-id"
21
+ warning every cycle. A user running `openclaw plugins list` sees two
22
+ `totalreclaw` rows.
23
+
24
+ Fix: `cleanupInstallStagingDirs(pluginDir)` runs at plugin register time
25
+ (one tick after the loader resolves our entrypoint). It scans the
26
+ extensions directory for `.openclaw-install-stage-*` siblings and
27
+ recursively removes each one. Best-effort — never crashes plugin init
28
+ on permission / race failures.
29
+
30
+ Regression: `install-staging-cleanup.test.ts` (16 assertions) covers
31
+ fresh install, idempotent re-run, package-root vs `dist/` invocation,
32
+ unrelated-dotfile preservation (`.git`, `.openclaw-cache`), and stray-
33
+ file (non-directory) skipping.
34
+
35
+ - **#128 — registerTool breadcrumbs no longer bleed into `--json` stdout.**
36
+ The rc.20 breadcrumb logs (`registerTool(totalreclaw_pair) returned. ...`
37
+ and the RC-only `totalreclaw_report_qa_bug registered ...`) were emitted
38
+ via `api.logger.info`, which OpenClaw routes to stdout decorated with
39
+ `[plugins] `. When a user invoked `openclaw agent --message "..." --json`
40
+ for programmatic parsing, the breadcrumb appeared on stdout alongside
41
+ the JSON-RPC body, breaking any naive `JSON.parse(stdout)`.
42
+
43
+ Fix: gate both breadcrumbs behind `CONFIG.verboseRegister`, OFF by
44
+ default. Ops can opt back in with `TOTALRECLAW_VERBOSE_REGISTER=1` (or
45
+ the general `TOTALRECLAW_DEBUG=1` toggle) when chasing a tool-injection
46
+ regression. Default-off keeps `openclaw agent --json` stdout clean.
47
+
48
+ Regression: `json-stdout-cleanliness.test.ts` (11 assertions) confirms
49
+ both breadcrumbs are wrapped in `if (CONFIG.verboseRegister)` blocks,
50
+ simulates the gated `--json` stdout path and `JSON.parse`s the result,
51
+ and exercises the env-var resolution (`TOTALRECLAW_VERBOSE_REGISTER`
52
+ -> `TOTALRECLAW_DEBUG` -> default false).
53
+
7
54
  ## [3.3.1-rc.16] — 2026-04-24
8
55
 
9
56
  Fixes #92 — slow-host install times out during ONNX-runtime / embedding-model
package/api-client.ts CHANGED
@@ -8,8 +8,15 @@
8
8
  * Authorization: Bearer <hex-encoded-auth-key>
9
9
  *
10
10
  * The server hashes the auth key with SHA-256 to look up the user.
11
+ *
12
+ * Every outbound request goes through `buildRelayHeaders()` so the
13
+ * `X-TotalReclaw-Client` tag is set + the optional QA-tracing
14
+ * `X-TotalReclaw-Session` tag is forwarded when `TOTALRECLAW_SESSION_ID`
15
+ * is set. See `relay-headers.ts` and internal#127.
11
16
  */
12
17
 
18
+ import { buildRelayHeaders } from './relay-headers.js';
19
+
13
20
  // ---------------------------------------------------------------------------
14
21
  // Request / Response Types
15
22
  // ---------------------------------------------------------------------------
@@ -126,7 +133,7 @@ export function createApiClient(serverUrl: string) {
126
133
  ): Promise<{ user_id: string }> {
127
134
  const res = await fetch(`${baseUrl}/v1/register`, {
128
135
  method: 'POST',
129
- headers: { 'Content-Type': 'application/json', 'X-TotalReclaw-Client': 'openclaw-plugin' },
136
+ headers: buildRelayHeaders({ 'Content-Type': 'application/json' }),
130
137
  body: JSON.stringify({ auth_key_hash: authKeyHash, salt: saltHex }),
131
138
  });
132
139
  await assertOk(res, 'register');
@@ -160,10 +167,10 @@ export function createApiClient(serverUrl: string) {
160
167
  ): Promise<{ ids: string[]; duplicate_ids?: string[] }> {
161
168
  const res = await fetch(`${baseUrl}/v1/store`, {
162
169
  method: 'POST',
163
- headers: {
170
+ headers: buildRelayHeaders({
164
171
  'Content-Type': 'application/json',
165
172
  Authorization: `Bearer ${authKeyHex}`,
166
- },
173
+ }),
167
174
  body: JSON.stringify({ user_id: userId, facts }),
168
175
  });
169
176
  await assertOk(res, 'store');
@@ -198,10 +205,10 @@ export function createApiClient(serverUrl: string) {
198
205
  ): Promise<SearchCandidate[]> {
199
206
  const res = await fetch(`${baseUrl}/v1/search`, {
200
207
  method: 'POST',
201
- headers: {
208
+ headers: buildRelayHeaders({
202
209
  'Content-Type': 'application/json',
203
210
  Authorization: `Bearer ${authKeyHex}`,
204
- },
211
+ }),
205
212
  body: JSON.stringify({
206
213
  user_id: userId,
207
214
  trapdoors,
@@ -229,9 +236,9 @@ export function createApiClient(serverUrl: string) {
229
236
  async deleteFact(factId: string, authKeyHex: string): Promise<void> {
230
237
  const res = await fetch(`${baseUrl}/v1/facts/${encodeURIComponent(factId)}`, {
231
238
  method: 'DELETE',
232
- headers: {
239
+ headers: buildRelayHeaders({
233
240
  Authorization: `Bearer ${authKeyHex}`,
234
- },
241
+ }),
235
242
  });
236
243
  await assertOk(res, 'deleteFact');
237
244
  const json = (await res.json()) as Record<string, unknown>;
@@ -254,10 +261,10 @@ export function createApiClient(serverUrl: string) {
254
261
  async batchDelete(factIds: string[], authKeyHex: string): Promise<number> {
255
262
  const res = await fetch(`${baseUrl}/v1/facts/batch-delete`, {
256
263
  method: 'POST',
257
- headers: {
264
+ headers: buildRelayHeaders({
258
265
  'Content-Type': 'application/json',
259
266
  Authorization: `Bearer ${authKeyHex}`,
260
- },
267
+ }),
261
268
  body: JSON.stringify({ fact_ids: factIds }),
262
269
  });
263
270
  await assertOk(res, 'batchDelete');
@@ -290,9 +297,9 @@ export function createApiClient(serverUrl: string) {
290
297
 
291
298
  const res = await fetch(`${baseUrl}/v1/export?${params.toString()}`, {
292
299
  method: 'GET',
293
- headers: {
300
+ headers: buildRelayHeaders({
294
301
  Authorization: `Bearer ${authKeyHex}`,
295
- },
302
+ }),
296
303
  });
297
304
  await assertOk(res, 'exportFacts');
298
305
  const json = (await res.json()) as Record<string, unknown>;
package/config.ts CHANGED
@@ -9,8 +9,15 @@
9
9
  *
10
10
  * v1 env var cleanup — see `docs/guides/env-vars-reference.md`.
11
11
  * Removed user-facing vars: TOTALRECLAW_CHAIN_ID, TOTALRECLAW_EMBEDDING_MODEL,
12
- * TOTALRECLAW_STORE_DEDUP, TOTALRECLAW_LLM_MODEL, TOTALRECLAW_SESSION_ID,
13
- * TOTALRECLAW_TAXONOMY_VERSION.
12
+ * TOTALRECLAW_STORE_DEDUP, TOTALRECLAW_LLM_MODEL, TOTALRECLAW_TAXONOMY_VERSION.
13
+ *
14
+ * NOTE: ``TOTALRECLAW_SESSION_ID`` was in the removed list during the v1
15
+ * cleanup and silently rejected with a warning. That broke Axiom log tracing
16
+ * for QA — the qa-totalreclaw skill prescribes setting the var so relay logs
17
+ * are searchable by ``X-TotalReclaw-Session``. Restored as a SUPPORTED
18
+ * variable: read here, forwarded as the ``X-TotalReclaw-Session`` header on
19
+ * every outbound relay call. Mirrors the Python-side fix
20
+ * (`python/src/totalreclaw/agent/state.py`, v2.0.2). See internal#127.
14
21
  * Removed legacy gates: TOTALRECLAW_CLAIM_FORMAT, TOTALRECLAW_DIGEST_MODE,
15
22
  * TOTALRECLAW_AUTO_RESOLVE_MODE (the last one moved to an internal debug
16
23
  * module; see `contradiction-sync.ts`).
@@ -33,7 +40,9 @@ const REMOVED_ENV_VARS = [
33
40
  'TOTALRECLAW_EMBEDDING_MODEL',
34
41
  'TOTALRECLAW_STORE_DEDUP',
35
42
  'TOTALRECLAW_LLM_MODEL',
36
- 'TOTALRECLAW_SESSION_ID',
43
+ // NOTE: TOTALRECLAW_SESSION_ID was here before; restored as SUPPORTED
44
+ // (forwarded as X-TotalReclaw-Session header). Do NOT add it back to this
45
+ // list — see file header + internal#127.
37
46
  'TOTALRECLAW_TAXONOMY_VERSION',
38
47
  'TOTALRECLAW_CLAIM_FORMAT',
39
48
  'TOTALRECLAW_DIGEST_MODE',
@@ -63,6 +72,27 @@ export function getRecoveryPhrase(): string {
63
72
  return _recoveryPhraseOverride ?? process.env.TOTALRECLAW_RECOVERY_PHRASE ?? '';
64
73
  }
65
74
 
75
+ /**
76
+ * Read the QA / observability session tag from the environment.
77
+ *
78
+ * When set, every outbound relay call adds the ``X-TotalReclaw-Session``
79
+ * header so relay logs (and Axiom queries) can be filtered by this tag —
80
+ * this is what the qa-totalreclaw skill relies on to scope log searches per
81
+ * QA run. When unset, returns ``null`` and the header is omitted.
82
+ *
83
+ * Read via getter (not snapshotted) so operators / test harnesses can flip
84
+ * the var between calls without reloading the module.
85
+ *
86
+ * Mirrors the Python-side ``RelayClient._session_id`` resolution priority.
87
+ * See internal#127 / `docs/guides/env-vars-reference.md`.
88
+ */
89
+ export function getSessionId(): string | null {
90
+ const raw = process.env.TOTALRECLAW_SESSION_ID;
91
+ if (raw === undefined) return null;
92
+ const trimmed = raw.trim();
93
+ return trimmed.length > 0 ? trimmed : null;
94
+ }
95
+
66
96
  /**
67
97
  * Runtime override for chain ID, set after the relay billing response is
68
98
  * read. Free tier stays on 84532 (Base Sepolia); Pro tier flips to 100
@@ -92,6 +122,15 @@ export const CONFIG = {
92
122
  get recoveryPhrase(): string {
93
123
  return getRecoveryPhrase();
94
124
  },
125
+ /**
126
+ * Optional QA / observability session tag forwarded to the relay as
127
+ * ``X-TotalReclaw-Session``. See `getSessionId()` above. Getter form so
128
+ * tests + harnesses can flip the env between calls. ``null`` when unset
129
+ * (header omitted).
130
+ */
131
+ get sessionId(): string | null {
132
+ return getSessionId();
133
+ },
95
134
  serverUrl: (process.env.TOTALRECLAW_SERVER_URL || 'https://api.totalreclaw.xyz').replace(/\/+$/, ''),
96
135
  selfHosted: process.env.TOTALRECLAW_SELF_HOSTED === 'true',
97
136
  credentialsPath: process.env.TOTALRECLAW_CREDENTIALS_PATH || path.join(home, '.totalreclaw', 'credentials.json'),
@@ -214,6 +253,24 @@ export const CONFIG = {
214
253
  return process.env.TOTALRECLAW_QA_REPO || '';
215
254
  },
216
255
 
256
+ // 3.3.1-rc.21 (issue #128): verbose-register flag. When enabled, the
257
+ // plugin emits opt-in `info`-level breadcrumbs after sensitive
258
+ // registerTool calls (currently `totalreclaw_pair`) to help ops/QA
259
+ // grep gateway logs for definitive proof the tool was declared.
260
+ // Default OFF — the breadcrumb is debug-grade and was bleeding into
261
+ // `openclaw agent --json` stdout, breaking programmatic parsers.
262
+ // Enable with either:
263
+ // TOTALRECLAW_VERBOSE_REGISTER=1 (specific opt-in)
264
+ // TOTALRECLAW_DEBUG=1 (general debug toggle)
265
+ // Read via getter so flipping the env at runtime takes effect on the
266
+ // next gateway start without a rebuild.
267
+ get verboseRegister(): boolean {
268
+ const specific = (process.env.TOTALRECLAW_VERBOSE_REGISTER ?? '').trim().toLowerCase();
269
+ if (specific === '1' || specific === 'true' || specific === 'yes') return true;
270
+ const general = (process.env.TOTALRECLAW_DEBUG ?? '').trim().toLowerCase();
271
+ return general === '1' || general === 'true' || general === 'yes';
272
+ },
273
+
217
274
  // Paths
218
275
  home,
219
276
  billingCachePath: path.join(home, '.totalreclaw', 'billing-cache.json'),
package/crypto.ts CHANGED
@@ -15,10 +15,18 @@
15
15
  * -> HKDF-SHA256(seed, salt, "openmemory-dedup-v1", 32) -> dedupKey
16
16
  */
17
17
 
18
- // Lazy-load WASM to avoid crash when npm install hasn't finished yet.
18
+ // Lazy-load WASM. Uses createRequire so this module loads cleanly under bare
19
+ // Node ESM — the shipped `dist/index.js` declares `"type":"module"`, where
20
+ // the CJS `require` global is undefined at runtime. Prior to the rc.21 fix
21
+ // this file called bare `require('@totalreclaw/core')` and every consumer
22
+ // died with `require is not defined`. Matches the pattern already used by
23
+ // claims-helper / consolidation / contradiction-sync / digest-sync / pin /
24
+ // retype-setscope. See issue #124.
25
+ import { createRequire } from 'node:module';
26
+ const requireWasm = createRequire(import.meta.url);
19
27
  let _wasm: typeof import('@totalreclaw/core') | null = null;
20
28
  function getWasm() {
21
- if (!_wasm) _wasm = require('@totalreclaw/core');
29
+ if (!_wasm) _wasm = requireWasm('@totalreclaw/core');
22
30
  return _wasm;
23
31
  }
24
32
 
@@ -8,7 +8,13 @@
8
8
  * Authorization: Bearer <hex-encoded-auth-key>
9
9
  *
10
10
  * The server hashes the auth key with SHA-256 to look up the user.
11
+ *
12
+ * Every outbound request goes through `buildRelayHeaders()` so the
13
+ * `X-TotalReclaw-Client` tag is set + the optional QA-tracing
14
+ * `X-TotalReclaw-Session` tag is forwarded when `TOTALRECLAW_SESSION_ID`
15
+ * is set. See `relay-headers.ts` and internal#127.
11
16
  */
17
+ import { buildRelayHeaders } from './relay-headers.js';
12
18
  // ---------------------------------------------------------------------------
13
19
  // API Client Factory
14
20
  // ---------------------------------------------------------------------------
@@ -56,7 +62,7 @@ export function createApiClient(serverUrl) {
56
62
  async register(authKeyHash, saltHex) {
57
63
  const res = await fetch(`${baseUrl}/v1/register`, {
58
64
  method: 'POST',
59
- headers: { 'Content-Type': 'application/json', 'X-TotalReclaw-Client': 'openclaw-plugin' },
65
+ headers: buildRelayHeaders({ 'Content-Type': 'application/json' }),
60
66
  body: JSON.stringify({ auth_key_hash: authKeyHash, salt: saltHex }),
61
67
  });
62
68
  await assertOk(res, 'register');
@@ -80,10 +86,10 @@ export function createApiClient(serverUrl) {
80
86
  async store(userId, facts, authKeyHex) {
81
87
  const res = await fetch(`${baseUrl}/v1/store`, {
82
88
  method: 'POST',
83
- headers: {
89
+ headers: buildRelayHeaders({
84
90
  'Content-Type': 'application/json',
85
91
  Authorization: `Bearer ${authKeyHex}`,
86
- },
92
+ }),
87
93
  body: JSON.stringify({ user_id: userId, facts }),
88
94
  });
89
95
  await assertOk(res, 'store');
@@ -109,10 +115,10 @@ export function createApiClient(serverUrl) {
109
115
  async search(userId, trapdoors, maxCandidates, authKeyHex) {
110
116
  const res = await fetch(`${baseUrl}/v1/search`, {
111
117
  method: 'POST',
112
- headers: {
118
+ headers: buildRelayHeaders({
113
119
  'Content-Type': 'application/json',
114
120
  Authorization: `Bearer ${authKeyHex}`,
115
- },
121
+ }),
116
122
  body: JSON.stringify({
117
123
  user_id: userId,
118
124
  trapdoors,
@@ -136,9 +142,9 @@ export function createApiClient(serverUrl) {
136
142
  async deleteFact(factId, authKeyHex) {
137
143
  const res = await fetch(`${baseUrl}/v1/facts/${encodeURIComponent(factId)}`, {
138
144
  method: 'DELETE',
139
- headers: {
145
+ headers: buildRelayHeaders({
140
146
  Authorization: `Bearer ${authKeyHex}`,
141
- },
147
+ }),
142
148
  });
143
149
  await assertOk(res, 'deleteFact');
144
150
  const json = (await res.json());
@@ -157,10 +163,10 @@ export function createApiClient(serverUrl) {
157
163
  async batchDelete(factIds, authKeyHex) {
158
164
  const res = await fetch(`${baseUrl}/v1/facts/batch-delete`, {
159
165
  method: 'POST',
160
- headers: {
166
+ headers: buildRelayHeaders({
161
167
  'Content-Type': 'application/json',
162
168
  Authorization: `Bearer ${authKeyHex}`,
163
- },
169
+ }),
164
170
  body: JSON.stringify({ fact_ids: factIds }),
165
171
  });
166
172
  await assertOk(res, 'batchDelete');
@@ -185,9 +191,9 @@ export function createApiClient(serverUrl) {
185
191
  params.set('cursor', cursor);
186
192
  const res = await fetch(`${baseUrl}/v1/export?${params.toString()}`, {
187
193
  method: 'GET',
188
- headers: {
194
+ headers: buildRelayHeaders({
189
195
  Authorization: `Bearer ${authKeyHex}`,
190
- },
196
+ }),
191
197
  });
192
198
  await assertOk(res, 'exportFacts');
193
199
  const json = (await res.json());
package/dist/config.js CHANGED
@@ -9,8 +9,15 @@
9
9
  *
10
10
  * v1 env var cleanup — see `docs/guides/env-vars-reference.md`.
11
11
  * Removed user-facing vars: TOTALRECLAW_CHAIN_ID, TOTALRECLAW_EMBEDDING_MODEL,
12
- * TOTALRECLAW_STORE_DEDUP, TOTALRECLAW_LLM_MODEL, TOTALRECLAW_SESSION_ID,
13
- * TOTALRECLAW_TAXONOMY_VERSION.
12
+ * TOTALRECLAW_STORE_DEDUP, TOTALRECLAW_LLM_MODEL, TOTALRECLAW_TAXONOMY_VERSION.
13
+ *
14
+ * NOTE: ``TOTALRECLAW_SESSION_ID`` was in the removed list during the v1
15
+ * cleanup and silently rejected with a warning. That broke Axiom log tracing
16
+ * for QA — the qa-totalreclaw skill prescribes setting the var so relay logs
17
+ * are searchable by ``X-TotalReclaw-Session``. Restored as a SUPPORTED
18
+ * variable: read here, forwarded as the ``X-TotalReclaw-Session`` header on
19
+ * every outbound relay call. Mirrors the Python-side fix
20
+ * (`python/src/totalreclaw/agent/state.py`, v2.0.2). See internal#127.
14
21
  * Removed legacy gates: TOTALRECLAW_CLAIM_FORMAT, TOTALRECLAW_DIGEST_MODE,
15
22
  * TOTALRECLAW_AUTO_RESOLVE_MODE (the last one moved to an internal debug
16
23
  * module; see `contradiction-sync.ts`).
@@ -30,7 +37,9 @@ const REMOVED_ENV_VARS = [
30
37
  'TOTALRECLAW_EMBEDDING_MODEL',
31
38
  'TOTALRECLAW_STORE_DEDUP',
32
39
  'TOTALRECLAW_LLM_MODEL',
33
- 'TOTALRECLAW_SESSION_ID',
40
+ // NOTE: TOTALRECLAW_SESSION_ID was here before; restored as SUPPORTED
41
+ // (forwarded as X-TotalReclaw-Session header). Do NOT add it back to this
42
+ // list — see file header + internal#127.
34
43
  'TOTALRECLAW_TAXONOMY_VERSION',
35
44
  'TOTALRECLAW_CLAIM_FORMAT',
36
45
  'TOTALRECLAW_DIGEST_MODE',
@@ -53,6 +62,27 @@ export function setRecoveryPhraseOverride(phrase) {
53
62
  export function getRecoveryPhrase() {
54
63
  return _recoveryPhraseOverride ?? process.env.TOTALRECLAW_RECOVERY_PHRASE ?? '';
55
64
  }
65
+ /**
66
+ * Read the QA / observability session tag from the environment.
67
+ *
68
+ * When set, every outbound relay call adds the ``X-TotalReclaw-Session``
69
+ * header so relay logs (and Axiom queries) can be filtered by this tag —
70
+ * this is what the qa-totalreclaw skill relies on to scope log searches per
71
+ * QA run. When unset, returns ``null`` and the header is omitted.
72
+ *
73
+ * Read via getter (not snapshotted) so operators / test harnesses can flip
74
+ * the var between calls without reloading the module.
75
+ *
76
+ * Mirrors the Python-side ``RelayClient._session_id`` resolution priority.
77
+ * See internal#127 / `docs/guides/env-vars-reference.md`.
78
+ */
79
+ export function getSessionId() {
80
+ const raw = process.env.TOTALRECLAW_SESSION_ID;
81
+ if (raw === undefined)
82
+ return null;
83
+ const trimmed = raw.trim();
84
+ return trimmed.length > 0 ? trimmed : null;
85
+ }
56
86
  /**
57
87
  * Runtime override for chain ID, set after the relay billing response is
58
88
  * read. Free tier stays on 84532 (Base Sepolia); Pro tier flips to 100
@@ -79,6 +109,15 @@ export const CONFIG = {
79
109
  get recoveryPhrase() {
80
110
  return getRecoveryPhrase();
81
111
  },
112
+ /**
113
+ * Optional QA / observability session tag forwarded to the relay as
114
+ * ``X-TotalReclaw-Session``. See `getSessionId()` above. Getter form so
115
+ * tests + harnesses can flip the env between calls. ``null`` when unset
116
+ * (header omitted).
117
+ */
118
+ get sessionId() {
119
+ return getSessionId();
120
+ },
82
121
  serverUrl: (process.env.TOTALRECLAW_SERVER_URL || 'https://api.totalreclaw.xyz').replace(/\/+$/, ''),
83
122
  selfHosted: process.env.TOTALRECLAW_SELF_HOSTED === 'true',
84
123
  credentialsPath: process.env.TOTALRECLAW_CREDENTIALS_PATH || path.join(home, '.totalreclaw', 'credentials.json'),
@@ -192,6 +231,24 @@ export const CONFIG = {
192
231
  get qaRepoOverride() {
193
232
  return process.env.TOTALRECLAW_QA_REPO || '';
194
233
  },
234
+ // 3.3.1-rc.21 (issue #128): verbose-register flag. When enabled, the
235
+ // plugin emits opt-in `info`-level breadcrumbs after sensitive
236
+ // registerTool calls (currently `totalreclaw_pair`) to help ops/QA
237
+ // grep gateway logs for definitive proof the tool was declared.
238
+ // Default OFF — the breadcrumb is debug-grade and was bleeding into
239
+ // `openclaw agent --json` stdout, breaking programmatic parsers.
240
+ // Enable with either:
241
+ // TOTALRECLAW_VERBOSE_REGISTER=1 (specific opt-in)
242
+ // TOTALRECLAW_DEBUG=1 (general debug toggle)
243
+ // Read via getter so flipping the env at runtime takes effect on the
244
+ // next gateway start without a rebuild.
245
+ get verboseRegister() {
246
+ const specific = (process.env.TOTALRECLAW_VERBOSE_REGISTER ?? '').trim().toLowerCase();
247
+ if (specific === '1' || specific === 'true' || specific === 'yes')
248
+ return true;
249
+ const general = (process.env.TOTALRECLAW_DEBUG ?? '').trim().toLowerCase();
250
+ return general === '1' || general === 'true' || general === 'yes';
251
+ },
195
252
  // Paths
196
253
  home,
197
254
  billingCachePath: path.join(home, '.totalreclaw', 'billing-cache.json'),
package/dist/crypto.js CHANGED
@@ -14,11 +14,19 @@
14
14
  * -> HKDF-SHA256(seed, salt, "totalreclaw-encryption-key-v1", 32) -> encryptionKey
15
15
  * -> HKDF-SHA256(seed, salt, "openmemory-dedup-v1", 32) -> dedupKey
16
16
  */
17
- // Lazy-load WASM to avoid crash when npm install hasn't finished yet.
17
+ // Lazy-load WASM. Uses createRequire so this module loads cleanly under bare
18
+ // Node ESM — the shipped `dist/index.js` declares `"type":"module"`, where
19
+ // the CJS `require` global is undefined at runtime. Prior to the rc.21 fix
20
+ // this file called bare `require('@totalreclaw/core')` and every consumer
21
+ // died with `require is not defined`. Matches the pattern already used by
22
+ // claims-helper / consolidation / contradiction-sync / digest-sync / pin /
23
+ // retype-setscope. See issue #124.
24
+ import { createRequire } from 'node:module';
25
+ const requireWasm = createRequire(import.meta.url);
18
26
  let _wasm = null;
19
27
  function getWasm() {
20
28
  if (!_wasm)
21
- _wasm = require('@totalreclaw/core');
29
+ _wasm = requireWasm('@totalreclaw/core');
22
30
  return _wasm;
23
31
  }
24
32
  // ---------------------------------------------------------------------------
@@ -213,6 +213,88 @@ export function deleteFileIfExists(filePath) {
213
213
  }
214
214
  }
215
215
  // ---------------------------------------------------------------------------
216
+ // Install-staging cleanup (issue #126 — rc.20 finding F3)
217
+ // ---------------------------------------------------------------------------
218
+ /**
219
+ * Clean up `.openclaw-install-stage-*` sibling directories left behind by
220
+ * an interrupted `openclaw plugins install` run.
221
+ *
222
+ * Background
223
+ * ----------
224
+ * `openclaw plugins install @totalreclaw/totalreclaw` extracts the npm
225
+ * tarball into a staging directory named
226
+ * `<extensionsDir>/.openclaw-install-stage-XXXXXX/` and then renames it
227
+ * to `<extensionsDir>/totalreclaw/` on success. If the install is
228
+ * interrupted partway through (e.g. an auto-gateway-restart triggered by
229
+ * the same install kills the process — see rc.20 QA finding F3), the
230
+ * staging dir survives. On the next gateway start, OpenClaw's plugin
231
+ * loader auto-discovers BOTH directories — the real `totalreclaw/` and
232
+ * the orphaned `.openclaw-install-stage-XXXXXX/` — and registers two
233
+ * copies of the plugin. Hooks fire twice, the user sees a duplicate
234
+ * `totalreclaw` row in `openclaw plugins list`, and the gateway log
235
+ * spams a duplicate-plugin-id warning every cycle.
236
+ *
237
+ * Fix scope: best-effort cleanup driven by the plugin itself at register
238
+ * time. We resolve the extensions dir as the parent of the loaded
239
+ * plugin's own directory, scan for `.openclaw-install-stage-*` siblings,
240
+ * and recursively remove each one. If anything fails (permission,
241
+ * race with a concurrent install), we swallow the error — the existing
242
+ * loader-warning behavior is no worse than before.
243
+ *
244
+ * Returns the list of staging-dir paths that were successfully removed.
245
+ * Callers may log this for ops visibility. Empty list on a clean install.
246
+ *
247
+ * Parameters
248
+ * ----------
249
+ * @param pluginDir Absolute path to the loaded plugin's directory
250
+ * (typically `<extensionsDir>/totalreclaw/dist`). The
251
+ * helper walks up to the parent that holds sibling
252
+ * plugin directories (the `extensions/` root).
253
+ * @param _now Optional clock injector for testing — defaults to
254
+ * Date.now().
255
+ */
256
+ export function cleanupInstallStagingDirs(pluginDir, _now = Date.now) {
257
+ const removed = [];
258
+ try {
259
+ // pluginDir is `<extensionsDir>/totalreclaw/dist` after build, so the
260
+ // siblings live two levels up. Resolve both candidates so the helper
261
+ // works regardless of whether the caller passes the package root or
262
+ // its `dist/` subdir.
263
+ const candidates = [
264
+ path.resolve(pluginDir, '..'), // <extensionsDir>/totalreclaw → siblings dir if pluginDir is `dist`
265
+ path.resolve(pluginDir, '..', '..'), // <extensionsDir>/ → siblings dir if pluginDir is package root
266
+ ];
267
+ for (const extensionsDir of candidates) {
268
+ let entries;
269
+ try {
270
+ entries = fs.readdirSync(extensionsDir);
271
+ }
272
+ catch {
273
+ continue;
274
+ }
275
+ for (const name of entries) {
276
+ if (!name.startsWith('.openclaw-install-stage-'))
277
+ continue;
278
+ const target = path.join(extensionsDir, name);
279
+ try {
280
+ const st = fs.lstatSync(target);
281
+ if (!st.isDirectory())
282
+ continue;
283
+ fs.rmSync(target, { recursive: true, force: true });
284
+ removed.push(target);
285
+ }
286
+ catch {
287
+ // Best-effort — skip unreadable / racy entries.
288
+ }
289
+ }
290
+ }
291
+ }
292
+ catch {
293
+ // Best-effort — never crash plugin init on cleanup failure.
294
+ }
295
+ return removed;
296
+ }
297
+ // ---------------------------------------------------------------------------
216
298
  // Auto-bootstrap of credentials.json (3.1.0 first-run UX)
217
299
  // ---------------------------------------------------------------------------
218
300
  /**