@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.
Files changed (70) hide show
  1. package/CHANGELOG.md +330 -0
  2. package/SKILL.md +50 -83
  3. package/api-client.ts +18 -11
  4. package/config.ts +117 -3
  5. package/crypto.ts +10 -2
  6. package/dist/api-client.js +226 -0
  7. package/dist/billing-cache.js +100 -0
  8. package/dist/claims-helper.js +606 -0
  9. package/dist/config.js +280 -0
  10. package/dist/consolidation.js +258 -0
  11. package/dist/contradiction-sync.js +1034 -0
  12. package/dist/crypto.js +138 -0
  13. package/dist/digest-sync.js +361 -0
  14. package/dist/download-ux.js +63 -0
  15. package/dist/embedding.js +86 -0
  16. package/dist/extractor.js +1225 -0
  17. package/dist/first-run.js +103 -0
  18. package/dist/fs-helpers.js +563 -0
  19. package/dist/gateway-url.js +197 -0
  20. package/dist/generate-mnemonic.js +13 -0
  21. package/dist/hot-cache-wrapper.js +101 -0
  22. package/dist/import-adapters/base-adapter.js +64 -0
  23. package/dist/import-adapters/chatgpt-adapter.js +238 -0
  24. package/dist/import-adapters/claude-adapter.js +114 -0
  25. package/dist/import-adapters/gemini-adapter.js +201 -0
  26. package/dist/import-adapters/index.js +26 -0
  27. package/dist/import-adapters/mcp-memory-adapter.js +219 -0
  28. package/dist/import-adapters/mem0-adapter.js +158 -0
  29. package/dist/import-adapters/types.js +1 -0
  30. package/dist/index.js +5348 -0
  31. package/dist/llm-client.js +686 -0
  32. package/dist/llm-profile-reader.js +346 -0
  33. package/dist/lsh.js +62 -0
  34. package/dist/onboarding-cli.js +750 -0
  35. package/dist/pair-cli.js +344 -0
  36. package/dist/pair-crypto.js +359 -0
  37. package/dist/pair-http.js +404 -0
  38. package/dist/pair-page.js +826 -0
  39. package/dist/pair-qr.js +107 -0
  40. package/dist/pair-remote-client.js +410 -0
  41. package/dist/pair-session-store.js +566 -0
  42. package/dist/pin.js +542 -0
  43. package/dist/qa-bug-report.js +301 -0
  44. package/dist/relay-headers.js +44 -0
  45. package/dist/reranker.js +442 -0
  46. package/dist/retype-setscope.js +348 -0
  47. package/dist/semantic-dedup.js +75 -0
  48. package/dist/subgraph-search.js +289 -0
  49. package/dist/subgraph-store.js +694 -0
  50. package/dist/tool-gating.js +58 -0
  51. package/download-ux.ts +91 -0
  52. package/embedding.ts +32 -9
  53. package/fs-helpers.ts +124 -0
  54. package/gateway-url.ts +57 -9
  55. package/index.ts +586 -357
  56. package/llm-client.ts +211 -23
  57. package/lsh.ts +7 -2
  58. package/onboarding-cli.ts +114 -1
  59. package/package.json +19 -5
  60. package/pair-cli.ts +76 -8
  61. package/pair-crypto.ts +34 -24
  62. package/pair-page.ts +28 -17
  63. package/pair-qr.ts +152 -0
  64. package/pair-remote-client.ts +540 -0
  65. package/qa-bug-report.ts +381 -0
  66. package/relay-headers.ts +50 -0
  67. package/reranker.ts +73 -0
  68. package/retype-setscope.ts +12 -0
  69. package/subgraph-search.ts +4 -3
  70. 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
- console.error(`[TotalReclaw] Downloading embedding model (${activeModel.size}, one-time setup)...`);
58
- console.error('[TotalReclaw] This enables semantic search across your encrypted memories.');
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 AutoTokenizer.from_pretrained(activeModel.id);
63
- autoModel = await AutoModel.from_pretrained(activeModel.id, {
64
- dtype: activeModel.dtype as any,
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 pipeline('feature-extraction', activeModel.id, {
69
- dtype: activeModel.dtype as any,
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 === 'IPv4' && !a.internal) {
143
- return {
144
- kind: 'lan',
145
- host: a.address,
146
- tls: false,
147
- note: `LAN IPv4 on interface ${name} — only reachable from the same network.`,
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({ networkInterfaces: options?.networkInterfaces });
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
  }