@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
@@ -1,308 +0,0 @@
1
- /**
2
- * auto-pair-on-load — autonomously open a relay pair session when the
3
- * plugin loads without credentials, so the chat agent never has to guess
4
- * (or hallucinate) a pair URL.
5
- *
6
- * Background
7
- * ----------
8
- * Through 3.3.12 the plugin's only fallback on a fresh install was a log
9
- * line telling the user to run `openclaw totalreclaw onboard`. The chat
10
- * agent then guessed URLs / sessions from training data and shipped users
11
- * to dead links — Pop-OS QA (2026-05-10) saw an invented session ID,
12
- * 404/502 in the browser, and no recovery phrase generated.
13
- *
14
- * 3.3.13 flips the default: when register() finds no credentials, the
15
- * plugin opens its OWN relay session (the same one `tr pair --json` opens)
16
- * and writes URL + PIN + sid + expiry to
17
- * `~/.totalreclaw/.pair-pending.json`. A separate before_agent_start hook
18
- * (see `pair-pending-injection.ts`) reads that file and instructs the
19
- * agent to copy the values verbatim. There is no LLM-side string
20
- * generation in the path.
21
- *
22
- * Phrase safety
23
- * -------------
24
- * The recovery phrase is NEVER written to the sentinel. The background
25
- * WS listener (awaitPhraseUpload) writes the decrypted mnemonic directly
26
- * to credentials.json — the same code path the `totalreclaw_pair` tool
27
- * and the `tr pair --json` CLI already use. The sentinel only holds URL,
28
- * PIN, sid, and expiry — all values the user already needs to see in
29
- * chat to complete the flow.
30
- *
31
- * Idempotency / TTL
32
- * -----------------
33
- * - credentials.json present → no-op
34
- * - valid non-expired sentinel → no-op (reuse existing session)
35
- * - expired sentinel → delete + open fresh session
36
- * - no sentinel → open fresh session
37
- *
38
- * Scanner scope
39
- * -------------
40
- * This file MUST NOT import `node:fs` directly. All disk I/O is delegated
41
- * to `fs-helpers.ts`. We DO import `pair-remote-client.js`, which opens a
42
- * WebSocket — but that file already passes the scanner (no readFile*).
43
- */
44
-
45
- import { validateMnemonic } from '@scure/bip39';
46
- import { wordlist } from '@scure/bip39/wordlists/english.js';
47
-
48
- import {
49
- deletePairPendingFile,
50
- defaultPairPendingPath,
51
- loadCredentialsJson,
52
- loadPairPendingFile,
53
- writeCredentialsJson,
54
- writePairPendingFile,
55
- writeOnboardingState,
56
- type PairPendingFile,
57
- } from './fs-helpers.js';
58
- import {
59
- awaitPhraseUpload,
60
- openRemotePairSession,
61
- type RemotePairSession,
62
- } from './pair-remote-client.js';
63
- import { setRecoveryPhraseOverride } from './config.js';
64
-
65
- // ---------------------------------------------------------------------------
66
- // Types
67
- // ---------------------------------------------------------------------------
68
-
69
- /**
70
- * Minimal logger surface — matches the slice we use from
71
- * `OpenClawPluginApi['logger']` without dragging the whole API type in.
72
- */
73
- export interface AutoPairLogger {
74
- info(msg: string): void;
75
- warn(msg: string): void;
76
- error(msg: string): void;
77
- }
78
-
79
- /** Dependencies — injected so tests can stub the relay + filesystem. */
80
- export interface AutoPairDeps {
81
- /** Path to credentials.json. */
82
- credentialsPath: string;
83
- /** Path to the pair-pending sentinel. Defaults to `<credentialsDir>/.pair-pending.json`. */
84
- pendingPath?: string;
85
- /** Path to onboarding state.json — flipped to `active` when the user completes pairing. */
86
- onboardingStatePath: string;
87
- /** Relay base URL (`wss://api.totalreclaw.xyz` or staging). */
88
- relayBaseUrl: string;
89
- /** Plugin version stamped into onboarding state on success. */
90
- pluginVersion: string;
91
- /** Logger. */
92
- logger: AutoPairLogger;
93
- /**
94
- * Hard timeout for the background `awaitPhraseUpload` task (ms). The
95
- * relay-side TTL is 5 min; the gateway-side timer is the deadline the
96
- * user is told about in chat. Defaults to 300_000 (5 min) so the
97
- * sentinel-advertised expiry and the background listener stay aligned.
98
- */
99
- awaitTimeoutMs?: number;
100
- /** Pair mode to advertise to the relay. Defaults to 'generate'. */
101
- mode?: 'generate' | 'import';
102
- /**
103
- * Test injection — override `Date.now()` so expiry math is deterministic.
104
- */
105
- now?: () => number;
106
- /**
107
- * Test injection — replace the relay-session opener.
108
- */
109
- openSession?: (
110
- opts: { relayBaseUrl: string; mode: 'generate' | 'import' },
111
- ) => Promise<RemotePairSession>;
112
- /**
113
- * Test injection — replace the background await loop. Receives the
114
- * session + the same completion handler the production path uses.
115
- */
116
- awaitPhrase?: (
117
- session: RemotePairSession,
118
- onComplete: (mnemonic: string) => Promise<void>,
119
- ) => Promise<void>;
120
- }
121
-
122
- /** Result of `maybeStartAutoPair`. Tests + callers branch on `status`. */
123
- export type AutoPairResult =
124
- | { status: 'creds_exist' }
125
- | { status: 'pending_reused'; pending: PairPendingFile }
126
- | { status: 'started'; pending: PairPendingFile }
127
- | { status: 'failed'; error: string };
128
-
129
- // ---------------------------------------------------------------------------
130
- // Public entrypoint
131
- // ---------------------------------------------------------------------------
132
-
133
- /**
134
- * Check the credentials + pending-sentinel state and, if needed, open a
135
- * fresh relay pair session. Idempotent under concurrent calls in the same
136
- * gateway process — repeated calls within the TTL reuse the existing
137
- * sentinel (no second session is opened).
138
- *
139
- * Returns immediately after writing the sentinel. The background WS
140
- * listener that drains the encrypted phrase + writes credentials.json is
141
- * kicked off via `void (async () => ...)()` so register() can finish
142
- * synchronously.
143
- */
144
- export async function maybeStartAutoPair(
145
- deps: AutoPairDeps,
146
- ): Promise<AutoPairResult> {
147
- const now = deps.now ?? Date.now;
148
- const pendingPath = deps.pendingPath ?? defaultPairPendingPath(deps.credentialsPath);
149
- const logger = deps.logger;
150
-
151
- // 1. credentials.json present → setup already complete; clean up any
152
- // stale sentinel and return early.
153
- const creds = loadCredentialsJson(deps.credentialsPath);
154
- if (creds && (typeof creds.mnemonic === 'string' || typeof creds.recovery_phrase === 'string')) {
155
- if (deletePairPendingFile(pendingPath)) {
156
- logger.info('auto-pair-on-load: credentials present, cleared stale pending sentinel');
157
- }
158
- return { status: 'creds_exist' };
159
- }
160
-
161
- // 2. Valid non-expired sentinel → reuse it. The background WS listener
162
- // from the original call is presumed alive; if the gateway restarted
163
- // we cannot resurrect that listener but we leave the sentinel alone
164
- // so the existing PIN keeps working until the user navigates to the
165
- // URL. (If the listener died, the relay will time out the session;
166
- // on next plugin reload after expiry we open a fresh one.)
167
- const existing = loadPairPendingFile(pendingPath);
168
- if (existing && existing.expires_at_ms > now()) {
169
- logger.info(
170
- `auto-pair-on-load: reusing pending pair sentinel (sid=${existing.sid.slice(0, 8)}…, expires in ${Math.round((existing.expires_at_ms - now()) / 1000)}s)`,
171
- );
172
- return { status: 'pending_reused', pending: existing };
173
- }
174
-
175
- // 3. Expired sentinel → drop it before opening a new session so we never
176
- // leave two competing files on disk.
177
- if (existing) {
178
- deletePairPendingFile(pendingPath);
179
- logger.info('auto-pair-on-load: pending sentinel expired, opening fresh session');
180
- }
181
-
182
- // 4. Open a new relay session. This is the same call path as
183
- // `tr pair --json` and the `totalreclaw_pair` tool handler.
184
- const mode = deps.mode ?? 'generate';
185
- let session: RemotePairSession;
186
- try {
187
- const opener = deps.openSession ?? openRemotePairSession;
188
- session = await opener({ relayBaseUrl: deps.relayBaseUrl, mode });
189
- } catch (err) {
190
- const msg = err instanceof Error ? err.message : String(err);
191
- logger.warn(`auto-pair-on-load: failed to open relay session: ${msg}`);
192
- return { status: 'failed', error: msg };
193
- }
194
-
195
- // ISO-8601 -> ms. If the relay omits the field we fall back to 5 min.
196
- const parsedExpiresMs = Date.parse(session.expiresAt);
197
- const expiresAtMs = Number.isFinite(parsedExpiresMs)
198
- ? parsedExpiresMs
199
- : now() + 5 * 60_000;
200
-
201
- const payload: PairPendingFile = {
202
- v: 1,
203
- url: session.url,
204
- pin: session.pin,
205
- sid: session.token,
206
- expires_at_ms: expiresAtMs,
207
- created_at_ms: now(),
208
- mode,
209
- };
210
-
211
- if (!writePairPendingFile(pendingPath, payload)) {
212
- logger.warn('auto-pair-on-load: failed to write .pair-pending.json sentinel');
213
- // Best-effort: try to close the WS so we don't leak it.
214
- try {
215
- session._ws.close();
216
- } catch {
217
- /* ignore */
218
- }
219
- return { status: 'failed', error: 'pending_write_failed' };
220
- }
221
-
222
- logger.info(
223
- `auto-pair-on-load: opened pair session (sid=${session.token.slice(0, 8)}…, expires in ${Math.round((expiresAtMs - now()) / 1000)}s); sentinel written`,
224
- );
225
-
226
- // 5. Kick off the background WS listener. On success it writes
227
- // credentials.json + deletes the sentinel. `void` so register()
228
- // returns immediately.
229
- void runBackgroundAwait({
230
- session,
231
- deps,
232
- pendingPath,
233
- mode,
234
- });
235
-
236
- return { status: 'started', pending: payload };
237
- }
238
-
239
- // ---------------------------------------------------------------------------
240
- // Background listener
241
- // ---------------------------------------------------------------------------
242
-
243
- interface BackgroundOpts {
244
- session: RemotePairSession;
245
- deps: AutoPairDeps;
246
- pendingPath: string;
247
- mode: 'generate' | 'import';
248
- }
249
-
250
- async function runBackgroundAwait(opts: BackgroundOpts): Promise<void> {
251
- const { session, deps, pendingPath, mode } = opts;
252
- const timeoutMs = deps.awaitTimeoutMs ?? 5 * 60_000;
253
- const logger = deps.logger;
254
-
255
- const onComplete = async (mnemonic: string): Promise<void> => {
256
- const existingCreds = loadCredentialsJson(deps.credentialsPath) ?? {};
257
- const next = { ...existingCreds, mnemonic };
258
- if (!writeCredentialsJson(deps.credentialsPath, next)) {
259
- throw new Error('credentials_write_failed');
260
- }
261
- setRecoveryPhraseOverride(mnemonic);
262
- writeOnboardingState(deps.onboardingStatePath, {
263
- onboardingState: 'active',
264
- createdBy: mode === 'generate' ? 'generate' : 'import',
265
- credentialsCreatedAt: new Date().toISOString(),
266
- version: deps.pluginVersion,
267
- });
268
- // Sentinel is consumed — drop it so the before_agent_start hook
269
- // stops surfacing the URL on the next turn.
270
- deletePairPendingFile(pendingPath);
271
- logger.info(
272
- `auto-pair-on-load: pair completed (sid=${session.token.slice(0, 8)}…); credentials written, sentinel cleared`,
273
- );
274
- };
275
-
276
- if (deps.awaitPhrase) {
277
- try {
278
- await deps.awaitPhrase(session, onComplete);
279
- } catch (err) {
280
- const msg = err instanceof Error ? err.message : String(err);
281
- logger.warn(`auto-pair-on-load: background await failed: ${msg}`);
282
- }
283
- return;
284
- }
285
-
286
- try {
287
- await awaitPhraseUpload(session, {
288
- phraseValidator: (p: string) => validateMnemonic(p, wordlist),
289
- completePairing: async ({ mnemonic }) => {
290
- try {
291
- await onComplete(mnemonic);
292
- return { state: 'active' };
293
- } catch (err: unknown) {
294
- const msg = err instanceof Error ? err.message : String(err);
295
- logger.error(`auto-pair-on-load: completePairing failed: ${msg}`);
296
- return { state: 'error', error: msg };
297
- }
298
- },
299
- timeoutMs,
300
- });
301
- } catch (err) {
302
- const msg = err instanceof Error ? err.message : String(err);
303
- // Expected on TTL expiry or user abandon — warn not error.
304
- logger.warn(
305
- `auto-pair-on-load: background task ended (sid=${session.token.slice(0, 8)}…): ${msg}`,
306
- );
307
- }
308
- }
@@ -1,197 +0,0 @@
1
- /**
2
- * auto-pair-on-load — autonomously open a relay pair session when the
3
- * plugin loads without credentials, so the chat agent never has to guess
4
- * (or hallucinate) a pair URL.
5
- *
6
- * Background
7
- * ----------
8
- * Through 3.3.12 the plugin's only fallback on a fresh install was a log
9
- * line telling the user to run `openclaw totalreclaw onboard`. The chat
10
- * agent then guessed URLs / sessions from training data and shipped users
11
- * to dead links — Pop-OS QA (2026-05-10) saw an invented session ID,
12
- * 404/502 in the browser, and no recovery phrase generated.
13
- *
14
- * 3.3.13 flips the default: when register() finds no credentials, the
15
- * plugin opens its OWN relay session (the same one `tr pair --json` opens)
16
- * and writes URL + PIN + sid + expiry to
17
- * `~/.totalreclaw/.pair-pending.json`. A separate before_agent_start hook
18
- * (see `pair-pending-injection.ts`) reads that file and instructs the
19
- * agent to copy the values verbatim. There is no LLM-side string
20
- * generation in the path.
21
- *
22
- * Phrase safety
23
- * -------------
24
- * The recovery phrase is NEVER written to the sentinel. The background
25
- * WS listener (awaitPhraseUpload) writes the decrypted mnemonic directly
26
- * to credentials.json — the same code path the `totalreclaw_pair` tool
27
- * and the `tr pair --json` CLI already use. The sentinel only holds URL,
28
- * PIN, sid, and expiry — all values the user already needs to see in
29
- * chat to complete the flow.
30
- *
31
- * Idempotency / TTL
32
- * -----------------
33
- * - credentials.json present → no-op
34
- * - valid non-expired sentinel → no-op (reuse existing session)
35
- * - expired sentinel → delete + open fresh session
36
- * - no sentinel → open fresh session
37
- *
38
- * Scanner scope
39
- * -------------
40
- * This file MUST NOT import `node:fs` directly. All disk I/O is delegated
41
- * to `fs-helpers.ts`. We DO import `pair-remote-client.js`, which opens a
42
- * WebSocket — but that file already passes the scanner (no readFile*).
43
- */
44
- import { validateMnemonic } from '@scure/bip39';
45
- import { wordlist } from '@scure/bip39/wordlists/english.js';
46
- import { deletePairPendingFile, defaultPairPendingPath, loadCredentialsJson, loadPairPendingFile, writeCredentialsJson, writePairPendingFile, writeOnboardingState, } from './fs-helpers.js';
47
- import { awaitPhraseUpload, openRemotePairSession, } from './pair-remote-client.js';
48
- import { setRecoveryPhraseOverride } from './config.js';
49
- // ---------------------------------------------------------------------------
50
- // Public entrypoint
51
- // ---------------------------------------------------------------------------
52
- /**
53
- * Check the credentials + pending-sentinel state and, if needed, open a
54
- * fresh relay pair session. Idempotent under concurrent calls in the same
55
- * gateway process — repeated calls within the TTL reuse the existing
56
- * sentinel (no second session is opened).
57
- *
58
- * Returns immediately after writing the sentinel. The background WS
59
- * listener that drains the encrypted phrase + writes credentials.json is
60
- * kicked off via `void (async () => ...)()` so register() can finish
61
- * synchronously.
62
- */
63
- export async function maybeStartAutoPair(deps) {
64
- const now = deps.now ?? Date.now;
65
- const pendingPath = deps.pendingPath ?? defaultPairPendingPath(deps.credentialsPath);
66
- const logger = deps.logger;
67
- // 1. credentials.json present → setup already complete; clean up any
68
- // stale sentinel and return early.
69
- const creds = loadCredentialsJson(deps.credentialsPath);
70
- if (creds && (typeof creds.mnemonic === 'string' || typeof creds.recovery_phrase === 'string')) {
71
- if (deletePairPendingFile(pendingPath)) {
72
- logger.info('auto-pair-on-load: credentials present, cleared stale pending sentinel');
73
- }
74
- return { status: 'creds_exist' };
75
- }
76
- // 2. Valid non-expired sentinel → reuse it. The background WS listener
77
- // from the original call is presumed alive; if the gateway restarted
78
- // we cannot resurrect that listener but we leave the sentinel alone
79
- // so the existing PIN keeps working until the user navigates to the
80
- // URL. (If the listener died, the relay will time out the session;
81
- // on next plugin reload after expiry we open a fresh one.)
82
- const existing = loadPairPendingFile(pendingPath);
83
- if (existing && existing.expires_at_ms > now()) {
84
- logger.info(`auto-pair-on-load: reusing pending pair sentinel (sid=${existing.sid.slice(0, 8)}…, expires in ${Math.round((existing.expires_at_ms - now()) / 1000)}s)`);
85
- return { status: 'pending_reused', pending: existing };
86
- }
87
- // 3. Expired sentinel → drop it before opening a new session so we never
88
- // leave two competing files on disk.
89
- if (existing) {
90
- deletePairPendingFile(pendingPath);
91
- logger.info('auto-pair-on-load: pending sentinel expired, opening fresh session');
92
- }
93
- // 4. Open a new relay session. This is the same call path as
94
- // `tr pair --json` and the `totalreclaw_pair` tool handler.
95
- const mode = deps.mode ?? 'generate';
96
- let session;
97
- try {
98
- const opener = deps.openSession ?? openRemotePairSession;
99
- session = await opener({ relayBaseUrl: deps.relayBaseUrl, mode });
100
- }
101
- catch (err) {
102
- const msg = err instanceof Error ? err.message : String(err);
103
- logger.warn(`auto-pair-on-load: failed to open relay session: ${msg}`);
104
- return { status: 'failed', error: msg };
105
- }
106
- // ISO-8601 -> ms. If the relay omits the field we fall back to 5 min.
107
- const parsedExpiresMs = Date.parse(session.expiresAt);
108
- const expiresAtMs = Number.isFinite(parsedExpiresMs)
109
- ? parsedExpiresMs
110
- : now() + 5 * 60_000;
111
- const payload = {
112
- v: 1,
113
- url: session.url,
114
- pin: session.pin,
115
- sid: session.token,
116
- expires_at_ms: expiresAtMs,
117
- created_at_ms: now(),
118
- mode,
119
- };
120
- if (!writePairPendingFile(pendingPath, payload)) {
121
- logger.warn('auto-pair-on-load: failed to write .pair-pending.json sentinel');
122
- // Best-effort: try to close the WS so we don't leak it.
123
- try {
124
- session._ws.close();
125
- }
126
- catch {
127
- /* ignore */
128
- }
129
- return { status: 'failed', error: 'pending_write_failed' };
130
- }
131
- logger.info(`auto-pair-on-load: opened pair session (sid=${session.token.slice(0, 8)}…, expires in ${Math.round((expiresAtMs - now()) / 1000)}s); sentinel written`);
132
- // 5. Kick off the background WS listener. On success it writes
133
- // credentials.json + deletes the sentinel. `void` so register()
134
- // returns immediately.
135
- void runBackgroundAwait({
136
- session,
137
- deps,
138
- pendingPath,
139
- mode,
140
- });
141
- return { status: 'started', pending: payload };
142
- }
143
- async function runBackgroundAwait(opts) {
144
- const { session, deps, pendingPath, mode } = opts;
145
- const timeoutMs = deps.awaitTimeoutMs ?? 5 * 60_000;
146
- const logger = deps.logger;
147
- const onComplete = async (mnemonic) => {
148
- const existingCreds = loadCredentialsJson(deps.credentialsPath) ?? {};
149
- const next = { ...existingCreds, mnemonic };
150
- if (!writeCredentialsJson(deps.credentialsPath, next)) {
151
- throw new Error('credentials_write_failed');
152
- }
153
- setRecoveryPhraseOverride(mnemonic);
154
- writeOnboardingState(deps.onboardingStatePath, {
155
- onboardingState: 'active',
156
- createdBy: mode === 'generate' ? 'generate' : 'import',
157
- credentialsCreatedAt: new Date().toISOString(),
158
- version: deps.pluginVersion,
159
- });
160
- // Sentinel is consumed — drop it so the before_agent_start hook
161
- // stops surfacing the URL on the next turn.
162
- deletePairPendingFile(pendingPath);
163
- logger.info(`auto-pair-on-load: pair completed (sid=${session.token.slice(0, 8)}…); credentials written, sentinel cleared`);
164
- };
165
- if (deps.awaitPhrase) {
166
- try {
167
- await deps.awaitPhrase(session, onComplete);
168
- }
169
- catch (err) {
170
- const msg = err instanceof Error ? err.message : String(err);
171
- logger.warn(`auto-pair-on-load: background await failed: ${msg}`);
172
- }
173
- return;
174
- }
175
- try {
176
- await awaitPhraseUpload(session, {
177
- phraseValidator: (p) => validateMnemonic(p, wordlist),
178
- completePairing: async ({ mnemonic }) => {
179
- try {
180
- await onComplete(mnemonic);
181
- return { state: 'active' };
182
- }
183
- catch (err) {
184
- const msg = err instanceof Error ? err.message : String(err);
185
- logger.error(`auto-pair-on-load: completePairing failed: ${msg}`);
186
- return { state: 'error', error: msg };
187
- }
188
- },
189
- timeoutMs,
190
- });
191
- }
192
- catch (err) {
193
- const msg = err instanceof Error ? err.message : String(err);
194
- // Expected on TTL expiry or user abandon — warn not error.
195
- logger.warn(`auto-pair-on-load: background task ended (sid=${session.token.slice(0, 8)}…): ${msg}`);
196
- }
197
- }
@@ -1,125 +0,0 @@
1
- /**
2
- * pair-pending-injection — `before_agent_start` hook that surfaces the
3
- * pending pair URL + PIN to the chat agent verbatim, so the agent can NOT
4
- * hallucinate either value.
5
- *
6
- * Background
7
- * ----------
8
- * Companion to `auto-pair-on-load.ts`. When the plugin loads without
9
- * credentials, the auto-pair flow writes `~/.totalreclaw/.pair-pending.json`
10
- * with the exact URL + PIN the user must use. This hook reads that file on
11
- * every agent start and, when present, prepends a context block that tells
12
- * the agent EXACTLY what to say.
13
- *
14
- * No agent-generated string ever appears in the URL or PIN that reaches
15
- * the user.
16
- *
17
- * Phrase safety
18
- * -------------
19
- * The sentinel does NOT contain the recovery phrase. The injected context
20
- * deliberately warns the agent NOT to attempt to display, generate, or
21
- * relay any phrase. That instruction is reinforced via the staging-banner
22
- * hook + the SKILL.md phrase-safety section.
23
- *
24
- * Scanner scope
25
- * -------------
26
- * Pure-logic file — no `node:fs` imports, no `process.env` reads, no
27
- * subprocess module usage. Disk I/O is delegated to `fs-helpers.ts`.
28
- */
29
- import { defaultPairPendingPath, deletePairPendingFile, loadPairPendingFile, } from './fs-helpers.js';
30
- import { maybeStartAutoPair } from './auto-pair-on-load.js';
31
- // ---------------------------------------------------------------------------
32
- // Hook body (exposed for unit tests)
33
- // ---------------------------------------------------------------------------
34
- /**
35
- * Pure-async body of the hook. Test-callable.
36
- *
37
- * - sentinel missing -> no-op (return `undefined`)
38
- * - sentinel valid + non-expired -> return `{ prependContext: <block> }`
39
- * - sentinel expired -> delete + try re-create via maybeStartAutoPair; if
40
- * re-create returns a fresh `pending`, inject context for the NEW URL/PIN;
41
- * else return `undefined`
42
- */
43
- export async function runPairPendingInjection(deps, logger) {
44
- const now = deps.now ?? Date.now;
45
- const pendingPath = deps.pendingPath ?? defaultPairPendingPath(deps.credentialsPath);
46
- const existing = loadPairPendingFile(pendingPath);
47
- // No sentinel — nothing to inject.
48
- if (!existing)
49
- return undefined;
50
- // Valid + non-expired sentinel — inject context with verbatim values.
51
- if (existing.expires_at_ms > now()) {
52
- return { prependContext: buildPrependContext(existing) };
53
- }
54
- // Expired sentinel. Clean it up and optionally re-create.
55
- logger.info('pair-pending-injection: sentinel expired, cleaning up');
56
- deletePairPendingFile(pendingPath);
57
- const apFactory = deps.autoPairDepsFactory;
58
- if (!apFactory)
59
- return undefined;
60
- const apDeps = apFactory();
61
- if (!apDeps)
62
- return undefined;
63
- try {
64
- const runner = deps.startAutoPair ?? maybeStartAutoPair;
65
- const result = (await runner(apDeps));
66
- if (result
67
- && typeof result === 'object'
68
- && (result.status === 'started' || result.status === 'pending_reused')) {
69
- return { prependContext: buildPrependContext(result.pending) };
70
- }
71
- }
72
- catch (err) {
73
- const msg = err instanceof Error ? err.message : String(err);
74
- logger.warn(`pair-pending-injection: re-create failed: ${msg}`);
75
- }
76
- return undefined;
77
- }
78
- // ---------------------------------------------------------------------------
79
- // Wire-up
80
- // ---------------------------------------------------------------------------
81
- /**
82
- * Register the before_agent_start hook on the OpenClaw plugin API. Wraps
83
- * the hook body in try/catch so any error path returns `undefined` (i.e.
84
- * silently skip the injection — never block session start).
85
- */
86
- export function installBeforeAgentStartHook(api, deps) {
87
- api.on('before_agent_start', async (_event) => {
88
- try {
89
- return await runPairPendingInjection(deps, api.logger);
90
- }
91
- catch (err) {
92
- const msg = err instanceof Error ? err.message : String(err);
93
- api.logger.warn(`pair-pending-injection: hook failed: ${msg}`);
94
- return undefined;
95
- }
96
- },
97
- // Priority chosen lower than the staging-banner / memory-recall hook
98
- // (which uses default priority) so the pair-pending block lands BEFORE
99
- // those — the URL + PIN is the highest-priority action for a fresh user.
100
- { priority: 100 });
101
- }
102
- // ---------------------------------------------------------------------------
103
- // Context-block builder (exported for tests)
104
- // ---------------------------------------------------------------------------
105
- /**
106
- * Build the prepend block. URL + PIN are inlined VERBATIM from the
107
- * sentinel — there is no template substitution or string manipulation on
108
- * either value. The agent is told explicitly to read the file as the
109
- * source of truth.
110
- */
111
- export function buildPrependContext(pending) {
112
- return ('[TotalReclaw setup pending — read this carefully]\n\n'
113
- + 'The user has TotalReclaw installed but no recovery phrase yet. To complete setup, '
114
- + 'tell the user EXACTLY these two values (do NOT invent or modify them — read directly '
115
- + 'from ~/.totalreclaw/.pair-pending.json):\n\n'
116
- + `Open this URL in your browser: ${pending.url}\n`
117
- + `Enter this 6-digit PIN: ${pending.pin}\n\n`
118
- + 'The browser page lets the user either generate a fresh 12-word recovery phrase or '
119
- + 'import an existing one. The phrase is delivered to the gateway end-to-end encrypted; '
120
- + 'it never touches you (the agent) or any server in plaintext.\n\n'
121
- + 'After the user confirms "done", check ~/.totalreclaw/credentials.json — if present, '
122
- + 'setup is complete.\n\n'
123
- + 'CRITICAL: do NOT make up these values. The file ~/.totalreclaw/.pair-pending.json IS '
124
- + 'the source of truth. If the file is missing, run `tr pair --json` instead.\n\n');
125
- }