Skip to content

web fetch broken: unhandled EPIPE crash in safe-proxy, plus FETCH_BLOCKED false positive on Cloudflare-fronted 200s #283

Description

@ngaurav

webcmd web fetch currently fails on both of the URLs we tried, in two different ways. Environment: webcmd 0.6.0 (fresh webcmd update), node v22.23.1, macOS 15 (Darwin 25.6.0), webcmd doctor fully green (daemon 0.6.0, cloak 0.4.5 connected).


1. Unhandled EPIPE crashes the CLI

webcmd web fetch --url https://news.ycombinator.com/
node:events:497
      throw er; // Unhandled 'error' event
      ^
Error: This socket has been ended by the other party
    at Socket.writeAfterFIN [as write] (node:net:576:14)
    at Socket.ondata (node:internal/streams/readable:1009:22)
    ...
  code: 'EPIPE'
}

Reproducible 3/3, before and after webcmd daemon restart. The process dies with a raw Node stack — no CliError, no error envelope, no JSON — so an agent gets nothing structured to act on.

Root cause looks like dist/src/fetch/safe-proxy.js:54-68 (the CONNECT handler). The tunnel is wired as:

upstream.once('connect', () => { ...; upstream.pipe(client); client.pipe(upstream); });
upstream.once('error', error => client.destroy(error));

There is no error listener on client at all, and the upstream listener is once, so a write to a half-closed peer emits an unhandled 'error' and takes down the process. Related: proxy.close() only calls server.close(), which does not destroy established tunnel sockets, so in-flight tunnels race the teardown at the end of webFetch.

Suggested fix: persistent client.on('error', …) / upstream.on('error', …) that destroy the peer, and track + destroy open sockets in close(). Same missing-handler issue applies to the plain HTTP path (request.pipe(upstream) at line 48).


2. FETCH_BLOCKED false positive on any Cloudflare-fronted 200

webcmd web fetch --url https://example.com
# CliError: The site blocked non-browser fetches.  code: 'FETCH_BLOCKED'

example.com is a plain static 200 with no challenge:

HTTP/2 200
content-type: text/html
server: cloudflare
cf-cache-status: HIT

dist/src/fetch/classify.js builds its evidence string from headers plus body and tests /cloudflare|…/i against it, gated on status === 200 among others. Any origin behind Cloudflare emits server: cloudflare and cf-* headers on perfectly good 200 responses — which today is a large fraction of the web — so the classifier reports a block that did not happen, burns both impit retries, and then pushes the agent to fetch-browser for a page plain HTTP already returned.

Suggested fix: don't treat header values naming a CDN as challenge evidence. Match challenge markers against the body (and specific challenge headers like cf-mitigated), and drop status === 200 from the gate unless a body marker such as "Just a moment" is actually present.


Both were hit while following start.md step 3 (the Hacker News verification task) on a clean install.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions