@totalreclaw/totalreclaw 3.3.1-rc.2 → 3.3.1-rc.21
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 +330 -0
- package/SKILL.md +50 -83
- package/api-client.ts +18 -11
- package/config.ts +117 -3
- package/crypto.ts +10 -2
- package/dist/api-client.js +226 -0
- package/dist/billing-cache.js +100 -0
- package/dist/claims-helper.js +606 -0
- package/dist/config.js +280 -0
- package/dist/consolidation.js +258 -0
- package/dist/contradiction-sync.js +1034 -0
- package/dist/crypto.js +138 -0
- package/dist/digest-sync.js +361 -0
- package/dist/download-ux.js +63 -0
- package/dist/embedding.js +86 -0
- package/dist/extractor.js +1225 -0
- package/dist/first-run.js +103 -0
- package/dist/fs-helpers.js +563 -0
- package/dist/gateway-url.js +197 -0
- package/dist/generate-mnemonic.js +13 -0
- package/dist/hot-cache-wrapper.js +101 -0
- package/dist/import-adapters/base-adapter.js +64 -0
- package/dist/import-adapters/chatgpt-adapter.js +238 -0
- package/dist/import-adapters/claude-adapter.js +114 -0
- package/dist/import-adapters/gemini-adapter.js +201 -0
- package/dist/import-adapters/index.js +26 -0
- package/dist/import-adapters/mcp-memory-adapter.js +219 -0
- package/dist/import-adapters/mem0-adapter.js +158 -0
- package/dist/import-adapters/types.js +1 -0
- package/dist/index.js +5348 -0
- package/dist/llm-client.js +686 -0
- package/dist/llm-profile-reader.js +346 -0
- package/dist/lsh.js +62 -0
- package/dist/onboarding-cli.js +750 -0
- package/dist/pair-cli.js +344 -0
- package/dist/pair-crypto.js +359 -0
- package/dist/pair-http.js +404 -0
- package/dist/pair-page.js +826 -0
- package/dist/pair-qr.js +107 -0
- package/dist/pair-remote-client.js +410 -0
- package/dist/pair-session-store.js +566 -0
- package/dist/pin.js +542 -0
- package/dist/qa-bug-report.js +301 -0
- package/dist/relay-headers.js +44 -0
- package/dist/reranker.js +442 -0
- package/dist/retype-setscope.js +348 -0
- package/dist/semantic-dedup.js +75 -0
- package/dist/subgraph-search.js +289 -0
- package/dist/subgraph-store.js +694 -0
- package/dist/tool-gating.js +58 -0
- package/download-ux.ts +91 -0
- package/embedding.ts +32 -9
- package/fs-helpers.ts +124 -0
- package/gateway-url.ts +57 -9
- package/index.ts +586 -357
- package/llm-client.ts +211 -23
- package/lsh.ts +7 -2
- package/onboarding-cli.ts +114 -1
- package/package.json +19 -5
- package/pair-cli.ts +76 -8
- package/pair-crypto.ts +34 -24
- package/pair-page.ts +28 -17
- package/pair-qr.ts +152 -0
- package/pair-remote-client.ts +540 -0
- package/qa-bug-report.ts +381 -0
- package/relay-headers.ts +50 -0
- package/reranker.ts +73 -0
- package/retype-setscope.ts +12 -0
- package/subgraph-search.ts +4 -3
- package/subgraph-store.ts +109 -16
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tool gating predicate for 3.2.0 — the `before_tool_call` hook in index.ts
|
|
3
|
+
* delegates to this module so the logic is testable without standing up a
|
|
4
|
+
* full OpenClaw plugin host.
|
|
5
|
+
*
|
|
6
|
+
* Scope: the 3.2.0 state machine has two states (`fresh`, `active`). Memory
|
|
7
|
+
* tools are blocked when state is anything other than `active`. Billing +
|
|
8
|
+
* setup-adjacent tools remain usable — users need to be able to upgrade,
|
|
9
|
+
* migrate, and start onboarding before their vault is active.
|
|
10
|
+
*
|
|
11
|
+
* This module imports ONLY types + the state resolver. No I/O beyond what
|
|
12
|
+
* `resolveOnboardingState` already does; no network; no env reads.
|
|
13
|
+
*/
|
|
14
|
+
/**
|
|
15
|
+
* Tool names gated on `state=active`. Keep in sync with the actual
|
|
16
|
+
* `registerTool` calls in `index.ts`. Anything NOT in this set is always
|
|
17
|
+
* callable (e.g. totalreclaw_upgrade, totalreclaw_migrate,
|
|
18
|
+
* totalreclaw_onboarding_start, totalreclaw_setup).
|
|
19
|
+
*/
|
|
20
|
+
export const GATED_TOOL_NAMES = Object.freeze([
|
|
21
|
+
'totalreclaw_remember',
|
|
22
|
+
'totalreclaw_recall',
|
|
23
|
+
'totalreclaw_forget',
|
|
24
|
+
'totalreclaw_export',
|
|
25
|
+
'totalreclaw_status',
|
|
26
|
+
'totalreclaw_consolidate',
|
|
27
|
+
'totalreclaw_pin',
|
|
28
|
+
'totalreclaw_unpin',
|
|
29
|
+
'totalreclaw_retype',
|
|
30
|
+
'totalreclaw_set_scope',
|
|
31
|
+
'totalreclaw_import_from',
|
|
32
|
+
'totalreclaw_import_batch',
|
|
33
|
+
]);
|
|
34
|
+
/**
|
|
35
|
+
* Decide whether a specific tool call should be blocked given the current
|
|
36
|
+
* onboarding state. Does not read any files — caller resolves state first
|
|
37
|
+
* (that lets tests stub state without touching disk).
|
|
38
|
+
*/
|
|
39
|
+
export function decideToolGate(toolName, state) {
|
|
40
|
+
if (!toolName)
|
|
41
|
+
return { block: false };
|
|
42
|
+
if (!GATED_TOOL_NAMES.includes(toolName))
|
|
43
|
+
return { block: false };
|
|
44
|
+
if (state?.onboardingState === 'active')
|
|
45
|
+
return { block: false };
|
|
46
|
+
return {
|
|
47
|
+
block: true,
|
|
48
|
+
blockReason: 'TotalReclaw onboarding required. Run `openclaw totalreclaw onboard` ' +
|
|
49
|
+
'in a terminal (or call the `totalreclaw_onboarding_start` tool for ' +
|
|
50
|
+
'details). Memory tools are gated until the user completes setup.',
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Convenience predicate — useful for tests + documentation.
|
|
55
|
+
*/
|
|
56
|
+
export function isGatedToolName(toolName) {
|
|
57
|
+
return GATED_TOOL_NAMES.includes(toolName);
|
|
58
|
+
}
|
package/download-ux.ts
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* download-ux.ts — Wrapper for heavy first-call downloads (rc.16, fixes #92).
|
|
3
|
+
*
|
|
4
|
+
* Wraps a download promise with:
|
|
5
|
+
* - per-attempt timeout (default 600s, override via TOTALRECLAW_ONNX_INSTALL_TIMEOUT in seconds)
|
|
6
|
+
* - 60s keep-alive log so slow-bandwidth users don't think it's frozen
|
|
7
|
+
* - 3-attempt exponential-backoff retry (per-attempt timeout grows 1x/2x/4x)
|
|
8
|
+
* - loud actionable error after exhaustion
|
|
9
|
+
*
|
|
10
|
+
* No third-party imports here — pure stdlib so the unit test can exercise it
|
|
11
|
+
* without pulling the heavy `@huggingface/transformers` chain.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
const DEFAULT_DOWNLOAD_TIMEOUT_MS = 600_000;
|
|
15
|
+
const KEEPALIVE_INTERVAL_MS = 60_000;
|
|
16
|
+
const MAX_DOWNLOAD_ATTEMPTS = 3;
|
|
17
|
+
|
|
18
|
+
export function getDownloadTimeoutMs(): number {
|
|
19
|
+
const raw = process.env.TOTALRECLAW_ONNX_INSTALL_TIMEOUT;
|
|
20
|
+
if (!raw) return DEFAULT_DOWNLOAD_TIMEOUT_MS;
|
|
21
|
+
const parsed = Number(raw);
|
|
22
|
+
if (!Number.isFinite(parsed) || parsed <= 0) return DEFAULT_DOWNLOAD_TIMEOUT_MS;
|
|
23
|
+
// Spec accepts seconds; convert to ms.
|
|
24
|
+
return Math.floor(parsed * 1000);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface DownloadWithUXOpts {
|
|
28
|
+
/** Override the per-attempt base timeout in ms (env var takes precedence by default). */
|
|
29
|
+
timeoutMs?: number;
|
|
30
|
+
/** Override the keep-alive cadence in ms. */
|
|
31
|
+
keepaliveMs?: number;
|
|
32
|
+
/** Override the max attempts. */
|
|
33
|
+
maxAttempts?: number;
|
|
34
|
+
/** Logger override (defaults to console.error). */
|
|
35
|
+
log?: (msg: string) => void;
|
|
36
|
+
/** Sleep override for tests; defaults to setTimeout. */
|
|
37
|
+
sleep?: (ms: number) => Promise<void>;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export async function downloadWithUX<T>(
|
|
41
|
+
label: string,
|
|
42
|
+
download: () => Promise<T>,
|
|
43
|
+
opts?: DownloadWithUXOpts,
|
|
44
|
+
): Promise<T> {
|
|
45
|
+
const baseTimeoutMs = opts?.timeoutMs ?? getDownloadTimeoutMs();
|
|
46
|
+
const keepaliveMs = opts?.keepaliveMs ?? KEEPALIVE_INTERVAL_MS;
|
|
47
|
+
const maxAttempts = opts?.maxAttempts ?? MAX_DOWNLOAD_ATTEMPTS;
|
|
48
|
+
const log = opts?.log ?? ((msg: string) => console.error(msg));
|
|
49
|
+
const sleep = opts?.sleep ?? ((ms: number) => new Promise(r => setTimeout(r, ms)));
|
|
50
|
+
|
|
51
|
+
let lastErr: unknown = null;
|
|
52
|
+
|
|
53
|
+
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
54
|
+
const attemptTimeoutMs = baseTimeoutMs * Math.pow(2, attempt - 1);
|
|
55
|
+
const startedAt = Date.now();
|
|
56
|
+
const keepaliveTimer = setInterval(() => {
|
|
57
|
+
const elapsedSec = Math.floor((Date.now() - startedAt) / 1000);
|
|
58
|
+
log(`[TotalReclaw] ${label}: still downloading… (${elapsedSec}s elapsed, attempt ${attempt}/${maxAttempts})`);
|
|
59
|
+
}, keepaliveMs);
|
|
60
|
+
|
|
61
|
+
try {
|
|
62
|
+
const result = await Promise.race([
|
|
63
|
+
download(),
|
|
64
|
+
new Promise<never>((_, reject) =>
|
|
65
|
+
setTimeout(
|
|
66
|
+
() => reject(new Error(`Download timeout after ${Math.floor(attemptTimeoutMs / 1000)}s (attempt ${attempt}/${maxAttempts})`)),
|
|
67
|
+
attemptTimeoutMs,
|
|
68
|
+
),
|
|
69
|
+
),
|
|
70
|
+
]);
|
|
71
|
+
clearInterval(keepaliveTimer);
|
|
72
|
+
return result;
|
|
73
|
+
} catch (err) {
|
|
74
|
+
clearInterval(keepaliveTimer);
|
|
75
|
+
lastErr = err;
|
|
76
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
77
|
+
if (attempt < maxAttempts) {
|
|
78
|
+
const backoffMs = Math.min(5_000 * Math.pow(2, attempt - 1), 30_000);
|
|
79
|
+
log(`[TotalReclaw] ${label}: attempt ${attempt} failed (${msg}). Retrying in ${Math.floor(backoffMs / 1000)}s…`);
|
|
80
|
+
await sleep(backoffMs);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const finalMsg = lastErr instanceof Error ? lastErr.message : String(lastErr);
|
|
86
|
+
throw new Error(
|
|
87
|
+
`[TotalReclaw] Embedding model download failed after ${maxAttempts} attempts (last error: ${finalMsg}). ` +
|
|
88
|
+
`Check your network connection and retry: \`openclaw plugins install totalreclaw\`. ` +
|
|
89
|
+
`On slow connections, set TOTALRECLAW_ONNX_INSTALL_TIMEOUT=1200 (in seconds) to extend the per-attempt timeout.`,
|
|
90
|
+
);
|
|
91
|
+
}
|
package/embedding.ts
CHANGED
|
@@ -9,10 +9,17 @@
|
|
|
9
9
|
* `TOTALRECLAW_EMBEDDING_MODEL` user-facing env var was removed in v1.
|
|
10
10
|
*
|
|
11
11
|
* Dependencies: @huggingface/transformers
|
|
12
|
+
*
|
|
13
|
+
* Download UX (rc.16, fixes #92):
|
|
14
|
+
* First-call download is wrapped via `downloadWithUX` from `download-ux.ts`
|
|
15
|
+
* — configurable timeout (`TOTALRECLAW_ONNX_INSTALL_TIMEOUT`, default 600s),
|
|
16
|
+
* 60s keep-alive, 3-attempt exponential-backoff retry, loud actionable
|
|
17
|
+
* failure. Slow-bandwidth hosts no longer see a silent freeze.
|
|
12
18
|
*/
|
|
13
19
|
|
|
14
20
|
// @ts-ignore - @huggingface/transformers types may not be perfect
|
|
15
21
|
import { AutoTokenizer, AutoModel, pipeline, type FeatureExtractionPipeline } from '@huggingface/transformers';
|
|
22
|
+
import { downloadWithUX, getDownloadTimeoutMs } from './download-ux.js';
|
|
16
23
|
|
|
17
24
|
interface ModelConfig {
|
|
18
25
|
id: string;
|
|
@@ -54,20 +61,36 @@ export async function generateEmbedding(
|
|
|
54
61
|
): Promise<number[]> {
|
|
55
62
|
if (!activeModel) {
|
|
56
63
|
activeModel = getModelConfig();
|
|
57
|
-
|
|
58
|
-
console.error(
|
|
64
|
+
const timeoutSec = Math.floor(getDownloadTimeoutMs() / 1000);
|
|
65
|
+
console.error(
|
|
66
|
+
`[TotalReclaw] Downloading embedding model (${activeModel.size}) — this may take a few minutes on slower connections. Please wait.`,
|
|
67
|
+
);
|
|
68
|
+
console.error(
|
|
69
|
+
`[TotalReclaw] One-time setup. Per-attempt timeout: ${timeoutSec}s (configurable via TOTALRECLAW_ONNX_INSTALL_TIMEOUT). Cached after first download.`,
|
|
70
|
+
);
|
|
59
71
|
|
|
60
72
|
if (activeModel.pooling === 'sentence_embedding') {
|
|
61
73
|
// Harrier: use AutoModel (pipeline doesn't support sentence_embedding output)
|
|
62
|
-
autoTokenizer = await
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
74
|
+
autoTokenizer = await downloadWithUX(
|
|
75
|
+
'tokenizer',
|
|
76
|
+
() => AutoTokenizer.from_pretrained(activeModel!.id),
|
|
77
|
+
);
|
|
78
|
+
autoModel = await downloadWithUX(
|
|
79
|
+
'embedding model',
|
|
80
|
+
() =>
|
|
81
|
+
AutoModel.from_pretrained(activeModel!.id, {
|
|
82
|
+
dtype: activeModel!.dtype as any,
|
|
83
|
+
}),
|
|
84
|
+
);
|
|
66
85
|
} else {
|
|
67
86
|
// e5-small / Qwen: use pipeline
|
|
68
|
-
pipelineExtractor = await
|
|
69
|
-
|
|
70
|
-
|
|
87
|
+
pipelineExtractor = await downloadWithUX(
|
|
88
|
+
'embedding pipeline',
|
|
89
|
+
() =>
|
|
90
|
+
pipeline('feature-extraction', activeModel!.id, {
|
|
91
|
+
dtype: activeModel!.dtype as any,
|
|
92
|
+
}),
|
|
93
|
+
);
|
|
71
94
|
}
|
|
72
95
|
console.error('[TotalReclaw] Embedding model ready. Future startups will be instant.');
|
|
73
96
|
}
|
package/fs-helpers.ts
CHANGED
|
@@ -56,6 +56,15 @@ export interface CredentialsFile {
|
|
|
56
56
|
mnemonic?: string;
|
|
57
57
|
/** Alias for `mnemonic`, accepted on read only. */
|
|
58
58
|
recovery_phrase?: string;
|
|
59
|
+
/**
|
|
60
|
+
* Smart Account (scope) address derived from the mnemonic. Persisted at
|
|
61
|
+
* pair-finish so users + tools (`totalreclaw_status`) can read it before
|
|
62
|
+
* any on-chain write. Internal#130 — lazy SA derivation previously left
|
|
63
|
+
* the user blind to their scope address until first-write.
|
|
64
|
+
*
|
|
65
|
+
* Format: lowercase 0x-prefixed 40-hex-char address. Public, non-secret.
|
|
66
|
+
*/
|
|
67
|
+
scope_address?: string;
|
|
59
68
|
firstRunAnnouncementShown?: boolean;
|
|
60
69
|
[extra: string]: unknown;
|
|
61
70
|
}
|
|
@@ -107,6 +116,38 @@ export function ensureMemoryHeaderFile(
|
|
|
107
116
|
}
|
|
108
117
|
}
|
|
109
118
|
|
|
119
|
+
// ---------------------------------------------------------------------------
|
|
120
|
+
// Plugin version — 3.3.1-rc.3 helper for RC gating
|
|
121
|
+
// ---------------------------------------------------------------------------
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Read the plugin's own version string from `package.json`.
|
|
125
|
+
*
|
|
126
|
+
* Behaviour:
|
|
127
|
+
* - Resolves `package.json` next to the caller-provided directory
|
|
128
|
+
* (typically `path.dirname(fileURLToPath(import.meta.url))` from the
|
|
129
|
+
* caller).
|
|
130
|
+
* - Returns the `version` field, or `null` on any I/O / parse error.
|
|
131
|
+
*
|
|
132
|
+
* Used by the RC-gated `totalreclaw_report_qa_bug` tool registration in
|
|
133
|
+
* `index.ts`: if the version contains `-rc.`, register the tool; if not,
|
|
134
|
+
* skip it entirely so stable users never see it.
|
|
135
|
+
*
|
|
136
|
+
* Scanner-safe: pure filesystem. No outbound-request word markers in this
|
|
137
|
+
* helper — see the file-header guardrail.
|
|
138
|
+
*/
|
|
139
|
+
export function readPluginVersion(packageJsonDir: string): string | null {
|
|
140
|
+
try {
|
|
141
|
+
const pkgPath = path.join(packageJsonDir, 'package.json');
|
|
142
|
+
if (!fs.existsSync(pkgPath)) return null;
|
|
143
|
+
const raw = fs.readFileSync(pkgPath, 'utf-8');
|
|
144
|
+
const parsed = JSON.parse(raw) as { version?: string };
|
|
145
|
+
return typeof parsed.version === 'string' ? parsed.version : null;
|
|
146
|
+
} catch {
|
|
147
|
+
return null;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
110
151
|
// ---------------------------------------------------------------------------
|
|
111
152
|
// credentials.json load / write / delete
|
|
112
153
|
// ---------------------------------------------------------------------------
|
|
@@ -220,6 +261,89 @@ export function deleteFileIfExists(filePath: string): void {
|
|
|
220
261
|
}
|
|
221
262
|
}
|
|
222
263
|
|
|
264
|
+
// ---------------------------------------------------------------------------
|
|
265
|
+
// Install-staging cleanup (issue #126 — rc.20 finding F3)
|
|
266
|
+
// ---------------------------------------------------------------------------
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* Clean up `.openclaw-install-stage-*` sibling directories left behind by
|
|
270
|
+
* an interrupted `openclaw plugins install` run.
|
|
271
|
+
*
|
|
272
|
+
* Background
|
|
273
|
+
* ----------
|
|
274
|
+
* `openclaw plugins install @totalreclaw/totalreclaw` extracts the npm
|
|
275
|
+
* tarball into a staging directory named
|
|
276
|
+
* `<extensionsDir>/.openclaw-install-stage-XXXXXX/` and then renames it
|
|
277
|
+
* to `<extensionsDir>/totalreclaw/` on success. If the install is
|
|
278
|
+
* interrupted partway through (e.g. an auto-gateway-restart triggered by
|
|
279
|
+
* the same install kills the process — see rc.20 QA finding F3), the
|
|
280
|
+
* staging dir survives. On the next gateway start, OpenClaw's plugin
|
|
281
|
+
* loader auto-discovers BOTH directories — the real `totalreclaw/` and
|
|
282
|
+
* the orphaned `.openclaw-install-stage-XXXXXX/` — and registers two
|
|
283
|
+
* copies of the plugin. Hooks fire twice, the user sees a duplicate
|
|
284
|
+
* `totalreclaw` row in `openclaw plugins list`, and the gateway log
|
|
285
|
+
* spams a duplicate-plugin-id warning every cycle.
|
|
286
|
+
*
|
|
287
|
+
* Fix scope: best-effort cleanup driven by the plugin itself at register
|
|
288
|
+
* time. We resolve the extensions dir as the parent of the loaded
|
|
289
|
+
* plugin's own directory, scan for `.openclaw-install-stage-*` siblings,
|
|
290
|
+
* and recursively remove each one. If anything fails (permission,
|
|
291
|
+
* race with a concurrent install), we swallow the error — the existing
|
|
292
|
+
* loader-warning behavior is no worse than before.
|
|
293
|
+
*
|
|
294
|
+
* Returns the list of staging-dir paths that were successfully removed.
|
|
295
|
+
* Callers may log this for ops visibility. Empty list on a clean install.
|
|
296
|
+
*
|
|
297
|
+
* Parameters
|
|
298
|
+
* ----------
|
|
299
|
+
* @param pluginDir Absolute path to the loaded plugin's directory
|
|
300
|
+
* (typically `<extensionsDir>/totalreclaw/dist`). The
|
|
301
|
+
* helper walks up to the parent that holds sibling
|
|
302
|
+
* plugin directories (the `extensions/` root).
|
|
303
|
+
* @param _now Optional clock injector for testing — defaults to
|
|
304
|
+
* Date.now().
|
|
305
|
+
*/
|
|
306
|
+
export function cleanupInstallStagingDirs(
|
|
307
|
+
pluginDir: string,
|
|
308
|
+
_now: () => number = Date.now,
|
|
309
|
+
): string[] {
|
|
310
|
+
const removed: string[] = [];
|
|
311
|
+
try {
|
|
312
|
+
// pluginDir is `<extensionsDir>/totalreclaw/dist` after build, so the
|
|
313
|
+
// siblings live two levels up. Resolve both candidates so the helper
|
|
314
|
+
// works regardless of whether the caller passes the package root or
|
|
315
|
+
// its `dist/` subdir.
|
|
316
|
+
const candidates = [
|
|
317
|
+
path.resolve(pluginDir, '..'), // <extensionsDir>/totalreclaw → siblings dir if pluginDir is `dist`
|
|
318
|
+
path.resolve(pluginDir, '..', '..'), // <extensionsDir>/ → siblings dir if pluginDir is package root
|
|
319
|
+
];
|
|
320
|
+
|
|
321
|
+
for (const extensionsDir of candidates) {
|
|
322
|
+
let entries: string[];
|
|
323
|
+
try {
|
|
324
|
+
entries = fs.readdirSync(extensionsDir);
|
|
325
|
+
} catch {
|
|
326
|
+
continue;
|
|
327
|
+
}
|
|
328
|
+
for (const name of entries) {
|
|
329
|
+
if (!name.startsWith('.openclaw-install-stage-')) continue;
|
|
330
|
+
const target = path.join(extensionsDir, name);
|
|
331
|
+
try {
|
|
332
|
+
const st = fs.lstatSync(target);
|
|
333
|
+
if (!st.isDirectory()) continue;
|
|
334
|
+
fs.rmSync(target, { recursive: true, force: true });
|
|
335
|
+
removed.push(target);
|
|
336
|
+
} catch {
|
|
337
|
+
// Best-effort — skip unreadable / racy entries.
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
} catch {
|
|
342
|
+
// Best-effort — never crash plugin init on cleanup failure.
|
|
343
|
+
}
|
|
344
|
+
return removed;
|
|
345
|
+
}
|
|
346
|
+
|
|
223
347
|
// ---------------------------------------------------------------------------
|
|
224
348
|
// Auto-bootstrap of credentials.json (3.1.0 first-run UX)
|
|
225
349
|
// ---------------------------------------------------------------------------
|
package/gateway-url.ts
CHANGED
|
@@ -126,27 +126,66 @@ function shouldSkipIface(name: string): boolean {
|
|
|
126
126
|
return SKIP_IFACE_PREFIXES.some((p) => lower.startsWith(p));
|
|
127
127
|
}
|
|
128
128
|
|
|
129
|
+
/**
|
|
130
|
+
* Docker container internal IP detection — issue #110 fix 4.
|
|
131
|
+
*
|
|
132
|
+
* From INSIDE a Docker container, `eth0` carries the container's bridge IP
|
|
133
|
+
* (e.g. `172.18.0.2`). That IP is reachable from other containers on the
|
|
134
|
+
* SAME Docker network but NOT from the host browser, the user's phone, or
|
|
135
|
+
* any external device. Surfacing it as the pairing URL produces a hard-
|
|
136
|
+
* dead user experience: "scan QR" yields connection-refused.
|
|
137
|
+
*
|
|
138
|
+
* Docker default-bridge ranges:
|
|
139
|
+
* - 172.17.0.0/16 — `bridge` (default)
|
|
140
|
+
* - 172.18.0.0/16 .. 172.31.0.0/16 — user-defined networks
|
|
141
|
+
*
|
|
142
|
+
* We use the conservative test: 172.16.0.0/12 (the full RFC-1918 172.x
|
|
143
|
+
* range, which is what Docker draws from). If the host is clearly Docker
|
|
144
|
+
* (`/.dockerenv`), we treat 172.16-31.x.x AS Docker-internal and skip it.
|
|
145
|
+
*
|
|
146
|
+
* Outside Docker, 172.16.x.x can be a legitimate corporate LAN, so we
|
|
147
|
+
* only apply the rule when we have positive Docker evidence.
|
|
148
|
+
*/
|
|
149
|
+
export function isDockerInternalIp(addr: string): boolean {
|
|
150
|
+
if (!/^\d{1,3}(?:\.\d{1,3}){3}$/.test(addr)) return false;
|
|
151
|
+
const parts = addr.split('.').map((p) => Number.parseInt(p, 10));
|
|
152
|
+
if (parts[0] !== 172) return false;
|
|
153
|
+
return parts[1] >= 16 && parts[1] <= 31;
|
|
154
|
+
}
|
|
155
|
+
|
|
129
156
|
/**
|
|
130
157
|
* Pick the first non-loopback, non-virtual IPv4 address. Returns null if
|
|
131
158
|
* none found (headless VPS with only lo + tailscale, for example).
|
|
159
|
+
*
|
|
160
|
+
* issue #110 fix 4: when the host is detected as Docker (caller passes
|
|
161
|
+
* `isDocker: true`), skip Docker-bridge IPs in the 172.16/12 range — they
|
|
162
|
+
* are container-internal and useless for any external browser. Returning
|
|
163
|
+
* null from this function in that scenario lets `buildPairingUrl` fall
|
|
164
|
+
* through to the localhost-with-relay-fallback warning rather than handing
|
|
165
|
+
* the user a dead URL.
|
|
132
166
|
*/
|
|
133
167
|
export function detectLanHost(options?: {
|
|
134
168
|
/** Override os.networkInterfaces for tests. */
|
|
135
169
|
networkInterfaces?: () => NodeJS.Dict<os.NetworkInterfaceInfo[]>;
|
|
170
|
+
/** True when the host is Docker — skips 172.16/12 bridge IPs. */
|
|
171
|
+
isDocker?: boolean;
|
|
136
172
|
}): DetectedGatewayHost | null {
|
|
137
173
|
const nif = (options?.networkInterfaces ?? os.networkInterfaces)();
|
|
138
174
|
for (const [name, addrs] of Object.entries(nif)) {
|
|
139
175
|
if (shouldSkipIface(name)) continue;
|
|
140
176
|
if (!addrs) continue;
|
|
141
177
|
for (const a of addrs) {
|
|
142
|
-
if (a.family
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
178
|
+
if (a.family !== 'IPv4' || a.internal) continue;
|
|
179
|
+
// issue #110 fix 4 — Docker container internal IP is unreachable
|
|
180
|
+
// from any external browser. Skip it so the caller falls back to
|
|
181
|
+
// the relay-brokered URL.
|
|
182
|
+
if (options?.isDocker && isDockerInternalIp(a.address)) continue;
|
|
183
|
+
return {
|
|
184
|
+
kind: 'lan',
|
|
185
|
+
host: a.address,
|
|
186
|
+
tls: false,
|
|
187
|
+
note: `LAN IPv4 on interface ${name} — only reachable from the same network.`,
|
|
188
|
+
};
|
|
150
189
|
}
|
|
151
190
|
}
|
|
152
191
|
return null;
|
|
@@ -162,13 +201,22 @@ export function detectLanHost(options?: {
|
|
|
162
201
|
*
|
|
163
202
|
* Sync: no I/O, no subprocess, no network. Safe in sync callers like
|
|
164
203
|
* `buildPairingUrl` in index.ts.
|
|
204
|
+
*
|
|
205
|
+
* issue #110 fix 4: the `isDocker` option, when true, skips the 172.16/12
|
|
206
|
+
* Docker-bridge range during LAN detection. The caller (index.ts) passes
|
|
207
|
+
* `isRunningInDocker()` so we don't surface a container-internal IP that
|
|
208
|
+
* no external browser can reach.
|
|
165
209
|
*/
|
|
166
210
|
export function detectGatewayHost(options?: {
|
|
167
211
|
networkInterfaces?: () => NodeJS.Dict<os.NetworkInterfaceInfo[]>;
|
|
212
|
+
isDocker?: boolean;
|
|
168
213
|
}): DetectedGatewayHost | null {
|
|
169
214
|
const ts = detectTailscaleHost({ networkInterfaces: options?.networkInterfaces });
|
|
170
215
|
if (ts) return ts;
|
|
171
|
-
const lan = detectLanHost({
|
|
216
|
+
const lan = detectLanHost({
|
|
217
|
+
networkInterfaces: options?.networkInterfaces,
|
|
218
|
+
isDocker: options?.isDocker,
|
|
219
|
+
});
|
|
172
220
|
if (lan) return lan;
|
|
173
221
|
return null;
|
|
174
222
|
}
|