@totalreclaw/totalreclaw 3.3.6-rc.1 → 3.3.7-rc.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,79 @@ 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
+ ## [3.3.7-rc.2] — 2026-05-04
8
+
9
+ Follow-up to 3.3.7-rc.1, caught in Pedro's manual integration testing on the OpenClaw side 2026-05-03. The 5-tier auth fix shipped DEAD-CODE in rc.1: gateway logs surfaced `[gateway] [plugins] command registration failed: Command name "restart" is reserved by a built-in command (plugin=totalreclaw, source=/home/pdiogo/.openclaw/extensions/totalreclaw/dist/index.js)`. The plugin's `/restart` registration was rejected because OpenClaw's plugin registry hard-rejects `restart` (and the rest of `RESERVED_COMMANDS` — see `node_modules/openclaw/dist/registry-*.js` — `help`, `commands`, `status`, `whoami`, `context`, `stop`, `restart`, `reset`, `new`, `compact`, `config`, `debug`, `allowlist`, `activation`, `skill`, `subagents`, `kill`, `steer`, `tell`, `model`, `models`, `queue`, `send`, `bash`, `exec`, `think`, `verbose`, `reasoning`, `elevated`, `usage`). Built-in `/restart` retains its allow-from-only semantics, which is what gave Pedro the original "You are not authorized" — and our 5-tier fallback never ran because plugin registration never succeeded.
10
+
11
+ ### Fixed — rename plugin slash command to `/totalreclaw-restart` (issue #215, follow-up)
12
+
13
+ **Fix:** plugin command name renamed from `restart` to `totalreclaw-restart`. The 5-tier auth resolver from rc.1 (`restart-auth.ts`, `inbound-user-tracker.ts`) is unchanged — it's the correct logic, just needed to attach to a non-reserved name. SKILL.md / setup-guide both tell the agent to issue `/totalreclaw-restart` so end-users never type `/restart` directly. The OpenClaw built-in `/restart` keeps its allow-from-only semantics — both commands coexist; the plugin's namespaced form is the recommended path for default-config users.
14
+
15
+ The SIGUSR1 emit path (`process.kill(process.pid, 'SIGUSR1')`) is unchanged — gateway accepts iff `commands.restart=true` (default), and the policy keys on the gateway-level config flag, NOT on the plugin command name. So `/totalreclaw-restart` triggers a real gateway restart end-to-end.
16
+
17
+ **Upstream FR (filed alongside this PR):** OpenClaw should allow plugins to override built-in command names with an explicit precedence flag (e.g. `registerCommand({ name: 'restart', overrideBuiltIn: true, ... })`). Until that lands, the namespaced workaround is canonical. Reference back to issue #215 architectural concerns.
18
+
19
+ Implementation:
20
+ - `skill/plugin/index.ts` — `api.registerCommand({ name: 'totalreclaw-restart', ... })` (was `'restart'`). Log lines updated to `/totalreclaw-restart` for cross-grep with the new SKILL.md instructions.
21
+ - `skill/plugin/restart-auth.ts` — docstring header reflects the rename + the rc.1 → rc.2 trail. The resolver matrix is byte-identical to rc.1.
22
+ - `skill/plugin/inbound-user-tracker.ts` — comment refers to the new command name.
23
+ - `skill/plugin/SKILL.md` — every user-facing instance of `/restart` (in agent-instructions: "issue `/restart` autonomously…") replaced with `/totalreclaw-restart`. Added a one-line note explaining why we renamed.
24
+ - `skill/SKILL.md` — same.
25
+ - `docs/guides/openclaw-setup.md` — same. The "If `/restart` returns unauthorized" section is now keyed on `/totalreclaw-restart`. The rare-but-real built-in `/restart` referenced as the OpenClaw-shipped command (which the user does NOT type for our path).
26
+
27
+ ### Tests
28
+
29
+ - Plugin: existing `restart-auth.test.ts` (29 assertions) + `inbound-user-tracker.test.ts` (14 assertions) all pass against the new command name (the resolver doesn't know its caller's name — it's pure). New regression test `register-command-name.test.ts` asserts the registered name is `totalreclaw-restart` (NOT `restart`) and asserts the plugin would not trip OpenClaw's `RESERVED_COMMANDS` check (parses the upstream `validateCommandName` rule directly from `node_modules/openclaw/dist/registry-*.js`). All pre-existing tests remain green.
30
+ - Hermes: `python/tests/test_restart_auth_5_tier_2_3_6.py` (21 assertions) all pass — the resolver is name-agnostic. CHANGELOG entry on the Hermes side notes the matching rename for the future Hermes `register_command` wiring.
31
+
32
+ ## [3.3.7-rc.1] — 2026-05-03
33
+
34
+ Patch wave from Pedro's QA on 3.3.6-rc.1 (real-user Telegram → Pop OS Docker container → OpenClaw 2026.4.22 + plugin 3.3.6-rc.1). Two ship-stoppers, both architectural rather than config-drift:
35
+
36
+ ### Fixed — `/restart` rejects channel-paired owner when `allowFrom` config is unset (issue #215)
37
+
38
+ **Root cause:** OpenClaw's built-in `/restart` checks `commands.ownerAllowFrom` + `channels.<provider>.allowFrom`. Managed-service users (and most users on a fresh install) never set those keys — they may not even be allowed to. So a default-config user typing `/restart` to recover from the plugin tool-binding race (the dominant first-run install path) hit `"You are not authorized to use this command."` and was stuck.
39
+
40
+ **Fix:** plugin now registers its own `/restart` slash command with `requireAuth: false`. Plugin commands match BEFORE built-ins (see upstream `auto-reply/reply/commands-plugin.ts`), so this takes precedence whenever the plugin is loaded. The handler runs a 5-tier auth fallback (priority order):
41
+
42
+ 1. `commands.ownerAllowFrom` explicitly lists invoker → allow
43
+ 2. `channels.<provider>.allowFrom` explicitly lists invoker → allow
44
+ 3. Invoker is the same identity this channel session is bound to (paired channel + lone inbound user) → allow
45
+ 4. `credentials.json` exists AND was paired via this same channel → allow
46
+ 5. BOTH allow-from configs unset (default) AND only one user has ever messaged this gateway → allow (lone-user heuristic for first-run installs)
47
+
48
+ Rejection ONLY when explicit config exists and excludes the invoker. Allow → fire `process.kill(process.pid, 'SIGUSR1')` (gateway accepts iff `commands.restart=true`, the default).
49
+
50
+ Implementation:
51
+ - `restart-auth.ts` — pure resolver (no fs / process side effects so the matrix is exhaustively unit-testable). Tests in `restart-auth.test.ts` (29 assertions).
52
+ - `inbound-user-tracker.ts` — disk-backed counter persisted to `<credentialsDir>/.inbound-users.json` (mode 0o600). `message_received` hook records every inbound (channel, senderId) so tier 3 / tier 5 verdicts survive container restart. Tests in `inbound-user-tracker.test.ts` (14 assertions).
53
+ - `index.ts` — wires `api.registerCommand({ name: 'restart', requireAuth: false, ... })` immediately after the existing `/totalreclaw` registration; reuses `loadCredentialsJson` for tier 4.
54
+
55
+ ### Investigated / partially mitigated — Container restart doesn't bind plugin tools to active session (issue #216)
56
+
57
+ **Symptom:** after `openclaw plugins install @totalreclaw/totalreclaw@3.3.6-rc.1`, the bot still doesn't see `totalreclaw_pair`. User does `docker restart tr-openclaw` from host shell, bot reattaches, **STILL no tool**.
58
+
59
+ **Investigation from code reading** (no reproducer access — Pedro's pop-os stack is the only known trip):
60
+ - Plugin loader at `subagent-registry-DV5OCO20.js::loadOpenClawPlugins` calls `register(api)` synchronously on every gateway boot (line 60062). The plugin's `register()` would normally run.
61
+ - Async `register()` returns a promise; OpenClaw discards it with a warning ("plugin register returned a promise; async registration is ignored", line 60067). Our register IS sync — confirmed by inspection. Not the root cause.
62
+ - Plugin install dir: `~/.openclaw/extensions/<plugin>/`. In Docker, that path needs to be host-mounted OR populated inside the container. If host-mounted but install ran on host vs container with different uid/gid, register() can silently no-op on the mismatched ownership.
63
+
64
+ **Most-likely hypothesis** (cannot confirm without reproducer): hypothesis (b) — plugin IS registered but the session's tool-registry was cached pre-restart and not refreshed. Telegram session-state persists in `~/.openclaw/sessions/`; the cached toolset survives the gateway process restart. OpenClaw upstream issue.
65
+
66
+ **What we shipped:**
67
+ 1. **Boot-counter heartbeat in `.loaded.json`** — `writePluginManifest` reads the prior manifest and increments `bootCount` on every successful register(). Adds `bootAt` (ISO timestamp) + `pid`. User can `cat ~/.openclaw/extensions/totalreclaw/.loaded.json` after a container restart to verify register() ran in this process. Tests in `load-manifest.test.ts` (12 new assertions, 34 total).
68
+ 2. **`/totalreclaw diag` slash command** — exposes the same data inside the agent's view. Compares `manifest.pid` to `process.pid` and warns "STALE — file from prior boot, register() did NOT run in this process" when they differ. Lets Pedro / future QA agents prove (a) vs (b) without a docker exec.
69
+ 3. **Upstream issue path** — if Pedro's next QA confirms `bootCount` increments on container restart (i.e. register() DID run) but tools still don't bind, the root cause is upstream OpenClaw session-cache invalidation and we'll file the issue with the diagnostic data attached.
70
+
71
+ ### Doc updates
72
+
73
+ - `docs/guides/openclaw-setup.md` — drop the "configure `commands.ownerAllowFrom` before testing /restart" prerequisite. `/restart` now works in default-config installs; the user no longer has to learn allow-from semantics before they can recover from a tool-binding race.
74
+
75
+ ### Tests
76
+
77
+ - Plugin: 2 new test files (`restart-auth.test.ts`, `inbound-user-tracker.test.ts`) — 43 new assertions. `load-manifest.test.ts` extended by 12 assertions (boot-count regression + idempotency). All pre-existing tests remain green.
78
+ - Hermes parity: `python/src/totalreclaw/hermes/restart_auth.py` ships the same matrix as a reusable utility. `python/tests/test_restart_auth_5_tier_2_3_6.py` — 21 assertions covering all 5 tiers. NOTE on Hermes wiring: as of `hermes-agent` 2026.4.x the plugin context API does NOT expose `register_command()` (Hermes roadmap). Until that lands, the Hermes module is an exported util; when Hermes adds the API, the plugin's `register()` can wire `/restart` in two lines.
79
+
7
80
  ## [3.3.6-rc.1] — 2026-05-01
8
81
 
9
82
  Patch wave from Pedro's second QA cycle on 3.3.5-rc.1.
package/SKILL.md CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: totalreclaw
3
3
  description: "Install + set up TotalReclaw encrypted memory for OpenClaw, then use totalreclaw_remember / totalreclaw_recall. Trigger on 'install TotalReclaw', 'set up TotalReclaw', 'restore my recovery phrase', or any remember / recall request."
4
- version: 3.3.6-rc.1
4
+ version: 3.3.7-rc.2
5
5
  author: TotalReclaw Team
6
6
  license: MIT
7
7
  homepage: https://totalreclaw.xyz
@@ -38,7 +38,7 @@ Speak only these lines, in order. Substitute the bracketed placeholders with the
38
38
  > Installing TotalReclaw…
39
39
  2. (after install + auto-reload + `totalreclaw_pair` is bound)
40
40
  > ✓ Installed (totalreclaw &lt;version&gt;)
41
- 3. (optional — only if auto-reload didn't fire and you need to issue `/restart`)
41
+ 3. (optional — only if auto-reload didn't fire and you need to issue `/totalreclaw-restart`)
42
42
  > Restarting OpenClaw to load the plugin…
43
43
  4. (optional — only if you successfully disabled the built-in memory tool to prevent dual-write)
44
44
  > Disabled built-in memory tool.
@@ -63,15 +63,15 @@ If `totalreclaw_*` tools are already bound to your toolset, SKIP install and jum
63
63
 
64
64
  If either shell call fails with `command not found` / ENOENT / "not authorized" (managed-service or shell-restricted environment), tell the user verbatim *"Your environment doesn't expose the OpenClaw CLI to me. Please install `totalreclaw` from your service's plugins / skills UI, then reply `done`."* Wait for `done` and skip to step 3.
65
65
 
66
- 2. **Wait for auto-reload — then issue `/restart` autonomously if needed (silent — emit user-visible line 2 on success, line 3 only if you had to restart).** OpenClaw's default config (`gateway.reload.mode = "hybrid"`) auto-restarts the gateway via a file-watcher within 1-3 seconds. Wait ~5s, then run `openclaw plugins list | grep totalreclaw` and confirm `totalreclaw_pair` is in your toolset. When bound, emit user-visible line 2 and proceed.
66
+ 2. **Wait for auto-reload — then issue `/totalreclaw-restart` autonomously if needed (silent — emit user-visible line 2 on success, line 3 only if you had to restart).** OpenClaw's default config (`gateway.reload.mode = "hybrid"`) auto-restarts the gateway via a file-watcher within 1-3 seconds. Wait ~5s, then run `openclaw plugins list | grep totalreclaw` and confirm `totalreclaw_pair` is in your toolset. When bound, emit user-visible line 2 and proceed.
67
67
 
68
68
  **If `totalreclaw_pair` is NOT visible after 10s — restart imperative (read carefully):**
69
69
 
70
- > Your IMMEDIATE next message must be the literal slash command `/restart` — nothing else. Do not narrate. Do not ask permission. Do not describe what `/restart` does. Do not propose alternatives. Just send `/restart`. You may emit user-visible line 3 (`Restarting OpenClaw to load the plugin…`) ONCE in the same turn, then issue `/restart`. Nothing else.
70
+ > Your IMMEDIATE next message must be the literal slash command `/totalreclaw-restart` — nothing else. Do not narrate. Do not ask permission. Do not describe what `/totalreclaw-restart` does. Do not propose alternatives. Just send `/totalreclaw-restart`. You may emit user-visible line 3 (`Restarting OpenClaw to load the plugin…`) ONCE in the same turn, then issue `/totalreclaw-restart`. Nothing else.
71
71
 
72
- `/restart` is in-process — it drains active runs cleanly; your shell is NOT shelling out to `docker restart` or `openclaw gateway restart` (those would suicide the host). After `/restart`, wait ~3s and re-run `openclaw plugins list`.
72
+ `/totalreclaw-restart` is in-process — it drains active runs cleanly; your shell is NOT shelling out to `docker restart` or `openclaw gateway restart` (those would suicide the host). After `/totalreclaw-restart`, wait ~3s and re-run `openclaw plugins list`. (3.3.7-rc.2 renamed this from `/restart` because OpenClaw reserves `/restart` for its built-in — the plugin-side fallback can only attach to a unique name. The 5-tier auth resolver is unchanged; only the command name moved.)
73
73
 
74
- **If `/restart` returns "You are not authorized to use this command":** surface this verbatim and stop. Do NOT loop `/restart` calls. Do NOT propose alternatives.
74
+ **If `/totalreclaw-restart` returns "You are not authorized to use this command":** surface this verbatim and stop. Do NOT loop `/totalreclaw-restart` calls. Do NOT propose alternatives.
75
75
 
76
76
  > Your Telegram channel needs `allowFrom` configured. Run on your host: `jq '.channels.<channel>.allowFrom = ["<your-id>"]' ~/.openclaw/openclaw.json | sponge ~/.openclaw/openclaw.json && docker restart <your-container-name>`. Substitute `<channel>` (e.g. `telegram`, `slack`), `<your-id>` (Telegram: `@username` or numeric `chat_id`; Slack: workspace member ID), and `<your-container-name>` (`docker ps` shows it). For non-Docker installs substitute `openclaw gateway restart` for the `docker restart` portion. On a managed service, ask the service's support to enable slash commands. Reply `done` after.
77
77
 
@@ -84,12 +84,12 @@ If `totalreclaw_*` tools are already bound to your toolset, SKIP install and jum
84
84
  - "Quick fix…"
85
85
  - "However, I don't see…"
86
86
  - "I need to use…"
87
- - "Should I /restart" / "Do you have a public URL" / "Want me to restart" / "Let me check if the tool is bound" / "I need permission to restart"
87
+ - "Should I /totalreclaw-restart" / "Do you have a public URL" / "Want me to restart" / "Let me check if the tool is bound" / "I need permission to restart"
88
88
  - "Now installing…" / "Got it" / "In parallel…" / "Step 1a/1b/1c"
89
89
 
90
90
  Substitute all of these with silence — the next user-visible line in the set, or nothing.
91
91
 
92
- 3. **Verify `totalreclaw_pair` is bound.** If still missing after `/restart` (or after the user fixed allowFrom and said `done`), tell them the restart didn't take effect and ask them to retry. Do not loop the install.
92
+ 3. **Verify `totalreclaw_pair` is bound.** If still missing after `/totalreclaw-restart` (or after the user fixed allowFrom and said `done`), tell them the restart didn't take effect and ask them to retry. Do not loop the install.
93
93
 
94
94
  4. **Check for existing credentials (silent unless found).** Run `test -s ~/.totalreclaw/credentials.json`. If it exists and is non-empty, emit *"✓ TotalReclaw is already set up."* and stop.
95
95
 
@@ -523,6 +523,35 @@ function resolvePluginRootForManifest(pluginDir) {
523
523
  * plugin version. Cleared first so a stale `.error.json` from a previous
524
524
  * failed boot doesn't survive a successful boot.
525
525
  */
526
+ /**
527
+ * Read the existing `.loaded.json` manifest for diagnostic surfaces
528
+ * (3.3.7-rc.1 — issue #216). Returns `null` if the manifest is
529
+ * missing, unreadable, or malformed. Best-effort: never throws.
530
+ *
531
+ * Scanner note: this helper lives in fs-helpers.ts (where all fs.*
532
+ * operations are consolidated) so the diagnostic slash command in
533
+ * `index.ts` doesn't have to introduce a fresh `readFileSync` call —
534
+ * the OpenClaw scanner whole-file rule disallows fs.read* next to the
535
+ * outbound-request trigger markers that index.ts already has in its
536
+ * on-chain submission code paths.
537
+ */
538
+ export function readPluginLoadedManifest(pluginDir) {
539
+ try {
540
+ const root = resolvePluginRootForManifest(pluginDir);
541
+ const loadedPath = path.join(root, PLUGIN_LOADED_MANIFEST);
542
+ if (!fs.existsSync(loadedPath))
543
+ return null;
544
+ const raw = fs.readFileSync(loadedPath, 'utf-8');
545
+ const parsed = JSON.parse(raw);
546
+ if (typeof parsed.loadedAt !== 'number' || !Array.isArray(parsed.tools) || typeof parsed.version !== 'string') {
547
+ return null;
548
+ }
549
+ return parsed;
550
+ }
551
+ catch {
552
+ return null;
553
+ }
554
+ }
526
555
  export function writePluginManifest(pluginDir, manifest) {
527
556
  try {
528
557
  const root = resolvePluginRootForManifest(pluginDir);
@@ -540,7 +569,29 @@ export function writePluginManifest(pluginDir, manifest) {
540
569
  catch {
541
570
  // Swallow — best-effort.
542
571
  }
543
- fs.writeFileSync(loadedPath, JSON.stringify(manifest, null, 2));
572
+ // 3.3.7-rc.1 (issue #216) — derive bootCount by reading the prior
573
+ // manifest. Lets the user grep `.loaded.json` after a container
574
+ // restart to verify register() actually ran. If the prior manifest
575
+ // is unreadable we start at 1.
576
+ let priorBootCount = 0;
577
+ try {
578
+ if (fs.existsSync(loadedPath)) {
579
+ const prior = JSON.parse(fs.readFileSync(loadedPath, 'utf-8'));
580
+ if (typeof prior.bootCount === 'number' && Number.isFinite(prior.bootCount)) {
581
+ priorBootCount = prior.bootCount;
582
+ }
583
+ }
584
+ }
585
+ catch {
586
+ // Swallow — if the prior manifest is corrupt we just start the counter fresh.
587
+ }
588
+ const enriched = {
589
+ ...manifest,
590
+ bootCount: priorBootCount + 1,
591
+ bootAt: new Date(manifest.loadedAt).toISOString(),
592
+ pid: process.pid,
593
+ };
594
+ fs.writeFileSync(loadedPath, JSON.stringify(enriched, null, 2));
544
595
  return true;
545
596
  }
546
597
  catch {
@@ -0,0 +1,146 @@
1
+ /**
2
+ * Per-channel inbound-user tracker (issue #215, 3.3.7-rc.1).
3
+ *
4
+ * Tier 3 + Tier 5 of the `/totalreclaw-restart` 5-tier auth fallback need to know
5
+ * "how many distinct users have ever messaged this gateway on channel X".
6
+ *
7
+ * This module implements a simple disk-backed counter. Persistence
8
+ * survives gateway restarts (which is the whole point — a fresh
9
+ * container restart must NOT reset the count to 0 and let an attacker
10
+ * race the lone-user heuristic).
11
+ *
12
+ * Storage: a single JSON file at `<credentialsDir>/.inbound-users.json`
13
+ * with shape `{ channel: { user1: ts, user2: ts, ... } }`. The file
14
+ * sits next to credentials.json so it's covered by the same backup
15
+ * boundary.
16
+ *
17
+ * Operations:
18
+ * - `recordInboundUser(channel, senderId)` — idempotent insert; updates
19
+ * `ts` to last-seen-at on every call.
20
+ * - `getDistinctInboundUserCount(channel)` — returns number of distinct
21
+ * keys for that channel (0 if no entries / file missing).
22
+ *
23
+ * Thread safety: the module-level cache is mutated synchronously in
24
+ * one Node.js event-loop tick; concurrent message_received hooks share
25
+ * the same cache. Disk writes are best-effort (no fsync) because losing
26
+ * a few count updates is recoverable — the worst case is a stale-but-
27
+ * never-stale count that becomes correct on the next inbound message.
28
+ *
29
+ * Privacy: senderIds are stored AS RECEIVED. Telegram chat IDs are not
30
+ * secrets but they are user-identifying. The file is mode 0o600 (same
31
+ * as credentials.json) so only the gateway's user can read it.
32
+ *
33
+ * Pure file I/O is intentional. The OpenClaw scanner whole-file rule
34
+ * disallows fs.read* alongside outbound-request markers; we do not
35
+ * make any HTTP / network call here, so the tracker is scanner-clean.
36
+ */
37
+ import * as fs from 'node:fs';
38
+ import * as path from 'node:path';
39
+ const SCHEMA_VERSION = 1;
40
+ /** Module-level cache so consecutive lookups don't re-read disk. Reset
41
+ * whenever the disk file changes (we don't watch — instead, every
42
+ * `recordInboundUser` reloads the on-disk state to merge concurrent
43
+ * writers, then writes the merged result back). For the read path we
44
+ * always touch disk because Tier 5 verdict is correctness-critical. */
45
+ let cachedState = null;
46
+ function defaultState() {
47
+ return { channels: {}, version: SCHEMA_VERSION };
48
+ }
49
+ /** Resolve the on-disk path. Caller passes the credentials.json path
50
+ * (the plugin already knows it from CONFIG.credentialsPath); we share
51
+ * the parent directory so the tracker file is co-located. */
52
+ export function resolveTrackerPath(credentialsPath) {
53
+ const dir = path.dirname(credentialsPath);
54
+ return path.join(dir, '.inbound-users.json');
55
+ }
56
+ function readStateFromDisk(trackerPath) {
57
+ try {
58
+ if (!fs.existsSync(trackerPath))
59
+ return defaultState();
60
+ const raw = fs.readFileSync(trackerPath, 'utf-8');
61
+ const parsed = JSON.parse(raw);
62
+ if (!parsed || typeof parsed !== 'object' || !parsed.channels || typeof parsed.channels !== 'object') {
63
+ return defaultState();
64
+ }
65
+ // Light validation: channels must be Record<string, Record<string, number>>
66
+ const channels = {};
67
+ for (const [ch, users] of Object.entries(parsed.channels)) {
68
+ if (!users || typeof users !== 'object')
69
+ continue;
70
+ const u = {};
71
+ for (const [uid, ts] of Object.entries(users)) {
72
+ if (typeof ts === 'number' && Number.isFinite(ts))
73
+ u[uid] = ts;
74
+ }
75
+ channels[ch] = u;
76
+ }
77
+ return { channels, version: SCHEMA_VERSION, updatedAt: parsed.updatedAt };
78
+ }
79
+ catch {
80
+ return defaultState();
81
+ }
82
+ }
83
+ function writeStateToDisk(trackerPath, state) {
84
+ try {
85
+ const dir = path.dirname(trackerPath);
86
+ if (!fs.existsSync(dir))
87
+ fs.mkdirSync(dir, { recursive: true });
88
+ state.updatedAt = new Date().toISOString();
89
+ state.version = SCHEMA_VERSION;
90
+ fs.writeFileSync(trackerPath, JSON.stringify(state), { mode: 0o600 });
91
+ return true;
92
+ }
93
+ catch {
94
+ return false;
95
+ }
96
+ }
97
+ /**
98
+ * Idempotently record that `senderId` messaged on `channel`. Returns
99
+ * true on successful persist, false if the disk write failed (the
100
+ * in-memory cache is updated either way so the run-time count is
101
+ * still correct for this process).
102
+ */
103
+ export function recordInboundUser(trackerPath, channel, senderId) {
104
+ const ch = channel.trim().toLowerCase();
105
+ const sid = senderId.trim();
106
+ if (!ch || !sid)
107
+ return false;
108
+ // Always reload from disk before mutating — covers the multi-process
109
+ // case where another worker (e.g. a sidecar) may have written entries
110
+ // since our last read.
111
+ const state = readStateFromDisk(trackerPath);
112
+ if (!state.channels[ch])
113
+ state.channels[ch] = {};
114
+ state.channels[ch][sid] = Date.now();
115
+ cachedState = state;
116
+ return writeStateToDisk(trackerPath, state);
117
+ }
118
+ /**
119
+ * Read the distinct inbound-user count for the given channel from disk
120
+ * (or the in-memory cache). Tier 5 of the auth fallback uses this; we
121
+ * read fresh-from-disk to make sure a multi-user gateway can't trip
122
+ * the lone-user heuristic just because our cache is stale.
123
+ */
124
+ export function getDistinctInboundUserCount(trackerPath, channel) {
125
+ const ch = channel.trim().toLowerCase();
126
+ if (!ch)
127
+ return 0;
128
+ // We deliberately do NOT use the cache here — see fn doc.
129
+ const state = readStateFromDisk(trackerPath);
130
+ cachedState = state;
131
+ const users = state.channels[ch];
132
+ if (!users || typeof users !== 'object')
133
+ return 0;
134
+ return Object.keys(users).length;
135
+ }
136
+ /** Test-only: reset the in-memory cache. */
137
+ export function __resetForTesting() {
138
+ cachedState = null;
139
+ }
140
+ /** Test-only: peek at the cache (returns a deep copy so tests can
141
+ * mutate without affecting the module). */
142
+ export function __peekCacheForTesting() {
143
+ if (!cachedState)
144
+ return null;
145
+ return JSON.parse(JSON.stringify(cachedState));
146
+ }
package/dist/index.js CHANGED
@@ -66,9 +66,11 @@ import { PluginHotCache } from './hot-cache-wrapper.js';
66
66
  import { CONFIG, setRecoveryPhraseOverride } from './config.js';
67
67
  import { buildRelayHeaders } from './relay-headers.js';
68
68
  import { readBillingCache, writeBillingCache, BILLING_CACHE_PATH, } from './billing-cache.js';
69
- import { ensureMemoryHeaderFile, loadCredentialsJson, writeCredentialsJson, deleteCredentialsFile, isRunningInDocker, deleteFileIfExists, resolveOnboardingState, writeOnboardingState, readPluginVersion, cleanupInstallStagingDirs, clearPartialInstallMarker, writePluginManifest, writePluginError, } from './fs-helpers.js';
69
+ import { ensureMemoryHeaderFile, loadCredentialsJson, writeCredentialsJson, deleteCredentialsFile, isRunningInDocker, deleteFileIfExists, resolveOnboardingState, writeOnboardingState, readPluginVersion, cleanupInstallStagingDirs, clearPartialInstallMarker, writePluginManifest, writePluginError, readPluginLoadedManifest, } from './fs-helpers.js';
70
70
  import { isRcBuild } from './qa-bug-report.js';
71
71
  import { decideToolGate, isGatedToolName } from './tool-gating.js';
72
+ import { resolveRestartAuth, rejectMessageFor, } from './restart-auth.js';
73
+ import { recordInboundUser, getDistinctInboundUserCount, resolveTrackerPath, } from './inbound-user-tracker.js';
72
74
  import { detectFirstRun, buildWelcomePrepend } from './first-run.js';
73
75
  import { buildPairRoutes } from './pair-http.js';
74
76
  import { detectGatewayHost } from './gateway-url.js';
@@ -2817,16 +2819,193 @@ const plugin = {
2817
2819
  : 'Memory tools are gated. Run `openclaw totalreclaw onboard` (local) or `openclaw totalreclaw pair` (remote) to complete setup.'),
2818
2820
  };
2819
2821
  }
2822
+ if (sub === 'diag') {
2823
+ // 3.3.7-rc.1 (issue #216) — diagnostic surface for the
2824
+ // tool-binding-on-restart bug. Reports whether the
2825
+ // plugin's register() ran in this process (boot count +
2826
+ // pid + version + tool count). Non-secret: only public
2827
+ // package metadata. The actual filesystem read lives in
2828
+ // fs-helpers.readPluginLoadedManifest() so this file
2829
+ // stays scanner-clean (whole-file rule disallows fs.read*
2830
+ // co-located with `fetch` / `post` markers).
2831
+ //
2832
+ // Usage: `/totalreclaw diag` from chat OR `cat
2833
+ // <pluginDir>/.loaded.json` from the host shell. Both
2834
+ // surfaces should agree; if chat says boot=N but the
2835
+ // file says boot=N+1, the chat session is stale and a
2836
+ // /totalreclaw-restart is warranted.
2837
+ try {
2838
+ const m = _pluginDirForManifest
2839
+ ? readPluginLoadedManifest(_pluginDirForManifest)
2840
+ : null;
2841
+ if (!m) {
2842
+ return {
2843
+ text: 'TotalReclaw diag:\n' +
2844
+ ` pid=${process.pid}\n` +
2845
+ ` version=${pluginVersion ?? 'unknown'}\n` +
2846
+ ' loaded-manifest: NOT FOUND (register() may have failed — check .error.json)',
2847
+ };
2848
+ }
2849
+ const stalePid = typeof m.pid === 'number' && m.pid !== process.pid;
2850
+ return {
2851
+ text: 'TotalReclaw diag:\n' +
2852
+ ` current pid=${process.pid}\n` +
2853
+ ` manifest pid=${m.pid ?? '?'}${stalePid ? ' (STALE — file from prior boot, register() did NOT run in this process)' : ''}\n` +
2854
+ ` version=${m.version ?? 'unknown'}\n` +
2855
+ ` boot count=${m.bootCount ?? '?'}\n` +
2856
+ ` boot at=${m.bootAt ?? '?'}\n` +
2857
+ ` tools registered=${m.tools?.length ?? 0}`,
2858
+ };
2859
+ }
2860
+ catch (err) {
2861
+ const msg = err instanceof Error ? err.message : String(err);
2862
+ return { text: `TotalReclaw diag: error reading manifest (${msg})` };
2863
+ }
2864
+ }
2820
2865
  return {
2821
2866
  text: 'TotalReclaw slash commands:\n' +
2822
2867
  ' /totalreclaw onboard — how to set up TotalReclaw securely\n' +
2823
2868
  ' /totalreclaw pair — remote-gateway QR-pairing (3.3.0)\n' +
2824
- ' /totalreclaw status — current onboarding state',
2869
+ ' /totalreclaw status — current onboarding state\n' +
2870
+ ' /totalreclaw diag — plugin load diagnostics (boot count, pid, tool count)',
2871
+ };
2872
+ },
2873
+ });
2874
+ // ---------------------------------------------------------------
2875
+ // 3.3.7-rc.2 (issue #215, follow-up) — `/totalreclaw-restart`
2876
+ // ---------------------------------------------------------------
2877
+ //
2878
+ // Originally rc.1 registered this as `/restart` to override the
2879
+ // OpenClaw built-in. That was wrong: OpenClaw's plugin registry
2880
+ // hard-rejects the name on the reserved list (see upstream
2881
+ // `RESERVED_COMMANDS` in `dist/registry-*.js`) — registration
2882
+ // fails with `Command name "restart" is reserved by a built-in
2883
+ // command` and the 5-tier fallback never runs. Pedro caught this
2884
+ // in 3.3.7-rc.1 manual integration testing 2026-05-03; gateway
2885
+ // logs surfaced the rejection, so the rc.1 fix shipped DEAD-CODE.
2886
+ //
2887
+ // Workaround until upstream lands a plugin-override-precedence
2888
+ // flag (FR filed alongside this PR): use a unique, namespaced
2889
+ // command name. Plugin handles `/totalreclaw-restart`; the
2890
+ // built-in `/restart` keeps its allow-from-only semantics
2891
+ // unchanged. SKILL.md tells the agent to issue the namespaced
2892
+ // form, so end-users never type `restart` directly.
2893
+ //
2894
+ // We still use `requireAuth: false` to bypass the channel-layer
2895
+ // auth check — the 5-tier fallback in `restart-auth.ts` decides
2896
+ // allow / reject per the same matrix as rc.1.
2897
+ //
2898
+ // If allow → fire `process.kill(process.pid, 'SIGUSR1')`. The
2899
+ // gateway accepts SIGUSR1 iff `commands.restart=true` (the
2900
+ // default) — see upstream `setGatewaySigusr1RestartPolicy`.
2901
+ // (The SIGUSR1 policy still keys on `commands.restart`, NOT on
2902
+ // the plugin command name — gateways only honour one restart
2903
+ // signal.)
2904
+ //
2905
+ // If reject → return a short non-shaming message via
2906
+ // `rejectMessageFor` that points the user at the right config
2907
+ // key (no infinite loop — agent will follow the unauthorized
2908
+ // fallback path documented in SKILL.md instead).
2909
+ api.registerCommand({
2910
+ name: 'totalreclaw-restart',
2911
+ description: 'Restart OpenClaw gracefully (drains active runs first).',
2912
+ acceptsArgs: false,
2913
+ requireAuth: false,
2914
+ handler: async (ctx) => {
2915
+ const trackerPath = resolveTrackerPath(CREDENTIALS_PATH);
2916
+ const channel = (ctx.channel ?? '').toString().trim().toLowerCase();
2917
+ const senderId = (ctx.senderId ?? '').toString().trim();
2918
+ // Tier 4 + tier 3 helpers. We approximate "paired via this
2919
+ // channel" with the OpenClaw channel-allow-from store: if
2920
+ // pairing wrote an entry for this provider, the file under
2921
+ // ~/.openclaw/pairing/<channel>/allow_from.json (or env
2922
+ // override) will exist. We don't import the upstream SDK's
2923
+ // sync helper because the plugin loader sandbox sometimes
2924
+ // strips the alias; instead we check a robust filesystem
2925
+ // shape: pair-finish writes credentials.json AND OpenClaw's
2926
+ // pairing-store entry. Safe approximation: if the plugin's
2927
+ // own credentials.json exists AND the inbound-user tracker
2928
+ // has at least one entry for this channel, treat it as
2929
+ // "paired via this channel". This matches the bug-fix
2930
+ // intent (issue #215, tier 4) without coupling to upstream
2931
+ // internal APIs.
2932
+ const credentialsExists = () => {
2933
+ try {
2934
+ const c = loadCredentialsJson(CREDENTIALS_PATH);
2935
+ return c != null;
2936
+ }
2937
+ catch {
2938
+ return false;
2939
+ }
2825
2940
  };
2941
+ const pairedViaChannel = (ch) => {
2942
+ if (!ch)
2943
+ return false;
2944
+ // Tracker count > 0 means at least one user has messaged
2945
+ // this channel since plugin load. Combined with
2946
+ // credentialsExists() in tier 4, this is a robust proxy
2947
+ // for "the channel is bound to this gateway".
2948
+ return getDistinctInboundUserCount(trackerPath, ch) > 0;
2949
+ };
2950
+ const verdict = resolveRestartAuth({ senderId, channel, config: api.config }, {
2951
+ loadCredentialsExists: credentialsExists,
2952
+ wasPairedViaChannel: pairedViaChannel,
2953
+ getDistinctInboundUserCount: (ch) => getDistinctInboundUserCount(trackerPath, ch),
2954
+ });
2955
+ if (verdict.allow === false) {
2956
+ api.logger.info(`TotalReclaw: /totalreclaw-restart rejected (channel=${channel || '<none>'} sender=${senderId || '<none>'} reason=${verdict.reason})`);
2957
+ return { text: rejectMessageFor(verdict.reason) };
2958
+ }
2959
+ api.logger.info(`TotalReclaw: /totalreclaw-restart allowed (channel=${channel || '<none>'} sender=${senderId || '<none>'} tier=${verdict.reason})`);
2960
+ // Trigger the gateway's SIGUSR1 restart path. Wrap in
2961
+ // try/catch — `process.kill` can throw if the gateway is
2962
+ // already shutting down (rare but seen in the wild).
2963
+ try {
2964
+ process.kill(process.pid, 'SIGUSR1');
2965
+ }
2966
+ catch (err) {
2967
+ const msg = err instanceof Error ? err.message : String(err);
2968
+ api.logger.warn(`TotalReclaw: /totalreclaw-restart SIGUSR1 emit failed: ${msg}`);
2969
+ return {
2970
+ text: `Restart request acknowledged but the gateway didn't accept the signal (${msg}). Try \`docker restart <container>\` if running in Docker.`,
2971
+ };
2972
+ }
2973
+ return { text: 'Restarting OpenClaw — back in a few seconds.' };
2826
2974
  },
2827
2975
  });
2828
2976
  }
2829
2977
  // ---------------------------------------------------------------
2978
+ // 3.3.7-rc.1 (issue #215) — track distinct inbound users per channel
2979
+ // ---------------------------------------------------------------
2980
+ //
2981
+ // Tier 3 + tier 5 of the `/totalreclaw-restart` 5-tier auth
2982
+ // fallback need to know how many distinct users have messaged this
2983
+ // gateway on each channel. We instrument `message_received` to
2984
+ // record every (channel, senderId) pair to disk; the count
2985
+ // survives gateway restarts (see `inbound-user-tracker.ts`).
2986
+ //
2987
+ // Best-effort: we never throw out of this hook even if the disk
2988
+ // write fails — the auth fallback degrades gracefully (a stale
2989
+ // count doesn't break the explicit-allow tiers).
2990
+ api.on('message_received', async (event, ctx) => {
2991
+ try {
2992
+ const evt = event;
2993
+ const c = ctx;
2994
+ const sender = (evt?.from ?? '').toString().trim();
2995
+ const channel = (c?.channelId ?? '').toString().trim();
2996
+ if (!sender || !channel)
2997
+ return undefined;
2998
+ const trackerPath = resolveTrackerPath(CREDENTIALS_PATH);
2999
+ recordInboundUser(trackerPath, channel, sender);
3000
+ }
3001
+ catch (err) {
3002
+ // best-effort; never crash on tracker failure
3003
+ const msg = err instanceof Error ? err.message : String(err);
3004
+ api.logger.warn(`message_received tracker write failed: ${msg}`);
3005
+ }
3006
+ return undefined;
3007
+ }, { priority: 5 });
3008
+ // ---------------------------------------------------------------
2830
3009
  // Tool: totalreclaw_remember
2831
3010
  // ---------------------------------------------------------------
2832
3011
  api.registerTool({