@totalreclaw/totalreclaw 3.3.1-rc.9 → 3.3.1
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 +249 -1
- package/SKILL.md +29 -23
- package/api-client.ts +18 -11
- package/claims-helper.ts +47 -1
- package/config.ts +108 -4
- package/confirm-indexed.ts +191 -0
- package/crypto.ts +10 -2
- package/dist/api-client.js +226 -0
- package/dist/billing-cache.js +100 -0
- package/dist/claims-helper.js +624 -0
- package/dist/config.js +297 -0
- package/dist/confirm-indexed.js +127 -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/embedder-cache.js +185 -0
- package/dist/embedder-loader.js +121 -0
- package/dist/embedder-network.js +301 -0
- package/dist/embedding.js +141 -0
- package/dist/extractor.js +1225 -0
- package/dist/first-run.js +103 -0
- package/dist/fs-helpers.js +725 -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 +5388 -0
- package/dist/llm-client.js +687 -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 +556 -0
- package/dist/qa-bug-report.js +301 -0
- package/dist/relay-headers.js +44 -0
- package/dist/reranker.js +409 -0
- package/dist/retype-setscope.js +368 -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/embedder-cache.ts +230 -0
- package/embedder-loader.ts +189 -0
- package/embedder-network.ts +350 -0
- package/embedding.ts +118 -27
- package/fs-helpers.ts +277 -0
- package/gateway-url.ts +57 -9
- package/index.ts +469 -250
- package/llm-client.ts +4 -3
- package/lsh.ts +7 -2
- package/onboarding-cli.ts +114 -1
- package/package.json +24 -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/pin.ts +31 -0
- package/qa-bug-report.ts +84 -2
- package/relay-headers.ts +50 -0
- package/reranker.ts +40 -0
- package/retype-setscope.ts +69 -8
- package/skill.json +1 -1
- package/subgraph-search.ts +4 -3
- package/subgraph-store.ts +15 -10
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
|
}
|
|
@@ -252,6 +261,274 @@ export function deleteFileIfExists(filePath: string): void {
|
|
|
252
261
|
}
|
|
253
262
|
}
|
|
254
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
|
+
|
|
347
|
+
// ---------------------------------------------------------------------------
|
|
348
|
+
// Partial-install detection (rc.22 finding #5)
|
|
349
|
+
// ---------------------------------------------------------------------------
|
|
350
|
+
|
|
351
|
+
/**
|
|
352
|
+
* Marker filename written into the plugin directory at register-time. Its
|
|
353
|
+
* presence means a prior install was interrupted before the plugin successfully
|
|
354
|
+
* loaded — a confirmed-broken half-state that the next `openclaw plugins
|
|
355
|
+
* install` retry can detect and clean.
|
|
356
|
+
*
|
|
357
|
+
* Conceptually the marker is dropped BEFORE npm install completes (the
|
|
358
|
+
* complementary npm script removes it on success) and additionally
|
|
359
|
+
* re-asserted at register-time as a second-line check. If you see this file
|
|
360
|
+
* in `<extensionsDir>/totalreclaw/`, the install never reached register()
|
|
361
|
+
* AND the marker drop wasn't undone.
|
|
362
|
+
*
|
|
363
|
+
* Constants are exported so the npm preinstall/cleanup scripts in
|
|
364
|
+
* `package.json` use the same name as the runtime detector.
|
|
365
|
+
*/
|
|
366
|
+
export const PARTIAL_INSTALL_MARKER = '.tr-partial-install';
|
|
367
|
+
|
|
368
|
+
/** Package name we own — used to confirm a directory is OUR plugin, not a stray. */
|
|
369
|
+
export const PLUGIN_PACKAGE_NAME = '@totalreclaw/totalreclaw';
|
|
370
|
+
|
|
371
|
+
/**
|
|
372
|
+
* Outcome of `detectPartialInstall`.
|
|
373
|
+
* - `'clean'` — the dir is a fully-installed plugin (package.json claims our
|
|
374
|
+
* name AND `dist/index.js` exists AND no marker present).
|
|
375
|
+
* - `'partial'` — the dir is OUR plugin but in a corrupt half-state. Caller
|
|
376
|
+
* should wipe + retry. Returned reasons include:
|
|
377
|
+
* * marker file present (preinstall fired, postinstall did not)
|
|
378
|
+
* * dist/index.js missing (build never finished)
|
|
379
|
+
* - `'foreign'` — package.json missing or claims a different name. Helper
|
|
380
|
+
* refuses to act so we never delete an unrelated plugin.
|
|
381
|
+
* - `'absent'` — dir does not exist at all.
|
|
382
|
+
*/
|
|
383
|
+
export type PartialInstallStatus = 'clean' | 'partial' | 'foreign' | 'absent';
|
|
384
|
+
|
|
385
|
+
export interface PartialInstallResult {
|
|
386
|
+
status: PartialInstallStatus;
|
|
387
|
+
/** Why the caller decided this is partial — surfaces in error messages. */
|
|
388
|
+
reasons: string[];
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/**
|
|
392
|
+
* Inspect a plugin install directory to decide whether it is fully installed,
|
|
393
|
+
* a corrupted half-state from an interrupted install, or someone else's
|
|
394
|
+
* plugin. Pure filesystem inspection; never deletes anything.
|
|
395
|
+
*
|
|
396
|
+
* Background — rc.22 finding #5
|
|
397
|
+
* ------------------------------
|
|
398
|
+
* After a partial `openclaw plugins install @totalreclaw/totalreclaw` (e.g.
|
|
399
|
+
* the auto-gateway-restart kills npm mid-build), `extensions/totalreclaw/`
|
|
400
|
+
* survives with a populated package.json but a missing or empty `dist/`. The
|
|
401
|
+
* agent's recovery retry then fires another install; OpenClaw's plugin
|
|
402
|
+
* loader scans `extensions/` and tries to register the half-state as a "hook
|
|
403
|
+
* pack", failing with the cryptic "package.json missing openclaw.hooks". The
|
|
404
|
+
* fix: detect the partial state up-front so the retry can wipe + reinstall
|
|
405
|
+
* instead of cargo-culting a confused error.
|
|
406
|
+
*
|
|
407
|
+
* Decision rules
|
|
408
|
+
* --------------
|
|
409
|
+
* 1. `pluginRootDir` does not exist → `'absent'`.
|
|
410
|
+
* 2. package.json missing or unparsable → `'foreign'` (don't touch).
|
|
411
|
+
* 3. package.json `name !== '@totalreclaw/totalreclaw'` → `'foreign'`.
|
|
412
|
+
* 4. `<root>/.tr-partial-install` exists → `'partial'` (the canonical signal).
|
|
413
|
+
* 5. `<root>/dist/index.js` missing → `'partial'` (build never finished).
|
|
414
|
+
* 6. otherwise → `'clean'`.
|
|
415
|
+
*
|
|
416
|
+
* The function is intentionally conservative: it returns `'foreign'` on any
|
|
417
|
+
* ambiguous read. Callers should NEVER auto-wipe a `'foreign'` directory.
|
|
418
|
+
*
|
|
419
|
+
* @param pluginRootDir Absolute path to the suspect plugin dir, e.g.
|
|
420
|
+
* `~/.openclaw/extensions/totalreclaw`.
|
|
421
|
+
*/
|
|
422
|
+
export function detectPartialInstall(pluginRootDir: string): PartialInstallResult {
|
|
423
|
+
const reasons: string[] = [];
|
|
424
|
+
|
|
425
|
+
// Rule 1 — absent dir.
|
|
426
|
+
let rootStat: fs.Stats;
|
|
427
|
+
try {
|
|
428
|
+
rootStat = fs.statSync(pluginRootDir);
|
|
429
|
+
} catch {
|
|
430
|
+
return { status: 'absent', reasons: ['directory does not exist'] };
|
|
431
|
+
}
|
|
432
|
+
if (!rootStat.isDirectory()) {
|
|
433
|
+
return { status: 'foreign', reasons: ['path exists but is not a directory'] };
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
// Rules 2-3 — package.json must claim our name.
|
|
437
|
+
const pkgJsonPath = path.join(pluginRootDir, 'package.json');
|
|
438
|
+
let pkgRaw: string;
|
|
439
|
+
try {
|
|
440
|
+
pkgRaw = fs.readFileSync(pkgJsonPath, 'utf-8');
|
|
441
|
+
} catch {
|
|
442
|
+
return { status: 'foreign', reasons: ['package.json missing or unreadable'] };
|
|
443
|
+
}
|
|
444
|
+
let parsed: { name?: unknown };
|
|
445
|
+
try {
|
|
446
|
+
parsed = JSON.parse(pkgRaw) as { name?: unknown };
|
|
447
|
+
} catch {
|
|
448
|
+
return { status: 'foreign', reasons: ['package.json is not valid JSON'] };
|
|
449
|
+
}
|
|
450
|
+
if (parsed.name !== PLUGIN_PACKAGE_NAME) {
|
|
451
|
+
return {
|
|
452
|
+
status: 'foreign',
|
|
453
|
+
reasons: [`package.json declares "${String(parsed.name)}" not "${PLUGIN_PACKAGE_NAME}"`],
|
|
454
|
+
};
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
// Rule 4 — explicit partial marker wins.
|
|
458
|
+
const markerPath = path.join(pluginRootDir, PARTIAL_INSTALL_MARKER);
|
|
459
|
+
if (fs.existsSync(markerPath)) {
|
|
460
|
+
reasons.push(`${PARTIAL_INSTALL_MARKER} marker present (preinstall fired, postinstall did not)`);
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
// Rule 5 — dist/index.js must exist for the loader to register.
|
|
464
|
+
const distIndex = path.join(pluginRootDir, 'dist', 'index.js');
|
|
465
|
+
if (!fs.existsSync(distIndex)) {
|
|
466
|
+
reasons.push('dist/index.js missing (build artifact absent)');
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
if (reasons.length > 0) {
|
|
470
|
+
return { status: 'partial', reasons };
|
|
471
|
+
}
|
|
472
|
+
return { status: 'clean', reasons: [] };
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
/**
|
|
476
|
+
* Wipe a partial-install directory so the next `openclaw plugins install`
|
|
477
|
+
* starts from a blank slate. Only acts when `detectPartialInstall(...)`
|
|
478
|
+
* returns `'partial'` — `'foreign'` and `'clean'` are no-ops by design.
|
|
479
|
+
*
|
|
480
|
+
* Returns `true` if the directory was wiped, `false` otherwise.
|
|
481
|
+
*
|
|
482
|
+
* SAFETY: this helper is the only place that recursively deletes a plugin
|
|
483
|
+
* dir. It refuses to act on `'foreign'` and `'clean'` results so a
|
|
484
|
+
* misconfigured caller can never wipe a healthy install or someone else's
|
|
485
|
+
* plugin.
|
|
486
|
+
*/
|
|
487
|
+
export function wipePartialInstall(pluginRootDir: string): boolean {
|
|
488
|
+
const detection = detectPartialInstall(pluginRootDir);
|
|
489
|
+
if (detection.status !== 'partial') return false;
|
|
490
|
+
try {
|
|
491
|
+
fs.rmSync(pluginRootDir, { recursive: true, force: true });
|
|
492
|
+
return true;
|
|
493
|
+
} catch {
|
|
494
|
+
return false;
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
/**
|
|
499
|
+
* Drop the `.tr-partial-install` marker into `pluginRootDir`. Idempotent
|
|
500
|
+
* (overwrites any existing marker) and best-effort — returns `true` on
|
|
501
|
+
* success, `false` if the dir doesn't exist or write fails. Used by the
|
|
502
|
+
* `preinstall` npm script and (defensively) by the runtime if the npm
|
|
503
|
+
* preinstall/cleanup script pair did not fire.
|
|
504
|
+
*/
|
|
505
|
+
export function writePartialInstallMarker(pluginRootDir: string): boolean {
|
|
506
|
+
try {
|
|
507
|
+
if (!fs.existsSync(pluginRootDir)) return false;
|
|
508
|
+
fs.writeFileSync(path.join(pluginRootDir, PARTIAL_INSTALL_MARKER), '');
|
|
509
|
+
return true;
|
|
510
|
+
} catch {
|
|
511
|
+
return false;
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
/**
|
|
516
|
+
* Remove the partial-install marker. Called by the `postinstall` script and
|
|
517
|
+
* (defensively) at register-time once we've confirmed the load succeeded.
|
|
518
|
+
* Returns `true` if a marker was removed, `false` if there was nothing to
|
|
519
|
+
* remove.
|
|
520
|
+
*/
|
|
521
|
+
export function clearPartialInstallMarker(pluginRootDir: string): boolean {
|
|
522
|
+
try {
|
|
523
|
+
const markerPath = path.join(pluginRootDir, PARTIAL_INSTALL_MARKER);
|
|
524
|
+
if (!fs.existsSync(markerPath)) return false;
|
|
525
|
+
fs.unlinkSync(markerPath);
|
|
526
|
+
return true;
|
|
527
|
+
} catch {
|
|
528
|
+
return false;
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
|
|
255
532
|
// ---------------------------------------------------------------------------
|
|
256
533
|
// Auto-bootstrap of credentials.json (3.1.0 first-run UX)
|
|
257
534
|
// ---------------------------------------------------------------------------
|
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
|
}
|