From e555b944ed4fb16e2279f0cf8ba99f3b398f20bf Mon Sep 17 00:00:00 2001 From: Yuriy Gerasymov Date: Fri, 28 Aug 2026 15:02:39 -0700 Subject: [PATCH] Route local dev hostnames that resolve to loopback to the host machine run-worker.mjs only rewrote literal localhost/127.0.0.1-style hostnames to host.docker.internal. Local dev tooling (ddev's *.ddev.site, Lando's *.lndo.site, *.test/*.localhost setups) serves custom hostnames whose public DNS also resolves to a loopback address, but the screenshot-worker container can't reach the host through those - 127.0.0.1 inside the container is the container itself, so captures silently failed with "site can't be reached". containerReachableUrl() now does a DNS lookup for any hostname that isn't one of the known local literals, and if it resolves to a loopback address, routes it to the host via an extra `--add-host :host-gateway` instead of rewriting the URL - keeping the original hostname intact so TLS SNI and Host-based vhost routing (as ddev's router relies on) still work. --- plugins/diffy/.claude-plugin/plugin.json | 2 +- plugins/diffy/scripts/run-worker.mjs | 51 ++++++++++++++++--- .../diffy/skills/upload-screenshot/SKILL.md | 4 +- 3 files changed, 47 insertions(+), 10 deletions(-) diff --git a/plugins/diffy/.claude-plugin/plugin.json b/plugins/diffy/.claude-plugin/plugin.json index fe4a2fd..ac5584f 100644 --- a/plugins/diffy/.claude-plugin/plugin.json +++ b/plugins/diffy/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "diffy", "description": "Diffy visual-regression testing for Claude Code, built on the diffy CLI. Capture screenshots either locally (inside Diffy's published screenshot-worker Docker container, its production rendering runtime) or remotely on Diffy's servers, then create projects, run visual diffs, and read results. When a request doesn't say which, Claude asks whether to run local or remote (app.diffy.website).", - "version": "2.2.0", + "version": "2.2.1", "author": { "name": "Diffy", "email": "support@diffy.website" diff --git a/plugins/diffy/scripts/run-worker.mjs b/plugins/diffy/scripts/run-worker.mjs index 81baf63..a9c063a 100644 --- a/plugins/diffy/scripts/run-worker.mjs +++ b/plugins/diffy/scripts/run-worker.mjs @@ -24,7 +24,10 @@ * node run-worker.mjs --project-id=12345 --url=http://localhost:3000 [--name="label"] * * Because capture runs in a container, a local dev server URL (localhost / 127.0.0.1) is rewritten - * to host.docker.internal so the container can reach your host. + * to host.docker.internal so the container can reach your host. Any other hostname that *resolves* + * to a loopback address — ddev's *.ddev.site, Lando's *.lndo.site, *.test / *.localhost setups, + * etc. — is assumed to mean the same host machine: its literal hostname is kept (so TLS SNI / + * vhost routing still match) and routed to the host via an extra container --add-host instead. * * Worker CODE location is resolved from (first hit wins): * --worker-dir= | $DIFFY_WORKER_DIR | ./diffy-worker | ../diffy-worker | /diffy-worker @@ -38,6 +41,7 @@ import { spawn, spawnSync } from 'node:child_process'; import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs'; +import { lookup } from 'node:dns/promises'; import path from 'node:path'; import os from 'node:os'; @@ -116,19 +120,47 @@ function sh(cmd, cmdArgs, opts = {}) { if (r.status !== 0) throw new Error(`${cmd} exited with status ${r.status}`); } -// Rewrite a host-local URL so it is reachable from inside the container. -function containerReachableUrl(rawUrl) { +const LOCAL_LITERAL_HOSTNAMES = new Set(['localhost', '127.0.0.1', '0.0.0.0', '::1', '[::1]']); + +function isLoopbackAddress(address) { + return address === '127.0.0.1' || address === '::1' || address === '0.0.0.0' || address.startsWith('127.'); +} + +// Figure out how to reach a host-local URL from inside the container. Returns +// { url, extraHost }: `url` is what to pass to the worker, and `extraHost` — when set — is a +// hostname that needs an explicit `--add-host :host-gateway` on the container so its +// DNS resolves to the host machine instead of the container's own loopback. +async function containerReachableUrl(rawUrl) { let u; try { u = new URL(rawUrl); } catch { - return rawUrl; + return { url: rawUrl, extraHost: null }; } - if (['localhost', '127.0.0.1', '0.0.0.0', '::1', '[::1]'].includes(u.hostname)) { + + if (LOCAL_LITERAL_HOSTNAMES.has(u.hostname)) { u.hostname = 'host.docker.internal'; - return u.toString().replace(/\/+$/, ''); + return { url: u.toString().replace(/\/+$/, ''), extraHost: null }; + } + + if (u.hostname === 'host.docker.internal') { + return { url: rawUrl.replace(/\/+$/, ''), extraHost: null }; } - return rawUrl.replace(/\/+$/, ''); + + // Local dev tooling (ddev, Lando, *.test / *.localhost setups, ...) often serves a custom + // hostname whose DNS record resolves to a loopback address. That still means "the host + // machine" — but rewriting the URL itself to host.docker.internal would break TLS SNI / the + // dev proxy's Host-based vhost routing, so keep the hostname and route it via --add-host. + try { + const { address } = await lookup(u.hostname); + if (isLoopbackAddress(address)) { + return { url: rawUrl.replace(/\/+$/, ''), extraHost: u.hostname }; + } + } catch { + // Unresolvable in this environment — fall through and treat it as a normal remote URL. + } + + return { url: rawUrl.replace(/\/+$/, ''), extraHost: null }; } // ---- provisioning ---------------------------------------------------------- @@ -255,7 +287,7 @@ if (checkMode) { const projectId = required('project-id'); const rawUrl = required('url').replace(/\/+$/, ''); - const url = containerReachableUrl(rawUrl); + const { url, extraHost } = await containerReachableUrl(rawUrl); const name = args.name && args.name !== true ? String(args.name) : ''; if (!apiKey) { @@ -268,6 +300,8 @@ if (checkMode) { if (url !== rawUrl) { console.error(`Rewrote ${rawUrl} -> ${url} so the container can reach your host.`); + } else if (extraHost) { + console.error(`${extraHost} resolves to a loopback address — routing it to your host machine (container --add-host).`); } // Env values are passed by name (docker reads them from our env) so the key never lands in argv. @@ -276,6 +310,7 @@ if (checkMode) { const dockerArgs = ['run', '--rm', '-e', 'DIFFY_API_KEY', '-e', 'DIFFY_PROJECT_ID']; if (process.env.DIFFY_MAX_WORKERS) dockerArgs.push('-e', 'DIFFY_MAX_WORKERS'); dockerArgs.push('--add-host', 'host.docker.internal:host-gateway'); + if (extraHost) dockerArgs.push('--add-host', `${extraHost}:host-gateway`); dockerArgs.push('-v', `${workerDir}:/diffy-worker`, '-w', '/diffy-worker'); dockerArgs.push(IMAGE, 'node', 'diffy-screenshots.js', `--url=${url}`); if (name) dockerArgs.push(`--screenshot-name=${name}`); diff --git a/plugins/diffy/skills/upload-screenshot/SKILL.md b/plugins/diffy/skills/upload-screenshot/SKILL.md index 62f24de..c01c49c 100644 --- a/plugins/diffy/skills/upload-screenshot/SKILL.md +++ b/plugins/diffy/skills/upload-screenshot/SKILL.md @@ -18,7 +18,9 @@ worker reads the project's pages, breakpoints, and advanced settings itself, re- your local URL, captures every page × breakpoint with the container's Chromium, uploads the set, and returns the ID — so there is no local `upload.json` to build and no separate `screenshot:create-uploaded` call. The container reaches your host dev server via `host.docker.internal` (the runner rewrites -`localhost`/`127.0.0.1` for you). +`localhost`/`127.0.0.1` for you) — and it also detects local dev hostnames that merely *resolve* to a +loopback address (ddev's `*.ddev.site`, Lando's `*.lndo.site`, `*.test`/`*.localhost` setups, etc.) and +routes those to your host machine too, without rewriting the hostname itself. Do not upload pre-existing image files or pre-built payloads, create a visual diff, or summarize diff results in this skill.