refactor(fetch): move web into core, one auto-escalating fetch command - #295
Open
ankitranjan7 wants to merge 5 commits into
Open
refactor(fetch): move web into core, one auto-escalating fetch command#295ankitranjan7 wants to merge 5 commits into
ankitranjan7 wants to merge 5 commits into
Conversation
…tier `webcmd web` lived in two places: `web fetch` was a hardcoded fast path in main.ts, and `web fetch-browser` was an adapter in clis/web that the published tarball never shipped (`clis/` is not in package.json `files`). The split produced a recurring class of bug rather than isolated ones. The whole ladder now lives in src/fetch and ships in dist/: - `web fetch` walks plain HTTP -> impit -> browser in one command. A blocked page is rendered and returned instead of raising an error that names a second command. `--browser false` opts out; escalation is local-mode only, since hosted mode executes adapters server-side. - `web fetch-browser` keeps the article-export pipeline (--output, --download-images, --wait-for, --diagnose) for callers who want files. - Both are registered in the core registry, so help, `list`, completions and the manifests carry them with no plugin installed. - The fast path stays for plain fetches but hands `-h`/`-f`/`--trace` to the registered command, and renders the standard error envelope instead of leaking a raw Node stack trace. clis/web is deleted; build-manifest now emits core-registered commands with no modulePath, since there is no adapter file under clis/ to resolve. Also fixes the challenge classifier, which flattened every response header into one string and grepped it. A CSP naming cdnjs.cloudflare.com, or `server: cloudflare` on a healthy 200, was read as a bot challenge — so example.com and news.ycombinator.com both burned two retries and failed with FETCH_BLOCKED. Header evidence is now limited to headers describing the response itself, and markers are split into decisive (cf-mitigated, "just a moment") vs corroborating (cloudflare, captcha), the latter requiring a 403/429/503. Fixes #246, #247, #252, #264 Fixes #283 (classifier half; the safe-proxy EPIPE half landed in #265) Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…s paths Follow-ups found while verifying the move end to end: - `web fetch -f md` rendered a nine-column table of the result object while the fast path printed a document — the same command with two shapes. Adds a `renderMarkdown` hook on CliCommand so a command whose payload is prose can own its markdown, and points `web fetch` at the formatter the fast path already used. Both paths now emit byte-identical output. - Hosted mode kept falling through to the hosted runner once flag handling moved to the registered command, which would cloud-route a command the service cannot execute yet. Hosted mode now always takes the fast path, so its behavior is unchanged from before this refactor. - `--format=json` (equals form) slipped past the fast-path flag guard and was silently ignored; the guard now splits on `=`. - Drops the dead duplicate of the flag guard from fetch/command.ts. - The article-download e2e still invoked `web read`, renamed to `web fetch-browser` back in 0.5.x. It swallowed the resulting CLI failure and passed vacuously, so it had been testing nothing since. All six real sites now exercise the pipeline for real. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Contributor
🟠 Maintainer review suggested — low confidenceAutomated semantic review could not be completed. Limitations
This review is advisory and does not block merging. |
Core-registered commands carry no modulePath, since there is no adapter file under clis/ to resolve. That leaves a consumer which loads adapters by path — webcmd-cloud's hosted executor — with nothing to import. Rather than have the cloud hardcode that `web` lives in dist/src/fetch/, the manifest now states it: `packageExport: './fetch/command'`, a subpath export the package already declares. The cloud resolves it through the pinned package's own export map, so this keeps working if the internal layout moves. A new test asserts every manifest entry is resolvable — a clis/ path or a package export that exists and points at a real source file. Without it a rename would only surface as a hosted runtime failure after publish. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ng runtime The hosted cloud executor calls adapter command functions directly, in a worker that has no local browser. There, a blocked `web fetch` escalated by calling webcmd's own executeCommand, reached for a local Chromium, and hung on a CDP connect for ~12s before failing with an opaque BrowserCommandError instead of a structured FETCH_BLOCKED. The existing hosted-mode guard did not cover this: shouldUseHostedMode() reads the local ~/.webcmd config, which on a cloud worker is absent, so it reported local mode and escalation proceeded. An embedder now sets WEBCMD_EMBEDDED_EXECUTOR=1 to declare that it drives these functions and owns browser execution itself. Escalation then declines with a hint naming the browser-backed command. The CLI never sets it, so local behaviour is unchanged. Reproduced against a packed build with no local config: 11.8s BrowserCommandError before, 1.6s structured FETCH_BLOCKED after. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What this does
Moves all web fetching into the core module (
src/fetch/), deletesclis/web, and makeswebcmd web fetchwalk the whole ladder — plain HTTP → impit → real browser — in one command.webcmd web fetch --url https://www.g2.com/categories/crm # Cloudflare-protected. One command. Returns the page. Extraction: browserBefore this PR that sequence was:
FETCH_BLOCKED→ hint says runweb fetch-browser→Site "web" is not installed→ dead end.Why the bugs kept coming back
web fetchandweb fetch-browserlived in two different places with two different lifecycles:web fetchmain.tsweb fetch-browserclis/web/clis/is not inpackage.jsonfilesSo the CLI shipped a command it never listed, and listed a command it never shipped. Every bug below is a symptom of that split, not an independent defect.
Issues fixed
fetch-browsernot installed, so the escalation hint was a dead endweb fetch; nothing to install--help,list, completions,-hFETCH_BLOCKED(CSP named a CDN)example.com→FETCH_BLOCKED(server: cloudflareon a 200)#283 is half-fixed here. Its other half — the unhandled
EPIPEthat killed the CLI process — was already fixed onmainby #265 and is only unreleased. Merging #279 ships it.The classifier bug (#264 / #283)
isChallengeResponseflattened every response header plus the body into one string and grepped it forcloudflare|captcha|.... Two consequences:server: cloudflareon a healthy 200 → "challenge"content-security-policyallow-listingcdnjs.cloudflare.com→ "challenge"Both pages had already returned full usable HTML. The classifier then burned both impit retries and failed with a misleading
FETCH_BLOCKED.Now: header evidence comes only from headers that describe this response (
server,cf-mitigated,cf-chl-*,x-datadome*,set-cookie). CSP /report-to/linkare excluded — they are third-party allow-lists. Markers are split into two tiers:cf-mitigated,cf-chl,__cf_bm,datadome,"just a moment","verify you are human") — count at any status, so a real managed-challenge 200 is still caughtcloudflare,akamai,captcha) — need a 403/429/503, so a login page with a reCAPTCHA widget no longer trips itCommand surface
web fetch— the one you want. Escalates on its own.web fetch-browser— unchanged, for article export to disk:Escalation is local-mode only. Hosted mode executes adapters server-side, so this never silently launches a browser on a hosted user's machine; it returns the error with a hint saying so. Cloud-routing the browser tier through Browser Use is a follow-up PR in
webcmd-cloud.Escalation fires only on
FETCH_BLOCKED/FETCH_REQUIRES_BROWSER. A timeout, refused connection, or oversized body still fails fast rather than hiding behind a slow browser run.Surfaces, before → after
webcmd --helpwebwebunder Site adapterswebcmd list[builtin]webcmd web fetch -hArgumentError+ stack trace--get-completions web ''fetch,fetch-browserweb fetch --url ftp://xclis/dist/Also fixed while verifying
evaluate, destroying the execution context — the normal case on the escalation path, since escalation only happens for pages that blocked us. Both browser paths now retry once after the new document settles. Found by running against a real Cloudflare site, not in review.web fetch -f mdrendered a nine-column table of the result object while the fast path printed a document. Adds arenderMarkdownhook onCliCommandso a command whose payload is prose owns its markdown. Both paths now emit byte-identical output.--format=json(equals form) slipped past the fast-path flag guard and was silently ignored.web read, renamed toweb fetch-browserback in 0.5.x. It swallowed the CLI failure and passed vacuously — it had been testing nothing since the rename. All six real sites now exercise the pipeline for real.Verification
npm test— 4820 passed, 1 skipped (was 4798; +22 new tests)npm run test:e2earticle pipeline — 6/6 real sites, genuinely runningnpx tsc --noEmit— cleancheck:hosted-contract,check:package-bin,check:plugin-parity— passcheck:silent-column-drop,check:typed-error-lint— pass, baselines shrunk (staleclis/webentries removed)example.comandnews.ycombinator.comfetch on the first tier;g2.comescalates and returns contentCloud counterpart
webcmd-cloud needs a matching change to load these core-owned commands: agentrhq/webcmd-cloud#33 (draft). Merge order: this PR → release/publish → bump the cloud's pin → un-draft #33.
The currently deployed cloud is unaffected until it bumps its pin:
loadDefaultHostedCommandsstarts fromsource.manifest, which is[]whenever the package ships noclis/directory — which it does not.Manifest note
build-manifestemits core-registered commands with nomodulePath/sourceFile— there is no adapter file underclis/to resolve, and claiming one would point consumers at a path absent from the published tarball. They instead declarepackageExport: './fetch/command', a subpath export the package already ships, so a consumer that loads adapters by path resolves it through the package's own export map rather than guessing thedist/layout.A test asserts every manifest entry is resolvable — a
clis/path, or a package export that exists and points at a real source file — so a rename cannot silently break hosted execution after publish.Embedding runtimes
A runtime that drives command functions itself (the hosted cloud executor) must set
WEBCMD_EMBEDDED_EXECUTOR=1. Escalation then never self-dispatches.This was a real bug found by probing, not review: in a worker with no local
~/.webcmdconfig,shouldUseHostedMode()reports local mode, so a blocked fetch escalated, reached for a local Chromium the worker does not have, and hung ~12s before failing with an opaqueBrowserCommandError. With the flag it returns a structuredFETCH_BLOCKEDin 1.6s.Supersedes
Closes as superseded: #263 (discoverability), #266 (classifier subset), #271 (packaging), #281 (error formatting), #286 and #287 (both duplicate the classifier fix and were written against a
mainthat no longer exists — both show asCONFLICTING).🤖 Generated with Claude Code