@totalreclaw/totalreclaw 3.3.12-rc.9 → 3.3.12

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.
Files changed (79) hide show
  1. package/SKILL.md +33 -27
  2. package/api-client.ts +17 -9
  3. package/batch-gate.ts +42 -0
  4. package/billing-cache.ts +72 -23
  5. package/claims-helper.ts +2 -1
  6. package/config.ts +95 -13
  7. package/consolidation.ts +6 -13
  8. package/contradiction-sync.ts +19 -14
  9. package/credential-provider.ts +184 -0
  10. package/crypto.ts +27 -160
  11. package/dist/api-client.js +17 -9
  12. package/dist/batch-gate.js +40 -0
  13. package/dist/billing-cache.js +54 -24
  14. package/dist/claims-helper.js +2 -1
  15. package/dist/config.js +91 -13
  16. package/dist/consolidation.js +6 -15
  17. package/dist/contradiction-sync.js +15 -15
  18. package/dist/credential-provider.js +145 -0
  19. package/dist/crypto.js +17 -137
  20. package/dist/download-ux.js +11 -7
  21. package/dist/embedder-loader.js +266 -0
  22. package/dist/embedding.js +36 -3
  23. package/dist/entry.js +123 -0
  24. package/dist/fs-helpers.js +75 -379
  25. package/dist/import-adapters/gemini-adapter.js +29 -159
  26. package/dist/index.js +864 -2631
  27. package/dist/memory-runtime.js +459 -0
  28. package/dist/native-memory.js +123 -0
  29. package/dist/onboarding-cli.js +4 -8
  30. package/dist/pair-cli-relay.js +1 -8
  31. package/dist/pair-cli.js +1 -1
  32. package/dist/pair-crypto.js +16 -358
  33. package/dist/pair-http.js +147 -4
  34. package/dist/relay.js +140 -0
  35. package/dist/reranker.js +13 -8
  36. package/dist/semantic-dedup.js +5 -7
  37. package/dist/skill-register.js +97 -0
  38. package/dist/subgraph-search.js +3 -1
  39. package/dist/subgraph-store.js +348 -290
  40. package/dist/tool-gating.js +39 -26
  41. package/dist/tools.js +367 -0
  42. package/dist/tr-cli-export-helper.js +3 -3
  43. package/dist/tr-cli.js +65 -156
  44. package/dist/trajectory-poller.js +155 -9
  45. package/dist/vault-crypto.js +551 -0
  46. package/download-ux.ts +12 -6
  47. package/embedder-loader.ts +293 -1
  48. package/embedding.ts +43 -3
  49. package/entry.ts +132 -0
  50. package/fs-helpers.ts +93 -458
  51. package/import-adapters/gemini-adapter.ts +38 -183
  52. package/index.ts +912 -2917
  53. package/memory-runtime.ts +723 -0
  54. package/native-memory.ts +196 -0
  55. package/onboarding-cli.ts +3 -9
  56. package/openclaw.plugin.json +5 -17
  57. package/package.json +6 -3
  58. package/pair-cli-relay.ts +0 -9
  59. package/pair-cli.ts +1 -1
  60. package/pair-crypto.ts +41 -483
  61. package/pair-http.ts +194 -5
  62. package/postinstall.mjs +138 -0
  63. package/relay.ts +172 -0
  64. package/reranker.ts +13 -8
  65. package/semantic-dedup.ts +5 -6
  66. package/skill-register.ts +146 -0
  67. package/skill.json +1 -1
  68. package/subgraph-search.ts +3 -1
  69. package/subgraph-store.ts +367 -307
  70. package/tool-gating.ts +39 -26
  71. package/tools.ts +499 -0
  72. package/tr-cli-export-helper.ts +3 -3
  73. package/tr-cli.ts +69 -182
  74. package/trajectory-poller.ts +162 -10
  75. package/vault-crypto.ts +705 -0
  76. package/auto-pair-on-load.ts +0 -308
  77. package/dist/auto-pair-on-load.js +0 -197
  78. package/dist/pair-pending-injection.js +0 -125
  79. package/pair-pending-injection.ts +0 -205
package/fs-helpers.ts CHANGED
@@ -29,6 +29,7 @@
29
29
 
30
30
  import fs from 'node:fs';
31
31
  import path from 'node:path';
32
+ import { envHomeDir } from './entry.js';
32
33
 
33
34
  // ---------------------------------------------------------------------------
34
35
  // Types
@@ -132,8 +133,7 @@ export function ensureMemoryHeaderFile(
132
133
  * covers the OpenClaw plugin sandbox case where the loaded module
133
134
  * lives at `<pluginRoot>/dist/index.js` while `package.json` lives
134
135
  * at `<pluginRoot>/package.json` (3.3.4-rc.1 fix — without this
135
- * walk-up, the `.loaded.json` manifest gets `version=unknown` and
136
- * all RC-gated logic that depends on the version string fails
136
+ * walk-up, version-dependent logic gets `version=unknown` and fails
137
137
  * silently in production OpenClaw deployments).
138
138
  * - Returns the `version` field, or `null` on any I/O / parse error.
139
139
  *
@@ -544,208 +544,15 @@ export function wipePartialInstall(pluginRootDir: string): boolean {
544
544
  }
545
545
 
546
546
  // ---------------------------------------------------------------------------
547
- // Plugin load manifest (.loaded.json / .error.json) — 3.3.2-rc.1 #186
547
+ // Plugin load manifest (.loaded.json / .error.json) — RETIRED Phase 3.4
548
548
  // ---------------------------------------------------------------------------
549
-
550
- /**
551
- * Filenames written into the plugin root dir at the end of register() and
552
- * (on failure) from the surrounding try/catch. The presence of `.loaded.json`
553
- * is the canonical filesystem signal that the plugin's register() body ran
554
- * to completion AND the SDK tool/route/hook registries received calls.
555
- *
556
- * Acceptance criteria (issue #186):
557
- * - `cat ~/.openclaw/extensions/totalreclaw/.loaded.json` shows
558
- * `{loadedAt, tools, version}` after every successful gateway start.
559
- * - `cat ~/.openclaw/extensions/totalreclaw/.error.json` shows
560
- * `{loadedAt, error, stack}` if register() threw.
561
- * - Both are overwritten on each register() call so the agent can rely
562
- * on the timestamp matching the most recent gateway start.
563
- *
564
- * Why these MUST be synchronous writes (same constraint as
565
- * `registerHttpRoute` per the comment in index.ts around the route
566
- * registration site): the SDK loader treats register() returning as the
567
- * signal to freeze the registries. An async `fs.promises.writeFile` would
568
- * settle one microtask AFTER the loader has moved on, so the manifest
569
- * could miss tools that registered late OR drop entirely if the gateway
570
- * exits before the microtask runs. `writeFileSync` is the only safe choice.
571
- */
572
- export const PLUGIN_LOADED_MANIFEST = '.loaded.json';
573
- export const PLUGIN_ERROR_MANIFEST = '.error.json';
574
-
575
- /** Schema written to `.loaded.json` — see PLUGIN_LOADED_MANIFEST. */
576
- export interface PluginLoadManifest {
577
- /** Unix epoch milliseconds when register() finished. */
578
- loadedAt: number;
579
- /** Tool names passed to api.registerTool() during register(). */
580
- tools: string[];
581
- /** Plugin version string from package.json (or "unknown"). */
582
- version: string;
583
- /**
584
- * 3.3.7-rc.1 (issue #216) — incremented every time register() completes.
585
- * Container restart that successfully re-runs register() should bump this.
586
- * If a user reports tools missing after-a-restart but bootCount is still N,
587
- * the plugin's register() was NOT called on boot (separate root cause
588
- * from "register ran but tools didn't bind to active session").
589
- */
590
- bootCount?: number;
591
- /** ISO timestamp of the most recent boot — easier to read than `loadedAt`. */
592
- bootAt?: string;
593
- /** PID of the gateway process that wrote this manifest. Lets the user
594
- * verify the manifest is from the currently-running container vs a
595
- * stale-mounted copy. */
596
- pid?: number;
597
- /**
598
- * 3.3.8-rc.1 — true when registerTool() calls are no-op'd due to the
599
- * OC 2026.5.2 issue #223 hybrid workaround. Tools in `tools[]` are
600
- * exposed via the `tr` CLI binary instead of via the plugin API.
601
- */
602
- hybridMode?: boolean;
603
- /**
604
- * 3.3.8-rc.1 — CLI commands that replace the tool registrations
605
- * when hybridMode=true. Agent runs these from shell instead of using
606
- * tool calls.
607
- */
608
- hybridCliTools?: string[];
609
- }
610
-
611
- /** Schema written to `.error.json` when register() throws. */
612
- export interface PluginLoadError {
613
- loadedAt: number;
614
- error: string;
615
- stack?: string;
616
- version?: string;
617
- }
618
-
619
- /**
620
- * Resolve the plugin root dir from the loaded module's directory. The plugin
621
- * is shipped with `dist/index.js` as the entry, so `import.meta.url` resolves
622
- * to `<root>/dist/`. We walk up one level to put the manifests next to
623
- * `package.json`. Defensive: if the caller already passed the root (no
624
- * trailing `dist`), we still return a sensible path.
625
- */
626
- function resolvePluginRootForManifest(pluginDir: string): string {
627
- const base = path.basename(pluginDir);
628
- return base === 'dist' ? path.resolve(pluginDir, '..') : pluginDir;
629
- }
630
-
631
- /**
632
- * Write the success manifest. SYNCHRONOUS — the SDK freezes the plugin
633
- * registries the moment register() returns; `fs.promises.writeFile` would
634
- * race that freeze and the manifest could miss late tool registrations or
635
- * never land at all if the process exits before the microtask runs.
636
- *
637
- * Best-effort: returns `true` on success, `false` on any I/O error. Never
638
- * throws — failing to write the manifest is a diagnostic loss, not a
639
- * correctness loss, so we don't propagate.
640
- *
641
- * The manifest is written at mode 0644 (world-readable). It contains no
642
- * secrets — only a timestamp, the (publicly known) tool names, and the
643
- * plugin version. Cleared first so a stale `.error.json` from a previous
644
- * failed boot doesn't survive a successful boot.
645
- */
646
- /**
647
- * Read the existing `.loaded.json` manifest for diagnostic surfaces
648
- * (3.3.7-rc.1 — issue #216). Returns `null` if the manifest is
649
- * missing, unreadable, or malformed. Best-effort: never throws.
650
- *
651
- * Scanner note: this helper lives in fs-helpers.ts (where all fs.*
652
- * operations are consolidated) so the diagnostic slash command in
653
- * `index.ts` doesn't have to introduce a fresh `readFileSync` call —
654
- * the OpenClaw scanner whole-file rule disallows fs.read* next to the
655
- * outbound-request trigger markers that index.ts already has in its
656
- * on-chain submission code paths.
657
- */
658
- export function readPluginLoadedManifest(
659
- pluginDir: string,
660
- ): PluginLoadManifest | null {
661
- try {
662
- const root = resolvePluginRootForManifest(pluginDir);
663
- const loadedPath = path.join(root, PLUGIN_LOADED_MANIFEST);
664
- if (!fs.existsSync(loadedPath)) return null;
665
- const raw = fs.readFileSync(loadedPath, 'utf-8');
666
- const parsed = JSON.parse(raw) as Partial<PluginLoadManifest>;
667
- if (typeof parsed.loadedAt !== 'number' || !Array.isArray(parsed.tools) || typeof parsed.version !== 'string') {
668
- return null;
669
- }
670
- return parsed as PluginLoadManifest;
671
- } catch {
672
- return null;
673
- }
674
- }
675
-
676
- export function writePluginManifest(
677
- pluginDir: string,
678
- manifest: PluginLoadManifest,
679
- ): boolean {
680
- try {
681
- const root = resolvePluginRootForManifest(pluginDir);
682
- if (!fs.existsSync(root)) return false;
683
- const loadedPath = path.join(root, PLUGIN_LOADED_MANIFEST);
684
- const errorPath = path.join(root, PLUGIN_ERROR_MANIFEST);
685
- // Best-effort error-file cleanup — a successful boot supersedes any
686
- // prior failure marker. If the unlink fails (e.g. permission), the
687
- // .loaded.json timestamp still tells the agent which is current.
688
- try {
689
- if (fs.existsSync(errorPath)) fs.unlinkSync(errorPath);
690
- } catch {
691
- // Swallow — best-effort.
692
- }
693
-
694
- // 3.3.7-rc.1 (issue #216) — derive bootCount by reading the prior
695
- // manifest. Lets the user grep `.loaded.json` after a container
696
- // restart to verify register() actually ran. If the prior manifest
697
- // is unreadable we start at 1.
698
- let priorBootCount = 0;
699
- try {
700
- if (fs.existsSync(loadedPath)) {
701
- const prior = JSON.parse(fs.readFileSync(loadedPath, 'utf-8')) as Partial<PluginLoadManifest>;
702
- if (typeof prior.bootCount === 'number' && Number.isFinite(prior.bootCount)) {
703
- priorBootCount = prior.bootCount;
704
- }
705
- }
706
- } catch {
707
- // Swallow — if the prior manifest is corrupt we just start the counter fresh.
708
- }
709
-
710
- const enriched: PluginLoadManifest = {
711
- ...manifest,
712
- bootCount: priorBootCount + 1,
713
- bootAt: new Date(manifest.loadedAt).toISOString(),
714
- pid: process.pid,
715
- };
716
-
717
- fs.writeFileSync(loadedPath, JSON.stringify(enriched, null, 2));
718
- return true;
719
- } catch {
720
- return false;
721
- }
722
- }
723
-
724
- /**
725
- * Write the error manifest. SYNCHRONOUS for the same reason as
726
- * `writePluginManifest`. Called from the try/catch surrounding the
727
- * register() body so the agent has a filesystem signal that the plugin
728
- * registered AT LEAST attempted to load and failed in a specific way.
729
- *
730
- * Does NOT clear `.loaded.json` from a prior successful boot — keeping
731
- * the older success marker around lets the agent see "last good boot was
732
- * X, current boot failed at Y" without spelunking logs. The newer
733
- * `.error.json` timestamp wins as "current state".
734
- */
735
- export function writePluginError(
736
- pluginDir: string,
737
- error: PluginLoadError,
738
- ): boolean {
739
- try {
740
- const root = resolvePluginRootForManifest(pluginDir);
741
- if (!fs.existsSync(root)) return false;
742
- const errorPath = path.join(root, PLUGIN_ERROR_MANIFEST);
743
- fs.writeFileSync(errorPath, JSON.stringify(error, null, 2));
744
- return true;
745
- } catch {
746
- return false;
747
- }
748
- }
549
+ // The 3.3.2-rc.1 `.loaded.json` / `.error.json` manifest machinery (writer
550
+ // + reader + schema) was fully removed in Phase 3.4. Phase 3.1 already
551
+ // dropped the `writePluginManifest` call from register(); 3.4 retires the
552
+ // remaining reader (`readPluginLoadedManifest`) + the error-path writer
553
+ // (`writePluginError`) + the schema types now that no production path
554
+ // references them. The gateway log is the source of truth for register()
555
+ // failures; `/totalreclaw diag` reports pid + in-memory version only.
749
556
 
750
557
  /**
751
558
  * Drop the `.tr-partial-install` marker into `pluginRootDir`. Idempotent
@@ -1130,6 +937,24 @@ export function resolveOnboardingState(
1130
937
  // ---------------------------------------------------------------------------
1131
938
  // OpenClaw 2026.5.x config auto-patch (3.3.9-rc.2 — issues #225 + #226)
1132
939
  // ---------------------------------------------------------------------------
940
+ //
941
+ // Phase 3.4 note (2026-06-22): this is the minimal idempotent slot-ensure
942
+ // that survives the config-patch state-machine retirement. The auto-pair-
943
+ // on-load + .pair-pending.json + SIGUSR1-for-pair dance was retired because
944
+ // pairing is now user-initiated QR. `patchOpenClawConfig` itself was NEVER
945
+ // part of the auto-pair state machine — it applies OpenClaw 2026.5.x
946
+ // compatibility keys (hook access, telegram streaming) and is idempotent
947
+ // (returns `'unchanged'` when all keys are already correct). Its only caller
948
+ // is register() in index.ts; the SIGUSR1 emitted on `'patched'` is the
949
+ // "restart-so-the-new-keys-take-effect-this-boot" signal, NOT a pair signal.
950
+ //
951
+ // Retired in rc.20 (#402): the memory-slot write (formerly Fix #1), the
952
+ // install-record self-heal (formerly Fix #6), and the plugins.allow self-append
953
+ // + bundledDiscovery="compat" pair (formerly Fix #5 + Fix #4). OpenClaw 2026.6.8
954
+ // claims the memory slot natively on `plugins install`/`enable`, manages the
955
+ // allowlist itself, and discards the whole installs map if any entry fails
956
+ // schema validation — so this helper no longer touches `plugins.slots.memory`,
957
+ // `plugins.installs`, `plugins.allow`, or `plugins.bundledDiscovery`.
1133
958
 
1134
959
  /**
1135
960
  * Outcome of `patchOpenClawConfig`.
@@ -1149,14 +974,11 @@ export type OpenClawConfigPatchResult = 'patched' | 'unchanged' | 'skipped' | 'e
1149
974
  * Auto-patch `~/.openclaw/openclaw.json` with the entries required by
1150
975
  * OpenClaw 2026.5.x for clean operation (issues #225 + #226 + verbosity):
1151
976
  *
1152
- * 1. `plugins.slots.memory = "totalreclaw"` (gated on install record)
1153
- * Claim the memory slot so the plugin loads instead of deferring to
1154
- * the built-in `memory-core` tenant. As of 3.3.9-rc.4 this fix is
1155
- * gated on `plugins.installs.totalreclaw.version` being present
1156
- * writing the slot without an install record produces a startup
1157
- * crash loop ("plugins.slots.memory: plugin not found: totalreclaw")
1158
- * that survives container restarts until `openclaw plugins install`
1159
- * repopulates the install record.
977
+ * NOTE (rc.20, #402): this helper no longer writes `plugins.slots.memory`
978
+ * (OpenClaw 2026.6.8 claims the memory slot natively on install/enable) nor
979
+ * `plugins.installs` (the former Fix #6 self-heal its record reader
980
+ * discards the whole installs map if any entry fails schema validation).
981
+ * Pre-existing values of both are left untouched. The remaining fixes are:
1160
982
  *
1161
983
  * 2. `plugins.entries.totalreclaw.hooks.allowConversationAccess = true`
1162
984
  * Grant the plugin access to `agent_end` and `before_agent_start`
@@ -1170,15 +992,11 @@ export type OpenClawConfigPatchResult = 'patched' | 'unchanged' | 'skipped' | 'e
1170
992
  * this to "off" on first run for a clean UX. Existing explicit values
1171
993
  * ("partial", "block", "progress") are preserved.
1172
994
  *
1173
- * 4. `plugins.bundledDiscovery = "compat"` (only if unset, and only when
1174
- * `plugins.allow` is populated). When `plugins.allow` is a non-empty
1175
- * array, OpenClaw 2026.5.x switches the loader into strict-allowlist
1176
- * mode and silently rejects non-bundled plugins like totalreclaw
1177
- * EVEN IF they are listed in the allow array. Setting
1178
- * `bundledDiscovery: "compat"` restores the looser behavior so allow-
1179
- * listed non-bundled plugins load. Without this fix, users with
1180
- * telegram or any model provider configured before TR install (which
1181
- * populates allow) get a silent plugin-skip on every gateway boot.
995
+ * Fix #4 (`plugins.bundledDiscovery="compat"`) and Fix #5 (appending
996
+ * "totalreclaw" to `plugins.allow`) were RETIRED together in rc.20 (#402):
997
+ * native install manages the allowlist, and TR growing it was the only
998
+ * thing that forced strict-allowlist mode (the sole reason the compat
999
+ * workaround existed). Pre-existing values of both keys are left untouched.
1182
1000
  *
1183
1001
  * Design constraints
1184
1002
  * ------------------
@@ -1202,11 +1020,11 @@ export type OpenClawConfigPatchResult = 'patched' | 'unchanged' | 'skipped' | 'e
1202
1020
  */
1203
1021
  export function patchOpenClawConfig(
1204
1022
  configPath?: string,
1205
- // 3.3.12-rc.3 plugin version (used by Fix #6 to self-heal a stripped
1206
- // `plugins.installs.totalreclaw` record so Fix #1 (slot) can fire).
1023
+ // Retained on the signature (register() passes it) but no longer consumed:
1024
+ // the Fix #6 install-record self-heal it fed was retired in rc.20 (#402).
1207
1025
  pluginVersion?: string,
1208
1026
  ): OpenClawConfigPatchResult {
1209
- const home = process.env.HOME ?? '/home/node';
1027
+ const home = envHomeDir();
1210
1028
  const target = configPath ?? path.join(home, '.openclaw', 'openclaw.json');
1211
1029
 
1212
1030
  // `'skipped'` when the config file is absent — this host may not be
@@ -1225,112 +1043,46 @@ export function patchOpenClawConfig(
1225
1043
 
1226
1044
  let mutated = false;
1227
1045
 
1228
- // --- Fix #6 (3.3.12-rc.3): self-heal `plugins.installs.totalreclaw` ---
1229
- //
1230
- // OpenClaw 2026.5.6 has a config-rewrite-after-restart behaviour
1231
- // observed on Pedro's pop-os QA host (2026-05-08): `openclaw plugins
1232
- // install` writes the install record, gateway restart fires, but
1233
- // after the restart something STRIPS `plugins.installs.totalreclaw` (and
1234
- // sometimes `plugins.allow`, `plugins.entries.totalreclaw`,
1235
- // `plugins.slots.memory`) from openclaw.json. The plugin's binary
1236
- // remains in `~/.openclaw/npm/node_modules/@totalreclaw/totalreclaw/`,
1237
- // but `openclaw plugins list` shows it as `disabled` because no
1238
- // install record + no allow entry.
1239
- //
1240
- // Defensive self-heal: when this register() runs (which means the
1241
- // plugin IS physically loaded by the gateway), if the install record
1242
- // is missing or has no version, write a minimal record. This unlocks
1243
- // Fix #1 (slot) and avoids the user-visible "plugin disabled"
1244
- // condition without requiring `openclaw plugins install --force`.
1245
- //
1246
- // Phrase-safety: writes only metadata (version, spec, source,
1247
- // installedAt). No mnemonic / userId / SA leakage.
1248
- if (pluginVersion) {
1249
- if (typeof cfg.plugins.installs !== 'object' || cfg.plugins.installs === null) {
1250
- cfg.plugins.installs = {};
1251
- }
1252
- const existing = cfg.plugins.installs.totalreclaw;
1253
- const existingVersion = (typeof existing === 'object' && existing !== null && typeof existing.version === 'string')
1254
- ? existing.version
1255
- : null;
1256
- if (!existingVersion) {
1257
- cfg.plugins.installs.totalreclaw = {
1258
- ...(typeof existing === 'object' && existing !== null ? existing : {}),
1259
- version: pluginVersion,
1260
- spec: '@totalreclaw/totalreclaw',
1261
- source: 'self-heal',
1262
- installedAt: new Date().toISOString(),
1263
- };
1264
- mutated = true;
1265
- }
1266
- }
1267
-
1268
- // --- Fix #5 (3.3.12-rc.3): plugins.allow includes "totalreclaw" ---
1269
- //
1270
- // OpenClaw 2026.5.x: when `plugins.allow` is a non-empty array, the
1271
- // gateway switches into strict-allowlist mode. Plugins NOT in the
1272
- // allow list are silently rejected at load time — even bundled ones
1273
- // are gated. Pedro's pop-os 2026-05-08 QA had `plugins.allow` =
1274
- // ['device-pair', 'google', 'telegram', 'zai'] AFTER `openclaw
1275
- // plugins install @totalreclaw/totalreclaw@rc` ran. The install
1276
- // command did NOT add 'totalreclaw' to the allow list. Plugin
1277
- // shipped as `disabled`. Setup never proceeded.
1278
- //
1279
- // Defensive: when allow is a non-empty array and 'totalreclaw' is
1280
- // not in it, append. Don't touch null/undefined allow (means
1281
- // auto-discover mode — plugin is reachable without explicit allow).
1282
- if (Array.isArray(cfg.plugins.allow) && cfg.plugins.allow.length > 0) {
1283
- if (!cfg.plugins.allow.includes('totalreclaw')) {
1284
- cfg.plugins.allow.push('totalreclaw');
1285
- mutated = true;
1286
- }
1287
- }
1288
-
1289
- // --- Fix #1: plugins.slots.memory = "totalreclaw" (gated on install) ---
1046
+ // --- Fix #6 (installs self-heal) RETIRED in rc.20 (#402) ---
1290
1047
  //
1291
- // DEFENSIVE GATE (3.3.9-rc.4 2026-05-05): only write the slot when
1292
- // the plugin is genuinely INSTALLED (`plugins.installs.totalreclaw`
1293
- // present with a `version`). Writing the slot unconditionally
1294
- // produced a startup crash loop on Pedro's pop-os QA host on
1295
- // 2026-05-05 after a config reset, `plugins.installs.totalreclaw`
1296
- // was missing but a previously-written `slots.memory = "totalreclaw"`
1297
- // had survived. OpenClaw's startup validator refuses to start with
1048
+ // patchOpenClawConfig used to fabricate a `plugins.installs.totalreclaw`
1049
+ // record (version/spec/source:"self-heal"/installedAt) whenever a version
1050
+ // was passed and no record existed. Removed because it was inert at best
1051
+ // and harmful at worst on OpenClaw 2026.6.8:
1052
+ // (a) the record reader validates the WHOLE installs map against a
1053
+ // schema where `source` must be a valid PluginInstallSource the
1054
+ // fabricated `source:"self-heal"` (and any source-less record) fails
1055
+ // safeParse, discarding the ENTIRE installs map at load; and
1056
+ // (b) the native installer never persists install records into
1057
+ // openclaw.json (it strips them via withoutPluginInstallRecords and
1058
+ // commits to the SQLite index, which always wins on merge).
1059
+ // So the plugin no longer writes `plugins.installs` at all; a pre-existing
1060
+ // record is left byte-identical. `pluginVersion` is retained on the
1061
+ // signature (register() passes it) but is no longer consumed here.
1062
+ void pluginVersion;
1063
+
1064
+ // --- Fix #5 (plugins.allow self-append) RETIRED in rc.20 (#402) ---
1298
1065
  //
1299
- // Gateway failed to start: Error: Invalid config at openclaw.json.
1300
- // plugins.slots.memory: plugin not found: totalreclaw
1301
- // Run "openclaw doctor --fix" to repair, then retry.
1066
+ // patchOpenClawConfig used to append "totalreclaw" to a non-empty
1067
+ // `plugins.allow`. Removed as a pair with Fix #4 (below): OpenClaw's native
1068
+ // `persistPluginInstall` already calls `addInstalledPluginToAllowlist`, and
1069
+ // an empty/absent allowlist means allow-all (no write needed). TR
1070
+ // unconditionally growing an allowlist was the very thing that flipped
1071
+ // hosts into strict-allowlist enforcement — which then required the Fix #4
1072
+ // `bundledDiscovery:"compat"` workaround. Dropping both lets native install
1073
+ // own the allowlist; existing hosts keep whatever is already on disk.
1074
+
1075
+ // --- Fix #1 (memory-slot write) RETIRED in rc.20 (#402) ---
1302
1076
  //
1303
- // The container restart-loop drained ~13 attempts (12:10-12:23 UTC)
1304
- // until `openclaw plugins install` was re-run and re-populated
1305
- // `plugins.installs.totalreclaw`. With this gate, future installs
1306
- // that wipe `plugins.installs` (config reset, `doctor --fix`,
1307
- // migration tools) cannot regress into the same boot loop slot is
1308
- // only ever written when the install record exists, and the install
1309
- // record is the install pipeline's authoritative signal that the
1310
- // plugin is on disk and registered with the gateway.
1311
- //
1312
- // The hooks patch (Fix #2) and Telegram streaming patch (Fix #3) are
1313
- // not gated this way — they write under `plugins.entries` and
1314
- // `channels` which are inert without an install record, so they can
1315
- // never trip the validator.
1316
- const installsRoot = cfg.plugins.installs;
1317
- const installEntry = typeof installsRoot === 'object' && installsRoot !== null
1318
- ? installsRoot.totalreclaw
1319
- : undefined;
1320
- const pluginIsInstalled = typeof installEntry === 'object'
1321
- && installEntry !== null
1322
- && typeof installEntry.version === 'string'
1323
- && installEntry.version.length > 0;
1324
-
1325
- if (pluginIsInstalled) {
1326
- if (typeof cfg.plugins.slots !== 'object' || cfg.plugins.slots === null) {
1327
- cfg.plugins.slots = {};
1328
- }
1329
- if (cfg.plugins.slots.memory !== 'totalreclaw') {
1330
- cfg.plugins.slots.memory = 'totalreclaw';
1331
- mutated = true;
1332
- }
1333
- }
1077
+ // patchOpenClawConfig used to write `plugins.slots.memory = "totalreclaw"`
1078
+ // (install-gated, to dodge a startup crash loop). OpenClaw 2026.6.8 now
1079
+ // claims the memory slot NATIVELY during `plugins install`/`enable` (its
1080
+ // persistPluginInstall "slot selection" phase), so the hand-written write
1081
+ // was redundant on the good path and twice failed in production. The
1082
+ // plugin no longer touches the memory slot a pre-existing value is left
1083
+ // byte-identical. Fix numbering below is preserved for continuity with the
1084
+ // #225/#226 history. The Fix #6 install-record self-heal was retired
1085
+ // above too (rc.20, #402).
1334
1086
 
1335
1087
  // --- Fix #2: plugins.entries.totalreclaw.hooks.allowConversationAccess = true ---
1336
1088
  if (typeof cfg.plugins.entries !== 'object' || cfg.plugins.entries === null) {
@@ -1375,36 +1127,15 @@ export function patchOpenClawConfig(
1375
1127
  }
1376
1128
  }
1377
1129
 
1378
- // --- Fix #4: plugins.bundledDiscovery = "compat" (3.3.11-rc.4) ---
1130
+ // --- Fix #4 (plugins.bundledDiscovery="compat") RETIRED in rc.20 (#402) ---
1379
1131
  //
1380
- // OpenClaw 2026.5.x: when `plugins.allow` is populated (any non-empty
1381
- // array), the gateway's plugin loader switches into strict-allowlist
1382
- // mode. In strict mode, NON-BUNDLED plugins like totalreclaw are
1383
- // silently rejected even when listed in `plugins.allow`, unless
1384
- // `plugins.bundledDiscovery = "compat"` is explicitly set. Pedro's
1385
- // 2026-05-07 QA on pop-os surfaced this the gateway booted with only
1386
- // the bundled providers (telegram, device-pair) and skipped totalreclaw
1387
- // despite it being in the allow list. `openclaw doctor --fix` cures it
1388
- // by setting `bundledDiscovery: "compat"`, but users shouldn't need
1389
- // to run doctor manually for the plugin to load.
1390
- //
1391
- // Fix: when `plugins.allow` is a non-empty array AND
1392
- // `plugins.bundledDiscovery` is unset, set it to "compat". If the user
1393
- // explicitly chose "allowlist" (the stricter mode), preserve their
1394
- // choice — only first-run defaults are touched.
1395
- //
1396
- // This bug was missed by the auto-QA harness because the harness ran
1397
- // on a fresh canonical container with `plugins.allow=null`, hitting
1398
- // the auto-discover code path. Real users with telegram + a model
1399
- // provider configured before TR install have a populated allow list,
1400
- // hitting the strict-mode path. The auto-QA harness in 3.3.11-rc.4
1401
- // adds a populated-allow scenario to catch future regressions.
1402
- if (Array.isArray(cfg.plugins.allow) && cfg.plugins.allow.length > 0) {
1403
- if (cfg.plugins.bundledDiscovery === undefined) {
1404
- cfg.plugins.bundledDiscovery = 'compat';
1405
- mutated = true;
1406
- }
1407
- }
1132
+ // patchOpenClawConfig used to set `plugins.bundledDiscovery = "compat"`
1133
+ // whenever `plugins.allow` was populated, to keep non-bundled totalreclaw
1134
+ // loadable under strict-allowlist mode. Retired as a pair with Fix #5: the
1135
+ // ONLY reason a host was in strict-allowlist mode was that TR itself grew
1136
+ // the allowlist (Fix #5). With Fix #5 gone, native install manages the
1137
+ // allowlist and this compat workaround is unnecessary. An existing
1138
+ // `bundledDiscovery` value on disk is left untouched.
1408
1139
 
1409
1140
  if (!mutated) return 'unchanged';
1410
1141
 
@@ -1485,108 +1216,12 @@ export function checkCredentialsFileMode(
1485
1216
  }
1486
1217
 
1487
1218
  // ---------------------------------------------------------------------------
1488
- // Pair-pending sentinel (3.3.13 — auto-pair-on-load)
1219
+ // Pair-pending sentinel — RETIRED Phase 3.4
1489
1220
  // ---------------------------------------------------------------------------
1221
+ // The 3.3.13 `~/.totalreclaw/.pair-pending.json` sentinel + its utilities
1222
+ // (loadPairPendingFile / writePairPendingFile / deletePairPendingFile /
1223
+ // defaultPairPendingPath / PairPendingFile) were removed in Phase 3.4.
1224
+ // Pairing is now user-initiated QR (`tr pair` CLI / the pair HTTP route)
1225
+ // per the native-integration design — the auto-pair-on-load state machine
1226
+ // that wrote + consumed this sentinel is gone.
1490
1227
 
1491
- /**
1492
- * Shape of `~/.totalreclaw/.pair-pending.json`. Written by the auto-pair
1493
- * on plugin-load flow when no credentials.json exists; consumed by the
1494
- * `before_agent_start` hook to surface the URL + PIN to the user without
1495
- * the agent ever guessing them.
1496
- *
1497
- * Phrase-safety invariant: the recovery phrase is NEVER stored here. Only
1498
- * the URL + PIN + session token + relay-provided expiry. Phrase travels
1499
- * end-to-end encrypted from browser -> relay -> gateway and is written
1500
- * directly to credentials.json by the awaitPhraseUpload completion handler.
1501
- *
1502
- * `v` is a schema version so future migrations can detect old layouts.
1503
- */
1504
- export interface PairPendingFile {
1505
- v: 1;
1506
- /** User-facing pair URL (https://...#pk=). */
1507
- url: string;
1508
- /** 6-digit PIN the user types into the browser. */
1509
- pin: string;
1510
- /** Relay-issued opaque session id. */
1511
- sid: string;
1512
- /** Session expiry from the relay (ms since epoch). */
1513
- expires_at_ms: number;
1514
- /** When the gateway opened this session (ms since epoch). */
1515
- created_at_ms: number;
1516
- /** Pair mode advertised to the relay. */
1517
- mode: 'generate' | 'import' | 'either';
1518
- }
1519
-
1520
- /**
1521
- * Load `.pair-pending.json` if it exists and parses. Returns null on
1522
- * missing-file, corrupt JSON, or unknown schema version. Never throws.
1523
- */
1524
- export function loadPairPendingFile(pendingPath: string): PairPendingFile | null {
1525
- try {
1526
- if (!fs.existsSync(pendingPath)) return null;
1527
- const raw = fs.readFileSync(pendingPath, 'utf-8');
1528
- const parsed = JSON.parse(raw) as Partial<PairPendingFile> & { v?: number };
1529
- if (parsed.v !== 1) return null;
1530
- if (typeof parsed.url !== 'string' || !parsed.url) return null;
1531
- if (typeof parsed.pin !== 'string' || !parsed.pin) return null;
1532
- if (typeof parsed.sid !== 'string' || !parsed.sid) return null;
1533
- if (typeof parsed.expires_at_ms !== 'number') return null;
1534
- if (typeof parsed.created_at_ms !== 'number') return null;
1535
- if (
1536
- parsed.mode !== 'generate'
1537
- && parsed.mode !== 'import'
1538
- && parsed.mode !== 'either'
1539
- ) return null;
1540
- return parsed as PairPendingFile;
1541
- } catch {
1542
- return null;
1543
- }
1544
- }
1545
-
1546
- /**
1547
- * Write `.pair-pending.json` atomically-ish (single `writeFileSync`). Creates
1548
- * the parent directory if missing. Uses mode `0o600` to match credentials.json
1549
- * even though the contents are NOT secret (URL + PIN are surfaced to chat
1550
- * anyway) — defensive default for the .totalreclaw/ dir.
1551
- *
1552
- * Returns `true` on success, `false` on any I/O error.
1553
- */
1554
- export function writePairPendingFile(
1555
- pendingPath: string,
1556
- payload: PairPendingFile,
1557
- ): boolean {
1558
- try {
1559
- const dir = path.dirname(pendingPath);
1560
- if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
1561
- fs.writeFileSync(pendingPath, JSON.stringify(payload), { mode: 0o600 });
1562
- return true;
1563
- } catch {
1564
- return false;
1565
- }
1566
- }
1567
-
1568
- /**
1569
- * Delete the pair-pending sentinel. Used by:
1570
- * - the pair-completion path (after credentials.json is finalized)
1571
- * - the auto-pair flow when it detects an expired pending session
1572
- *
1573
- * Returns `true` if a file was deleted, `false` otherwise (no file or error).
1574
- */
1575
- export function deletePairPendingFile(pendingPath: string): boolean {
1576
- try {
1577
- if (!fs.existsSync(pendingPath)) return false;
1578
- fs.unlinkSync(pendingPath);
1579
- return true;
1580
- } catch {
1581
- return false;
1582
- }
1583
- }
1584
-
1585
- /**
1586
- * Default `.pair-pending.json` path derived from a credentials.json path:
1587
- * the sentinel lives in the same directory under the dot-prefixed name.
1588
- * Callers MAY pass an explicit path (tests, custom layouts) instead.
1589
- */
1590
- export function defaultPairPendingPath(credentialsPath: string): string {
1591
- return path.join(path.dirname(credentialsPath), '.pair-pending.json');
1592
- }