@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.
- package/SKILL.md +33 -27
- package/api-client.ts +17 -9
- package/batch-gate.ts +42 -0
- package/billing-cache.ts +72 -23
- package/claims-helper.ts +2 -1
- package/config.ts +95 -13
- package/consolidation.ts +6 -13
- package/contradiction-sync.ts +19 -14
- package/credential-provider.ts +184 -0
- package/crypto.ts +27 -160
- package/dist/api-client.js +17 -9
- package/dist/batch-gate.js +40 -0
- package/dist/billing-cache.js +54 -24
- package/dist/claims-helper.js +2 -1
- package/dist/config.js +91 -13
- package/dist/consolidation.js +6 -15
- package/dist/contradiction-sync.js +15 -15
- package/dist/credential-provider.js +145 -0
- package/dist/crypto.js +17 -137
- package/dist/download-ux.js +11 -7
- package/dist/embedder-loader.js +266 -0
- package/dist/embedding.js +36 -3
- package/dist/entry.js +123 -0
- package/dist/fs-helpers.js +75 -379
- package/dist/import-adapters/gemini-adapter.js +29 -159
- package/dist/index.js +864 -2631
- package/dist/memory-runtime.js +459 -0
- package/dist/native-memory.js +123 -0
- package/dist/onboarding-cli.js +4 -8
- package/dist/pair-cli-relay.js +1 -8
- package/dist/pair-cli.js +1 -1
- package/dist/pair-crypto.js +16 -358
- package/dist/pair-http.js +147 -4
- package/dist/relay.js +140 -0
- package/dist/reranker.js +13 -8
- package/dist/semantic-dedup.js +5 -7
- package/dist/skill-register.js +97 -0
- package/dist/subgraph-search.js +3 -1
- package/dist/subgraph-store.js +348 -290
- package/dist/tool-gating.js +39 -26
- package/dist/tools.js +367 -0
- package/dist/tr-cli-export-helper.js +3 -3
- package/dist/tr-cli.js +65 -156
- package/dist/trajectory-poller.js +155 -9
- package/dist/vault-crypto.js +551 -0
- package/download-ux.ts +12 -6
- package/embedder-loader.ts +293 -1
- package/embedding.ts +43 -3
- package/entry.ts +132 -0
- package/fs-helpers.ts +93 -458
- package/import-adapters/gemini-adapter.ts +38 -183
- package/index.ts +912 -2917
- package/memory-runtime.ts +723 -0
- package/native-memory.ts +196 -0
- package/onboarding-cli.ts +3 -9
- package/openclaw.plugin.json +5 -17
- package/package.json +6 -3
- package/pair-cli-relay.ts +0 -9
- package/pair-cli.ts +1 -1
- package/pair-crypto.ts +41 -483
- package/pair-http.ts +194 -5
- package/postinstall.mjs +138 -0
- package/relay.ts +172 -0
- package/reranker.ts +13 -8
- package/semantic-dedup.ts +5 -6
- package/skill-register.ts +146 -0
- package/skill.json +1 -1
- package/subgraph-search.ts +3 -1
- package/subgraph-store.ts +367 -307
- package/tool-gating.ts +39 -26
- package/tools.ts +499 -0
- package/tr-cli-export-helper.ts +3 -3
- package/tr-cli.ts +69 -182
- package/trajectory-poller.ts +162 -10
- package/vault-crypto.ts +705 -0
- package/auto-pair-on-load.ts +0 -308
- package/dist/auto-pair-on-load.js +0 -197
- package/dist/pair-pending-injection.js +0 -125
- package/pair-pending-injection.ts +0 -205
package/auto-pair-on-load.ts
DELETED
|
@@ -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
|
-
}
|