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