From d116ca297f2a2369b78db192fb1b42490f7b4736 Mon Sep 17 00:00:00 2001 From: Ankit Ranjan Date: Mon, 10 Aug 2026 18:36:01 +0530 Subject: [PATCH 1/3] fix: make client-owned web fetch discoverable (#252) `web fetch` always worked via the main.ts fast path but was invisible to `--help`, `list`, `cli-manifest.json`, and completions unless the `web` plugin was installed, and `web fetch -h` threw instead of printing help. Register the command from clis/web/fetch.js via a makeWebFetchCommand() factory so build-manifest and filesystem discovery both see it, and keep execution on the fast path so hosted mode never cloud-routes it. The fast path now honours the flags its help advertises: -f/--format for output and structured --help, an error for unsupported formats, and an error for a flag-shaped --timeout/--max-chars value instead of coercing it to 1. Co-Authored-By: Claude Opus 5 --- cli-manifest.json | 41 +++++++++++ clis/web/fetch.js | 12 +++- src/discovery.ts | 4 +- src/fetch/command.test.ts | 114 +++++++++++++++++++++++++++-- src/fetch/command.ts | 147 +++++++++++++++++++++++++++++++++----- 5 files changed, 292 insertions(+), 26 deletions(-) diff --git a/cli-manifest.json b/cli-manifest.json index cc96cc2c..915ce685 100644 --- a/cli-manifest.json +++ b/cli-manifest.json @@ -1,4 +1,45 @@ [ + { + "site": "web", + "name": "fetch", + "description": "Fetch a URL locally without launching a browser", + "access": "read", + "strategy": "public", + "browser": false, + "args": [ + { + "name": "url", + "type": "string", + "required": true, + "help": "http(s) URL to fetch" + }, + { + "name": "timeout", + "type": "int", + "default": 30, + "required": false, + "help": "Fetch budget in seconds" + }, + { + "name": "max-chars", + "type": "int", + "default": 50000, + "required": false, + "help": "Maximum characters of extracted content" + }, + { + "name": "allow-private", + "type": "boolean", + "default": false, + "required": false, + "help": "Allow private/loopback addresses" + } + ], + "defaultFormat": "md", + "type": "js", + "modulePath": "web/fetch.js", + "sourceFile": "web/fetch.js" + }, { "site": "web", "name": "fetch-browser", diff --git a/clis/web/fetch.js b/clis/web/fetch.js index 113b6169..1670878c 100644 --- a/clis/web/fetch.js +++ b/clis/web/fetch.js @@ -1 +1,11 @@ -import '@agentrhq/webcmd/fetch/command'; +/** + * Discovery entry for client-owned `web fetch`. + * + * Execution stays on the main.ts fast path so hosted mode never cloud-routes + * this command. This module exists so build-manifest, `webcmd list`, + * completions, and Commander help can see the same registration as the + * always-available fast path. + */ +import { makeWebFetchCommand } from '@agentrhq/webcmd/fetch/command'; + +export const command = makeWebFetchCommand(); diff --git a/src/discovery.ts b/src/discovery.ts index c65f59d9..b50ae420 100644 --- a/src/discovery.ts +++ b/src/discovery.ts @@ -38,8 +38,8 @@ export const USER_WEBCMD_DIR = getUserWebcmdDir(); export const USER_CLIS_DIR = getUserClisDir(); /** Plugins directory: ~/.webcmd/plugins/ */ export const PLUGINS_DIR = getPluginsDir(); -/** Matches files that register commands via cli() or lifecycle hooks */ -const PLUGIN_MODULE_PATTERN = /\b(?:cli|registerSiteAuthCommands|onStartup|onBeforeExecute|onAfterExecute)\s*\(/; +/** Matches files that register commands via cli() / factories or lifecycle hooks */ +const PLUGIN_MODULE_PATTERN = /\b(?:cli|registerSiteAuthCommands|onStartup|onBeforeExecute|onAfterExecute)\s*\(|\bmake[A-Z]\w*Command\s*\(/; function parseStrategy(rawStrategy: string | undefined, fallback: Strategy = Strategy.COOKIE): Strategy { if (!rawStrategy) return fallback; diff --git a/src/fetch/command.test.ts b/src/fetch/command.test.ts index 5b9823d2..7d0de079 100644 --- a/src/fetch/command.test.ts +++ b/src/fetch/command.test.ts @@ -1,13 +1,119 @@ import { describe, expect, it, vi } from 'vitest'; -import { formatWebFetchMarkdown, runClientOwnedWebFetch } from './command.js'; +import { + formatWebFetchHelp, + formatWebFetchMarkdown, + runClientOwnedWebFetch, + WEB_FETCH_ARGS, + webFetchCommand, +} from './command.js'; describe('web fetch command', () => { it('renders fetch metadata before content', () => { - expect(formatWebFetchMarkdown({ status: 200, requestedUrl: 'https://a', finalUrl: 'https://b', contentType: 'text/plain', tier: 'plain', title: 'T', extractionSource: 'raw', truncated: false, content: 'body' })).toContain('Source: https://a'); + expect(formatWebFetchMarkdown({ + status: 200, + requestedUrl: 'https://a', + finalUrl: 'https://b', + contentType: 'text/plain', + tier: 'plain', + title: 'T', + extractionSource: 'raw', + truncated: false, + content: 'body', + })).toContain('Source: https://a'); }); + it('runs the client-owned command without Cloud routing', async () => { - const webFetch = vi.fn().mockResolvedValue({ status: 200, requestedUrl: 'https://a', finalUrl: 'https://a', contentType: 'text/plain', tier: 'plain', title: '', extractionSource: 'raw', truncated: false, content: 'ok' }); - await runClientOwnedWebFetch(['web', 'fetch', '--url', 'https://a'], { webFetch, stdout: { write: vi.fn() } as never }); + const webFetch = vi.fn().mockResolvedValue({ + status: 200, + requestedUrl: 'https://a', + finalUrl: 'https://a', + contentType: 'text/plain', + tier: 'plain', + title: '', + extractionSource: 'raw', + truncated: false, + content: 'ok', + }); + await runClientOwnedWebFetch(['web', 'fetch', '--url', 'https://a'], { + webFetch, + stdout: { write: vi.fn() } as never, + }); expect(webFetch).toHaveBeenCalledOnce(); }); + + it('keeps discovery args aligned with the registered command', () => { + expect(webFetchCommand.site).toBe('web'); + expect(webFetchCommand.name).toBe('fetch'); + expect(webFetchCommand.browser).toBe(false); + expect(webFetchCommand.args).toEqual(WEB_FETCH_ARGS); + }); + + it('prints real help for -h and --help without requiring --url', async () => { + for (const flag of ['-h', '--help'] as const) { + const write = vi.fn(); + const webFetch = vi.fn(); + await runClientOwnedWebFetch(['web', 'fetch', flag], { + webFetch, + stdout: { write } as never, + }); + expect(webFetch).not.toHaveBeenCalled(); + expect(write).toHaveBeenCalledOnce(); + const help = String(write.mock.calls[0]![0]); + expect(help).toContain('Usage:'); + expect(help).toContain('web fetch'); + expect(help).toContain('--url'); + expect(help).toContain('--timeout'); + expect(help).toBe(formatWebFetchHelp()); + } + }); + + it('honours -f for output instead of always printing markdown', async () => { + const result = { + status: 200, + requestedUrl: 'https://a', + finalUrl: 'https://a', + contentType: 'text/plain', + tier: 'plain', + title: '', + extractionSource: 'raw', + truncated: false, + content: 'ok', + }; + const write = vi.fn(); + await runClientOwnedWebFetch(['web', 'fetch', '--url', 'https://a', '-f', 'json'], { + webFetch: vi.fn().mockResolvedValue(result), + stdout: { write } as never, + }); + expect(JSON.parse(String(write.mock.calls[0]![0]))).toMatchObject({ content: 'ok' }); + }); + + it('serves structured help for --help -f yaml', async () => { + const write = vi.fn(); + await runClientOwnedWebFetch(['web', 'fetch', '--help', '-f', 'yaml'], { + webFetch: vi.fn(), + stdout: { write } as never, + }); + expect(String(write.mock.calls[0]![0])).toContain('name: fetch'); + }); + + it('rejects an unsupported --format instead of silently printing a table', async () => { + await expect(runClientOwnedWebFetch(['web', 'fetch', '--url', 'https://a', '-f', 'xml'], { + webFetch: vi.fn(), + stdout: { write: vi.fn() } as never, + })).rejects.toThrow('--format must be one of'); + }); + + it('rejects a flag-shaped value for --timeout instead of coercing it', async () => { + await expect(runClientOwnedWebFetch(['web', 'fetch', '--url', 'https://a', '--timeout', '-5'], { + webFetch: vi.fn(), + stdout: { write: vi.fn() } as never, + })).rejects.toThrow('--timeout requires a value'); + }); + + it('rejects missing --url with ArgumentError when help is not requested', async () => { + await expect(runClientOwnedWebFetch(['web', 'fetch'], { + webFetch: vi.fn(), + stdout: { write: vi.fn() } as never, + })).rejects.toThrow('--url must be an http or https URL'); + }); }); diff --git a/src/fetch/command.ts b/src/fetch/command.ts index 103e8487..3d5536a0 100644 --- a/src/fetch/command.ts +++ b/src/fetch/command.ts @@ -1,37 +1,146 @@ -import { cli, Strategy } from '../registry.js'; +import { cli, Strategy, type Arg, type CliCommand } from '../registry.js'; import { ArgumentError } from '../errors.js'; +import { commandHelpData, formatCommandHelp, toPresentableCommand } from '../command-presentation.js'; +import { renderStructuredHelp } from '../help.js'; +import { formatOutput } from '../output.js'; import { webFetch, type WebFetchOptions, type WebFetchResult } from './client.js'; -export const webFetchCommand = cli({ - site: 'web', name: 'fetch', access: 'read', strategy: Strategy.PUBLIC, browser: false, - description: 'Fetch a URL locally without launching a browser', defaultFormat: 'md', - args: [ - { name: 'url', type: 'string', required: true }, - { name: 'timeout', type: 'int', default: 30 }, - { name: 'max-chars', type: 'int', default: 50000 }, - { name: 'allow-private', type: 'boolean', default: false }, - ], - func: async kwargs => webFetch({ url: String(kwargs.url), timeoutSeconds: Number(kwargs.timeout ?? 30), maxChars: Number(kwargs['max-chars'] ?? 50000), allowPrivate: kwargs['allow-private'] === true }), -}); +/** Single source of truth for discovery, Commander help, and the client-owned fast path. */ +export const WEB_FETCH_ARGS: Arg[] = [ + { name: 'url', type: 'string', required: true, help: 'http(s) URL to fetch' }, + { name: 'timeout', type: 'int', default: 30, help: 'Fetch budget in seconds' }, + { name: 'max-chars', type: 'int', default: 50000, help: 'Maximum characters of extracted content' }, + { name: 'allow-private', type: 'boolean', default: false, help: 'Allow private/loopback addresses' }, +]; + +const DEFAULT_TIMEOUT = 30; +const DEFAULT_MAX_CHARS = 50000; + +let registered: CliCommand | undefined; + +/** + * Register (or return) the builtin `web fetch` command. + * Called from clis/web/fetch.js so build-manifest / filesystem discovery see it. + * Safe to call more than once — returns the same command object. + */ +export function makeWebFetchCommand(): CliCommand { + if (registered) return registered; + registered = cli({ + site: 'web', + name: 'fetch', + access: 'read', + strategy: Strategy.PUBLIC, + browser: false, + description: 'Fetch a URL locally without launching a browser', + defaultFormat: 'md', + args: WEB_FETCH_ARGS, + func: async kwargs => webFetch(kwargsToOptions(kwargs)), + }); + return registered; +} + +/** Eager registration for consumers that import the command object directly. */ +export const webFetchCommand = makeWebFetchCommand(); export function formatWebFetchMarkdown(result: WebFetchResult): string { return [`# ${result.title || 'Fetched content'}`, '', `Source: ${result.requestedUrl}`, `Final URL: ${result.finalUrl}`, `Content type: ${result.contentType || 'unknown'}`, `Extraction: ${result.extractionSource}`, '', result.content].join('\n'); } +export function formatWebFetchHelp(): string { + return formatCommandHelp(toPresentableCommand(webFetchCommand)); +} + +function defaultInt(name: string): number { + if (name === 'timeout') return DEFAULT_TIMEOUT; + if (name === 'max-chars') return DEFAULT_MAX_CHARS; + return 0; +} + +function kwargsToOptions(kwargs: Record): WebFetchOptions { + return { + url: String(kwargs.url), + timeoutSeconds: Number(kwargs.timeout ?? DEFAULT_TIMEOUT), + maxChars: Number(kwargs['max-chars'] ?? DEFAULT_MAX_CHARS), + allowPrivate: kwargs['allow-private'] === true, + }; +} + +function wantsHelp(argv: readonly string[]): boolean { + return argv.slice(2).some(arg => arg === '-h' || arg === '--help'); +} + +/** Formats advertised by the common-options block the help text prints. */ +const OUTPUT_FORMATS = ['table', 'plain', 'json', 'yaml', 'md', 'csv'] as const; + +/** Reads -f/--format in the same shapes Commander accepts, so help cannot over-promise. */ +function requestedFormat(argv: readonly string[]): string | undefined { + for (let index = 2; index < argv.length; index++) { + const arg = argv[index]!; + let value: string | undefined; + if (arg === '-f' || arg === '--format') value = argv[index + 1]; + else if (arg.startsWith('--format=')) value = arg.slice('--format='.length); + else if (arg.startsWith('-f') && arg.length > 2) value = arg.slice(2); + else continue; + if (value === undefined || !OUTPUT_FORMATS.includes(value as typeof OUTPUT_FORMATS[number])) { + throw new ArgumentError(`--format must be one of: ${OUTPUT_FORMATS.join(', ')}`); + } + return value; + } + return undefined; +} + function clientOptions(argv: readonly string[]): WebFetchOptions { const values: Record = {}; for (let index = 2; index < argv.length; index++) { const arg = argv[index]!; if (!arg.startsWith('--')) continue; - const name = arg.slice(2); const value = argv[index + 1]; - if (value && !value.startsWith('--')) { values[name] = value; index++; } else values[name] = true; + const name = arg.slice(2); + const value = argv[index + 1]; + if (value && !value.startsWith('-')) { + values[name] = value; + index++; + } else { + values[name] = true; + } } - if (typeof values.url !== 'string' || !/^https?:\/\//i.test(values.url)) throw new ArgumentError('--url must be an http or https URL'); - const int = (name: string, fallback: number) => { const value = values[name]; const number = value === undefined ? fallback : Number(value); if (!Number.isInteger(number) || number < 0) throw new ArgumentError(`--${name} must be a non-negative integer`); return number; }; - return { url: values.url, timeoutSeconds: int('timeout', 30), maxChars: int('max-chars', 50000), allowPrivate: values['allow-private'] === true || values['allow-private'] === 'true' }; + if (typeof values.url !== 'string' || !/^https?:\/\//i.test(values.url)) { + throw new ArgumentError('--url must be an http or https URL'); + } + const int = (name: string) => { + const fallback = defaultInt(name); + const value = values[name]; + if (value === true) throw new ArgumentError(`--${name} requires a value`); + const number = value === undefined ? fallback : Number(value); + if (!Number.isInteger(number) || number < 0) { + throw new ArgumentError(`--${name} must be a non-negative integer`); + } + return number; + }; + return { + url: values.url, + timeoutSeconds: int('timeout'), + maxChars: int('max-chars'), + allowPrivate: values['allow-private'] === true || values['allow-private'] === 'true', + }; } -export async function runClientOwnedWebFetch(argv: readonly string[], dependencies: { webFetch?: typeof webFetch; stdout?: NodeJS.WritableStream } = {}): Promise { +export async function runClientOwnedWebFetch( + argv: readonly string[], + dependencies: { + webFetch?: typeof webFetch; + stdout?: NodeJS.WritableStream; + } = {}, +): Promise { + const stdout = dependencies.stdout ?? process.stdout; + const format = requestedFormat(argv); + if (wantsHelp(argv)) { + stdout.write(format === 'yaml' || format === 'json' + ? renderStructuredHelp(commandHelpData(toPresentableCommand(webFetchCommand)), format) + : formatWebFetchHelp()); + return; + } const result = await (dependencies.webFetch ?? webFetch)(clientOptions(argv)); - (dependencies.stdout ?? process.stdout).write(`${formatWebFetchMarkdown(result)}\n`); + stdout.write(format === undefined || format === 'md' + ? `${formatWebFetchMarkdown(result)}\n` + : formatOutput(result, { fmt: format, fmtExplicit: true })); } From 67900005b28272286b278e6914308483b5ef222c Mon Sep 17 00:00:00 2001 From: Ankit Ranjan Date: Wed, 12 Aug 2026 01:08:21 +0530 Subject: [PATCH 2/3] fix: declare web fetch client-owned instead of inferring it from main.ts web fetch is executed by the CLI on every path, including hosted mode. That was only true because of where main.ts intercepts it, so once Cloud reads the staged manifest the same grammar could also resolve to a server-side default with different network, timeout, billing and SSRF semantics. Carry the ownership in metadata: commands can declare clientOwned, it flows through cli() into the manifest, and deriveHostedAvailability marks such a command local-only with reason client-owned. The command stays in local help, list and completions; the hosted contract states it is never served. Adds e2e coverage for each surface (help, list, completions, help without --url, structured help, local execution in both modes) and the contract. Co-Authored-By: Claude Opus 5 --- cli-manifest.json | 1 + src/build-manifest.ts | 1 + src/fetch/command.ts | 3 + src/hosted/availability.test.ts | 6 ++ src/hosted/availability.ts | 10 ++- src/hosted/contract.ts | 1 + src/manifest-types.ts | 2 + src/registry.ts | 10 +++ tests/e2e/web-fetch-discoverability.test.ts | 96 +++++++++++++++++++++ vitest.config.ts | 1 + 10 files changed, 130 insertions(+), 1 deletion(-) create mode 100644 tests/e2e/web-fetch-discoverability.test.ts diff --git a/cli-manifest.json b/cli-manifest.json index 915ce685..82d7ec72 100644 --- a/cli-manifest.json +++ b/cli-manifest.json @@ -36,6 +36,7 @@ } ], "defaultFormat": "md", + "clientOwned": true, "type": "js", "modulePath": "web/fetch.js", "sourceFile": "web/fetch.js" diff --git a/src/build-manifest.ts b/src/build-manifest.ts index 5cb93350..82aa9079 100644 --- a/src/build-manifest.ts +++ b/src/build-manifest.ts @@ -127,6 +127,7 @@ function toManifestEntry(cmd: CliCommand, modulePath: string, sourceFile?: strin ...(cmd.tags?.length ? { tags: [...cmd.tags] } : {}), ...(cmd.keywords?.length ? { keywords: [...cmd.keywords] } : {}), defaultFormat: cmd.defaultFormat, + ...(cmd.clientOwned ? { clientOwned: true } : {}), type: 'js', modulePath, sourceFile, diff --git a/src/fetch/command.ts b/src/fetch/command.ts index 3d5536a0..04e52264 100644 --- a/src/fetch/command.ts +++ b/src/fetch/command.ts @@ -31,6 +31,9 @@ export function makeWebFetchCommand(): CliCommand { access: 'read', strategy: Strategy.PUBLIC, browser: false, + // main.ts runs this before the hosted-mode boundary, so the CLI owns it in + // every mode. Declared here so the hosted contract says so too. + clientOwned: true, description: 'Fetch a URL locally without launching a browser', defaultFormat: 'md', args: WEB_FETCH_ARGS, diff --git a/src/hosted/availability.test.ts b/src/hosted/availability.test.ts index df4ce587..09daa636 100644 --- a/src/hosted/availability.test.ts +++ b/src/hosted/availability.test.ts @@ -172,6 +172,12 @@ describe('hosted availability', () => { .toEqual({ mode: 'local-only', reason: 'desktop-app' }); expect(deriveHostedAvailability({ strategy: 'cookie', domain: 'example.com' })) .toEqual({ mode: 'hosted' }); + // A client-owned command is PUBLIC and non-browser: without the explicit + // flag nothing would stop the server advertising its own copy. + expect(deriveHostedAvailability({ strategy: 'public', domain: undefined, clientOwned: true })) + .toEqual({ mode: 'local-only', reason: 'client-owned' }); + expect(deriveHostedAvailability({ strategy: 'public', domain: 'example.com' })) + .toEqual({ mode: 'hosted' }); expect(deriveBrowserAvailability('bind')).toEqual({ mode: 'hosted' }); expect(deriveBrowserAvailability('run')).toEqual({ mode: 'hosted' }); expect(deriveBrowserAvailability('tabs')).toEqual({ mode: 'hosted' }); diff --git a/src/hosted/availability.ts b/src/hosted/availability.ts index 3bcdcce0..516cf494 100644 --- a/src/hosted/availability.ts +++ b/src/hosted/availability.ts @@ -3,14 +3,22 @@ import { Strategy } from '../registry.js'; export type HostedAvailability = | { mode: 'hosted' } - | { mode: 'local-only'; reason: 'desktop-app' | 'local-tool' | 'browser-bind' }; + | { mode: 'local-only'; reason: 'desktop-app' | 'local-tool' | 'browser-bind' | 'client-owned' }; export interface HostedAvailabilityMetadata { strategy?: Strategy | string; domain?: string; + /** The CLI always executes this command itself — see BaseCliCommand.clientOwned. */ + clientOwned?: boolean; } export function deriveHostedAvailability(command: HostedAvailabilityMetadata): HostedAvailability { + // Ownership is declared, not inferred: a client-owned command is PUBLIC and + // non-browser, so nothing else in its metadata would keep the server from + // advertising and executing a second, differently-behaved copy of it. + if (command.clientOwned === true) { + return { mode: 'local-only', reason: 'client-owned' }; + } if (String(command.strategy).toLowerCase() === Strategy.LOCAL) { return { mode: 'local-only', reason: 'local-tool' }; } diff --git a/src/hosted/contract.ts b/src/hosted/contract.ts index e31118e5..c990bd06 100644 --- a/src/hosted/contract.ts +++ b/src/hosted/contract.ts @@ -115,6 +115,7 @@ export interface HostedContractCommandInput { tags?: string[]; keywords?: string[]; defaultFormat?: CliCommand['defaultFormat']; + clientOwned?: boolean; } type SharedOption = { diff --git a/src/manifest-types.ts b/src/manifest-types.ts index 69a52d21..006f17ab 100644 --- a/src/manifest-types.ts +++ b/src/manifest-types.ts @@ -35,6 +35,8 @@ export interface ManifestEntry { keywords?: string[]; pipeline?: Record[]; defaultFormat?: 'table' | 'plain' | 'json' | 'yaml' | 'yml' | 'md' | 'markdown' | 'csv'; + /** The CLI executes this command itself; hosted mode must never serve it. */ + clientOwned?: boolean; type: 'js'; /** Relative path from clis/ dir, e.g. 'youtube/search.js' */ modulePath?: string; diff --git a/src/registry.ts b/src/registry.ts index 07368968..8cec63d5 100644 --- a/src/registry.ts +++ b/src/registry.ts @@ -97,6 +97,15 @@ interface BaseCliCommand { * combining them is a contradiction and fails at registration. */ freshPage?: boolean; + /** + * This command is executed by the CLI itself, on every path, in every mode. + * It is carried into the manifest so the hosted contract marks it local-only + * rather than leaving ownership to be inferred from main.ts control flow: a + * client-owned command must not also become a server-side default, or its + * network origin, timeout, billing, proxy and SSRF semantics would depend on + * how it was reached. + */ + clientOwned?: boolean; /** Override the default CLI output format when the user does not pass -f/--format. */ defaultFormat?: 'table' | 'plain' | 'json' | 'yaml' | 'yml' | 'md' | 'markdown' | 'csv'; /** Optional auth-status metadata attached by shared auth adapters. */ @@ -177,6 +186,7 @@ export function cli(opts: CliOptions): CliCommand { siteSession: opts.siteSession, freshPage: opts.freshPage, defaultFormat: opts.defaultFormat, + ...(opts.clientOwned ? { clientOwned: true } : {}), authStatus: opts.authStatus, }; diff --git a/tests/e2e/web-fetch-discoverability.test.ts b/tests/e2e/web-fetch-discoverability.test.ts new file mode 100644 index 00000000..8cd50d48 --- /dev/null +++ b/tests/e2e/web-fetch-discoverability.test.ts @@ -0,0 +1,96 @@ +/** + * E2E coverage for the `web fetch` command surface (#252). + * + * The bug was cross-surface: the command always executed, but help, list and + * completions did not know it existed. Unit tests on the parser cannot catch + * that, so these assertions drive the built binary and check each surface. + * + * They also pin the ownership contract: `web fetch` is client-owned. It is + * present everywhere the CLI presents commands, and marked local-only in the + * hosted contract so Cloud can never advertise or execute a second copy of it. + */ + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import * as os from 'node:os'; +import { fileURLToPath } from 'node:url'; +import { runCli, parseJsonOutput } from './helpers.js'; + +const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); +const TEST_HOME = fs.mkdtempSync(path.join(os.tmpdir(), 'webcmd-web-fetch-e2e-')); +const LOCAL_ENV = { HOME: TEST_HOME, USERPROFILE: TEST_HOME }; +// Hosted mode is selected by config.json, so pointing the config dir at a +// fixture is enough to make the CLI believe it is a hosted client. +const HOSTED_CONFIG_DIR = path.join(TEST_HOME, 'hosted-config'); +const HOSTED_ENV = { ...LOCAL_ENV, WEBCMD_CONFIG_DIR: HOSTED_CONFIG_DIR }; + +describe('web fetch command surface', () => { + beforeAll(() => { + fs.mkdirSync(HOSTED_CONFIG_DIR, { recursive: true }); + fs.writeFileSync(path.join(HOSTED_CONFIG_DIR, 'config.json'), `${JSON.stringify({ + mode: 'hosted', + updatedAt: new Date().toISOString(), + hosted: { apiBaseUrl: 'https://cloud.invalid' }, + })}\n`); + }); + + afterAll(() => { + fs.rmSync(TEST_HOME, { recursive: true, force: true }); + }); + + it('lists the web site group in top-level help', async () => { + const { stdout, code } = await runCli(['--help'], { env: LOCAL_ENV }); + expect(code).toBe(0); + expect(stdout).toMatch(/\bweb\b/); + }); + + it('reports exactly one web/fetch entry in list -f json', async () => { + const { stdout, code } = await runCli(['list', '-f', 'json'], { env: LOCAL_ENV }); + expect(code).toBe(0); + const data = parseJsonOutput(stdout) as Array<{ command: string }>; + expect(data.filter(entry => entry.command === 'web/fetch')).toHaveLength(1); + }); + + it('offers fetch as a completion under web', async () => { + const { stdout, code } = await runCli(['--get-completions', '--cursor', '2', 'web'], { env: LOCAL_ENV }); + expect(code).toBe(0); + expect(stdout.split('\n')).toContain('fetch'); + }); + + it('prints help without requiring --url', async () => { + const { stdout, code } = await runCli(['web', 'fetch', '--help'], { env: LOCAL_ENV }); + expect(code).toBe(0); + expect(stdout).toContain('--url'); + expect(stdout).not.toMatch(/required|missing/i); + }); + + it('prints structured help without requiring --url', async () => { + const { stdout, code } = await runCli(['web', 'fetch', '--help', '-f', 'json'], { env: LOCAL_ENV }); + expect(code).toBe(0); + const help = parseJsonOutput(stdout) as { command?: string }; + expect(help.command).toContain('fetch'); + }); + + it('executes locally on the client-owned fast path, in hosted mode too', async () => { + // A missing --url must be rejected by the local argument parser, not by a + // Cloud round-trip: the fixture API base URL does not resolve, so any + // hosted dispatch would surface as a network error instead. + for (const env of [LOCAL_ENV, HOSTED_ENV]) { + const { stdout, stderr, code } = await runCli(['web', 'fetch'], { env }); + expect(code).not.toBe(0); + expect(`${stdout}${stderr}`).toMatch(/--url/); + expect(`${stdout}${stderr}`).not.toMatch(/cloud\.invalid|ENOTFOUND|EAI_AGAIN/); + } + }); + + it('is marked local-only in the hosted contract so Cloud cannot serve it', () => { + const contract = JSON.parse(fs.readFileSync(path.join(ROOT, 'hosted-contract.json'), 'utf-8')) as { + commands: Array<{ command: string; sessionPolicy: string; availability: { mode: string; reason?: string } }>; + }; + const entry = contract.commands.find(command => command.command === 'web/fetch'); + expect(entry).toBeDefined(); + expect(entry!.availability).toEqual({ mode: 'local-only', reason: 'client-owned' }); + expect(entry!.sessionPolicy).toBe('local-only'); + }); +}); diff --git a/vitest.config.ts b/vitest.config.ts index d264b1e0..31bf38bf 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -55,6 +55,7 @@ export default defineConfig({ 'tests/e2e/article-download-pipeline.test.ts', 'tests/e2e/cloak-runtime.test.ts', 'tests/e2e/browser-run.test.ts', + 'tests/e2e/web-fetch-discoverability.test.ts', // Extended browser tests (20+ sites) — opt-in only: // WEBCMD_E2E=1 npx vitest run ...(includeExtendedE2e ? ['tests/e2e/browser-public-extended.test.ts', 'tests/e2e/browser-auth.test.ts'] : []), From dfd970e204a72642f7b64d7add9f83884bde8315 Mon Sep 17 00:00:00 2001 From: Ankit Ranjan Date: Wed, 12 Aug 2026 01:10:40 +0530 Subject: [PATCH 3/3] test: run the web fetch surface checks where CI actually runs them The e2e project only runs a named subset of browser files in CI, so the new assertions would never have executed there. The plugin project runs in full on every OS after a build, which is what a test driving the built binary needs. Co-Authored-By: Claude Opus 5 --- .../web/test/fetch-surface.test.ts | 10 +++++++--- vitest.config.ts | 1 - 2 files changed, 7 insertions(+), 4 deletions(-) rename tests/e2e/web-fetch-discoverability.test.ts => clis/web/test/fetch-surface.test.ts (91%) diff --git a/tests/e2e/web-fetch-discoverability.test.ts b/clis/web/test/fetch-surface.test.ts similarity index 91% rename from tests/e2e/web-fetch-discoverability.test.ts rename to clis/web/test/fetch-surface.test.ts index 8cd50d48..6dc19494 100644 --- a/tests/e2e/web-fetch-discoverability.test.ts +++ b/clis/web/test/fetch-surface.test.ts @@ -1,5 +1,9 @@ /** - * E2E coverage for the `web fetch` command surface (#252). + * Cross-surface coverage for the `web fetch` command surface (#252). + * + * Lives in the `plugin` project rather than `e2e`: it drives the built binary + * (so it needs dist/), and that project is the one CI runs in full on every + * OS. The `e2e` project runs only a named subset of browser files. * * The bug was cross-surface: the command always executed, but help, list and * completions did not know it existed. Unit tests on the parser cannot catch @@ -15,9 +19,9 @@ import * as fs from 'node:fs'; import * as path from 'node:path'; import * as os from 'node:os'; import { fileURLToPath } from 'node:url'; -import { runCli, parseJsonOutput } from './helpers.js'; +import { runCli, parseJsonOutput } from '../../../tests/e2e/helpers.js'; -const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); +const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../..'); const TEST_HOME = fs.mkdtempSync(path.join(os.tmpdir(), 'webcmd-web-fetch-e2e-')); const LOCAL_ENV = { HOME: TEST_HOME, USERPROFILE: TEST_HOME }; // Hosted mode is selected by config.json, so pointing the config dir at a diff --git a/vitest.config.ts b/vitest.config.ts index 31bf38bf..d264b1e0 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -55,7 +55,6 @@ export default defineConfig({ 'tests/e2e/article-download-pipeline.test.ts', 'tests/e2e/cloak-runtime.test.ts', 'tests/e2e/browser-run.test.ts', - 'tests/e2e/web-fetch-discoverability.test.ts', // Extended browser tests (20+ sites) — opt-in only: // WEBCMD_E2E=1 npx vitest run ...(includeExtendedE2e ? ['tests/e2e/browser-public-extended.test.ts', 'tests/e2e/browser-auth.test.ts'] : []),